C++ · lesson 3 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Variables 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
-
In
init.cpp, changeint truncated = 3.99;toint 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 innarrowing.cpp. -
In
auto_loop.cpp, add a third loopfor (const auto& n : nums) n *= 10;. What happens?Solution
It fails to compile:
nis a reference toconst int, so assigning through it is an error (“assignment of read-only reference ‘n’”). Useconst auto&for read-only loops over large elements andauto&only when you mean to modify. -
Write a
constexpr int factorial(int n)and usestatic_assert(factorial(5) == 120);. Then call it with a value read fromstd::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_assertis checked during compilation; thecincall runs at runtime. One definition serves both. -
In
refs.cpp, addf(NULL);. Compile with GCC and explain the result.Solution
GCC 13 refuses to compile it:
call of overloaded 'f(NULL)' is ambiguous. GCC’sNULLis__null, an integer null constant that converts equally well tointand toconst char*, so neither overload wins. Other compilers may instead pickf(int)silently —nullptris unambiguous everywhere. -
Use structured bindings to swap the values in a
std::pair<int,int> p{1, 2}withoutstd::swap.Solution
auto& [a, b] = p; int tmp = a; a = b; b = tmp; std::cout << p.first << " " << p.second << "\n"; // 2 1auto&makesaandbrefer intop; with plainautothey’d be members of a hidden copy andpwould stay1 2.