Chapter 21 of 24 | C++

Safety, portability, and performance

C++ gives programmers powerful control, so safe design depends on disciplined interfaces and well-defined behavior. Undefined behavior means the language imposes no requirements on what happens; the program may appear to work, fail only under optimization, or behave differently on another build.

Prevent common correctness failures

Warnings, static analysis, tests, and runtime sanitizers detect different classes of problems. None proves a program correct by itself. Use them together and investigate reports rather than suppressing them without understanding.

Write portable C++

Use standard language and library features, avoid assumptions about integer widths and byte order, and do not depend on unspecified evaluation details. Test with more than one compiler when portability matters. Platform-specific code should be isolated behind a clear interface.

Character encoding, file paths, clocks, and filesystem behavior can vary across environments. Make assumptions explicit at boundaries and document any platform requirements.

Measure before optimizing

Begin with a correct, understandable implementation. Profile a representative workload to locate actual bottlenecks. Then measure a focused change using the same inputs and build settings. Optimizations may trade memory, latency, throughput, and maintainability against one another.

Prefer algorithmic improvements and suitable data structures before micro-optimizing expressions. Avoid undefined behavior as an attempted speed trick; compilers may exploit its absence in ways that invalidate the program's assumptions.

Undefined, unspecified, and implementation-defined behavior

Undefined behavior means the C++ standard places no requirements on the program's result. Out-of-bounds indexing and dereferencing a dangling pointer are common examples. Once undefined behavior occurs, it is not safe to reason that the program will merely “do something random”; an optimizer may assume the invalid case never happens and transform nearby code in surprising ways.

Unspecified behavior allows one of several outcomes without requiring the implementation to document which choice it makes. Implementation-defined behavior is a choice that the implementation documents. These categories are different from undefined behavior, but portable code should still avoid depending on a choice unless it deliberately targets and documents a particular environment.

Integer and conversion boundaries

Before adding, multiplying, or converting numbers, consider the representable range. Signed integer overflow is undefined; it is not guaranteed to wrap around. Unsigned arithmetic wraps modulo its range, but that behavior is not automatically suitable for a count or financial calculation. Validate external values and perform checked arithmetic when overflow would invalidate the result.

Conversions between signed and unsigned integers or between floating-point and integer types can change values. A cast makes intent more visible but does not prove that the value is in range. Check the range before converting when data comes from a user, file, network, or calculation that may exceed assumptions.

Concurrency and data races

When multiple threads access shared data, at least one access modifies the data, and no synchronization protects the accesses, the program may have a data race. In C++, a data race is undefined behavior. Use synchronization primitives such as mutexes, or carefully designed atomics, to establish safe communication. A mutex should protect a coherent invariant, not merely one individual variable if several values must change together.

Concurrency also introduces logical concerns: deadlocks, lost updates, and order-dependent behavior. Tests that pass repeatedly do not prove a concurrent program race-free. Keep shared mutable state small and document which thread owns or protects it.

Portability across platforms and compilers

Use standard types and library facilities where possible. Do not assume a path separator, terminal encoding, newline convention, hardware endianness, or integer size without an explicit platform requirement. Isolate platform-specific code so the rest of the program can be tested independently.

Test with the compiler and library versions you plan to support. A standard feature may be implemented at different times by different toolchains. Avoid compiler extensions in portable interfaces, or hide them behind a small adapter and document the supported configuration.

Performance is a measured property

Identify the performance goal first: lower latency, higher throughput, reduced memory, or predictable worst-case time. Profile the real workload to find where time is spent. Then change one thing, repeat the same measurement, and confirm the result is not noise. A faster microbenchmark does not always improve the complete application.

Prefer simple algorithms and clear data ownership. Better complexity or fewer unnecessary copies can produce meaningful gains without sacrificing readability. If a low-level optimization depends on subtle assumptions, document those assumptions and keep a benchmark that demonstrates its value.

Practice

  1. Compile a small project with strict warnings and resolve each warning.
  2. Test boundary values, empty input, and large input for a numeric routine.
  3. Profile a repeated operation and document the measurement before optimizing it.
  4. Identify one platform assumption in your project and isolate or remove it.
  5. Inspect every conversion at a user-input boundary and decide what range check is needed.
  6. Explain the difference between signed overflow and unsigned wraparound.
  7. Set a performance goal, profile a representative run, and record a baseline before changing code.

Key idea: safety, portability, and performance reinforce one another when correctness and measurement guide design.

The C++ memory model

In a multithreaded program, the memory model defines which evaluations are ordered and what values a thread may observe. The relation called happens-before is established by sequencing within a thread and synchronization between threads. If conflicting non-atomic accesses are not ordered by happens-before, a data race occurs and behavior is undefined.

Mutex lock and unlock operations establish synchronization when used correctly. Atomic operations provide indivisible access and can establish ordering according to their memory-order argument. Relaxed atomics guarantee atomicity and modification order for that object but do not publish unrelated data. Acquire/release operations can establish a synchronizes-with relation when they interact on the appropriate atomic object. These guarantees are subtle; use mutexes as the initial correct design and weaken ordering only after proving the requirements and measuring a bottleneck.

Atomics are not just faster Booleans

An atomic variable prevents a data race on that variable, but it does not automatically make a multi-variable invariant atomic. If a thread updates a pointer and a related count, readers may observe inconsistent combinations unless the operations are coordinated. Sequential consistency provides a single total order for sequentially consistent atomic operations, but does not solve every lifetime or higher-level protocol problem.

Lock-free algorithms require reasoning about atomic ordering, object lifetime, progress guarantees, and reclamation. A pointer can be atomically loaded and still point to an object that another thread has destroyed. Hazard pointers, epoch-based reclamation, or reference counting address reclamation in different ways. Use established, reviewed implementations for such structures unless the research objective is specifically to develop one.

Undefined behavior, sanitizers, and security

Memory safety failures can become security vulnerabilities when untrusted input influences out-of-bounds access, use-after-free, integer overflow, or lifetime errors. A sanitizer can reveal some exercised defects, while static analysis and fuzzing can explore other paths. None guarantees safety. Reduce risk through bounded interfaces, validated lengths, explicit ownership, and well-tested abstractions before adding low-level optimizations.

Compiler flags such as strict aliasing and overflow assumptions affect how optimization uses language rules. Disabling an optimization may hide a symptom without repairing undefined behavior. Fix the source-level violation and keep a regression test rather than relying on one build's observed output.

Performance across memory hierarchy

Modern processors have multiple caches, translation lookaside buffers, branch predictors, and out-of-order execution. A data structure with contiguous access may outperform one with fewer abstract operations because it uses the memory hierarchy efficiently. False sharing can also occur when independent threads update different variables that occupy the same cache line. These effects are hardware-dependent and should be investigated with profiling counters only after a representative workload demonstrates a need.

Optimization can trade latency for throughput, power, code size, or tail behavior. A system with real-time constraints may care more about a worst-case bound than average throughput. Record the target metric and constraints before comparing implementations.

Advanced exercise

  1. Write a two-thread publication example using a mutex, then reason about the happens-before relationship.
  2. Explain why changing a shared Boolean to an atomic does not necessarily make a multi-object invariant safe.
  3. Profile a contiguous and pointer-based traversal and relate results to cache locality without overgeneralizing one machine's measurements.
  4. Audit a small input-processing routine for memory-safety and integer-range boundaries.
0% read