Skip to main content
C++ · lesson 3 of 24
C++ beginner Lesson 3 of 24

Variables and Type System in C++

C++ variables with real compiler output: brace initialization and narrowing, auto, decltype, constexpr, references vs pointers, and structured bindings.

By SkillByExample · Updated

Declaring Variables

C++ is statically typed: every variable’s type is fixed at compile time. There are three initialization syntaxes, and they don’t behave identically.

#include <iostream>

int main() {
    int age = 30;          // copy initialization
    double price{9.99};    // brace initialization
    bool is_active(true);  // direct initialization
    char grade = 'A';

    int truncated = 3.99;  // double -> int: compiles, silently drops .99
    int uninit_safe{};     // empty braces: value-initialized to 0

    std::cout << age << " " << price << " " << is_active << " " << grade << "\n";
    std::cout << "truncated = " << truncated << "\n";
    std::cout << "uninit_safe = " << uninit_safe << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o init init.cpp
$ ./init
30 9.99 1 A
truncated = 3
uninit_safe = 0

All three forms initialize, but int truncated = 3.99; compiled without a single warning under -Wall -Wextra and quietly produced 3. bool prints as 1 unless you ask for std::boolalpha. And int uninit_safe{} is guaranteed to be 0 — a plain int x; inside a function holds whatever was in memory.

Brace initialization is the one that refuses narrowing conversions:

int main() {
    double measured = 3.99;
    int a{measured};   // narrowing inside braces: ill-formed
    return a;
}
$ g++ -std=c++20 -Wall -Wextra -o narrowing narrowing.cpp 2>&1 | grep -E 'warning|error'
narrowing.cpp:3:11: warning: narrowing conversion of 'measured' from 'double' to 'int' [-Wnarrowing]
$ clang++ -std=c++20 -Wall -Wextra -o narrowing narrowing.cpp 2>&1 | grep -E 'warning|error'
narrowing.cpp:3:11: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
1 error generated.

The standard says narrowing inside braces is ill-formed, but compilers disagree on how hard to enforce it for a non-constant value: GCC 13 only warns and still builds the program, while Clang 18 rejects it. With a literal (int a{3.99};) both reject it outright. Either way you hear about it, which is more than = gives you — add -Werror to make GCC stop too.

The auto Keyword

auto deduces the type from the initializer. The type is still fixed at compile time; you just don’t write it.

#include <iostream>
#include <map>
#include <string>
#include <vector>

int main() {
    std::vector<int> nums = {1, 2, 3};

    for (auto n : nums) n *= 10;          // n is a copy
    std::cout << "after auto:  " << nums[0] << " " << nums[1] << " " << nums[2] << "\n";

    for (auto& n : nums) n *= 10;         // n is a reference into the vector
    std::cout << "after auto&: " << nums[0] << " " << nums[1] << " " << nums[2] << "\n";

    auto it = nums.begin();               // std::vector<int>::iterator
    auto half = nums.size() / 2;          // std::size_t, not int
    std::cout << "middle: " << *(it + half) << "\n";

    std::map<std::string, int> scores = {{"Bob", 87}, {"Alice", 95}};
    for (const auto& [name, score] : scores)   // no copies of the strings
        std::cout << name << "=" << score << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o auto_loop auto_loop.cpp
$ ./auto_loop
after auto:  1 2 3
after auto&: 10 20 30
middle: 20
Alice=95
Bob=87

The first loop multiplied copies — the vector is unchanged. auto& binds each element by reference, so the second loop writes through. That one character is the most common auto bug. half is deduced as std::size_t because that’s what size() returns; spelling it as int would silently narrow the value. And std::map iterates in key order, so Alice prints before Bob regardless of insertion order.

decltype

decltype gives the declared type of an expression without evaluating it. Unlike auto, it keeps const and references.

#include <iostream>
#include <type_traits>

template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) { return t + u; }

int main() {
    int x = 5;
    const int& cref = x;

    auto a = cref;             // auto drops const and &
    decltype(cref) b = x;      // decltype keeps them: const int&
    decltype(x + 2.0) c = 0;   // int + double -> double
    decltype((x)) d = x;       // parenthesized name -> int&

    std::cout << std::boolalpha;
    std::cout << "auto a is int:        " << std::is_same_v<decltype(a), int> << "\n";
    std::cout << "b is const int&:      " << std::is_same_v<decltype(b), const int&> << "\n";
    std::cout << "c is double:          " << std::is_same_v<decltype(c), double> << "\n";
    std::cout << "d is int&:            " << std::is_same_v<decltype(d), int&> << "\n";
    d = 99;
    std::cout << "x after d = 99:       " << x << "\n";
    std::cout << "add(1, 2.5) =         " << add(1, 2.5) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o decltype decltype.cpp
$ ./decltype
auto a is int:        true
b is const int&:      true
c is double:          true
d is int&:            true
x after d = 99:       99
add(1, 2.5) =         3.5

auto a = cref produced a plain int copy — auto strips top-level const and references. decltype(cref) kept const int&. The surprise is decltype((x)): wrapping a name in parentheses makes it an expression, and an lvalue expression yields a reference, so d is an alias and d = 99 changed x. add uses a trailing return type, -> decltype(t + u), so int + double returns double; since C++14 plain auto return deduction usually does the same job.

const and constexpr

const means “doesn’t change after initialization”. constexpr means “known at compile time”, which is what array sizes, template arguments and static_assert require.

#include <array>
#include <iostream>
#include <string>
#include <type_traits>

constexpr int square(int n) { return n * n; }

template <typename T>
void describe(T val) {
    if constexpr (std::is_integral_v<T>)
        std::cout << "integer " << val << ", doubled " << val * 2 << "\n";
    else
        std::cout << "other: " << val << "\n";   // val * 2 would not compile for string
}

constinit int counter = 0;     // C++20: initialized at compile time, still mutable

int main() {
    constexpr int buffer_size = square(8);        // computed by the compiler
    static_assert(buffer_size == 64);             // checked at compile time
    std::array<char, buffer_size> buf{};          // size must be a constant

    int runtime = 7;
    int s2 = square(runtime);                     // same function, run at runtime

    std::cout << "buf.size() = " << buf.size() << ", s2 = " << s2 << "\n";
    describe(21);
    describe(std::string("hi"));
    ++counter;
    std::cout << "counter = " << counter << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o constexpr constexpr.cpp
$ ./constexpr
buf.size() = 64, s2 = 49
integer 21, doubled 42
other: hi
counter = 1

square(8) ran inside the compiler: static_assert verified the result before the program existed, and it sized a std::array. The same function also ran at runtime for square(runtime) — constexpr functions can do both. if constexpr discards the branch that doesn’t apply, which is why describe(std::string("hi")) compiles even though val * 2 is meaningless for a string. constinit (C++20) guarantees compile-time initialization of a global but, unlike constexpr, leaves it mutable.

The difference becomes a hard error when a value is only known at runtime:

#include <iostream>

int main() {
    int n;
    std::cin >> n;
    const int c = n;       // fine: const, fixed after initialization
    constexpr int k = n;   // not fine: n is only known at runtime
    return c + k;
}
$ g++ -std=c++20 -Wall -Wextra -o constexpr_fail constexpr_fail.cpp 2>&1 | grep error
constexpr_fail.cpp:7:23: error: the value of 'n' is not usable in a constant expression

const int c = n is fine — c is read-only, but its value comes from cin. constexpr int k = n asks the compiler for a value it cannot have.

References and Pointers

A reference is an alias; a pointer is an address. References must be initialized, can’t be null and can’t be reseated.

#include <iostream>

void f(int)         { std::cout << "f(int)\n"; }
void f(const char*) { std::cout << "f(const char*)\n"; }

int main() {
    int value = 42;

    int& ref = value;      // alias: another name for the same int
    ref = 100;
    std::cout << "value after ref = 100: " << value << "\n";

    int* ptr = &value;     // holds the address of value
    *ptr = 200;
    std::cout << "value after *ptr = 200: " << value << "\n";

    int other = 7;
    ptr = &other;          // a pointer can be reseated...
    ref = other;           // ...a reference cannot: this assigns 7 to value
    std::cout << "value after ref = other: " << value << "\n";

    ptr = nullptr;
    std::cout << "ptr is null: " << (ptr == nullptr) << "\n";
    f(0);                  // 0 is an int, so f(int) wins
    f(nullptr);            // nullptr converts only to pointers
}
$ g++ -std=c++20 -Wall -Wextra -o refs refs.cpp
$ ./refs
value after ref = 100: 100
value after *ptr = 200: 200
value after ref = other: 7
ptr is null: 1
f(int)
f(const char*)

The third line is the trap: ref = other doesn’t make ref refer to other — a reference can never be reseated — so it assigns 7 into value. The pointer, by contrast, happily moved to other. The last two lines show why nullptr exists: the literal 0 is an int and picks f(int), while nullptr has type std::nullptr_t and only converts to pointers. NULL is typically 0 (or 0L), so it has the same problem as the literal.

Structured Bindings (C++17)

Structured bindings unpack tuples, pairs, arrays and structs into named variables.

#include <iostream>
#include <map>
#include <string>
#include <tuple>

struct RGB { int r, g, b; };

int main() {
    auto [id, ratio, label] = std::make_tuple(1, 2.5, "hello");
    std::cout << id << " " << ratio << " " << label << "\n";

    RGB color{255, 128, 0};
    auto [red, green, blue] = color;
    std::cout << "rgb(" << red << ", " << green << ", " << blue << ")\n";

    std::map<std::string, int> leaderboard = {
        {"Carol", 1050}, {"Alice", 1200}, {"Bob", 980}};
    for (const auto& [player, points] : leaderboard)
        std::cout << player << " -> " << points << "\n";

    auto [it1, added1] = leaderboard.insert({"Dave", 870});
    auto [it2, added2] = leaderboard.insert({"Alice", 1});
    std::cout << std::boolalpha;
    std::cout << "Dave added: " << added1 << " (" << it1->second << ")\n";
    std::cout << "Alice added: " << added2 << " (" << it2->second << ")\n";
}
$ g++ -std=c++20 -Wall -Wextra -o bindings bindings.cpp
$ ./bindings
1 2.5 hello
rgb(255, 128, 0)
Alice -> 1200
Bob -> 980
Carol -> 1050
Dave added: true (870)
Alice added: false (1200)

The map loop prints in key order, and [player, points] replaces it->first/it->second. The last two lines are the useful surprise: insert returns a pair of (iterator, bool), and inserting an existing key does nothing — added2 is false and the iterator points at Alice’s original 1200, not the 1 we tried to insert. Use insert_or_assign when you want overwrite semantics.

Type Aliases

using names a complex type once. It does everything typedef does, plus templates.

#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include <vector>

typedef unsigned long long u64_old;             // old style
using u64 = unsigned long long;                 // modern style, same type
using Callback = std::function<void(int, const std::string&)>;

template <typename T>
using Matrix = std::vector<std::vector<T>>;     // template alias: using only

int main() {
    u64 big = 18'000'000'000'000'000'000ull;
    u64_old same = big;
    std::cout << big << " " << (same == big) << "\n";

    Matrix<double> m(2, std::vector<double>(3, 0.5));
    std::cout << m.size() << "x" << m[0].size() << " matrix\n";

    Callback log = [](int code, const std::string& msg) {
        std::cout << "[" << code << "] " << msg << "\n";
    };
    log(200, "ok");
    std::cout << "sizeof(std::int32_t) = " << sizeof(std::int32_t) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o aliases aliases.cpp
$ ./aliases
18000000000000000000 1
2x3 matrix
[200] ok
sizeof(std::int32_t) = 4

u64 and u64_old are the same type — an alias is a new name, not a new type, so they compare and convert freely. Matrix<double> is a template alias, which typedef can’t express. The digit separators in 18'000'000'000'000'000'000ull (C++14) are ignored by the compiler; the ull suffix matters, because that value doesn’t fit in a signed long long.

Practice

  1. In init.cpp, change int truncated = 3.99; to int truncated{3.99};. What do GCC and Clang do?

    Solution

    Both reject it: with a constant (literal) initializer, narrowing inside braces is a hard error in GCC as well as Clang — narrowing conversion of '3.9900000000000002e+0' from 'double' to 'int'. GCC only downgrades to a warning when the value isn’t a constant expression, as in narrowing.cpp.

  2. In auto_loop.cpp, add a third loop for (const auto& n : nums) n *= 10;. What happens?

    Solution

    It fails to compile: n is a reference to const int, so assigning through it is an error (“assignment of read-only reference ‘n’”). Use const auto& for read-only loops over large elements and auto& only when you mean to modify.

  3. Write a constexpr int factorial(int n) and use static_assert(factorial(5) == 120);. Then call it with a value read from std::cin.

    Solution
    constexpr int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); }
    static_assert(factorial(5) == 120);
    
    int main() {
        int n;
        std::cin >> n;
        std::cout << factorial(n) << "\n";   // runtime call, same function
    }

    The static_assert is checked during compilation; the cin call runs at runtime. One definition serves both.

  4. In refs.cpp, add f(NULL);. Compile with GCC and explain the result.

    Solution

    GCC 13 refuses to compile it: call of overloaded 'f(NULL)' is ambiguous. GCC’s NULL is __null, an integer null constant that converts equally well to int and to const char*, so neither overload wins. Other compilers may instead pick f(int) silently — nullptr is unambiguous everywhere.

  5. Use structured bindings to swap the values in a std::pair<int,int> p{1, 2} without std::swap.

    Solution
    auto& [a, b] = p;
    int tmp = a; a = b; b = tmp;
    std::cout << p.first << " " << p.second << "\n";   // 2 1

    auto& makes a and b refer into p; with plain auto they’d be members of a hidden copy and p would stay 1 2.

Frequently Asked Questions

When should I use auto in C++?
Use auto when the type is obvious from the initializer or too long to be worth writing, such as iterators, lambdas and template-heavy code. Spell the type out when it carries intent the reader needs, for example int user_count = get_count().
What is the difference between const and constexpr?
const means the value cannot change after initialization, but it may be computed at runtime. constexpr requires the value to be computable at compile time, so it can size a std::array, be a template argument or appear in static_assert.
Why prefer brace initialization in C++?
Braces reject narrowing conversions such as double to int, which ordinary = initialization performs silently by truncating. Empty braces also value-initialize, so int x{}; is guaranteed to be 0 instead of an indeterminate value.
What is the difference between a reference and a pointer?
A reference is an alias that must be initialized, cannot be null and cannot be reseated to another object. A pointer holds an address, can be null or reassigned, and must be dereferenced with * to reach the object.
Why use nullptr instead of NULL or 0?
NULL is usually just the integer 0, so an overloaded call like f(NULL) can pick an int overload instead of a pointer one. nullptr has its own type, std::nullptr_t, which converts only to pointer types and always selects the pointer overload.