Low-Level Design (Object-Oriented Design) Module 9 – One design, six languages
LLD in modern C++: ownership and variants
Write a low-level design in C++23: values and unique_ptr for ownership, safe polymorphic bases, std::variant for states and std::expected for errors.
What you will learn
- Express composition and aggregation with values, unique_ptr and references
- Write polymorphic hierarchies safely with virtual destructors, override and final
- Use std::variant, std::expected and concepts for closed sets and errors
- Catch ownership and override mistakes with compiler warnings and sanitizers
Before you start
What you’ll learn · Before you start
What you will learn
- Express composition and aggregation with values, unique_ptr and references
- Write polymorphic hierarchies safely with virtual destructors, override and final
- Use std::variant, std::expected and concepts for closed sets and errors
- Catch ownership and override mistakes with compiler warnings and sanitizers
Before you start
On this page
In C++ a low-level design has to say who owns each object, because nothing frees memory unless its owner does. Modern
C++ writes ownership into the types. A member held by value is part of its owner and is destroyed with it. A
std::unique_ptr is the sole owner of one object on the heap and deletes it when it goes away, and a reference or a
raw pointer only observes something that another object owns. With those three, most classes follow the rule of zero:
their members clean up after themselves, so the class declares no destructor, copy or move functions at all.
Variants are the second tool. A std::variant holds exactly one alternative of a closed set of types, and
std::visit with one handler per alternative turns a lift’s states or a booking’s outcomes into a type the compiler
checks. Errors that a caller must handle come back as std::expected<T, E>, which holds a value or an error. Virtual
functions remain the tool for open sets, such as notification channels, and there a base class needs a virtual
destructor and every override needs override.
Every program in this lesson was built with Apple clang 21 in C++23 mode and run under AddressSanitizer and UndefinedBehaviorSanitizer.
A lift in C++23: ownership, a variant and an expected
Ownership in the lift design: values, one unique_ptr, a variant of states and one polymorphic base
Text description of the diagram
A class diagram in one column, from top to bottom. Filled diamonds mark the owner of a part, which is destroyed with its owner.
- main(), which holds lifts: std::vector<Lift>. A line labelled "owns by value" goes to Lift, with a filled diamond at main() and multiplicity 0..* at Lift.
- Lift: private members id_: int, state_: LiftState and notifier_: std::unique_ptr<Notifier>; operations call(floor), which returns std::expected<LiftState, CallError>, arrive() and close().
- A group labelled "LiftState = std::variant of three structs" holds Idle (floor: int), Moving (from: int, to: int) and DoorsOpen (floor: int). A line labelled "state_, one alternative at a time" goes from Lift to the group, with a filled diamond at Lift and multiplicity 1.
- Notifier, marked «abstract», with a virtual destructor and a pure virtual notify(message). A line labelled "notifier_ (unique_ptr)" goes from Lift to Notifier, with a filled diamond at Lift and multiplicity 1.
- Display (final), which overrides notify(message). A generalization line with a hollow triangle at Notifier shows that Display derives from Notifier.
The building keeps its lifts in a std::vector<Lift>, so the lifts are values that it owns. Each lift holds its state
by value and its display through a std::unique_ptr<Notifier>, the one heap object in the design:
session · lift_session.py
from session import run, version
version()
run(
"""
clang++ -std=c++23 -Wall -Wextra -Werror -g -fsanitize=address,undefined lift/lift.cpp -o lift_demo
./lift_demo
"""
) Output
# Apple clang version 21.0.0 (clang-2100.3.34.2) $ clang++ -std=c++23 -Wall -Wextra -Werror -g -fsanitize=address,undefined lift/lift.cpp -o lift_demo $ ./lift_demo [display] lift 1: moving 0 -> 4 call 4: accepted call 12: no such floor [display] lift 1: doors open at 4 call 7: doors are open [display] lift 1: idle at 4 [display] lift 1: moving 4 -> 2 [display] lift 1: moving 4 -> 9 call 2, then 9: moving 4 -> 9
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lift_session.py
lift/lift.cpp
// A lift in C++23. The building owns its lifts by value, each lift owns its notifier through a
// std::unique_ptr, the lift's state is a std::variant, and a call returns a std::expected.
#include <expected>
#include <memory>
#include <print>
#include <string>
#include <utility>
#include <variant>
#include <vector>
struct Idle { int floor; };
struct Moving { int from, to; };
struct DoorsOpen { int floor; };
using LiftState = std::variant<Idle, Moving, DoorsOpen>;
template <class... Fs>
struct overloaded : Fs... { using Fs::operator()...; };
std::string describe(const LiftState& state) {
return std::visit(overloaded{
[](const Idle& s) { return std::format("idle at {}", s.floor); },
[](const Moving& s) { return std::format("moving {} -> {}", s.from, s.to); },
[](const DoorsOpen& s) { return std::format("doors open at {}", s.floor); },
}, state);
}
enum class CallError { NoSuchFloor, DoorsAreOpen, AlreadyThere };
std::string_view text(CallError e) {
switch (e) {
case CallError::NoSuchFloor: return "no such floor";
case CallError::DoorsAreOpen: return "doors are open";
case CallError::AlreadyThere: return "already there";
}
std::unreachable();
}
// An open set of channels: a polymorphic base with a virtual destructor.
struct Notifier {
virtual ~Notifier() = default;
virtual void notify(const std::string& message) = 0;
};
struct Display final : Notifier {
void notify(const std::string& message) override { std::println(" [display] {}", message); }
};
class Lift {
public:
Lift(int id, std::unique_ptr<Notifier> notifier) : id_(id), notifier_(std::move(notifier)) {}
[[nodiscard]] std::expected<LiftState, CallError> call(int floor) {
if (floor < 0 || floor > 9) return std::unexpected(CallError::NoSuchFloor);
auto next = std::visit(overloaded{
[&](const Idle& s) -> std::expected<LiftState, CallError> {
if (s.floor == floor) return std::unexpected(CallError::AlreadyThere);
return Moving{s.floor, floor};
},
[&](const Moving& s) -> std::expected<LiftState, CallError> { return Moving{s.from, floor}; },
[&](const DoorsOpen&) -> std::expected<LiftState, CallError> { return std::unexpected(CallError::DoorsAreOpen); },
}, state_);
if (next) set(*next);
return next;
}
void arrive() {
if (auto* moving = std::get_if<Moving>(&state_)) set(DoorsOpen{moving->to});
}
void close() {
if (auto* open = std::get_if<DoorsOpen>(&state_)) set(Idle{open->floor});
}
private:
void set(LiftState next) {
state_ = next;
notifier_->notify(std::format("lift {}: {}", id_, describe(state_)));
}
int id_;
LiftState state_ = Idle{0};
std::unique_ptr<Notifier> notifier_;
};
int main() {
std::vector<Lift> lifts; // the building owns its lifts; each lift owns its notifier
lifts.emplace_back(1, std::make_unique<Display>());
Lift& lift = lifts.front();
for (int floor : {4, 12}) {
auto result = lift.call(floor);
std::println("call {}: {}", floor, result ? "accepted" : text(result.error()));
}
lift.arrive();
auto doors_open = lift.call(7);
std::println("call 7: {}", doors_open ? "accepted" : text(doors_open.error()));
lift.close();
auto chained = lift.call(2).and_then([&](const LiftState&) { return lift.call(9); }).transform(describe);
std::println("call 2, then 9: {}", chained.value_or("refused"));
} session.py
"""Prints a terminal session: "$ " and each command, then everything it printed (standard output and standard
error together), and its exit status when that is not 0.
The compiler is the system's Apple clang with its libc++ (clang++ -std=c++23), and every program is built with
AddressSanitizer and UndefinedBehaviorSanitizer. The compiler's launcher keeps a cache file in the user's temporary
folder, which learn:verify's sandbox does not allow, so these sessions are recorded with LEARN_VERIFY_NETWORK=allow;
everything else the tools write stays in the folder the session runs in, and nothing uses the network.
A command that fails makes the session exit with status 1, unless the script lists it in may_fail. For a command
that fails, `keep` (a regular expression) shows only the lines of its output that matter, and in those lines a path
into the compiler's own headers is shortened to the header's name. Addresses that a sanitizer prints change on
every run, so they are shown as 0x….
"""
import os
import re
import signal
import subprocess
import sys
from pathlib import Path
SYSTEM_HEADER = re.compile(r"/\S*/include/c\+\+/v1/")
ADDRESS = re.compile(r"0x[0-9a-f]{6,}")
PROCESS_ID = re.compile(r"^==\d+==")
def _env():
here = Path.cwd()
for folder in ("home", "tmp"):
(here / folder).mkdir(exist_ok=True)
env = dict(os.environ)
env.update(PATH="/usr/bin:/bin", HOME=str(here / "home"), TMPDIR=str(here / "tmp"), NO_COLOR="1")
return env
def _shell(command):
done = subprocess.run(command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
lines = [PROCESS_ID.sub("", ADDRESS.sub("0x…", line)) for line in done.stdout.splitlines()]
return done.returncode, lines
def version():
"""Prints the compiler's version line as a comment."""
status, lines = _shell("clang++ --version")
if status != 0 or not lines:
sys.exit("clang++ is not installed on this computer")
print("#", lines[0])
def run(script, may_fail=(), keep=None):
"""Runs each line of the script as one shell command, in the examples folder."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
status, lines = _shell(command)
if status != 0 and keep:
lines = [SYSTEM_HEADER.sub("", line) for line in lines if re.search(keep, line)]
for line in lines:
print(line)
if status < 0:
print(f"[stopped by signal {signal.Signals(-status).name}]")
elif status != 0:
print(f"[exit status {status}]")
if command not in may_fail:
unexpected += 1
if unexpected:
sys.exit(1) Every state change reached the display, which the lift owns and the program never deletes by hand. A call to floor 12
came back as an error value, no such floor, rather than an exception, and so did a call while the doors were open.
The last line chains two calls with and_then, which runs the second only if the first succeeded, and transform
turns the resulting state into text. The build used -Werror, so a single warning would have stopped it.
Each tool has a sharp edge, and a design avoids it on purpose:
- The rule of zero removes the hand-written destructor and copy functions that cause double frees.
- A virtual destructor on a polymorphic base makes
deletethrough the base run the derived destructor. overridemakes the compiler refuse an override whose signature has drifted from the base.std::visitrefuses a visitor that forgets one alternative of the variant.[[nodiscard]]on a function that returnsstd::expectedmakes ignoring a refusal a compile-time warning.- Concepts state what a policy type must offer when the policy is chosen at compile time.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Ownership in members, and the rule of zero Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Polymorphic hierarchies: virtual destructors, override and final
- Closed sets: std::variant and an exhaustive std::visit
- Errors as values: std::expected and its chains
- Compile-time policies with concepts
- Practise: port a library model to C++23
- Interview questions
- What is the rule of zero in C++, and why does it matter in a low-level design?
- A notification service stores channels as Notifier* and deletes them through the base. Memory grows in production and some SMS batches are never sent, yet the sanitizer job on macOS reports nothing. What do you suspect, how do you confirm it, and how do you fix the design?
- In a C++ parking design, spots have states (free, reserved, occupied, out of service) and payments have methods (card, wallet, cash, bank transfer, with more planned). Which do you model with std::variant and which with virtual functions, and why?
- Two senior engineers disagree about a C++23 booking engine: one wants exceptions everywhere, the other std::expected everywhere. What do you decide, where does each mechanism belong, and how do you enforce the decision?
- Key takeaways
7 runnable examples (Python, checkout.cpp, door.cpp, library.hpp, notifier.cpp, policy.cpp, receipt_raw.cpp, receipt_zero.cpp, session, tariff.cpp, test_library.cpp) · 3 interview answers · about 18 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
References
- C++ Core Guidelines (Standard C++ Foundation)
- std::variant (cppreference.com)
- std::visit (cppreference.com)
- std::expected (cppreference.com)
- std::unique_ptr (cppreference.com)
- Constraints and concepts (cppreference.com)
- C++ Support in Clang (The LLVM Project)
- AddressSanitizer (The LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress