Variables and Type System in C++
Master C++'s rich type system including auto, decltype, constexpr, and C++17 structured bindings.
Declaring Variables
C++ is statically typed — every variable has a type known at compile time. This matters because the compiler uses type information to catch mistakes before your program ever runs, to generate efficient machine code, and to choose the right behavior when you call overloaded functions. You declare a variable by specifying its type, then its name, and optionally an initializer.
int age = 30;
double price = 9.99;
bool is_active = true;
char grade = 'A';
Prefer brace initialization (uniform initialization, C++11) over assignment syntax. It prevents narrowing conversions — silent truncations that are a common source of bugs — and works consistently across all types:
int x{42}; // brace initialization — preferred
int y = 42; // copy initialization — fine but less safe
int z(42); // direct initialization — avoids some issues
// Narrowing conversion: caught at compile time with braces
int a{3.14}; // ERROR: narrowing double -> int — the compiler saves you
int b = 3.14; // compiles silently, truncates to 3 — silent bug
The auto Keyword
auto tells the compiler to deduce the variable’s type from its initializer. Introduced in C++11, it reduces verbosity without sacrificing type safety — the type is still fixed at compile time, you just don’t have to write it out. This pays off most with iterator types, lambdas, and template-heavy code where the type names are long and noisy.
#include <vector>
#include <map>
#include <string>
int main() {
auto x = 42; // int
auto pi = 3.14159; // double
auto name = std::string{"Alice"}; // std::string
std::vector<int> nums = {1, 2, 3, 4, 5};
// Without auto: verbose iterator type that adds no clarity
std::vector<int>::iterator it1 = nums.begin();
// With auto: concise and just as correct
auto it2 = nums.begin();
// auto in range-for — idiomatic modern C++
for (auto n : nums) {
// n is int (copy)
}
// auto& for modifying elements in-place
for (auto& n : nums) {
n *= 2;
}
// const auto& to avoid copies of large objects while iterating read-only
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) {
// name is const std::string&, score is const int& — no copies made
}
}
Use auto when the type is obvious from the right-hand side or when it would be verbose (iterators, lambdas). Avoid it when the type itself communicates intent — int user_count = get_count() is clearer than auto user_count = get_count().
decltype
decltype yields the type of an expression without evaluating it. Unlike auto, it preserves reference and const qualifiers exactly as they appear. It is most useful in template code where you need to express a return type that depends on the parameter types.
#include <type_traits>
int x = 5;
double y = 3.14;
decltype(x) a = 10; // a is int
decltype(x + y) b = 0.0; // b is double (int + double promotes to double)
// Useful in templates: return type depends on argument types
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
// C++14 simplification: auto return type deduction
// The compiler looks at the return statement and deduces the type
template <typename T, typename U>
auto multiply(T t, U u) {
return t * u; // compiler deduces return type
}
const and constexpr
const and constexpr both express immutability, but at different points in time. const means a variable cannot be changed after initialization — but its value may be determined at runtime. constexpr goes further: it requires the value to be computable at compile time, enabling its use anywhere a compile-time constant is required (array sizes, template arguments, switch cases).
#include <array>
const int max_connections = 100; // immutable at runtime, may be runtime value
constexpr int buffer_size = 4096; // evaluated at compile time — guaranteed
// constexpr enables use in compile-time contexts
std::array<char, buffer_size> buf; // OK: array size must be compile-time constant
// std::array<char, max_connections> buf2; // may fail: const int isn't guaranteed constexpr
// constexpr functions: evaluated at compile time when arguments are constexpr
// This means zero runtime cost for known-at-compile-time inputs
constexpr int square(int n) {
return n * n;
}
constexpr int s = square(8); // computed at compile time: s == 64
int runtime_val = 7;
int s2 = square(runtime_val); // evaluated at runtime: also fine
// constexpr if — branch selection at compile time (C++17)
// The discarded branch is not compiled at all for that instantiation
template <typename T>
void describe(T val) {
if constexpr (std::is_integral_v<T>) {
// this branch only compiled for integer types
std::cout << "Integer: " << val << "\n";
} else {
std::cout << "Other: " << val << "\n";
}
}
constinit (C++20)
constinit ensures a variable with static storage duration is initialized at compile time, catching the subtle “static initialization order fiasco” — but unlike constexpr, it does not make the variable immutable.
constinit int global_counter = 0; // initialized at compile time, no SIOF risk
// global_counter++; // OK: still mutable at runtime
References and Pointers
C++ has both references and pointers. References are simpler and safer: they must be initialized, cannot be null, cannot be reseated to point elsewhere, and require no special syntax to use. Use references by default and reach for pointers only when you need nullability or pointer arithmetic.
int value = 42;
int& ref = value; // reference: an alias for value — no overhead
ref = 100; // modifies value directly, as if you wrote value = 100
// int& null_ref; // ERROR: references must be initialized
int* ptr = &value; // pointer: holds address of value
*ptr = 200; // dereference to read or write
ptr = nullptr; // pointer can be null or reassigned — reference cannot
// const reference: efficient read-only access to large objects (no copy made)
void print(const std::string& s) {
std::cout << s << "\n";
// s = "new"; // ERROR: const reference prevents modification
}
// rvalue reference (C++11): enables move semantics — binds to temporaries
std::string make_name() { return "Alice"; }
std::string&& rref = make_name(); // binds to temporary, extends its lifetime
Always use nullptr instead of NULL or 0 for null pointers. nullptr has type std::nullptr_t and avoids ambiguity in overload resolution — NULL is just 0, which can accidentally match integer overloads.
Structured Bindings (C++17)
Before C++17, iterating over a map meant writing it->first and it->second everywhere — verbose and opaque. Structured bindings let you unpack pairs, tuples, arrays, and structs into named variables in a single declaration, making code dramatically more readable.
#include <map>
#include <tuple>
#include <string>
#include <iostream>
// Unpacking a pair — no more .first/.second
std::pair<std::string, int> get_entry() {
return {"Alice", 95};
}
auto [name, score] = get_entry();
std::cout << name << ": " << score << "\n"; // Alice: 95
// Unpacking a tuple
auto [x, y, z] = std::make_tuple(1, 2.5, "hello");
// Unpacking a struct — works with any aggregate type
struct RGB { int r, g, b; };
RGB color{255, 128, 0};
auto [red, green, blue] = color;
// Iterating a map with structured bindings — the killer use case
std::map<std::string, int> leaderboard = {
{"Alice", 1200}, {"Bob", 980}, {"Carol", 1050}
};
for (const auto& [player, points] : leaderboard) {
std::cout << player << " -> " << points << "\n";
}
// Unpacking insert result — structured binding makes both values usable
auto [iter, inserted] = leaderboard.insert({"Dave", 870});
if (inserted) {
std::cout << "Added " << iter->first << "\n";
}
Structured bindings eliminate the noise of .first/.second and make map iteration readable.
Type Aliases
As codebases grow, type names become long and repeated — std::function<void(int, std::string)> scattered across a file is hard to read and hard to change. Type aliases give a short, descriptive name to a complex type, and using (the modern syntax) is strictly more powerful than the old typedef because it supports templates.
#include <vector>
#include <functional>
#include <cstdint>
// Old style
typedef unsigned long long u64;
// Modern style (using) — cleaner, works with templates
using u64 = unsigned long long;
using i32 = int32_t;
using StringList = std::vector<std::string>;
using Callback = std::function<void(int, std::string)>;
// Template alias (only possible with using, not typedef)
template <typename T>
using Matrix = std::vector<std::vector<T>>;
Matrix<double> m(3, std::vector<double>(3, 0.0)); // 3x3 matrix of doubles
Type aliases pay off immediately in large codebases — Callback is far more readable than std::function<void(int, std::string)> repeated throughout a file.