<< Home

Chapter 3: Lexical Elements, Declarations, and Initialization

This chapter tours the surface of C++ that the compiler reads first: the tokens, literals, and punctuation that form source code; the ways you declare objects and types; how initialization works; how the language helps you infer types with auto and decltype; how qualifiers such as const alter types; and how names are found across scopes and translation units. By the end you will read C++ code with clearer eyes and write declarations that say exactly what you mean.

Tokens, literals, and basic syntax

C++ is processed as a stream of tokens. A token is a smallest meaningful unit such as an identifier, a keyword, a literal, an operator, or a punctuation mark. Whitespace usually separates tokens. Comments are removed during translation, so they never become tokens. Understanding tokens helps you read tricky declarations and spot where one token ends and the next begins.

Recognizing tokens

Identifiers name things; keywords reserve language features; literals embed values like numbers or text; operators and punctuators shape expressions and statements. The compiler performs maximal munch when forming tokens (it takes the longest valid token), so >>= is one token rather than >> followed by =.

⚠️ The sequence template<<T>> in expressions can collide with >> as a shift operator in older code. Modern C++ parses nested template closers without extra spaces, yet spacing can still aid clarity.

Working with literals

Literals include integers, floating points, characters, strings, and booleans. You can add suffixes such as u, l, ull for integers; f, l for floating points; and s, sv for string types when using the standard literal operators. Digit separators with single quotes improve readability.

auto dec = 1'000'000;    // int
auto hex = 0xFFu;        // unsigned int
auto bin = 0b1010′1100;  // binary literal
auto fp  = 6.022e23f;    // float
auto ch  = 'é';          // UTF-8 code unit in narrow char
                         // literal (implementation encoding)
auto up  = U'𝛑';         // char32_t
auto s   = "hello"s;     // std::string via ""s
auto sv  = "view"sv;     // std::string_view via ""sv

Separating statements and grouping

Statements usually end with a semicolon, and blocks use braces to form compound statements. When showing elided content inside braces, write { … } to indicate omitted statements or members.

💡 Prefer braces around multi-statement bodies even if the grammar allows a single statement, since this prevents accidental attachment of future lines.

Types, objects, and declarations

A declaration introduces a name and its type. A definition allocates storage or provides the body for a function or class. Every object has a type that determines its size, alignment, and operations. Declarations can be simple or very dense, so read them from the name outward.

Declaring and defining clearly

You declare a variable by providing its type and a name; you define it when you also create storage. You declare a function by providing its type and name; you define it when you also provide the function body.

extern int g;                        // declaration (no storage here)
int g = 42;                          // definition

int square(int x);                   // function declaration
int square(int x) { return x * x; }  // definition

Understanding declarator syntax

In a full declaration, the base type appears first, followed by the declarator that attaches pointers, references, arrays, and function parts to the name. Reading from the identifier outward prevents confusion.

int* p;          // p is a pointer to int
int& r = g;      // r is a reference to int
int a[10];       // a is an array of 10 int
int (*fp)(int);  // fp is a pointer to function taking int and returning int
⚠️ The placement of * binds to the declarator, not the base type. Write one name per line when mixing pointers and non-pointers to avoid surprises.

Initialization styles

C++ offers several initialization forms. Each form has distinct rules, especially around narrowing conversions and constructor selection. Pick the form that expresses intent; do not treat them as interchangeable.

Using direct initialization

Direct initialization calls constructors or performs direct conversion. Parentheses signal that a constructor or converting function should be considered.

std::string s("hi");       // direct
std::vector<int> v(3, 1);  // 3 elements, all 1
auto x = int(3.14);        // direct conversion, value becomes 3

Using copy initialization

Copy initialization uses =. It can call constructors too, although some narrowing can occur and some constructors may be considered explicit only for direct initialization.

std::string s = "hi";          // copy
std::vector<int> v = {1,2,3};  // copy-list (still uses list rules)
auto n = 3.0f;                 // copy from float literal

Using list initialization

Braced lists create objects with uniform initialization rules. List initialization prevents narrowing by default and prefers initializer_list constructors when available.

int a{42};                  // ok
// int b{3.14};             // error: narrowing
std::vector<int> v{1,2,3};  // chooses initializer_list constructor

Aggregates and aggregate initialization

Aggregates are simple classes or arrays that have public data members, no user-declared constructors, no virtual functions, and no private or protected non-static data. They can be initialized directly with braces in member order.

