C++ Engineering Notes

This note is adapted from course materials for CS2311 Computer Programming at City University of Hong Kong. Instructor: Shuaicheng Li.

Central Question

How does C++ provide high-level abstraction while retaining explicit control over object lifetime, memory, hardware resources, and performance?

C++ exists because some programs need both sides of an uncomfortable tradeoff. We want abstractions such as strings, vectors, classes, generic algorithms, and thread objects. But we also want predictable lifetime, memory layout, cache locality, deterministic cleanup, and direct access to operating-system or hardware resources. C++ does not hide those costs; it gives the programmer tools to make them explicit.

The causal spine is:

\[\text{source code} \rightarrow \text{types and expressions} \rightarrow \text{compilation and linking} \rightarrow \text{storage duration} \rightarrow \text{object lifetime} \rightarrow \text{ownership} \rightarrow \text{abstraction} \rightarrow \text{resource management} \rightarrow \text{concurrency} \rightarrow \text{measured performance}.\]

The running example is a small sensor-processing subsystem. A program acquires readings from a sensor, stores samples, processes them, and possibly shares results across threads. The hard questions are not only “what syntax works?” but:


1. Translation: From Source Text to Executable

C++ source is not interpreted line by line. A program is split into translation units, usually .cpp files after preprocessing. Each translation unit is compiled to an object file, then the linker combines object files and libraries into an executable.

C++ pipeline from headers and source files through preprocessing compilation object files linking and executable
Figure 1. C++ compilation and linking pipeline. Headers are textually included during preprocessing; object files are linked after separate compilation.

This model explains many beginner errors:

// hello.cpp
#include <iostream>

int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

The main function is the program entry point. Returning 0 conventionally means success.


2. Types: The Compiler as an Early Error Detector

C++ is statically typed. Every expression has a type known at compile time, and the compiler uses those types to reject invalid operations before the program runs.

2.1 Fundamental and Fixed-Width Types

Built-in types include bool, character types, signed and unsigned integers, floating-point types, and void. Their exact sizes are partly implementation-dependent. That is why portable code uses <cstdint> when width matters:

#include <cstdint>

std::int32_t encoder_count = 0;     // exactly 32 bits if provided
std::uint64_t timestamp_ns = 0;     // unsigned 64-bit timestamp
std::size_t n_samples = 1024;       // size/index type

Use std::size_t for container sizes and indexing quantities that cannot be negative. Be careful when mixing signed and unsigned arithmetic: underflow in unsigned integers wraps by definition, which can hide bugs.

2.2 const, constexpr, and volatile

const expresses a promise not to modify through a particular name or access path:

void calibrate(const std::vector<double>& samples);

The function can read samples without copying it and cannot accidentally modify it.

constexpr means a value or function can participate in compile-time evaluation when its inputs are available:

constexpr double square(double x) { return x * x; }
constexpr double area = 3.14159 * square(0.25);

volatile is for special memory whose value may change outside ordinary program flow, such as memory-mapped hardware registers. It is not a thread-synchronization tool. Use std::atomic, mutexes, or other synchronization primitives for communication between threads.

2.3 Initialization and Narrowing

Brace initialization helps catch narrowing conversions:

int ok{42};
// int bad{3.14};  // error: narrowing conversion
int truncated = 3.14;  // allowed, but usually not what was intended

This is not just style. In sensor or simulation code, silent unit or precision loss can become a physical error.


3. Storage Duration and Object Lifetime

Memory correctness in C++ depends on distinguishing where storage comes from and when object lifetime begins and ends.

Diagram of automatic dynamic static and thread local storage duration and object lifetime
Figure 2. Storage duration and object lifetime. Stack objects are scope-bound, heap objects need ownership, static objects live for the program, and thread-local objects live for one thread.

3.1 Automatic Storage: Usually the Default

Local variables normally have automatic storage duration. They are constructed when execution enters their scope and destroyed when execution leaves it.

void process_once() {
    std::array<double, 3> accel{0.0, 0.0, 9.81};
} // accel is destroyed here

Automatic objects are fast and simple because lifetime follows lexical scope. The limitation is size and lifetime: a huge array can overflow the stack, and an object cannot safely outlive the scope that created it unless ownership is transferred elsewhere.

