Skip to content

Latest commit

 

History

History
1619 lines (1265 loc) · 61.7 KB

File metadata and controls

1619 lines (1265 loc) · 61.7 KB

C++ Basics — Interview Questions (Medium to Hard)

Table of Contents

  1. What are the differences between pointers and references in C++?
  2. Explain const correctness with pointers and references.
  3. What is pointer arithmetic and what are its risks?
  4. What is a dangling pointer and how do you avoid it?
  5. What causes memory leaks and how do you detect them?
  6. Explain the difference between stack and heap memory.
  7. What are the differences between new/delete and malloc/free?
  8. What happens if you use delete instead of delete[]?
  9. What is the volatile keyword and when should it be used?
  10. Explain static_cast and when to use it.
  11. Explain dynamic_cast and its runtime behavior.
  12. What is reinterpret_cast and when is it dangerous?
  13. What is const_cast and what are its legitimate uses?
  14. What is undefined behavior in C++? Give examples.
  15. What are lvalues and rvalues in C++?
  16. Explain the strict aliasing rule and its implications.
  17. What is the difference between integer overflow and unsigned wraparound?
  18. What are const member functions and the mutable keyword?
  19. How do RVO, NRVO, and guaranteed copy elision work?
  20. How does the compiler exploit undefined behaviour for optimisation? Give concrete examples.
  21. Argument-Dependent Lookup (ADL) and Koenig Lookup
  22. Integer promotion rules and usual arithmetic conversions
  23. C++20 three-way comparison operator (<=>)
  24. consteval and constinit (C++20)
  25. std::launder and placement-new pointer provenance
  26. Designated initializers (C++20)
  27. Name mangling and extern "C"
  28. C++ attribute specifiers
  29. One Definition Rule (ODR)
  30. std::initializer_list pitfalls
  31. Structured bindings (C++17)
  32. The as-if rule

Q1: What are the differences between pointers and references in C++?

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.

Q2: Explain const correctness with pointers and references.

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:

  1. int* p — non-const pointer to non-const int: both pointer and value can change.
  2. const int* p — non-const pointer to const int: value cannot change, pointer can reseat.
  3. int* const p — const pointer to non-const int: pointer cannot reseat, value can change.
  4. 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 const declarations right-to-left: const int* p = "p is a pointer to int that is const".
  • Always use const references for function parameters when the value should not be modified — avoids copies and prevents accidental mutations.
  • const member functions signal they do not modify the object.

Q3: What is pointer arithmetic and what are its risks?

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.

Q4: What is a dangling pointer and how do you avoid it?

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 nullptr after delete.
  • 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.

Q5: What causes memory leaks and how do you detect them?

Difficulty: Medium

Answer: A memory leak occurs when heap-allocated memory is never freed. Over time, it exhausts available memory. Common causes:

  1. Forgetting to call delete/delete[].
  2. Early returns or exceptions bypassing cleanup.
  3. Overwriting a pointer before freeing old memory.
  4. 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_ptr instead of raw new/delete.
  • Tools: Valgrind, AddressSanitizer, Visual Studio's diagnostic tools.

Q6: Explain the difference between stack and heap memory.

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.

Q7: What are the differences between new/delete and malloc/free?

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 new with free or malloc with delete — they use different allocators.
  • new can be overloaded to use custom allocators; malloc cannot.
  • In modern C++, prefer smart pointers over raw new/delete.

Q8: What happens if you use delete instead of 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 with delete[], and new must be paired with delete.
  • Prefer std::vector or std::unique_ptr<T[]> for arrays — they handle cleanup automatically.
  • AddressSanitizer detects this mismatch at runtime.

Q9: What is the volatile keyword and when should it be used?

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:

  • volatile prevents compiler optimizations on that variable — every read/write goes to memory.
  • volatile does not guarantee atomicity or memory ordering — use std::atomic for thread safety.
  • Primary use cases: memory-mapped I/O, signal handlers, setjmp/longjmp scenarios.

Q10: Explain static_cast and when to use it.

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_cast over C-style casts — it documents intent and is searchable.
  • Use dynamic_cast when you need a safe downcast with runtime checking.
  • static_cast will not remove const — use const_cast for that.

Q11: Explain dynamic_cast and its runtime behavior.

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_cast has runtime overhead (vtable traversal). Avoid in tight loops.
  • Only works with polymorphic types (at least one virtual function).
  • Compile with -fno-rtti to disable RTTI, which also disables dynamic_cast.
  • Prefer redesigning with virtual functions over frequent dynamic_cast usage.

