C++ Programming
Integrated C++ Design, Diagnostics and Program Reasoning
PGCP-AC
1. Designing Around Invariants
A class invariant is a condition that must hold whenever an object is observable through its public interface. A Date may require a valid day for its month. A Buffer may require its size not to exceed capacity. A BankAccount may require every transaction record to balance.
Constructors establish invariants. Public operations preserve them. Destructors release owned resources without depending on partially invalid state. Writing invariants before implementation makes representation and error handling easier to judge.
Keep data private and provide operations that change related fields together. Returning a modifiable reference to internal state can let callers bypass validation.
2. Value Types and Resource Handles
A value type behaves like an independent value. Copying a string, date or coordinate produces another object whose later changes do not unexpectedly alter the first.
A resource-handle type represents ownership or access to something with identity, such as a file, socket, lock or operating-system handle. It must document whether it owns the resource, whether it can be copied, how ownership moves and when the resource becomes invalid.
Confusing the two models creates defects. Shallow-copying an owning pointer creates duplicate owners, while unnecessarily forbidding copies of a natural value type makes use awkward.
3. The Rule of Zero
Application classes should prefer members that manage themselves:
class Customer {
public:
Customer(std::string name, std::vector<int> orders)
: name_(std::move(name)), orders_(std::move(orders)) {}
private:
std::string name_;
std::vector<int> orders_;
};
Customer needs no manual destructor, copy constructor, move constructor or assignment operator. Its members already implement correct resource management. This Rule of Zero design reduces code and keeps generated special members consistent.
Write custom special members only when the class itself directly manages a resource or needs unusual copy and move semantics.
4. Ownership in Interfaces
Types communicate lifetime:
- T& or const T& usually represents a nonowning reference that must be valid for the call;
- T* may represent optional nonownership when null is meaningful;
- unique_ptr<T> represents exclusive ownership transfer;
- shared_ptr<T> represents shared lifetime ownership;
- weak_ptr<T> observes shared ownership without extending lifetime;
- an ordinary value represents independent owned state.
Do not use shared_ptr merely to avoid deciding who owns an object. Shared ownership adds allocation and synchronization overhead and can form cycles. Prefer one clear owner whenever the model permits it.
Views such as string_view and span-like ranges do not own data. Their lifetime must remain within that of the source.
5. Polymorphic Ownership
A heterogeneous collection can preserve complete derived objects with smart pointers:
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
Storing Shape values would slice derived state. unique_ptr<Shape> keeps exclusive ownership of each complete object. The virtual destructor makes deletion through the base interface destroy the derived part correctly.
If polymorphic objects need value-like copying, define an explicit virtual clone operation that returns a new owning pointer.
6. Const Correctness
A const member function promises not to modify the object's logical state through that access path:
double area() const;
It can be called on const and nonconst objects. Const overloads of element access normally return const references, while nonconst overloads return modifiable references.
The mutable keyword permits selected physical changes, such as caching or locking, inside const functions. Such changes must not violate the logical const contract.
Const does not make an object thread-safe. It does not coordinate concurrent access, protect external resources or stop another alias from modifying the same object.
7. Explicit Conversions
Unexpected implicit conversions enlarge overload resolution and hide mistakes:
class Seconds {
public:
explicit Seconds(double value) : value_(value) {}
private:
double value_;
};
Explicit construction still permits Seconds{2.5} but prevents an arbitrary double from silently becoming a Seconds argument. Use named factories when units, validation, failure or information loss deserve visible source syntax.
8. Separating Interfaces and Implementations
Headers contain declarations needed by clients. Source files contain non-template definitions and implementation details. Include guards or pragma once prevent repeated header inclusion within one translation unit.
Include what a declaration needs. A pointer or reference may permit a forward declaration, while a member stored by value requires the complete type.
Keep headers self-contained so each can compile when included first. Minimize unnecessary includes to reduce rebuild time and coupling. Template definitions normally remain visible in headers.
9. Compilation and Linkage Diagnostics
A compiler error occurs while parsing or type-checking a translation unit. Examples include mismatched types, inaccessible members, ambiguous overloads and missing declarations.
A link error occurs after compilation when object files are combined. An undefined reference often means a declared function lacks a linked definition, its signature differs between declaration and definition or the defining object file or library was omitted.
Multiple-definition errors can come from defining non-inline external variables or functions in headers. One-definition rules govern which entities may have multiple identical definitions and which require exactly one program definition.
Read the first relevant diagnostic, identify the source location and instantiated types and repair the root cause before interpreting cascaded messages.
10. Defined, Unspecified and Undefined Behavior
Defined behavior follows requirements established by the language and library. Implementation-defined behavior allows documented choices by the implementation. Unspecified behavior permits one of several valid results without requiring documentation.
Undefined behavior imposes no requirements. Examples include out-of-bounds access, use after an object's lifetime ends, signed integer overflow, dereferencing invalid pointers and violating required comparator rules.
Undefined behavior may appear to work in one build. Optimization, different input or a small unrelated change can expose it. A successful test run is not evidence that an undefined operation is safe.
11. Object Lifetime Reasoning
Storage and object lifetime are related but distinct. Memory may exist before an object begins its lifetime or after that lifetime ends. A pointer to released storage is dangling even if the address bits remain unchanged.
For each reference, pointer, iterator and view, ask:
- Which object owns the referred data?
- Has that object's lifetime begun?
- Can it end before this access?
- Can an operation relocate or invalidate it?
Returning a reference to a local variable, capturing a local by reference in a long-lived lambda, retaining string_view into a temporary and using a vector iterator after reallocation are all lifetime failures.
12. Iterator and Reference Invalidation
Container operations have specific invalidation contracts. Vector growth can invalidate every iterator, pointer and reference to its elements. Erasing a vector element invalidates positions from that point onward. Node-based containers usually preserve unrelated element references but still invalidate erased elements.
Never assume an iterator remains valid because its numeric-looking position seems unchanged. Use the container's documented rule and reacquire a position after invalidating operations.
Before dereferencing a search result, compare it with the appropriate end iterator:
auto it = std::find(values.begin(), values.end(), target);
if (it != values.end()) {
use(*it);
}
13. Choosing Data Structures
Begin with required operations and guarantees. Vector provides compact contiguous traversal and indexing. Map provides ordered keys and logarithmic operations. Unordered map provides average constant lookup. Queue expresses FIFO processing and stack expresses LIFO processing.
Consider ownership, element stability, ordering, worst-case behavior, memory overhead and data size. Big-O growth matters, but cache locality and allocation can decide actual performance.
Prefer a standard container whose contract matches the task. A custom structure creates additional work in iterators, exception safety, copying, invalidation and testing.
14. Algorithmic Reasoning
State the input size and dominant operations. A loop over n elements is linear. Sorting is ordinarily O(n log n). Repeated linear search inside a loop can become quadratic. A hash table may improve average lookup but adds hashing, memory and worst-case considerations.
Complexity does not establish correctness. An O(1) access through a dangling pointer is still invalid. First prove lifetime, bounds, invariants and preconditions; then compare performance.
Use standard algorithms when their operation matches the intent. Their iterator requirements and complexity are established and the resulting code often exposes the operation more clearly than an index loop.
15. Exception-Safe Composition
Put every acquired resource immediately into an RAII owner. Prepare new state in temporaries before changing established state. Use a nonthrowing commit such as swap when the strong guarantee is needed.
Partial construction deserves explicit reasoning. If a later member constructor throws, earlier completed members are destroyed. A raw resource acquired before it is owned may leak.
Destructors should not propagate cleanup failures. Provide an explicit close or commit operation when failure must be reported, with the destructor retaining safe fallback cleanup.
16. Assertions and Defensive Checks
An assertion documents a condition that must be true if the program is internally correct:
assert(index < size());
Assertions may be disabled, so do not use them as the only validation for untrusted input or recoverable runtime conditions. Validate external data and report failures through the interface's error policy.
Avoid duplicating the same check at every internal layer without a clear contract. Establish strong boundary validation and maintain invariants within the program.
17. Compiler Warnings
Enable a strong warning level and treat warnings as defects to investigate. Warnings can identify narrowing conversions, shadowed variables, suspicious conditions, uninitialized values, unreachable paths and missing virtual-destructor concerns.
Do not silence a warning with an arbitrary cast. Determine whether the conversion, ownership or comparison is correct, then express that decision precisely.
Warnings are analysis, not a proof of correctness. They cannot understand every domain invariant or test every runtime path.
18. Runtime Diagnostic Tools
AddressSanitizer detects many out-of-bounds accesses, use-after-free errors and related memory defects during instrumented runs. UndefinedBehaviorSanitizer detects selected undefined operations. ThreadSanitizer detects many data races. Leak detection identifies unreleased allocations in exercised paths.
These tools only observe executed behavior and supported defect classes. Run representative tests, especially error paths and boundary cases. A clean sanitizer run increases confidence but does not prove that unexecuted paths are correct.
A debugger helps inspect control flow, variables, call stacks and memory at a failure. Reduce a defect to the smallest reproducible input before guessing at fixes.
19. Testing Boundaries and Failures
Test empty and one-element containers, first and last positions, duplicate keys, missing values, maximum and minimum permitted values, invalid input and partially completed operations.
For resource-owning classes, test copying, moving, self-assignment where relevant, destruction, repeated operations and exceptions during construction or update. For polymorphic interfaces, test calls and destruction through base references and owning pointers.
Tests should verify observable contracts rather than private implementation structure. A refactoring that preserves behavior should not require unrelated tests to change.
20. A Repeatable Reasoning Method
When reading a C++ program, first identify objects, their owners and their lifetimes. Mark conversions, copies, moves and reference bindings. For inheritance, count base subobjects and determine whether each call is virtual. For containers, record invalidating operations. For exceptions, follow destruction during every exit path.
Then verify type and operation contracts: bounds, nullability, comparator requirements, iterator categories and error policies. Only after correctness is established should performance changes be considered.
Good C++ design makes invalid states difficult to express. It uses types to show ownership, constructors to establish invariants, standard members to manage resources and narrow interfaces to restrict mutation. Diagnostics and tests then check those decisions rather than compensating for unclear lifetime and ownership.
Continue learning
Related notes
Put this topic into timed practice
Open mock tests when you want full-exam pacing, or keep drilling in practice mode.