3.2 Dynamic Storage: Useful but Dangerous Without Ownership

Dynamic storage, often called heap storage, is used when size or lifetime is not known statically:

auto samples = std::make_unique<std::vector<double>>();
samples->push_back(1.0);

The old style is:

auto* p = new int(42);
delete p;

This is legal C++, but it is usually the wrong interface for application code. A raw owning pointer can leak, be deleted twice, or be used after deletion. In modern C++, raw pointers are better treated as non-owning views unless the surrounding API very clearly says otherwise.

3.3 Static and Thread-Local Storage

Objects with static storage duration live for the whole program. They are useful for constants and carefully controlled global state, but global mutable state makes testing and concurrency harder.

constexpr double gravity = 9.80665;

thread_local creates one instance per thread:

thread_local std::vector<double> scratch_buffer;

This can reduce locking, but it also increases memory use and can make behavior depend on thread structure.


4. Pointers, References, and Ownership

A pointer stores an address. A reference is an alias to an existing object. A smart pointer expresses ownership.

int x = 42;
int* p = &x;     // p points to x
int& r = x;      // r is another name for x
*p = 7;          // modifies x
r = 9;           // also modifies x

Use a reference when the target must exist and cannot be reseated. Use a pointer when nullptr is meaningful, pointer arithmetic is needed, or reseating is needed. Use a smart pointer when ownership must be represented.

Ownership graph showing unique pointer shared pointer weak pointer raw pointer and reference relationships
Figure 3. Ownership graph for common pointer forms. Raw pointers and references can observe; smart pointers encode unique, shared, or weak ownership.

4.1 Smart Pointers

std::unique_ptr<T> represents exclusive ownership:

#include <memory>

struct Sensor {
    double read() const { return 0.0; }
};

std::unique_ptr<Sensor> make_sensor() {
    return std::make_unique<Sensor>();
}

It cannot be copied because there must be one owner, but it can be moved.

std::shared_ptr<T> represents shared ownership through reference counting. It is appropriate when no single owner can be named cleanly. It is not a default replacement for unique_ptr: it has overhead and can create cycles.

std::weak_ptr<T> observes an object managed by shared_ptr without extending its lifetime. It is commonly used to break cycles in graphs or parent-child structures.

4.2 Move Semantics

Moving transfers resources instead of copying them:

std::vector<double> a(1'000'000);
std::vector<double> b = std::move(a);  // usually moves buffer ownership

After a move, the moved-from object remains valid but its value is unspecified except for operations promised by its type. Do not assume a is empty unless the type documents that behavior.


5. RAII: Resource Lifetime as a Design Tool

RAII means Resource Acquisition Is Initialization. A resource is acquired by an object’s constructor and released by its destructor. This makes cleanup deterministic, including during exceptions.

Timeline showing constructor acquiring resource use during scope and destructor releasing resource on normal return or exception
Figure 4. RAII lifecycle. The constructor acquires a resource, normal code uses it, and the destructor releases it on every scope exit path.

The sensor example can be written as an owning class:

#include <stdexcept>
#include <string>
#include <utility>

class SensorSession {
public:
    explicit SensorSession(std::string port)
        : port_(std::move(port)) {
        // open device or simulation handle here
        open_ = true;
    }

    ~SensorSession() {
        if (open_) {
            // close handle here
        }
    }

    SensorSession(const SensorSession&) = delete;
    SensorSession& operator=(const SensorSession&) = delete;

    SensorSession(SensorSession&& other) noexcept
        : port_(std::move(other.port_)), open_(std::exchange(other.open_, false)) {}

    SensorSession& operator=(SensorSession&& other) noexcept {
        if (this != &other) {
            if (open_) {
                // close current handle here
            }
            port_ = std::move(other.port_);
            open_ = std::exchange(other.open_, false);
        }
        return *this;
    }

    double read() const {
        if (!open_) throw std::runtime_error("sensor is closed");
        return 0.0;
    }

private:
    std::string port_;
    bool open_{false};
};

This class is non-copyable because duplicating a hardware handle may be invalid. It is movable because ownership can transfer from one session object to another.