Q12: What is reinterpret_cast and when is it dangerous?

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_cast bypasses 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.

Q13: What is const_cast and what are its legitimate uses?

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_cast is safe only when removing const from a pointer to an object that was not originally declared const.
  • Common legitimate use: adapting to legacy C APIs that don't use const but don't actually modify data.
  • Never use const_cast to modify a truly const object — that is undefined behavior.

Q14: What is undefined behavior in C++? Give examples.

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,address to catch UB at runtime.
  • Enable warnings: -Wall -Wextra -Wundef to catch potential UB at compile time.
  • Common UB list: https://en.cppreference.com/w/cpp/language/ub

Q15: What are lvalues and rvalues in C++?

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::move does not move anything; it casts to rvalue reference, enabling move operations.
  • Named rvalue references are themselves lvalues inside their scope.

Q16: Explain the strict aliasing rule and its implications.

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* and float* never point to the same memory.
  • char*, unsigned char*, and std::byte* can alias any object type (exemption).
  • Use memcpy or std::bit_cast (C++20) for type punning — never raw reinterpret_cast dereference.
  • Compile with -fno-strict-aliasing to disable the optimization (performance cost).

Q17: What is the difference between integer overflow and unsigned wraparound?

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=undefined to detect signed overflow at runtime.
  • For safe arithmetic, consider <numeric> utilities or compiler builtins.

Q18: What are const member functions and the mutable keyword?

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:

  • const member functions can be called on both const and non-const objects; non-const functions can only be called on non-const objects.
  • mutable should 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 const is semantically about.

Q19: How do RVO, NRVO, and guaranteed copy elision work?

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: ctor

When 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.

Q20: How does the compiler exploit undefined behaviour for optimisation? Give concrete examples.

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 true

Example 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.cpp

Key Takeaways:

  • UB is not "implementation-defined" — the compiler may assume it never occurs and eliminate surrounding code entirely.
  • -O2/-O3 exposes UB that -O0 masks; 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.

Q21: Argument-Dependent Lookup (ADL) and Koenig Lookup

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.

Q22: Integer promotion rules and usual arithmetic conversions

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:

  • char and short are promoted to int before arithmetic; results are never computed in smaller types.
  • Assigning 255 to signed char is implementation-defined; for unsigned char it 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_greater perform safe, mathematically correct signed-versus-unsigned comparisons.

Q23: C++20 three-way comparison operator (<=>)

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:

  • = default on <=> generates all six comparison operators with member-by-member lexicographic ordering.
  • Choose strong_ordering (integers), weak_ordering (equivalence classes), or partial_ordering (floats/NaN).
  • A defaulted <=> also implicitly defaults operator== unless you declare it yourself.
  • Before C++20, six separate operator definitions were required; the spaceship operator eliminates that boilerplate.

Q24: consteval and constinit (C++20)

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:

  • consteval guarantees compile-time evaluation; the compiler rejects any runtime call.
  • constexpr functions may execute at either compile time or runtime depending on context.
  • constinit ensures a variable undergoes constant initialization, eliminating dynamic-init order bugs for globals.
  • constinit does not make the variable immutable; pair with const if immutability is also needed.

Q25: std::launder and placement-new pointer provenance

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 new into existing storage, the original pointer is invalid for the new object without std::launder.
  • std::launder acts as an optimization barrier: it prevents the compiler from reusing values cached from the old object.
  • std::launder is not needed when using std::aligned_storage helpers that return a fresh pointer from placement new directly.
  • Standard library types (std::optional, std::variant, std::any) use std::launder internally; prefer them over raw aligned_storage.

Q26: Designated initializers (C++20)

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.

Q27: Name mangling and extern "C"

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" { #endif so the same header works in both languages.
  • Use c++filt or nm -C to demangle symbol names when diagnosing linker errors.

Q28: C++ attribute specifiers

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.

Q29: One Definition Rule (ODR)

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.
  • inline functions/variables may appear in multiple TUs but all definitions must be token-for-token identical; violations are silent UB.
  • C++17 inline constexpr variables 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.

Q30: std::initializer_list pitfalls

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. doubleint) 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_list is 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_list constructors 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.

Q31: Structured bindings (C++17)

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, and get<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 constexpr contexts in C++17 (relaxed in later standards).
  • Structured bindings greatly reduce boilerplate in range-for loops over maps and multi-return functions.

Q32: The as-if rule

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 volatile or std::atomic sinks may be optimised away entirely; use benchmark::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.