- What are the differences between pointers and references in C++?
- Explain
constcorrectness with pointers and references. - What is pointer arithmetic and what are its risks?
- What is a dangling pointer and how do you avoid it?
- What causes memory leaks and how do you detect them?
- Explain the difference between stack and heap memory.
- What are the differences between
new/deleteandmalloc/free? - What happens if you use
deleteinstead ofdelete[]? - What is the
volatilekeyword and when should it be used? - Explain
static_castand when to use it. - Explain
dynamic_castand its runtime behavior. - What is
reinterpret_castand when is it dangerous? - What is
const_castand what are its legitimate uses? - What is undefined behavior in C++? Give examples.
- What are lvalues and rvalues in C++?
- Explain the strict aliasing rule and its implications.
- What is the difference between integer overflow and unsigned wraparound?
- What are
constmember functions and themutablekeyword? - How do RVO, NRVO, and guaranteed copy elision work?
- How does the compiler exploit undefined behaviour for optimisation? Give concrete examples.
- Argument-Dependent Lookup (ADL) and Koenig Lookup
- Integer promotion rules and usual arithmetic conversions
- C++20 three-way comparison operator (<=>)
- consteval and constinit (C++20)
- std::launder and placement-new pointer provenance
- Designated initializers (C++20)
- Name mangling and extern "C"
- C++ attribute specifiers
- One Definition Rule (ODR)
- std::initializer_list pitfalls
- Structured bindings (C++17)
- The as-if rule
Difficulty: Medium
Answer: A pointer is a variable that holds the memory address of another variable. A reference is an alias — another name for an existing variable. Key differences:
- A pointer can be null (
nullptr); a reference must be bound to a valid object at initialization and can never be null. - A pointer can be reseated (made to point to a different object); a reference always refers to the same object.
- Pointers require explicit dereferencing (
*p); references use the variable name directly. - Pointers support arithmetic; references do not.
- Pointers have their own storage; a reference typically has no storage overhead (implemented as an alias by the compiler).
#include <iostream>
int main() {
int a = 10, b = 20;
// Pointer
int* p = &a;
std::cout << *p << "\n"; // 10
p = &b; // reseating allowed
std::cout << *p << "\n"; // 20
// Reference
int& r = a;
std::cout << r << "\n"; // 10
r = b; // copies VALUE of b into a, does NOT reseat
std::cout << r << "\n"; // 20, but r still refers to a
std::cout << a << "\n"; // 20
// int& bad; // ERROR: reference must be initialized
// int* ok = nullptr; // fine for pointers
}Key Takeaways:
- Prefer references when null is not a valid state and reseating is not needed.
- Use pointers for optional values, dynamic memory, and data structures.
- References cannot be stored in containers; pointers can.
Difficulty: Medium
Answer:
const correctness is the practice of using const to express that an object should not be modified. With pointers, there are four combinations:
int* p— non-const pointer to non-const int: both pointer and value can change.const int* p— non-const pointer to const int: value cannot change, pointer can reseat.int* const p— const pointer to non-const int: pointer cannot reseat, value can change.const int* const p— const pointer to const int: neither can change.
#include <iostream>
int main() {
int x = 5, y = 10;
const int* p1 = &x; // pointer to const int
// *p1 = 6; // ERROR: cannot modify through p1
p1 = &y; // OK: can reseat
int* const p2 = &x; // const pointer to int
*p2 = 6; // OK: can modify value
// p2 = &y; // ERROR: cannot reseat
const int* const p3 = &x; // const pointer to const int
// *p3 = 7; // ERROR
// p3 = &y; // ERROR
// References
const int& r = x;
// r = 7; // ERROR: r is a reference to const int
std::cout << r << "\n"; // reads fine
}Key Takeaways:
- Read
constdeclarations right-to-left:const int* p= "p is a pointer to int that is const". - Always use
constreferences for function parameters when the value should not be modified — avoids copies and prevents accidental mutations. constmember functions signal they do not modify the object.
Difficulty: Medium
Answer:
Pointer arithmetic allows navigating memory by adding/subtracting integers from a pointer. When you add n to a pointer of type T*, the address advances by n * sizeof(T). This is valid only within an array (including one past the end).
#include <iostream>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;
std::cout << *p << "\n"; // 10
std::cout << *(p + 2) << "\n"; // 30
p++;
std::cout << *p << "\n"; // 20
// Pointer difference
int* first = &arr[0];
int* last = &arr[4];
std::ptrdiff_t diff = last - first; // 4
std::cout << diff << "\n";
// DANGER: going out of bounds — undefined behavior
// int* bad = arr + 10;
// std::cout << *bad << "\n"; // UB
}Risks:
- Going beyond array bounds is undefined behavior.
- Arithmetic on pointers to unrelated objects is UB.
- Pointer arithmetic is not meaningful for
void*.
Key Takeaways:
- Pointer arithmetic is well-defined only within the same array object (or one-past-the-end for comparisons).
- Prefer iterators or index-based loops to reduce the risk of off-by-one errors.
- Use
std::span(C++20) to bundle pointer and size together safely.
Difficulty: Medium
Answer: A dangling pointer is a pointer that refers to memory that has already been freed or gone out of scope. Dereferencing it is undefined behavior and can cause crashes or security vulnerabilities.
#include <iostream>
int* danglingLocal() {
int x = 42;
return &x; // x is destroyed on return — dangling pointer!
}
void danglingHeap() {
int* p = new int(10);
delete p;
// p is now dangling
// std::cout << *p; // UB: use-after-free
p = nullptr; // good practice: set to nullptr after delete
}
int main() {
int* p = danglingLocal();
// std::cout << *p; // UB
// Another case: iterator invalidation
std::vector<int> v = {1, 2, 3};
int* ptr = &v[0];
v.push_back(4); // may reallocate — ptr is now dangling
// std::cout << *ptr; // UB
}How to avoid:
- Set pointers to
nullptrafterdelete. - Never return the address of a local variable.
- Prefer smart pointers (
unique_ptr,shared_ptr). - Be aware of iterator invalidation rules in STL containers.
Key Takeaways:
- AddressSanitizer (
-fsanitize=address) can detect dangling pointer dereferences at runtime. - Smart pointers eliminate most dangling pointer issues for heap memory.
Difficulty: Medium
Answer: A memory leak occurs when heap-allocated memory is never freed. Over time, it exhausts available memory. Common causes:
- Forgetting to call
delete/delete[]. - Early returns or exceptions bypassing cleanup.
- Overwriting a pointer before freeing old memory.
- Circular references with
shared_ptr.
#include <iostream>
#include <stdexcept>
void leakOnException() {
int* p = new int[1000];
// if this throws, p is never deleted
throw std::runtime_error("oops");
delete[] p; // never reached
}
void leakOverwrite() {
int* p = new int(5);
p = new int(10); // original allocation lost — leak!
delete p;
}
// Fix: use RAII / smart pointers
#include <memory>
void noLeak() {
auto p = std::make_unique<int[]>(1000);
throw std::runtime_error("safe"); // destructor frees memory
}
int main() {
// Detection tools:
// Valgrind: valgrind --leak-check=full ./program
// ASan: compile with -fsanitize=address
// AddressSanitizer reports: "definitely lost", "indirectly lost"
}Key Takeaways:
- RAII (Resource Acquisition Is Initialization) is the primary C++ mechanism to prevent leaks.
- Use
std::unique_ptr/std::shared_ptrinstead of rawnew/delete. - Tools: Valgrind, AddressSanitizer, Visual Studio's diagnostic tools.
Difficulty: Medium
Answer:
| Feature | Stack | Heap |
|---|---|---|
| Allocation | Automatic (LIFO) | Manual (new/malloc) |
| Size | Limited (~1-8 MB typically) | Large (limited by RAM/virtual memory) |
| Speed | Very fast (pointer bump) | Slower (allocator overhead, fragmentation) |
| Lifetime | Tied to scope | Until explicitly freed |
| Thread safety | Each thread has its own stack | Shared among threads |
#include <iostream>
#include <memory>
struct BigObject { char data[1024 * 1024]; }; // 1 MB
void stackExample() {
int x = 5; // stack allocation
int arr[100] = {}; // stack allocation
// BigObject obj; // stack overflow risk!
}
void heapExample() {
int* p = new int(5); // heap allocation
auto big = std::make_unique<BigObject>(); // heap — safe for large objects
delete p;
}
int main() {
stackExample();
heapExample();
// Stack frame layout:
// [return address][saved registers][local variables] <- stack pointer
}Key Takeaways:
- Prefer stack allocation for small, short-lived objects.
- Use heap for large objects, objects with dynamic lifetime, or when size is unknown at compile time.
- Stack overflow typically occurs from deep recursion or very large stack allocations.
Difficulty: Medium
Answer:
| Feature | new/delete |
malloc/free |
|---|---|---|
| Constructor/Destructor | Called | Not called |
| Type safety | Returns typed pointer | Returns void* |
| Failure behavior | Throws std::bad_alloc |
Returns nullptr |
| Overridable | Yes (operator overloading) | No |
| Array support | new[]/delete[] |
malloc/free (no special handling) |
| Header | Built-in | <cstdlib> |
#include <iostream>
#include <cstdlib>
struct MyClass {
MyClass() { std::cout << "Constructor\n"; }
~MyClass() { std::cout << "Destructor\n"; }
};
int main() {
// new/delete — constructor and destructor called
MyClass* obj1 = new MyClass(); // prints "Constructor"
delete obj1; // prints "Destructor"
// malloc/free — no constructor/destructor
MyClass* obj2 = static_cast<MyClass*>(malloc(sizeof(MyClass))); // no "Constructor"
free(obj2); // no "Destructor" — obj2 was never properly constructed
// Mixing them is UB:
// MyClass* bad = new MyClass();
// free(bad); // UB: destructor not called, allocator mismatch
}Key Takeaways:
- Never mix
newwithfreeormallocwithdelete— they use different allocators. newcan be overloaded to use custom allocators;malloccannot.- In modern C++, prefer smart pointers over raw
new/delete.
Difficulty: Hard
Answer:
Using delete on an array allocated with new[] is undefined behavior. In practice, it typically only destroys the first element (calls destructor once instead of N times) and the allocator may not know the correct size to free.
#include <iostream>
struct Tracked {
int id;
Tracked(int i) : id(i) { std::cout << "Ctor " << id << "\n"; }
~Tracked() { std::cout << "Dtor " << id << "\n"; }
};
int main() {
// Correct
Tracked* arr = new Tracked[3]{Tracked(1), Tracked(2), Tracked(3)};
delete[] arr; // calls all 3 destructors
// Wrong — UB
Tracked* bad = new Tracked[3]{Tracked(1), Tracked(2), Tracked(3)};
delete bad; // UB: may only call ~Tracked() for element 0
// remaining memory may be leaked or heap corrupted
// Safe alternative
auto safe = std::make_unique<Tracked[]>(3); // automatic cleanup
}Key Takeaways:
new[]must be paired withdelete[], andnewmust be paired withdelete.- Prefer
std::vectororstd::unique_ptr<T[]>for arrays — they handle cleanup automatically. - AddressSanitizer detects this mismatch at runtime.
Difficulty: Medium
Answer:
volatile tells the compiler that a variable's value may change at any time outside the program's control (e.g., hardware registers, memory-mapped I/O, signal handlers). It prevents the compiler from caching the value in a register or reordering accesses to it.
#include <iostream>
#include <csignal>
volatile bool stop = false; // modified by signal handler
void handler(int) { stop = true; }
void busyWait() {
signal(SIGINT, handler);
while (!stop) {
// Without volatile, compiler may optimize to: if (!stop) while(true);
}
std::cout << "Stopped\n";
}
// Hardware register example
volatile uint32_t* const STATUS_REG = reinterpret_cast<volatile uint32_t*>(0x4000'0000);
void waitReady() {
while (!(*STATUS_REG & 0x1)) {
// Each loop iteration re-reads STATUS_REG from memory
}
}
int main() {
// Note: volatile does NOT provide thread synchronization!
// For multithreading, use std::atomic instead.
volatile int x = 5;
x = 10; // not optimized away even if x is never read again
}Key Takeaways:
volatileprevents compiler optimizations on that variable — every read/write goes to memory.volatiledoes not guarantee atomicity or memory ordering — usestd::atomicfor thread safety.- Primary use cases: memory-mapped I/O, signal handlers, setjmp/longjmp scenarios.
Difficulty: Medium
Answer:
static_cast performs compile-time checked conversions. It is the safest and most common explicit cast. It can convert between numeric types, perform pointer upcasts/downcasts in a class hierarchy (without runtime checks), and invoke explicit conversion operators.
#include <iostream>
class Base { public: virtual ~Base() {} };
class Derived : public Base {};
int main() {
// Numeric conversions
double d = 3.14;
int i = static_cast<int>(d); // truncates to 3
std::cout << i << "\n";
// Enum to int
enum class Color { Red = 0, Green, Blue };
int c = static_cast<int>(Color::Green); // 1
// Upcast (always safe)
Derived obj;
Base* bp = static_cast<Base*>(&obj); // fine
// Downcast (unsafe — no runtime check)
Base* b2 = new Base();
Derived* dp = static_cast<Derived*>(b2); // compiles but UB at runtime if b2 is not Derived
// Use dynamic_cast for safe downcasts
// void* conversion
void* vp = static_cast<void*>(&i);
int* ip2 = static_cast<int*>(vp); // round-trip ok
}Key Takeaways:
- Prefer
static_castover C-style casts — it documents intent and is searchable. - Use
dynamic_castwhen you need a safe downcast with runtime checking. static_castwill not removeconst— useconst_castfor that.
Difficulty: Hard
Answer:
dynamic_cast performs a runtime-checked cast in a polymorphic class hierarchy (at least one virtual function required). It uses RTTI (Run-Time Type Information) to verify the actual type. Returns nullptr (for pointers) or throws std::bad_cast (for references) if the cast fails.
#include <iostream>
#include <stdexcept>
class Animal { public: virtual ~Animal() {} };
class Dog : public Animal { public: void bark() { std::cout << "Woof!\n"; } };
class Cat : public Animal { public: void meow() { std::cout << "Meow!\n"; } };
void makeSound(Animal* a) {
if (Dog* d = dynamic_cast<Dog*>(a)) {
d->bark();
} else if (Cat* c = dynamic_cast<Cat*>(a)) {
c->meow();
} else {
std::cout << "Unknown animal\n";
}
}
int main() {
Animal* a1 = new Dog();
Animal* a2 = new Cat();
makeSound(a1); // Woof!
makeSound(a2); // Meow!
// Reference cast — throws on failure
try {
Dog& d = dynamic_cast<Dog&>(*a2); // a2 is Cat — throws std::bad_cast
} catch (const std::bad_cast& e) {
std::cout << "Bad cast: " << e.what() << "\n";
}
delete a1; delete a2;
}Key Takeaways:
dynamic_casthas runtime overhead (vtable traversal). Avoid in tight loops.- Only works with polymorphic types (at least one
virtualfunction). - Compile with
-fno-rttito disable RTTI, which also disablesdynamic_cast. - Prefer redesigning with virtual functions over frequent
dynamic_castusage.
Difficulty: Hard
Answer:
reinterpret_cast reinterprets the bit pattern of one type as another. It performs no type checking and almost never generates code. It is the most dangerous cast and should only be used for low-level operations like interfacing with hardware or serialization.
#include <iostream>
#include <cstdint>
int main() {
// Inspecting float bits
float f = 3.14f;
uint32_t bits = *reinterpret_cast<uint32_t*>(&f);
std::cout << std::hex << bits << "\n"; // 4048f5c3
// This is technically UB in C++ (strict aliasing violation)
// Safe way: use memcpy or std::bit_cast (C++20)
uint32_t safe_bits;
std::memcpy(&safe_bits, &f, sizeof(f));
// C++20 bit_cast — no UB
// auto bits2 = std::bit_cast<uint32_t>(f);
// Memory-mapped I/O (legitimate use)
volatile uint32_t* reg = reinterpret_cast<volatile uint32_t*>(0xDEAD'BEEF);
// *reg = 1; // hardware register write (valid in embedded context)
// Dangerous: unrelated pointer types
int x = 42;
double* dp = reinterpret_cast<double*>(&x); // UB to dereference
// std::cout << *dp; // UB: strict aliasing violation
}Key Takeaways:
reinterpret_castbypasses the type system entirely — it is the programmer's promise that they know what they are doing.- Strict aliasing rule: accessing an object through a pointer of a different type is UB (exceptions:
char*,unsigned char*,std::byte*). - Prefer
std::bit_cast<>(C++20) for type-punning.
Difficulty: Medium
Answer:
const_cast is the only cast that can add or remove const (or volatile) from a type. Removing const from a pointer and then modifying the object is only safe if the underlying object is not actually const.
#include <iostream>
void legacyPrint(char* str) { // old C API — takes non-const char*
std::cout << str << "\n";
}
int main() {
// Legitimate: removing const to call a legacy API that won't modify the data
const char* msg = "Hello";
legacyPrint(const_cast<char*>(msg)); // safe if legacyPrint doesn't modify
// Legitimate: const method returning non-const reference to internal data
// (see mutable/const_cast idiom for caching)
// DANGEROUS: modifying a truly const object
const int x = 42;
int* p = const_cast<int*>(&x);
*p = 99; // UB: x was declared const — behavior is undefined
std::cout << x << "\n"; // may print 42 (compiler may have inlined it)
std::cout << *p << "\n"; // may print 99 — inconsistency!
}Key Takeaways:
const_castis safe only when removingconstfrom a pointer to an object that was not originally declaredconst.- Common legitimate use: adapting to legacy C APIs that don't use
constbut don't actually modify data. - Never use
const_castto modify a trulyconstobject — that is undefined behavior.
Difficulty: Hard
Answer: Undefined behavior (UB) means the C++ standard places no requirements on what the program does. The compiler is free to generate any code — including deleting the UB branch entirely, reformatting your hard drive theoretically, or appearing to work correctly.
#include <iostream>
int main() {
// 1. Signed integer overflow (UB — unlike unsigned which wraps)
int x = INT_MAX;
int y = x + 1; // UB: compiler may assume this never happens
// 2. Null pointer dereference
int* p = nullptr;
// *p = 5; // UB
// 3. Out-of-bounds array access
int arr[3] = {1, 2, 3};
// int z = arr[5]; // UB
// 4. Use after free
int* q = new int(10);
delete q;
// *q = 5; // UB: use-after-free
// 5. Uninitialized variable
int uninit;
// std::cout << uninit; // UB
// 6. Shift past bit width
int a = 1;
// int b = a << 32; // UB on 32-bit int
// 7. Data race (accessing shared variable from multiple threads without sync)
// Compilers exploit UB for optimization:
// void f(int* p) {
// *p = 5; // compiler assumes p != nullptr (dereference implies non-null)
// if (p) { ... } // compiler may eliminate this check!
// }
}Key Takeaways:
- UB is a compile-time contract, not a runtime error — it can be silently "optimized away".
- Enable sanitizers:
-fsanitize=undefined,addressto catch UB at runtime. - Enable warnings:
-Wall -Wextra -Wundefto catch potential UB at compile time. - Common UB list: https://en.cppreference.com/w/cpp/language/ub
Difficulty: Medium
Answer: lvalue (locator value): an expression that refers to a memory location and has an address. It persists beyond a single expression. rvalue: a temporary that does not persist — it's on the "right side" and has no persistent address.
C++11 added rvalue references (&&) to enable move semantics, distinguishing between:
- lvalue reference (
T&): binds to lvalues. - rvalue reference (
T&&): binds to rvalues (temporaries). - xvalue (expiring value): rvalue that can be moved from (e.g., result of
std::move).
#include <iostream>
#include <string>
#include <utility>
int global = 0;
int& getLRef() { return global; } // returns lvalue reference
int getRVal() { return global; } // returns rvalue (temporary)
void process(int& x) { std::cout << "lvalue ref: " << x << "\n"; }
void process(int&& x) { std::cout << "rvalue ref: " << x << "\n"; }
int main() {
int a = 5; // a is lvalue
int b = a + 3; // (a+3) is rvalue — temporary
process(a); // calls lvalue overload
process(5); // calls rvalue overload
process(a + 1); // calls rvalue overload
// std::move casts lvalue to rvalue
process(std::move(a)); // calls rvalue overload; a is now in moved-from state
std::string s = "hello";
std::string t = std::move(s); // move constructor — efficient: no copy
// s is valid but unspecified after move
}Key Takeaways:
- lvalues have identity (address); rvalues are temporaries.
- Rvalue references enable move semantics — avoiding expensive copies.
std::movedoes not move anything; it casts to rvalue reference, enabling move operations.- Named rvalue references are themselves lvalues inside their scope.
Difficulty: Hard
Answer: The strict aliasing rule states that you may only access an object through a pointer (or reference) of a compatible type. Accessing an object through an incompatible pointer type is undefined behavior and allows compilers to assume the two pointers don't alias — enabling aggressive optimizations.
#include <iostream>
#include <cstring>
// UB: accessing int through float pointer
void badAlias() {
int x = 0x3f800000; // IEEE 754 for 1.0f
float* fp = reinterpret_cast<float*>(&x); // strict aliasing violation
// Reading *fp is UB — compiler may produce wrong result with -O2
}
// Safe: use memcpy for type punning
float intBitsToFloat(int x) {
float f;
std::memcpy(&f, &x, sizeof(f)); // defined behavior
return f;
}
// Safe: char/unsigned char/std::byte are exempt from strict aliasing
void safeRead(int& x) {
unsigned char* bytes = reinterpret_cast<unsigned char*>(&x);
for (size_t i = 0; i < sizeof(int); ++i) {
std::cout << static_cast<int>(bytes[i]) << " ";
}
std::cout << "\n";
}
int main() {
std::cout << intBitsToFloat(0x3f800000) << "\n"; // 1.0
int x = 0x01020304;
safeRead(x);
}Key Takeaways:
- Strict aliasing allows the compiler to assume
int*andfloat*never point to the same memory. char*,unsigned char*, andstd::byte*can alias any object type (exemption).- Use
memcpyorstd::bit_cast(C++20) for type punning — never rawreinterpret_castdereference. - Compile with
-fno-strict-aliasingto disable the optimization (performance cost).
Difficulty: Hard
Answer: Signed integer overflow is undefined behavior in C++. The compiler may assume it never happens and optimize accordingly. Unsigned integer arithmetic is defined to wrap around modulo 2^N — it is not UB.
#include <iostream>
#include <climits>
int main() {
// Signed overflow — UNDEFINED BEHAVIOR
int s = INT_MAX;
int s2 = s + 1; // UB — compiler may "optimize" based on assumption it won't overflow
// With -O2, this might not crash or might produce INT_MIN — but is UB either way
// Unsigned wraparound — DEFINED BEHAVIOR
unsigned int u = UINT_MAX; // 4294967295
unsigned int u2 = u + 1; // wraps to 0 — guaranteed
std::cout << u2 << "\n"; // 0
// Detecting signed overflow safely
int a = 2'000'000'000, b = 2'000'000'000;
// Safe check before adding:
if (a > 0 && b > INT_MAX - a) {
std::cout << "Would overflow!\n";
} else {
int sum = a + b;
std::cout << sum << "\n";
}
// C++20: std::add_overflow not standard, but __builtin_add_overflow (GCC/Clang)
int result;
if (__builtin_add_overflow(a, b, &result)) {
std::cout << "Overflow detected\n";
}
}Key Takeaways:
- Signed overflow is UB; never rely on it wrapping to INT_MIN.
- Unsigned overflow is defined wraparound — commonly used in hash functions and bit manipulation.
- Use
-fsanitize=undefinedto detect signed overflow at runtime. - For safe arithmetic, consider
<numeric>utilities or compiler builtins.
Difficulty: Medium
Answer:
A const member function promises not to modify the object's observable state. It can be called on const objects and through const references/pointers. The mutable keyword marks a member variable that may be modified even inside a const member function — useful for caches or mutexes.
#include <iostream>
#include <mutex>
#include <string>
class StringCache {
std::string data_;
mutable std::string cache_; // mutable: can be changed in const function
mutable bool cacheValid_ = false;
mutable std::mutex mtx_; // mutable: needed for locking in const function
public:
explicit StringCache(std::string s) : data_(std::move(s)) {}
// const member function — can be called on const objects
const std::string& getData() const {
std::lock_guard<std::mutex> lock(mtx_);
if (!cacheValid_) {
cache_ = "[" + data_ + "]"; // OK: cache_ is mutable
cacheValid_ = true;
}
return cache_;
}
void setData(const std::string& s) { // non-const — modifies data_
std::lock_guard<std::mutex> lock(mtx_);
data_ = s;
cacheValid_ = false;
}
};
int main() {
const StringCache sc("hello");
std::cout << sc.getData() << "\n"; // [hello] — calls const member on const object
}Key Takeaways:
constmember functions can be called on both const and non-const objects; non-const functions can only be called on non-const objects.mutableshould be used sparingly — only for internal implementation details (caching, mutexes) that don't change the logical state.- "Logical constness" vs "bitwise constness" — the former is what
constis semantically about.
Difficulty: Hard
Answer: Copy elision is the compiler's licence to construct a return value (or a temporary) directly in the caller's storage, skipping copy/move constructors entirely.
RVO (Return Value Optimisation) — the return expression is a prvalue (unnamed temporary):
Widget makeWidget() {
return Widget(42); // prvalue — compiler constructs directly in caller's slot
}NRVO (Named RVO) — the return expression is a named local variable:
Widget makeWidget() {
Widget w(42); // named variable
return w; // compiler may elide the copy/move — NRVO (optional, quality-of-implementation)
}Guaranteed copy elision (C++17) — when the initialiser is a prvalue of the same type, the copy/move constructor need not even exist. This is now a language rule, not an optimisation:
#include <iostream>
struct NoCopy {
NoCopy() { std::cout << "ctor\n"; }
NoCopy(const NoCopy&) = delete;
NoCopy(NoCopy&&) = delete;
};
NoCopy make() { return NoCopy{}; } // OK in C++17 — no copy/move needed
int main() {
NoCopy obj = make(); // C++17: one construction in-place, zero copies
}
// Output: ctorWhen NRVO can be defeated:
Widget makeWidget(bool flag) {
Widget a, b;
return flag ? a : b; // compiler can't NRVO — two possible return objects
}Interaction with noexcept and move:
struct Heavy {
Heavy() = default;
Heavy(const Heavy&) { std::cout << "copy\n"; }
Heavy(Heavy&&) noexcept { std::cout << "move\n"; }
};
Heavy make() {
Heavy h;
return h; // NRVO elides entirely; if not possible, move is preferred over copy
}Under -O2 with NRVO active, neither "copy" nor "move" is printed. Without NRVO (e.g., two possible return paths), the compiler selects move before copy when returning a named local.
Key Takeaways:
- Guaranteed copy elision (C++17): prvalue initialiser → zero copies, even with deleted copy/move.
- NRVO: optional but universally implemented; relies on a single named return variable.
- Return by value freely; do not write
return std::move(local)— that defeats NRVO. -fno-elide-constructors(GCC/Clang) disables optional elision for testing.
Difficulty: Hard
Answer: The C++ standard defines certain constructs as undefined behaviour (UB). Because UB "cannot happen" (by contract), the compiler may assume it never does and optimise aggressively — sometimes producing startling results.
Example 1 — Signed overflow elimination:
// Compiled with -O2
bool willOverflow(int x) {
return x + 1 > x; // always true after optimisation!
}
// clang/gcc with -O2: compiles to `return true` (a single `mov eax,1; ret`)
// Reasoning: signed overflow is UB → compiler assumes it never happens
// → x+1 is always mathematically greater than x → constant-fold to trueExample 2 — Null-pointer-based UB removal:
void process(int* p) {
int val = *p; // dereference — if p were null this would be UB
if (p == nullptr) // compiler: "p can't be null here (we just dereferenced it)"
explode(); // → dead-code-eliminated!
use(val);
}Example 3 — Loop-count inference via strict aliasing:
void scale(float* dst, const int* src, int n) {
for (int i = 0; i < n; ++i)
dst[i] = src[i] * 2.0f;
// float* and int* cannot alias (strict aliasing rule)
// → compiler may vectorise freely without reload fences
}Example 4 — Infinite loop removal (C++14 and later):
void spin() {
while (true) {} // no side effects, no I/O, no volatile, no sync
// C++ standard: a thread *must* make forward progress; infinite loop with no
// observable behaviour is UB → compiler may delete the entire loop!
}Detecting UB at runtime:
clang++ -fsanitize=undefined,address -g -o prog prog.cppKey Takeaways:
- UB is not "implementation-defined" — the compiler may assume it never occurs and eliminate surrounding code entirely.
-O2/-O3exposes UB that-O0masks; always test with sanitisers.- Common UB: signed overflow, null dereference, out-of-bounds access, strict aliasing violations, use-after-free.
- Read the assembly (
-S -O2) when behaviour seems inexplicable — the optimiser may have deleted your code.
Difficulty: Hard
Answer:
When a function call is written without explicit namespace qualification, the compiler not only searches the current scope and using-declared namespaces but also every namespace that contains a type of any argument. This extension is called Argument-Dependent Lookup (ADL), or Koenig Lookup after Andrew Koenig. ADL is what makes std::cout << value work: operator<< lives in std:: and is found because std::ostream (the type of cout) resides there. The std::swap ADL trick lets code call swap(a, b) unqualified so that a type-specific overload in its own namespace is preferred over the generic std::swap, enabling efficient specializations without explicit qualification.
#include <iostream>
#include <algorithm> // std::swap
#include <string>
namespace geometry {
struct Point { double x, y; };
// ADL finds this operator<< because Point lives in geometry::
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "(" << p.x << ", " << p.y << ")";
}
// Type-specific swap; ADL prefers this over std::swap
void swap(Point& a, Point& b) noexcept {
std::cout << "[geometry::swap called]\n";
double tx = a.x, ty = a.y;
a.x = b.x; a.y = b.y;
b.x = tx; b.y = ty;
}
} // namespace geometry
int main() {
geometry::Point p{1.0, 2.0}, q{3.0, 4.0};
// ADL: no 'std::' needed — found via geometry::Point's namespace
std::cout << p << "\n"; // prints (1, 2)
// ADL swap trick: bring std::swap into scope, then call unqualified.
// If geometry::swap exists, it wins; otherwise std::swap is used.
using std::swap;
swap(p, q); // calls geometry::swap (ADL wins)
std::cout << p << "\n"; // prints (3, 4)
}Key Takeaways:
- ADL extends name lookup to the namespaces of every argument type, enabling natural operator syntax without
using. - The
using std::swap; swap(a, b);idiom exploits ADL so type-specific swaps are preferred automatically. - ADL only applies to unqualified function calls;
std::swap(a, b)bypasses ADL entirely. - ADL can surprise: names in associated namespaces may be found even if you did not intend them to be in scope.
Difficulty: Medium
Answer:
Before most arithmetic operations, small integer types (char, short, bool, bit-fields) are implicitly promoted to int (or unsigned int if int cannot represent all values). This is called integral promotion. When operands of mixed types appear in the same expression, the usual arithmetic conversions then bring them to a common type following a rank-based hierarchy: first floating-point dominates, then long long > long > int; and crucially, if one operand is unsigned and the other is a signed type of the same or lower rank, the signed operand is converted to unsigned — which frequently produces surprising, counterintuitive results.
#include <iostream>
#include <climits>
int main() {
// 1. Promotion: char arithmetic happens in int
char a = 100, b = 100;
// char result = a + b; // potential overflow — but a+b is already int
int sum = a + b; // safe: promoted before addition
std::cout << "sum = " << sum << "\n"; // 200
// 2. char c = 255 is implementation-defined for signed char
// (signed char max is typically 127; assigning 255 is impl-defined)
signed char sc = static_cast<signed char>(255); // impl-defined: often -1
std::cout << "signed char 255 = " << static_cast<int>(sc) << "\n";
unsigned char uc = 255; // well-defined: wraps to 255 mod 256
std::cout << "unsigned char 255 = " << static_cast<int>(uc) << "\n";
// 3. Mixed signed/unsigned comparison surprise
int neg = -1;
unsigned int pos = 1u;
// neg < pos? You'd expect true, but...
if (neg < pos)
std::cout << "-1 < 1u (expected)\n";
else
// neg is converted to unsigned: -1 becomes UINT_MAX (4294967295)
std::cout << "-1 >= 1u (surprise! -1 converted to UINT_MAX)\n";
// 4. Safe comparison (C++20: std::cmp_less)
// #include <utility> // std::cmp_less
// std::cmp_less(neg, pos) => true, always correct
}Key Takeaways:
charandshortare promoted tointbefore arithmetic; results are never computed in smaller types.- Assigning 255 to
signed charis implementation-defined; forunsigned charit is always 255. - Comparing signed and unsigned integers of the same rank converts the signed operand to unsigned, causing negative values to appear very large.
- C++20
std::cmp_less/std::cmp_greaterperform safe, mathematically correct signed-versus-unsigned comparisons.
Difficulty: Medium
Answer:
The spaceship operator <=> returns an ordering category (std::strong_ordering, std::weak_ordering, or std::partial_ordering) rather than a bool. strong_ordering means equivalent elements are identical (integers, pointers); weak_ordering allows distinct elements to compare equivalent (case-insensitive strings); partial_ordering allows incomparable elements (floating-point NaN). Defaulting operator<=> in a class automatically synthesises all six comparison operators (<, <=, >, >=, ==, !=); the compiler also derives operator== from a defaulted operator<=> unless you declare == separately.
#include <compare>
#include <iostream>
#include <string>
struct Person {
std::string name;
int age;
// Default: lexicographic member-by-member comparison.
// Generates all 6 operators automatically.
auto operator<=>(const Person&) const = default;
};
struct CaseInsensitiveWord {
std::string word;
std::weak_ordering operator<=>(const CaseInsensitiveWord& o) const {
// two words are "equivalent" if they differ only in case
std::string a = word, b = o.word;
for (auto& c : a) c = static_cast<char>(std::tolower(c));
for (auto& c : b) c = static_cast<char>(std::tolower(c));
if (a < b) return std::weak_ordering::less;
if (a > b) return std::weak_ordering::greater;
return std::weak_ordering::equivalent;
}
bool operator==(const CaseInsensitiveWord&) const = default;
};
int main() {
Person alice{"Alice", 30}, bob{"Bob", 25};
std::cout << std::boolalpha;
std::cout << (alice < bob) << "\n"; // true (A < B)
std::cout << (alice == bob) << "\n"; // false
// Spaceship result can be examined directly
auto ord = 1 <=> 2;
if (ord < 0) std::cout << "1 < 2\n";
// Partial ordering: NaN is incomparable
auto nan_cmp = std::numeric_limits<double>::quiet_NaN() <=> 1.0;
std::cout << (nan_cmp == std::partial_ordering::unordered) << "\n"; // true
}Key Takeaways:
= defaulton<=>generates all six comparison operators with member-by-member lexicographic ordering.- Choose
strong_ordering(integers),weak_ordering(equivalence classes), orpartial_ordering(floats/NaN). - A defaulted
<=>also implicitly defaultsoperator==unless you declare it yourself. - Before C++20, six separate operator definitions were required; the spaceship operator eliminates that boilerplate.
Difficulty: Hard
Answer:
consteval declares an immediate function: every call to it must be evaluated at compile time; calling it in a runtime context is ill-formed. This is stronger than constexpr, which may fall back to runtime evaluation. constinit is a variable specifier (not a function qualifier) that mandates static initialization: the variable must be initialized at compile time (using a constant expression), preventing the Static Initialization Order Fiasco for globals. A constinit variable is not const — it can be mutated at runtime. Together they give fine-grained control over when and how initialization happens.
#include <iostream>
// consteval: MUST be called at compile time
consteval long long factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
// constexpr: may be called at compile or runtime
constexpr long long factorial_cx(int n) {
return n <= 1 ? 1 : n * factorial_cx(n - 1);
}
// constinit: initialized at compile time, but mutable at runtime
constinit int global_counter = 0; // must use constant initializer
// constinit prevents dynamic init (SIOF):
// constinit int bad = some_runtime_function(); // ERROR
int main() {
// consteval: result embedded as literal in binary
constexpr long long f10 = factorial(10); // OK: compile-time context
std::cout << "10! = " << f10 << "\n"; // 3628800
// int n = 5;
// factorial(n); // ERROR: n is not a constant expression
// constexpr may go runtime:
int n = 5;
long long runtime_result = factorial_cx(n); // runtime call is fine
std::cout << "5! = " << runtime_result << "\n";
// constinit global can be modified at runtime
global_counter += 42;
std::cout << "counter = " << global_counter << "\n"; // 42
}Key Takeaways:
constevalguarantees compile-time evaluation; the compiler rejects any runtime call.constexprfunctions may execute at either compile time or runtime depending on context.constinitensures a variable undergoes constant initialization, eliminating dynamic-init order bugs for globals.constinitdoes not make the variable immutable; pair withconstif immutability is also needed.
Difficulty: Hard
Answer:
After destroying an object and constructing a new one in the same memory via placement new, the original pointer technically refers to the old object (which no longer exists). Using it to access the new object is undefined behaviour under C++'s object model because the compiler may have cached the old object's value. std::launder (C++17) is a compiler barrier that "launders" a pointer: it tells the optimizer that a new object exists at that address and that the pointer should not be assumed to carry any previously known value. It is most commonly needed when implementing storage-backed containers (like std::optional or std::variant) using aligned_storage or alignas buffers.
#include <iostream>
#include <new> // std::launder, placement new
#include <memory>
struct Widget {
int value;
explicit Widget(int v) : value(v) {}
};
int main() {
// Manually managed aligned storage for one Widget
alignas(Widget) unsigned char storage[sizeof(Widget)];
// Construct first Widget
Widget* w1 = new (storage) Widget{10};
std::cout << "w1->value = " << w1->value << "\n"; // 10
// Destroy and construct a new Widget in the same memory
w1->~Widget();
new (storage) Widget{42}; // new object lives at 'storage'
// WRONG (UB): w1 still points to the old object's location
// std::cout << w1->value; // UB — optimizer may use cached value 10
// CORRECT: launder the pointer to get a valid pointer to the new object
Widget* w2 = std::launder(reinterpret_cast<Widget*>(storage));
std::cout << "w2->value = " << w2->value << "\n"; // 42
// Cleanup
w2->~Widget();
}Key Takeaways:
- After placement
newinto existing storage, the original pointer is invalid for the new object withoutstd::launder. std::launderacts as an optimization barrier: it prevents the compiler from reusing values cached from the old object.std::launderis not needed when usingstd::aligned_storagehelpers that return a fresh pointer from placementnewdirectly.- Standard library types (
std::optional,std::variant,std::any) usestd::launderinternally; prefer them over rawaligned_storage.
Difficulty: Medium
Answer:
Designated initializers allow aggregate members to be initialized by name using the .member = value syntax, making initialization self-documenting and robust against reordering bugs. The designators must appear in the same order as the members are declared in the struct (unlike C99, which allows out-of-order designators). Any member not named in the designated initializer list is value-initialized (zeroed for scalars). Designated initializers interact naturally with default member initializers: if a member has a default and is omitted from the designated list, the default is used.
#include <iostream>
#include <string>
struct Config {
std::string host = "localhost"; // default member initializer
int port = 8080;
bool verbose = false;
int timeout = 30;
};
struct Point3D {
double x = 0.0, y = 0.0, z = 0.0;
};
int main() {
// Designated init: only override what you need
Config cfg{
.host = "example.com",
.port = 443,
// verbose omitted → uses default (false)
.timeout = 60
};
std::cout << cfg.host << ":" << cfg.port
<< " verbose=" << cfg.verbose
<< " timeout=" << cfg.timeout << "\n";
// Partial designation: z keeps its default
Point3D p{ .x = 1.0, .y = 2.0 };
std::cout << p.x << " " << p.y << " " << p.z << "\n"; // 1 2 0
// Nested aggregate initialization
struct Line { Point3D start; Point3D end; };
Line seg{ .start = {.x = 0.0}, .end = {.x = 1.0, .y = 1.0} };
std::cout << seg.end.x << " " << seg.end.y << "\n"; // 1 1
// ERROR (order violation — would not compile):
// Config bad{ .port = 443, .host = "x" }; // designators out of order
}Key Takeaways:
- Designators must appear in declaration order; out-of-order is ill-formed in C++ (unlike C99).
- Omitted members are value-initialized or use their default member initializer.
- Designated initializers only work with aggregates (no user-provided constructors, no private members, no virtual functions).
- They dramatically improve readability at call sites with many parameters, serving as a lightweight alternative to named-parameter patterns.
Difficulty: Medium
Answer:
C++ compilers encode the fully qualified name, parameter types, and sometimes return type of every function into a symbol name — a process called name mangling. This allows function overloading (different parameter types produce different symbols) and is why a void foo(int) and void foo(double) can coexist in the same binary. When linking against C libraries (which do not mangle), the C++ compiler must be told to use the plain C symbol name; this is achieved with extern "C". Without it, the linker searches for a mangled symbol that does not exist in the C library, causing unresolved reference errors.
// mylib.h — C header
#ifdef __cplusplus
extern "C" { // C++ sees this block
#endif
int c_add(int a, int b);
void c_print(const char* msg);
#ifdef __cplusplus
} // end extern "C"
#endif// main.cpp — C++ consumer
#include <iostream>
#include "mylib.h" // extern "C" block tells C++ not to mangle these names
// Demonstrate overloading (requires mangling):
void process(int x) { std::cout << "int: " << x << "\n"; }
void process(double x) { std::cout << "double: " << x << "\n"; }
// You can also wrap a single function:
extern "C" void legacy_api(int code); // links to C symbol "legacy_api"
int main() {
// Calls C library functions via unmangled symbols
int result = c_add(3, 4); // links to C symbol "c_add"
c_print("hello from C++"); // links to C symbol "c_print"
// C++ overloads: linker sees distinct mangled symbols
process(42); // e.g. _Z7processi
process(3.14); // e.g. _Z7processd
}
// Inspect mangling with: nm -C my_binary | grep process
// c++filt _Z7processi => process(int)Key Takeaways:
- Name mangling encodes type information into linker symbols, enabling function overloading.
extern "C"disables mangling so C++ code can call (or export) functions with plain C symbol names.- The canonical idiom for C headers is
#ifdef __cplusplus extern "C" { #endifso the same header works in both languages. - Use
c++filtornm -Cto demangle symbol names when diagnosing linker errors.
Difficulty: Medium
Answer:
Standard C++ attributes (introduced in C++11 and extended through C++20) use the [[attribute]] double-bracket syntax and provide hints or mandates to the compiler without changing the language semantics. [[nodiscard]] causes a warning if a function's return value is discarded, preventing ignored error codes. [[deprecated("reason")]] emits a diagnostic when the annotated entity is used. [[likely]] and [[unlikely]] (C++20) are branch-prediction hints placed on statements inside conditional blocks, steering the compiler's code-layout decisions for hot paths.
#include <cstdio>
#include <system_error>
// [[nodiscard]]: warn if caller ignores the return value
[[nodiscard]] std::error_code writeFile(const char* path, const char* data);
[[nodiscard("check the error code")]] // C++20: custom message
int connect(const char* host, int port);
// [[deprecated]]: warn at use site
[[deprecated("use writeFile() instead")]]
void write_file_old(const char* path);
// [[noreturn]]: function never returns normally
[[noreturn]] void fatalError(const char* msg) {
std::fprintf(stderr, "FATAL: %s\n", msg);
std::abort();
}
int hotPath(int x) {
if (x > 0) [[likely]] { // C++20: x>0 is the common case
return x * 2;
} else [[unlikely]] {
fatalError("non-positive input");
}
}
int main() {
// writeFile("out.txt", "hello"); // WARNING: ignoring nodiscard return
auto ec = writeFile("out.txt", "hello"); // OK: result captured
write_file_old("x.txt"); // WARNING: deprecated
(void)connect("host", 80); // silenced via (void) cast
}Key Takeaways:
[[nodiscard]]prevents silent discard of error codes or resource handles; C++20 allows a custom warning message.[[deprecated("msg")]]helps migrate APIs by warning at every use site without breaking existing builds.[[likely]]/[[unlikely]]are hints only; the compiler may ignore them if its own profiling data disagrees.[[noreturn]]enables the compiler to omit unreachable code after calls and suppress missing-return warnings.
Difficulty: Hard
Answer:
The One Definition Rule (ODR) states: every translation unit may contain at most one definition of a non-inline entity; across the entire program there must be exactly one definition of every non-inline function or variable that is used. Inline functions and function templates may appear in multiple translation units, but every definition must be token-for-token identical — differing inline function bodies across TUs is silent undefined behaviour (the linker picks one arbitrarily). C++17 inline variables extend the same mechanism to variables, solving the problem of header-defined constants that previously required ugly workarounds.
// === constants.h ===
#pragma once
// C++17 inline variable: one definition across all TUs, ODR-safe
inline constexpr double PI = 3.14159265358979;
// WRONG in pre-C++17: each TU gets its own copy → ODR violation if not static
// const double PI = 3.14; // multiple definitions if included in >1 TU
// Inline function: must be identical in every TU that includes this header
inline int square(int x) { return x * x; }
// === a.cpp ===
// #include "constants.h"
// double area(double r) { return PI * r * r; }
// === b.cpp ===
// #include "constants.h"
// double circumference(double r) { return 2.0 * PI * r; }
// === odr_violation.h (BAD — do NOT do this) ===
// inline int compute(int x) { return x + 1; } // in one TU
// inline int compute(int x) { return x + 2; } // in another TU → UB
// Correct single-TU demonstration:
#include <iostream>
inline int square_inline(int x) { return x * x; } // fine in one TU
int main() {
std::cout << "PI = " << PI << "\n"; // 3.14159...
std::cout << "sq5 = " << square(5) << "\n"; // 25
std::cout << "sq5i = " << square_inline(5)<< "\n"; // 25
}Key Takeaways:
- Non-inline functions and variables must have exactly one definition across the whole program.
inlinefunctions/variables may appear in multiple TUs but all definitions must be token-for-token identical; violations are silent UB.- C++17
inline constexprvariables are the canonical way to define constants in headers without ODR issues. - Symptoms of ODR violations include wrong values, wrong function called, or random crashes — no required diagnostic.
Difficulty: Medium
Answer:
std::initializer_list<T> is backed by a compiler-generated temporary array with the lifetime of the enclosing full-expression. Storing an initializer_list past that point (e.g. as a data member or returned from a function) is undefined behaviour. Narrowing conversions (e.g. double → int) inside a braced-initializer-list are ill-formed. The most notorious footgun is std::vector<int>: {10} and (10) produce entirely different results because any constructor taking initializer_list is preferred when brace-init syntax is used.
#include <iostream>
#include <vector>
#include <initializer_list>
void print(std::initializer_list<int> il) {
for (int v : il) std::cout << v << " ";
std::cout << "\n";
}
// DANGER: returning initializer_list is UB — backing array is destroyed
// std::initializer_list<int> bad() { return {1, 2, 3}; } // UB!
// Correct: return a vector
std::vector<int> good() { return {1, 2, 3}; }
int main() {
// 1. vector{N} vs vector(N) distinction
std::vector<int> a(10); // 10 default-constructed elements (all 0)
std::vector<int> b{10}; // 1 element with value 10
std::cout << "a.size()=" << a.size() << "\n"; // 10
std::cout << "b.size()=" << b.size() << "\n"; // 1
// 2. Narrowing conversion is ill-formed
// std::initializer_list<int> il = {1, 2.5}; // ERROR: 2.5 narrows to int
// 3. initializer_list preferred over other ctors
struct Foo {
Foo(int, int) { std::cout << "two-int ctor\n"; }
Foo(std::initializer_list<int>) { std::cout << "init-list ctor\n"; }
};
Foo f1(1, 2); // two-int ctor
Foo f2{1, 2}; // init-list ctor (preferred!)
// 4. Lifetime: safe use within the expression
print({4, 5, 6}); // OK: initializer_list lives for the call
}Key Takeaways:
std::initializer_listis a view into a temporary array; never store or return it — the backing storage is destroyed immediately.- Narrowing conversions in braced-init are ill-formed (a hard error, not a warning).
initializer_listconstructors are greedily preferred over other constructors when brace-init syntax is used.vector<int>{10}creates one element;vector<int>(10)creates ten elements — a common source of bugs.
Difficulty: Medium
Answer:
Structured bindings (auto [a, b, c] = expr;) decompose an object into named components in a single declaration. They work with three kinds of types: arrays, aggregates (structs/classes with no user-provided constructor), and tuple-like types (any type providing std::tuple_size, std::tuple_element, and get<N>). The bound names are aliases into the members of the underlying object (with auto&) or copies (with auto). You can make your own types support structured bindings by specializing those three traits and providing get<N>.
#include <iostream>
#include <map>
#include <tuple>
#include <string>
// Custom tuple-like type
struct RGB { uint8_t r, g, b; };
// Specializations enabling structured bindings for RGB
namespace std {
template<> struct tuple_size<RGB> : integral_constant<size_t, 3> {};
template<> struct tuple_element<0, RGB> { using type = uint8_t; };
template<> struct tuple_element<1, RGB> { using type = uint8_t; };
template<> struct tuple_element<2, RGB> { using type = uint8_t; };
}
template<size_t I> uint8_t& get(RGB& c) {
if constexpr (I == 0) return c.r;
else if constexpr (I == 1) return c.g;
else return c.b;
}
int main() {
// 1. Pair / tuple decomposition
auto [key, val] = std::make_pair(std::string{"hello"}, 42);
std::cout << key << " = " << val << "\n";
// 2. Map iteration — cleanest with structured bindings
std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}};
for (auto& [name, score] : scores)
std::cout << name << ": " << score << "\n";
// 3. Array decomposition
int arr[3] = {10, 20, 30};
auto [x, y, z] = arr;
std::cout << x << " " << y << " " << z << "\n";
// 4. Custom type via get<>/tuple_size/tuple_element
RGB red{255, 0, 0};
auto [r, g, b] = red;
std::cout << "R=" << (int)r << " G=" << (int)g << " B=" << (int)b << "\n";
}Key Takeaways:
- Structured bindings work with arrays, aggregates, and any tuple-like type that provides
tuple_size,tuple_element, andget<N>. auto& [a, b]binds by reference;auto [a, b]copies the whole object first and then binds into the copy.- Binding names cannot be used in
constexprcontexts in C++17 (relaxed in later standards). - Structured bindings greatly reduce boilerplate in range-for loops over maps and multi-return functions.
Difficulty: Hard
Answer:
The as-if rule permits the compiler to perform any transformation — reordering, eliminating, or merging computations — as long as the program's observable behaviour is preserved. The standard defines observable behaviour precisely: writes to volatile objects, I/O operations, calls to std::exit, and synchronization/atomic operations. Pure computations (arithmetic, assignments to non-volatile locals, dead stores, constant expressions) carry no observable behaviour and may be freely eliminated or folded. This is what enables constant folding, dead code elimination, loop unrolling, and vectorisation; it also means "benchmarking" without volatile or std::atomic barriers may measure nothing.
#include <iostream>
#include <atomic>
// 1. Constant folding — as-if lets the compiler pre-compute everything
int constexpr_demo() {
int a = 6, b = 7;
return a * b; // emitted as: return 42; (no multiplication at runtime)
}
// 2. Dead store elimination
void dead_store() {
int x = 100; // written but never read — eliminated
x = 200; // also eliminated
(void)x; // still eliminated; cast to void is not "observable"
// volatile int vx = 100; // volatile: NOT eliminated (observable write)
}
// 3. Loop eliminated entirely (no observable effect)
void no_op_loop() {
int sum = 0;
for (int i = 0; i < 1'000'000; ++i) sum += i; // entirely removed by -O2
// (void)sum; // the result is never used externally
}
// 4. What IS observable: volatile writes, I/O, atomics
volatile int hw_reg = 0; // every write is observable; never eliminated
std::atomic<int> shared{0};
int main() {
std::cout << constexpr_demo() << "\n"; // I/O is observable
dead_store(); // call itself may be inlined to nothing
hw_reg = 1; // must not be removed — volatile write
hw_reg = 2; // must also be kept
shared.store(42, std::memory_order_release); // atomic: observable
std::cout << shared.load() << "\n";
}Key Takeaways:
- The as-if rule permits any transformation that leaves observable behaviour unchanged: I/O, volatile accesses, atomic operations, and program termination.
- Constant folding and dead code elimination are legal because pure computations have no observable effects.
- Micro-benchmarks without
volatileorstd::atomicsinks may be optimised away entirely; usebenchmark::DoNotOptimize. - Copy elision (RVO/NRVO) was historically a permitted as-if optimisation; since C++17 it is mandated and happens even when the copy constructor has side-effects.