The Rule of Zero is even better: compose classes from members such as std::vector, std::string, std::unique_ptr, std::fstream, and lock guards so the compiler-generated destructor, copy, and move operations are correct. Write custom special member functions only when the class directly owns a non-RAII resource.


6. Abstraction: Classes, Templates, and Runtime Polymorphism

6.1 Classes Own Invariants

A class is useful when it protects an invariant:

class SampleBuffer {
public:
    explicit SampleBuffer(std::size_t capacity) : capacity_(capacity) {
        samples_.reserve(capacity);
    }

    void push(double x) {
        if (samples_.size() == capacity_) {
            samples_.erase(samples_.begin());
        }
        samples_.push_back(x);
    }

    const std::vector<double>& data() const { return samples_; }

private:
    std::size_t capacity_;
    std::vector<double> samples_;
};

The invariant is that the buffer never stores more than capacity_ samples. private is not secrecy for its own sake; it keeps invalid states unreachable.

6.2 Templates Move Decisions to Compile Time

Templates generate code for specific types at compile time:

template<class T>
T clamp_value(T x, T lo, T hi) {
    return (x < lo) ? lo : (hi < x ? hi : x);
}

This is zero-overhead in the sense that no runtime type dispatch is required. The tradeoff is compile time, error-message complexity, and possible binary growth when many specializations are generated.

6.3 Virtual Dispatch Moves Decisions to Runtime

Runtime polymorphism is useful when the concrete type is not known until execution:

class SensorModel {
public:
    virtual ~SensorModel() = default;
    virtual double read() = 0;
};

class SimulatedSensor : public SensorModel {
public:
    double read() override { return 0.0; }
};

A virtual call usually requires an indirection through a virtual table. That cost is normally small, but it can matter in tight loops or memory-sensitive code. The design question is practical: do we need runtime substitution, or can templates/static polymorphism solve the problem earlier?

Watch for object slicing:

void bad(SensorModel model);        // impossible here because abstract,
                                    // but slicing occurs with concrete bases
void good(SensorModel& model);      // preserves dynamic type

Polymorphic base classes should have virtual destructors if objects may be deleted through base pointers.


7. Standard Containers, Iterators, and Memory Locality

The default container is often std::vector because it stores elements contiguously. Contiguity helps caches and makes iteration fast.

Comparison of contiguous vector memory blocks and scattered linked list nodes with pointers
Figure 5. Contiguous versus linked memory. A vector-like layout supports cache-friendly sequential access; a linked layout pays pointer-chasing costs.
Container Strength Cost
std::vector contiguous storage, fast iteration, amortized constant-time append insertion/erase in the middle moves elements
std::deque efficient front and back insertion not fully contiguous
std::list stable iterators and cheap splice when node is already known poor locality and linear search
std::map / std::set ordered keys, logarithmic lookup node allocation and pointer chasing
std::unordered_map / std::unordered_set average constant-time lookup hashing cost, rehashing, no order

Prefer algorithms over handwritten loops when they express the intent:

#include <algorithm>
#include <numeric>
#include <vector>

std::vector<double> xs{3.0, 1.0, 4.0};
std::sort(xs.begin(), xs.end());
double sum = std::accumulate(xs.begin(), xs.end(), 0.0);

Iterators are generalized positions in a range. They are not always raw pointers, but vector iterators behave pointer-like enough that algorithms can compile to efficient loops.


8. I/O, Error Handling, and Exception Safety

Streams are RAII objects:

#include <fstream>
#include <stdexcept>
#include <string>

void write_sample(const std::string& path, double x) {
    std::ofstream out(path);
    if (!out) {
        throw std::runtime_error("could not open output file");
    }
    out << x << '\n';
} // file closes here

Exception handling works because stack unwinding destroys local objects:

try {
    write_sample("sample.txt", 3.14);
} catch (const std::exception& e) {
    // report or recover
}

Throw by value and catch by const reference. Mark functions noexcept only when they really cannot throw or when termination is acceptable if they do. Move constructors are often marked noexcept because containers can then move elements safely during reallocation.

For high-volume I/O:


9. Concurrency: Shared State Is the Expensive Part