struct Point { int x; int y; };
Point p{10, 20};            // aggregate initialization
Point q{ .x = 1, .y = 2 };  // designated initializers (C++20)
💡 Prefer list initialization for aggregates and for situations where you want to guard against narrowing; it communicates shape and safety in one step.
FormSyntaxNotes
DirectT obj(args)Calls constructors; can pick explicit ones.
CopyT obj = expr;May call constructors; explicit constructors are not considered.
ListT obj{args}Prevents narrowing; prefers initializer_list when present.
AggregateAggregate{m1, m2, …}Initializes members in declaration order.

Type deduction

Type deduction reduces noise and repetition. With auto, the compiler deduces a variable’s type from its initializer. With decltype, you ask for the type of an expression without evaluating it. Together they make templates and modern code more readable.

Deducing with auto

auto drops top-level const and references in deduction, and it follows template deduction rules. You can add const, &, or && on top of the deduced type to control binding.

const int ci = 42;
auto a = ci;        // a is int
const auto b = ci;  // b is const int
auto& r = a;        // r is int&
auto&& rr = 5;      // rr is int&& bound to a temporary
                    // (lifetime until end of full-expression)

Capturing types with decltype

decltype(expr) yields the declared type of a name or the type of the expression, including references. If you want the type of the expression as a plain value, wrap the name with ( ) rules in mind.

int x = 0;
decltype(x)   d1 = x;  // int
decltype((x)) d2 = x;  // int& (lvalue expression)
decltype(42)  d3 = 0;  // int
⚠️ The difference between decltype(x) and decltype((x)) is significant. The second form preserves lvalue reference when the expression is an lvalue.

const/volatile qualifiers and references

The cv qualifiers modify types: const promises no mutation through that path; volatile marks objects that can change for reasons outside the program’s control. References alias objects and come in two flavors: lvalue references (&) and rvalue references (&&). The combination of cv and references determines what operations are permitted.

Applying const

Top-level const applies to the object itself; low-level const applies through a pointer or reference. Use const to signal intent and enable optimizations, and prefer initializing const objects directly.

const int ci = 7;       // top-level const
const int* p = &ci;     // low-level const (points to const int)
int const* q = &ci;     // same type as above
int* const r = nullptr; // top-level const (const pointer to int)

Binding references

Lvalue references bind to named objects. Rvalue references bind to temporaries and movable results, which enables efficient transfers of resources. A const& can bind to temporaries, which is useful for parameters.

std::string make(); 
std::string s = "hi";
std::string& lr = s;               // binds to lvalue
std::string&& rr = make();         // binds to rvalue (temporary)
const std::string& cr = "hello"s;  // binds to temporary via const lvalue reference
💡 Use const& for parameters that accept both lvalues and temporaries without copying; use && and perfect forwarding for templates that must preserve value category.

Name lookup, scope, and linkage

C++ resolves a name by searching scopes in a defined order. The scope determines visibility; the linkage determines whether the same name refers to the same entity across translation units. Reading the rules helps avoid accidental hiding and multiple-definition errors.

Understanding scope rules

Block scope begins at a brace and ends at the matching brace. Class scope and namespace scope introduce their own member lookup rules. A name declared in an inner scope hides an outer one with the same name.

int x = 1;
void f() { 
  int x = 2;  // hides outer x
  { int x = 3; /* … */ }
}

Choosing appropriate linkage

Names can have external linkage, internal linkage, or no linkage. static at namespace scope gives internal linkage to variables and functions in that translation unit. extern declares names with external linkage that can be defined elsewhere.

// a.cpp
static int counter = 0;  // internal linkage
void tick();             // extern by default for functions

// b.cpp
extern int counter;      // refers to a different name
                         // (b.cpp cannot see a.cpp's static)

Navigating unqualified and qualified lookup

Unqualified lookup searches local, class, and namespace scopes in a defined order. Qualified lookup with :: follows the scopes you name. For function calls, argument-dependent lookup (ADL) also inspects associated namespaces of the argument types.

namespace geo { struct Point {}; void draw(Point); }
void draw(int);

int main() {
  geo::Point p;
  draw(p);     // finds geo::draw via ADL
  ::draw(42);  // qualified lookup chooses global draw(int)
}
⚠️ Overload sets can change when you add using-directives or new functions in associated namespaces. Prefer explicit qualification or using declarations with care in headers.