[!IMPORTANT]
TL;DR — what you must remember: A run-it-yourself gallery: each topic (CRTP, move semantics, smart pointers, SFINAE/concepts, atomics, lock-free, ODR, sanitizers) is a self-contained program with the compile command, expected output, and the one sentence an interviewer wants to hear. Build with g++ -std=c++20 -O2. The point is to see the behavior in front of you, not just recite it.
A curated gallery of compile-and-run C++ programs — one per interview topic. Every entry shows what it proves, the full source, the exact compile + run command, the expected output, and the key takeaways an interviewer wants to hear.
[!TIP]
Every snippet below is self-contained. Copy it into a .cpp file and run the command shown. Unless noted otherwise, build with a C++20 compiler: g++ -std=c++20 -O2 file.cpp -o demo.
extern "C" — Building a LibraryWhat it proves: You can get polymorphic dispatch resolved entirely at compile time — no vptr, no vtable, no indirect jump — by passing the derived type into the base template.
#include <iostream> using namespace std; // The Base class is a template that takes the 'Derived' class as a parameter. // Every interface method casts 'this' to 'Derived*' via static_cast — no virtuals. template <typename Derived> class Logger { public: void log(const string& msg) { // Cast 'this' (Base*) to 'Derived*' to reach the implementation. static_cast<Derived*>(this)->log_implementation(msg); } void log_implementation(const string& msg) { // default (optional) cout << "[Default Logger]: " << msg << endl; } }; class FastLogger : public Logger<FastLogger> { public: void log_implementation(const string& msg) { cout << "[Fast Console]: " << msg << endl; } }; class SecretLogger : public Logger<SecretLogger> { public: void log_implementation(const string& msg) { cout << "[Secret Masked]: " << string(msg.length(), '*') << endl; } }; int main() { FastLogger fl; SecretLogger sl; cout << "--- STATIC POLYMORPHISM (CRTP) ---" << endl; fl.log("Price at 150.25"); sl.log("User Password123"); return 0; }
g++ -std=c++20 -O2 crtp.cpp -o crtp && ./crtp
--- STATIC POLYMORPHISM (CRTP) --- [Fast Console]: Price at 150.25 [Secret Masked]: *****************
[!NOTE]
Why it's faster: (1) No vptr → sizeof(FastLogger) is 1 byte, not 8. (2) No indirection — the CPU never jumps through a vtable. (3) The compiler can inline log_implementation straight into the call site. CRTP is the classic trade of runtime flexibility for raw speed — exactly the deal HFT and templated library code want.
What it proves: Why a default (shallow) copy double-frees, how the Rule of Three fixes it with deep copies, and how a move constructor steals the buffer instead of cloning it.
#include <iostream> #include <cstring> #include <utility> using namespace std; // --- DEEP COPY (Rule of Three) --- class Deep { public: int* data; size_t size; Deep(int value) : size(1) { data = new int(value); cout << "Deep Constructor: Allocated at " << data << endl; } Deep(const Deep& other) : size(other.size) { // 1. Copy ctor data = new int(*other.data); cout << "Deep Copy Constructor: New alloc at " << data << endl; } Deep& operator=(const Deep& other) { // 2. Copy assign cout << "Deep Copy Assignment" << endl; if (this == &other) return *this; // self-assign guard delete data; size = other.size; data = new int(*other.data); return *this; } ~Deep() { delete data; } // 3. Destructor }; // --- MOVE SEMANTICS (Rule of Five) --- class SmartBuffer { public: int* data; size_t size; SmartBuffer(size_t s) : size(s) { data = new int[size]; cout << "SmartBuffer Constructor: " << size << " elems at " << data << endl; } SmartBuffer(SmartBuffer&& other) noexcept // 4. Move ctor: "steal" : data(other.data), size(other.size) { other.data = nullptr; // leave source empty + safe other.size = 0; cout << "SmartBuffer Move Constructor (Stole " << data << ")" << endl; } ~SmartBuffer() { delete[] data; } }; int main() { cout << "--- Deep Copy ---" << endl; Deep d1(200); Deep d2 = d1; // calls copy ctor — distinct allocation cout << "\n--- Move ---" << endl; SmartBuffer b1(1000); SmartBuffer b2 = std::move(b1); // std::move → rvalue → move ctor cout << "b1.data: " << b1.data << " (null)\n"; cout << "b2.data: " << b2.data << " (old b1 buffer)\n"; return 0; }
g++ -std=c++20 -O2 copy_move.cpp -o copy_move && ./copy_move
--- Deep Copy --- Deep Constructor: Allocated at 0x55...e2c0 Deep Copy Constructor: New alloc at 0x55...e2e0 --- Move --- SmartBuffer Constructor: 1000 elems at 0x55...e300 SmartBuffer Move Constructor (Stole 0x55...e300) b1.data: 0 (null) b2.data: 0x55...e300 (old b1 buffer)
[!IMPORTANT]
A shallow copy of a pointer-owning class double-frees on destruction (both objects delete the same address). The fixes: Rule of Three (copy ctor + copy assign + dtor for deep copy) and Rule of Five (add move ctor + move assign to transfer ownership cheaply). Mark moves noexcept so std::vector will use them during reallocation.
What it proves: How unique_ptr enforces single ownership (copy deleted, move allowed), how shared_ptr/weak_ptr track and break reference cycles, and why enable_shared_from_this exists.
#include <iostream> #include <memory> using namespace std; // A minimal unique_ptr: RAII + move-only ownership. template <typename T> class SimpleUniquePtr { T* ptr; public: explicit SimpleUniquePtr(T* p = nullptr) : ptr(p) {} ~SimpleUniquePtr() { delete ptr; } SimpleUniquePtr(const SimpleUniquePtr&) = delete; // no copy SimpleUniquePtr& operator=(const SimpleUniquePtr&) = delete; SimpleUniquePtr(SimpleUniquePtr&& o) noexcept : ptr(o.ptr) { // move = transfer o.ptr = nullptr; } T* get() const { return ptr; } T& operator*() const { return *ptr; } }; int main() { { SimpleUniquePtr<int> p(new int(500)); cout << "value: " << *p << endl; SimpleUniquePtr<int> moved = std::move(p); if (!p.get()) cout << "Original is null after move." << endl; } // freed here auto shared = make_shared<int>(100); weak_ptr<int> weak = shared; cout << "Shared count: " << shared.use_count() << endl; // 1 (weak doesn't bump) if (auto locked = weak.lock()) cout << "Locked value: " << *locked << endl; shared.reset(); if (weak.expired()) cout << "Weak ptr expired after reset." << endl; return 0; }
g++ -std=c++20 -O2 smart_pointers.cpp -o sp && ./sp
value: 500 Original is null after move. Shared count: 1 Locked value: 100 Weak ptr expired after reset.
[!NOTE]
weak_ptr does not contribute to use_count — it's how you break shared_ptr cycles (e.g. parent↔child). lock() returns a valid shared_ptr only if the object is still alive, otherwise expired() is true. Reach for enable_shared_from_this when an object must hand out a shared_ptr to itself safely.
What it proves: Three generations of compile-time dispatch — enable_if SFINAE, C++20 concept constraints, and recursive template metaprogramming (compile-time Fibonacci).
#include <iostream> #include <type_traits> using namespace std; // 1. SFINAE — Substitution Failure Is Not An Error template <typename T> typename std::enable_if<std::is_integral<T>::value, void>::type process_value(T v) { cout << "Processing integral: " << v << endl; } template <typename T> typename std::enable_if<std::is_floating_point<T>::value, void>::type process_value(T v) { cout << "Processing float: " << v << endl; } // 2. C++20 Concepts — clean alternative to SFINAE template <typename T> concept Numeric = std::is_arithmetic_v<T>; void print_numeric(Numeric auto value) { cout << "Numeric value: " << value << endl; } // 3. Compile-time metaprogramming — Fibonacci computed by the compiler template <int N> struct Fibonacci { static constexpr int value = Fibonacci<N-1>::value + Fibonacci<N-2>::value; }; template <> struct Fibonacci<0> { static constexpr int value = 0; }; template <> struct Fibonacci<1> { static constexpr int value = 1; }; int main() { process_value(10); // integral overload process_value(3.14); // float overload print_numeric(100); // print_numeric("hi"); // COMPILE ERROR: does not satisfy Numeric cout << "Fibonacci<10> at compile time: " << Fibonacci<10>::value << endl; return 0; }
g++ -std=c++20 -O2 templates.cpp -o tmp && ./tmp
Processing integral: 10 Processing float: 3.14 Numeric value: 100 Fibonacci<10> at compile time: 55
[!TIP]
Interview soundbite: "Concepts are SFINAE with readable error messages." The Fibonacci<10>::value is a compile-time constant baked into the binary — there is zero runtime computation. That's the whole point of TMP.
What it proves: Exactly what each named cast emits: static_cast may adjust the pointer under multiple inheritance, dynamic_cast walks the RTTI at runtime, const_cast/reinterpret_cast emit zero instructions and just change the compiler's view of the bits.
#include <iostream> #include <typeinfo> #include <cstdint> using namespace std; class Base { public: virtual void speak() { cout << "Base::speak()\n"; } virtual ~Base() {} }; class Interface { public: virtual void run() = 0; virtual ~Interface() {} }; class Derived : public Base, public Interface { public: void speak() override { cout << "Derived::speak()\n"; } void run() override { cout << "Derived::run()\n"; } }; enum class Color { Red, Green, Blue }; int main() { // 1. static_cast — compile-time; adjusts 'this' across multiple inheritance. Color c = Color::Green; cout << "static_cast enum->int: " << static_cast<int>(c) << endl; Derived* d = new Derived(); Interface* inter = static_cast<Interface*>(d); // pointer shifted by +8 bytes inter->run(); // 2. dynamic_cast — runtime RTTI walk; cross-cast Base -> Interface. Base* b = new Derived(); if (Interface* i = dynamic_cast<Interface*>(b)) { cout << "dynamic_cast cross-cast OK\n"; i->run(); } // 3. const_cast — ZERO instructions; only strips const for type-checking. auto legacy_print = [](char* s) { cout << "Legacy: " << s << endl; }; const char* msg = "Hello Const"; legacy_print(const_cast<char*>(msg)); // safe iff callee doesn't write // 4. reinterpret_cast — ZERO instructions; treat the bits as another type. uintptr_t addr = reinterpret_cast<uintptr_t>(d); cout << "reinterpret ptr->int: " << hex << showbase << addr << dec << endl; delete d; delete b; return 0; }
g++ -std=c++20 -O2 casting.cpp -o cast && ./cast
static_cast enum->int: 1 Derived::run() dynamic_cast cross-cast OK Derived::run() Legacy: Hello Const reinterpret ptr->int: 0x55...a2c0
[!CAUTION]
The killer detail: under multiple inheritance, static_cast<Interface*>(d) produces a pointer offset by the size of the first base, while reinterpret_cast would not adjust it — using reinterpret_cast there would silently hand you a broken vtable. Use static_cast for type-aware conversions, reinterpret_cast only for raw bit-level reinterpretation.
What it proves: The lifetime ladder — static locals persist across calls, thread_local gives each thread its own copy, mutable punches through const, and constexpr/consteval push work to compile time.
#include <iostream> #include <thread> using namespace std; constexpr int get_size() { return 100; } // compile-time if possible consteval int square(int n) { return n*n; } // MUST be compile-time void counter() { static int count = 0; cout << "Count: " << ++count << " "; } thread_local int thread_id = 0; // per-thread storage class DataWrapper { mutable int access_count = 0; // modifiable inside a const method int data = 10; public: int get_data() const { access_count++; return data; } int get_access_count() const { return access_count; } }; int main() { constexpr int s = get_size(); int arr[s]; // size known at compile time cout << "constexpr array size: " << sizeof(arr)/sizeof(int) << endl; cout << "consteval square(5): " << square(5) << endl; cout << "static counter: "; for (int i = 0; i < 3; ++i) counter(); cout << endl; DataWrapper dw; dw.get_data(); dw.get_data(); cout << "mutable access_count: " << dw.get_access_count() << endl; thread t1([]{ thread_id = 1; cout << "T1 id: " << thread_id << endl; }); thread t2([]{ thread_id = 2; cout << "T2 id: " << thread_id << endl; }); t1.join(); t2.join(); cout << "main thread id: " << thread_id << endl; return 0; }
g++ -std=c++20 -O2 storage.cpp -o storage -pthread && ./storage
constexpr array size: 100 consteval square(5): 25 static counter: Count: 1 Count: 2 Count: 3 mutable access_count: 2 T1 id: 1 T2 id: 2 main thread id: 0
[!NOTE]
thread_id is thread_local, so each thread sees its own copy — the main thread still reads 0 even after the workers set 1 and 2. mutable is the sanctioned escape hatch for caches/counters that don't change an object's logical state. consteval is constexpr's stricter sibling: it is an error if it ever runs at runtime.
What it proves: The acquire/release handshake — a release store publishes every prior write, and a matching acquire load sees them. This is the foundation of every lock-free algorithm.
#include <iostream> #include <atomic> #include <thread> using namespace std; atomic<int> shared_data{0}; atomic<bool> ready{false}; void producer() { shared_data.store(42, memory_order_relaxed); // 1. write the payload ready.store(true, memory_order_release); // 2. publish (release) } void consumer() { while (!ready.load(memory_order_acquire)) {} // 3. wait (acquire) cout << "Acquired shared data: " << shared_data.load(memory_order_relaxed) << endl; // 4. now safe to read } int main() { cout << "--- MEMORY ORDERING DEMO ---" << endl; thread t1(producer), t2(consumer); t1.join(); t2.join(); return 0; }
g++ -std=c++20 -O2 atomics.cpp -o atomics -pthread && ./atomics
--- MEMORY ORDERING DEMO --- Acquired shared data: 42
[!IMPORTANT] The interview one-liner for each ordering:
relaxed — atomicity only, no ordering. Counters/stats.release — "I'm done writing; everything before this is now visible."acquire — "I'm starting to read; show me everything the producer released."seq_cst (default) — one total global order. Safest, slowest (inserts full fences).What it proves: A production-shaped single-producer/single-consumer ring buffer that beats mutexes by ~100× — combining cache-line alignment (kills false sharing), power-of-two masking, and acquire/release atomics. Plus branch hints and software prefetch.
#include <atomic> #include <immintrin.h> // _mm_prefetch #include <iostream> #include <new> // hardware_destructive_interference_size #include <thread> using namespace std; #ifdef __cpp_lib_hardware_interference_size using std::hardware_destructive_interference_size; #else constexpr std::size_t hardware_destructive_interference_size = 64; #endif template <typename T, size_t Size> class LockFreeQueue { static_assert((Size & (Size - 1)) == 0, "Size must be a power of 2"); // fast mask // head and tail live on DIFFERENT cache lines → no false sharing. alignas(hardware_destructive_interference_size) std::atomic<size_t> head{0}; alignas(hardware_destructive_interference_size) std::atomic<size_t> tail{0}; T buffer[Size]; static constexpr size_t mask = Size - 1; public: bool push(const T& item) { size_t h = head.load(std::memory_order_relaxed); // only producer writes head size_t t = tail.load(std::memory_order_acquire); // see consumer's progress if (((h + 1) & mask) == t) return false; // full buffer[h & mask] = item; head.store((h + 1) & mask, std::memory_order_release); // publish data + index return true; } bool pop(T& item) { size_t t = tail.load(std::memory_order_relaxed); size_t h = head.load(std::memory_order_acquire); if (h == t) return false; // empty item = buffer[t & mask]; tail.store((t + 1) & mask, std::memory_order_release); return true; } }; void performance_tricks() { unsigned int data_ready = 1; if (__builtin_expect(data_ready == 1, 1)) // branch-prediction hint cout << "Optimized branch taken.\n"; int* future = new int[100]; _mm_prefetch((const char*)&future[0], _MM_HINT_T0); // pull into L1 early unsigned int bits = 0b101101; cout << "POPCNT set bits: " << __builtin_popcount(bits) << endl; delete[] future; } int main() { cout << "--- HFT Low-Latency Demo ---\n"; LockFreeQueue<int, 256> q; thread producer([&]{ for (int i = 0; i < 5; ++i) { while (!q.push(i)); cout << "Produced: " << i << endl; } }); thread consumer([&]{ for (int i = 0; i < 5; ++i) { int v; while (!q.pop(v)); cout << "Consumed: " << v << endl; } }); producer.join(); consumer.join(); performance_tricks(); return 0; }
g++ -std=c++20 -O2 -march=native hft.cpp -o hft -pthread && ./hft
--- HFT Low-Latency Demo --- Produced: 0 Consumed: 0 Produced: 1 ... Optimized branch taken. POPCNT set bits: 4
[!NOTE]
The four latency wins here: (1) alignas(64) puts head/tail on separate cache lines so the producer and consumer never invalidate each other (false sharing can be 100× slower). (2) Power-of-two size turns % Size into a single & mask. (3) acquire/release instead of a mutex (~10–50 ns vs ~1–5 µs). (4) __builtin_expect + _mm_prefetch keep the pipeline full and hide DRAM latency. (Producer/consumer interleaving is timing-dependent.)
What it proves: int arr[] silently decays to int* (you lose the size), how passing by reference-to-array preserves it, and the value/ref/const-ref cost ladder for containers.
#include <iostream> #include <vector> using namespace std; void pass_1d_array_decay(int arr[], int size) { cout << "decay: sizeof(arr) = " << sizeof(arr) << " (pointer size)" << endl; if (size > 0) arr[0] = 999; } void pass_1d_array_ref(int (&arr)[5]) { // size preserved! cout << "ref: sizeof(arr) = " << sizeof(arr) << " (real array size)" << endl; arr[1] = 888; } void pass_vector_value(vector<int> v) { v.push_back(10); } // deep copy (slow) void pass_vector_ref(vector<int>& v) { v.push_back(20); } // no copy (fast) void pass_vector_cref(const vector<int>& v) { // read-only (best) if (!v.empty()) cout << "Vector[0]: " << v[0] << endl; } int main() { int my_array[5] = {1,2,3,4,5}; cout << "main: sizeof(my_array) = " << sizeof(my_array) << endl; pass_1d_array_decay(my_array, 5); pass_1d_array_ref(my_array); vector<int> vec = {10, 20}; pass_vector_value(vec); cout << "after value pass: size = " << vec.size() << " (still 2)" << endl; pass_vector_ref(vec); cout << "after ref pass: size = " << vec.size() << " (now 3)" << endl; pass_vector_cref(vec); return 0; }
g++ -std=c++20 -O2 passing.cpp -o passing && ./passing
main: sizeof(my_array) = 20 decay: sizeof(arr) = 8 (pointer size) ref: sizeof(arr) = 20 (real array size) after value pass: size = 2 (still 2) after ref pass: size = 3 (now 3) Vector[0]: 999
[!TIP]
The rule that wins points: pass by const& for read-only, & for mutate, by value only when you genuinely need a copy (or want to move into it). int arr[] as a parameter is int* — sizeof gives 8, not 20. Use std::span (C++20) or a reference-to-array to keep the length.
What it proves: Three implementations of lazy single-instance init and their thread-safety story: naive (racy), std::mutex (correct, locks every call), and std::call_once (correct, locks only once).
#include <iostream> #include <mutex> using namespace std; // 1. Naive — NOT thread-safe (two threads can both see instance == nullptr). class Singleton { static Singleton* instance; int counter = 0, multiplier = 2; Singleton() { cout << "normal singleton created\n"; } public: Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; static Singleton* get() { if (!instance) instance = new Singleton(); return instance; } void increment() { counter++; } int compute() const { return counter * multiplier; } }; Singleton* Singleton::instance = nullptr; // 2. call_once — thread-safe, runs the initializer exactly once. class SingletonOnce { static SingletonOnce* instance; static std::once_flag flag; int base = 1, factor = 3; SingletonOnce() { cout << "once_flag singleton created\n"; } public: static SingletonOnce* get() { std::call_once(flag, []{ instance = new SingletonOnce(); }); return instance; } void multiply(int x) { base *= x; } int compute() const { return base * factor; } }; SingletonOnce* SingletonOnce::instance = nullptr; std::once_flag SingletonOnce::flag; int main() { Singleton* s = Singleton::get(); s->increment(); s->increment(); cout << "[singleton] counter*multiplier = " << s->compute() << endl; SingletonOnce* o = SingletonOnce::get(); o->multiply(4); cout << "[once] base*factor = " << o->compute() << endl; SingletonOnce* o2 = SingletonOnce::get(); // same object, no re-init o2->multiply(2); cout << "[once again] base*factor = " << o2->compute() << endl; return 0; }
g++ -std=c++20 -O2 singleton.cpp -o singleton -pthread && ./singleton
normal singleton created [singleton] counter*multiplier = 4 once_flag singleton created [once] base*factor = 12 [once again] base*factor = 24
[!IMPORTANT]
Modern best practice is the Meyers Singleton: static Singleton instance; return instance; inside the accessor. C++11 guarantees function-local static initialization is thread-safe, giving you call_once semantics for free — no manual flag, no new, no leak.
What it proves: A field guide to the four classic heap bugs (leak, dangling pointer, use-after-free, buffer overflow) and the RAII mitigation. Pair it with Valgrind to see them.
#include <iostream> #include <memory> #include <vector> using namespace std; void memory_leak() { int* leak = new int[100]; // never deleted → leak for (int i = 0; i < 100; ++i) leak[i] = i; cout << "Allocated 100 ints, forgot to delete.\n"; } void dangling_pointer() { int* ptr = new int(10); delete ptr; // ptr now dangles // *ptr; // UB: read of freed memory } void out_of_bounds() { int* arr = new int[5]; // arr[10] = 99; // heap-buffer-overflow (Valgrind: "Invalid write of size 4") delete[] arr; } void mitigation() { unique_ptr<int[]> safe(new int[100]); // RAII: freed automatically at scope exit safe[0] = 42; cout << "unique_ptr manages memory safely.\n"; } int main() { memory_leak(); dangling_pointer(); out_of_bounds(); mitigation(); return 0; }
g++ -g -O0 memory_errors.cpp -o memerr valgrind --leak-check=full --show-leak-kinds=all ./memerr
Allocated 100 ints, forgot to delete. unique_ptr manages memory safely. ==12345== HEAP SUMMARY: ==12345== definitely lost: 400 bytes in 1 blocks ==12345== LEAK SUMMARY: ==12345== definitely lost: 400 bytes in 1 blocks
[!CAUTION]
In long-running processes (servers, HFT engines) these bugs don't crash immediately — they cause memory fragmentation and eventual OOM hours later. The cure is structural: prefer RAII (unique_ptr, vector, string) so destructors free resources deterministically. The detection tools below catch what slips through.
What it proves: Each -fsanitize flag instruments the binary to abort with a precise report the instant a specific bug class executes — far faster to diagnose than a Valgrind run or a silent corruption.
AddressSanitizer (ASan) — use-after-free, heap/stack overflow, leaks:
#include <iostream> void use_after_free() { int* p = new int(42); delete p; std::cout << "Value: " << *p << std::endl; // heap-use-after-free } int main() { use_after_free(); }
g++ -fsanitize=address -g -O1 asan_demo.cpp -o asan && ./asan
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x... READ of size 4 at 0x... thread T0 #0 in use_after_free() asan_demo.cpp:5 freed by thread T0 here: #0 in operator delete(void*)
ThreadSanitizer (TSan) — data races:
#include <iostream> #include <thread> int shared_counter = 0; void increment() { for (int i = 0; i < 100000; ++i) shared_counter++; } // race! int main() { std::thread t1(increment), t2(increment); t1.join(); t2.join(); std::cout << "Final: " << shared_counter << std::endl; // < 200000, nondeterministic }
g++ -fsanitize=thread -g -O1 tsan_demo.cpp -o tsan -pthread && ./tsan
================== WARNING: ThreadSanitizer: data race (pid=12345) Write of size 4 at 0x... by thread T2: #0 increment() tsan_demo.cpp:4 Previous write of size 4 ... by thread T1 ==================
UndefinedBehaviorSanitizer (UBSan) — signed overflow, null deref, bad shifts:
#include <climits> #include <iostream> int main() { int x = INT_MAX; x = x + 1; // signed integer overflow (UB) std::cout << "MAX+1: " << x << std::endl; }
g++ -fsanitize=undefined -g -O1 ubsan_demo.cpp -o ubsan && ./ubsan
ubsan_demo.cpp:5:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
[!TIP]
Day-to-day workflow: build your test binary with -fsanitize=address,undefined (they compose) and run your suite under it; add a separate -fsanitize=thread build for concurrency tests. Sanitizers are ~2× slowdown vs Valgrind's ~20×, so they belong in CI.
What it proves: Why a global variable or function defined in a header explodes the linker when included in two .cpp files, and the C++17 fix: inline variables and inline static data members can live safely in a header.
// header.h #ifndef ODR_HEADER_H #define ODR_HEADER_H #include <iostream> // int global_var = 10; // ODR VIOLATION if included in 2 TUs inline int shared_inline_var = 42; // C++17: safe in a header class MyClass { public: static int static_member; // declaration → define in ONE .cpp inline static int inline_static = 100; // C++17: definition right here }; inline void print_safe() { std::cout << "Safe inline function\n"; } // safe #endif
// src1.cpp — the single definition of the static member #include "header.h" int MyClass::static_member = 55; void mod_src1() { shared_inline_var += 10; MyClass::static_member += 1; }
// src2.cpp #include "header.h" void mod_src2() { shared_inline_var *= 2; }
// main.cpp #include "header.h" void mod_src1(); void mod_src2(); int main() { std::cout << "start shared_inline_var: " << shared_inline_var << "\n"; mod_src1(); // +10, +1 mod_src2(); // *2 std::cout << "final shared_inline_var: " << shared_inline_var << " (expected (42+10)*2 = 104)\n"; std::cout << "final static_member: " << MyClass::static_member << " (56)\n"; return 0; }
g++ -std=c++17 main.cpp src1.cpp src2.cpp -o odr && ./odr
start shared_inline_var: 42 final shared_inline_var: 104 (expected (42+10)*2 = 104) final static_member: 56 (56)
[!IMPORTANT]
The ODR says every entity may have exactly one definition across the whole program. Uncommenting int global_var = 10; in the header yields a linker error: multiple definition of 'global_var'. inline (functions since forever, variables since C++17) tells the linker "merge all identical definitions into one" — which is why header-only libraries work.
extern "C" — Building a LibraryWhat it proves: How to split code into a reusable library and link against it, and why extern "C" disables C++ name mangling so symbols stay ABI-stable and callable from C / dlsym.
// lib_math.h #ifndef LIB_MATH_H #define LIB_MATH_H extern "C" { // C linkage: no name mangling → stable symbol names int add(int a, int b); int multiply(int a, int b); } #endif
// lib_math.cpp #include "lib_math.h" int add(int a, int b) { return a + b; } int multiply(int a, int b) { return a * b; }
// main.cpp #include "lib_math.h" #include <iostream> int main() { int x = 10, y = 5; std::cout << "Adding: " << x << " + " << y << " = " << add(x, y) << "\n"; std::cout << "Multiplying: " << x << " * " << y << " = " << multiply(x, y) << "\n"; return 0; }
# Option A — static library g++ -c lib_math.cpp -o lib_math.o ar rcs libmath.a lib_math.o g++ main.cpp -L. -lmath -o app && ./app # Option B — shared library g++ -fPIC -shared lib_math.cpp -o libmath.so g++ main.cpp -L. -lmath -o app -Wl,-rpath,. && ./app
Adding: 10 + 5 = 15 Multiplying: 10 * 5 = 50
[!NOTE]
Without extern "C", the C++ compiler mangles add(int,int) into something like _Z3addii. With it, the symbol stays add — the precondition for calling it from C code, loading it via dlsym("add"), or keeping a stable shared-library ABI. Verify with nm libmath.so | grep add.
[!TIP] How to use this lab: pick the topic you'll be quizzed on, run the snippet, then change one thing and predict the output before re-running (uncomment the shallow copy, the out-of-bounds write, the data race). The fastest way to internalize C++ semantics is to watch the compiler and sanitizers react to your edits.
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.