Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 1,317 words · 22 segments analyzed
In a previous article, I discussed the C++ std::indirect type and how it helps implementing the PImpl idiom. However, the std::indirect type was inducted in the standard alongside another type called std::polymorphic. This helps implementing composite classes, where composite means a class composed of member objects in an has-a relation, where the composite own the components which are part of its values.
Examples include strategy objects, AST/expression-tree nodes, shapes-style hierarchies, plugin-configured behavior – and you want the owning class to remain a regular, copyable value.
In this article, we’ll look at how std::polymorphic can be used for this purpose. The need for std::polymorphic Let us consider the following class hierarchy: struct Event { virtual std::string describe() const = 0; }; struct TemperatureReadingEvent : public Event { std::string describe() const override { } }; struct SmokeDetectedEvent : public Event { std::string describe() const override { } }; struct CarbonMonoxideDetectedEvent : public Event { std::string describe() const override { } }; class EventLog { ??? events_; } We have various types of events from various sensors in a smart house and we want to store them in an event log. How do we do that?
There are various options: Event event – slices because a TemperatureReadingEvent assigned into it loses its temperature specific part.
Doesn’t even compile here since Event is abstract.
unique_ptr<Event> – preserves the dynamic type, but now your class is move-only (or you hand-write copying), const doesn’t propagate, and null lurks. variant<Event, TemperatureReadingEvent> – exhibits value semantics, but maintaince a closed set: adding a CarbonMonoxideDetectedEvent means touching the variant and every visitor.
unique_ptr<Event> and a virtual clone() function – the traditional “fix” for copying, which pushes boilerplate into every class in the hierarchy.
Let’s try to implement the last option in the list.
We start with the Event class: #include <iostream> #include <print> #include <chrono> #include <memory> #include <string> #include <vector> using Clock = std::chrono::system_clock; using TimePoint = Clock::time_point; class Event { public: virtual ~Event() = default; TimePoint when() const { return ts_; } virtual std::string describe() const = 0; virtual void apply(class HomeStatus& home) const = 0; virtual std::unique_ptr<Event> clone() const = 0; protected: TimePoint ts_ = Clock::now(); // protect copying/assigning through base pointers from slicing hazard Event() = default; Event(const Event&) = default; Event& operator=(const Event&) = default; }; We can define an overall status of the house readings derived from event as follows: class HomeStatus { public: double lastTemperature = 0.0; bool smokeAlarm = false; bool coAlarm = false; int alertCount = 0; bool evacuationNeeded() const { return smokeAlarm || coAlarm; } }; Then, we can define the actual event classes, such as the following: class TemperatureReadingEvent : public Event { std::string room_; double celsius_; public: TemperatureReadingEvent(std::string room, double celsius) : room_(std::move(room)), celsius_(celsius) {} double celsius() const { return celsius_; } std::string describe() const override { return std::format("[{}] temperature {} C", room_, celsius_); } void apply(HomeStatus& home) const override { home.lastTemperature = celsius_; } std::unique_ptr<Event> clone() const override { return std::make_unique<TemperatureReadingEvent>(*this); } }; class SmokeDetectedEvent : public Event { std::string room_; double density_; public: SmokeDetectedEvent(std::string room, double density) : room_(std::move(room)), density_(density) {} std::string describe() const override { return std::format("[{}] SMOKE detected, density {} %/m", room_, density_); } void apply(HomeStatus& home) const override { home.smokeAlarm = true; ++home.alertCount; } std::unique_ptr<Event> clone() const override { return std::make_unique<SmokeDetectedEvent>(*this); } }; class CarbonMonoxideDetectedEvent : public Event { std::string room_; int ppm_; public: CarbonMonoxideDetectedEvent(std::string room, int ppm) : room_(std::move(room)), ppm_(ppm) {} std::string describe() const override { return std::format("[{}] CARBON MONOXIDE {} ppm", room_, ppm_); } void apply(HomeStatus& home) const override { home.coAlarm = true; ++home.alertCount; } std::unique_ptr<Event> clone() const override { return std::make_unique<CarbonMonoxideDetectedEvent>(*this); } }; The event log that is composed of sensor reading events may look as defined below: class EventLog { std::vector<std::unique_ptr<Event>> events_; public: EventLog() = default; // The Rule of Five because unique_ptr members delete the copy operations EventLog(const EventLog& other) { events_.reserve(other.events_.size()); for (const auto& e : other.events_) events_.push_back(e->clone()); } EventLog& operator=(const EventLog& other) { if (this != &other) { std::vector<std::unique_ptr<Event>> tmp; // copy-then-commit tmp.reserve(other.events_.size()); // for exception safety for (const auto& e : other.events_) tmp.push_back(e->clone()); events_ = std::move(tmp); } return *this; } EventLog(EventLog&&) noexcept = default; EventLog& operator=(EventLog&&) noexcept = default; ~EventLog() = default; template <typename E, typename...
Args> void record(Args&&...
args) { events_.push_back(std::make_unique<E>(std::forward<Args>(args)...)); } HomeStatus replay() const { HomeStatus home; for (const auto& e : events_) e->apply(home); return home; } void print() const { for (const auto& e : events_) std::println(" {}", e->describe()); } std::size_t size() const { return events_.size(); } }; Putting it all together, we can use these classes as shown in the following snippet: int main() { EventLog live; live.record<TemperatureReadingEvent>("kitchen", 21.5); live.record<TemperatureReadingEvent>("kitchen", 48.0); live.record<CarbonMonoxideDetectedEvent>("garage", 120); std::println("Live log has {} events", live.size()); live.print(); HomeStatus now = live.replay(); std::println("Current home status: lastTemp={}, smoke={}, co={}, alerts={}, evacuate={}", now.lastTemperature, now.smokeAlarm, now.coAlarm, now.alertCount, now.evacuationNeeded()); } There are a series of problems with this implementation: The owner loses copyability.
unique_ptr is move-only, so the composite’s copy operations are deleted. Getting them back means hand-writing a copy constructor and copy assignment that clone every component, which means the Rule of Zero is defeated.
The clone() function affects the whole hierarchy, as every derived class must implement it.
But on the other hand, the compiler can’t enforce it, which means a forgotten override doesn’t fail to build; it silently produces sliced copies. Infrastructure leaks into the interface. The function clone() says nothing about the type.
It is a copying workaround sitting in the public API next to the methods that actually define the type.
const doesn’t propagate.
Through a const unique_ptr, the pointee is still mutable (because const protects the pointer, not the component).
null is representable.
Nothing prevents a null pointer from entering the container or member, so every dereference carries an implicit precondition the type system doesn’t express.
Using the C++26 type std::polymorphic, we no longer need to use a cloning mechanism to allow components to behave polymorphically. For the given example, we can simplify the Event class to the following: class Event { public: virtual ~Event() = default; TimePoint when() const { return ts_; } virtual std::string describe() const = 0; virtual void apply(class HomeStatus& home) const = 0; protected: TimePoint ts_ = Clock::now(); }; Consequently, the derived classes change as shown here: class TemperatureReadingEvent : public Event { std::string room_; double celsius_; public: TemperatureReadingEvent(std::string room, double celsius) : room_(std::move(room)), celsius_(celsius) {} double celsius() const { return celsius_; } std::string describe() const override { return std::format("[{}] temperature {} C", room_, celsius_); } void apply(HomeStatus& home) const override { home.lastTemperature = celsius_; } }; class SmokeDetectedEvent : public Event { std::string room_; double density_; public: SmokeDetectedEvent(std::string room, double density) : room_(std::move(room)), density_(density) {} std::string describe() const override { return std::format("[{}] SMOKE detected, density {} %/m", room_, density_); } void apply(HomeStatus& home) const override { home.smokeAlarm = true; ++home.alertCount; } }; class CarbonMonoxideDetectedEvent : public Event { std::string room_; int ppm_; public: CarbonMonoxideDetectedEvent(std::string room, int ppm) : room_(std::move(room)), ppm_(ppm) {} std::string describe() const override { return std::format("[{}] CARBON MONOXIDE {} ppm", room_, ppm_); } void apply(HomeStatus& home) const override { home.coAlarm = true; ++home.alertCount; } }; The EventLog class no longer need to adhere to the Rule of Five and implement all the special member functions. In fact, it can follow the Rule of Zero and implement none. class EventLog { std::vector<std::polymorphic<Event>> events_; public: template <typename E, typename...
Args> void record(Args&&...
args) { events_.emplace_back(std::in_place_type<E>, std::forward<Args>(args)...); } HomeStatus replay() const { HomeStatus home; for (const auto& e : events_) e->apply(home); return home; } void print() const { for (const auto& e : events_) std::cout << " " << e->describe() << "\n"; } std::size_t size() const { return events_.size(); } }; The client code does not change. The main() function from the previous section does not change. However, the entire code has become simpler. std::polymorphic solves a series of issues: The owner is copyable again: std::polymorphic is itself copyable, performing a correct deep copy of the owned object.