Threads are useful when work can proceed independently. They become dangerous when they share mutable state without synchronization.

#include <thread>

void worker(int id) {
    // do independent work
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();
}

Prefer join() unless there is a carefully designed ownership model for detached work. In C++20, std::jthread improves lifecycle management by joining automatically and supporting cooperative cancellation.

9.1 Mutexes and Lock RAII

#include <mutex>
#include <vector>

std::mutex m;
std::vector<double> shared_samples;

void push_sample(double x) {
    std::lock_guard<std::mutex> lock(m);
    shared_samples.push_back(x);
}

Manual lock() and unlock() are fragile because an exception or early return can leave the mutex locked. std::lock_guard, std::unique_lock, and std::scoped_lock express lock lifetime as object lifetime.

Deadlock occurs when threads wait on locks in a cycle. The usual defenses are:

9.2 Condition Variables, Semaphores, and Atomics

A condition variable lets a thread sleep until a predicate becomes true:

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<double> q;
bool done = false;

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [] { return done || !q.empty(); });
        if (done && q.empty()) break;
        double x = q.front();
        q.pop();
        lock.unlock();
        // process x outside the lock
    }
}

Always wait with a predicate because wakeups can be spurious and because another thread may consume the condition first.

C++20 adds standard semaphores in <semaphore>. A counting semaphore limits concurrent access to a resource pool:

#include <semaphore>

std::counting_semaphore<3> slots(3);

std::atomic is appropriate for simple shared counters and flags:

#include <atomic>

std::atomic<int> count{0};
count.fetch_add(1, std::memory_order_relaxed);

Use default sequential consistency unless a weaker memory order is justified by measurement and careful reasoning. Relaxed atomics are not a magic performance switch; they solve only specific ordering problems.


10. Performance: Measure the Bottleneck You Actually Have

C++ makes performance possible, not automatic. The first question is which resource is limiting:

Cache-aware layout often matters more than asymptotic complexity for moderate data sizes. A linear scan through a std::vector can beat tree lookup because the vector uses contiguous memory and predictable access.

Struct layout also matters:

struct Bad {
    char a;
    int b;
    char c;
};

struct Better {
    int b;
    char a;
    char c;
};

The exact sizes are implementation-dependent, but Bad often contains more padding because int needs alignment. Reordering fields can reduce memory footprint in large arrays. Use sizeof, profiling, compiler reports, and hardware performance counters rather than guessing.

For thread-level performance, beware false sharing: two threads write different variables that sit on the same cache line, causing the cache line to bounce between cores. Padding or data partitioning can help, but only after measurement shows it is real.


11. Modern C++ Working Rules


What This Framework Lets Us Do

This framework turns C++ from a list of language features into a resource model. It lets us ask:

That is the engineering value of C++: the program can express high-level intent while still controlling the machinery beneath it.

Where the Framework Stops Being Reliable

Rules of thumb fail at boundaries. Embedded code may require memory-mapped registers and restricted allocation. High-performance systems may use custom allocators. Legacy APIs may expose raw pointers. Some projects disable exceptions. Some real-time systems avoid unbounded heap allocation. The right C++ style depends on the project constraints, compiler, standard version, platform ABI, and measurement.

Where the Subject Leads Next

The next steps are:


Technical and Editorial Audit

Area Correction or decision
Central question Reframed the note around high-level abstraction with explicit lifetime and resource control.
Preserved material Kept representative examples for pointers, smart pointers, classes, templates, containers, I/O, mutexes, condition variables, semaphores, and atomics.
Ownership Softened absolute rules around raw new and delete; application code should avoid them, but low-level libraries and legacy interfaces may expose them.
Version caveats Marked semaphores and std::jthread as C++20 features; avoided claiming universal compiler/library availability.
Performance claims Replaced fixed speedup numbers with mechanism-based explanations and measurement guidance.
Concurrency Clarified that volatile is not synchronization and that relaxed atomics require a specific memory-ordering argument.
Figures Added five original SVG diagrams for compilation, storage/lifetime, ownership, RAII, and memory locality.
Sources requiring verification For production guidance, check the active project standard level, compiler, standard library, ABI constraints, and coding guidelines.

Sources and Further Reading