Samengestelde objecten en C++26: het std::polymorphic type
Voorbeelden hiervan zijn strategy-objecten, AST/expressie-boomknoppen, hiërarchieën in de stijl van vormen (shapes) en plugin-geconfigureerd gedrag. In dergelijke gevallen wil je dat de bezittende klasse een reguliere, kopieerbare waarde blijft. In dit artikel bekijken we hoe std::polymorphic voor dit doel kan worden gebruikt.
De behoefte aan std::polymorphic
Laten we de volgende klassenhiërarchie overwegen:
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 hebben verschillende soorten gebeurtenissen van diverse sensoren in een smart home en we willen deze opslaan in een eventlog. Hoe pakken we dat aan? Er zijn verschillende opties:
Event event: Dit leidt tot slicing, omdat eenTemperatureReadingEventdie hieraan wordt toegewezen zijn temperatuurspecifieke deel verliest. Bovendien compileert dit hier niet, aangezienEventabstract is.unique_ptr<Event>: Dit behoudt het dynamische type, maar nu is je klasse move-only (of je moet handmatig kopiëren implementeren),constwordt niet doorgegeven en er is altijd de kans op eennull-waarde.variant<Event, TemperatureReadingEvent>: Dit vertoont waardesemantiek, maar vereist een gesloten set: het toevoegen van eenCarbonMonoxideDetectedEventbetekent dat je zowel de variant als elke visitor moet aanpassen.unique_ptr<Event>en een virtueleclone()functie: De traditionele "oplossing" voor kopiëren, wat echter in elke klasse van de hiërarchie voor boilerplate-code zorgt.
Laten we proberen de laatste optie te implementeren. We beginnen met de Event-klasse:
#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();
// Bescherm kopiëren/toewijzen via basispointers tegen slicing-gevaar
Event() = default;
Event(const Event&) = default;
Event& operator=(const Event&) = default;
};
We kunnen een algemene status van de woning definiëren die is afgeleid van de gebeurtenissen als volgt:
class HomeStatus
{
public:
double lastTemperature = 0.0;
bool smokeAlarm = false;
bool coAlarm = false;
int alertCount = 0;
bool evacuationNeeded() const { return smokeAlarm || coAlarm; }
};
Vervolgens definiëren we de werkelijke event-klassen:
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);
}
};
De eventlog die is samengesteld uit sensor-gebeurtenissen kan er als volgt uitzien:
class EventLog {
std::vector<std::unique_ptr<Event>> events_;
public:
EventLog() = default;
// De Rule of Five omdat unique_ptr-leden de kopieeroperaties verwijderen
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()); // voor 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(); }
};
Alles samengevoegd kunnen we deze klassen als volgt gebruiken:
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());
}
Er zijn echter een reeks problemen met deze implementatie:
- De eigenaar verliest de kopieerbaarheid:
unique_ptris move-only, waardoor de kopieeroperaties van het samengestelde object worden verwijderd. Om deze terug te krijgen, moet je handmatig een kopieerconstructor en kopieer-toewijzing schrijven die elk component kloont, wat betekent dat de Rule of Zero wordt doorbroken. - De
clone()functie beïnvloedt de hele hiërarchie: Elke afgeleide klasse moet deze implementeren. De compiler kan dit echter niet afdwingen, wat betekent dat een vergeten override niet tot een build-fout leidt, maar stilletjes resulteert in sliced kopieën. - Infrastructuur lekt in de interface: De functie
clone()zegt niets over het type; het is een workaround voor kopiëren die in de publieke API staat, naast de methoden die het type daadwerkelijk definiëren. constwordt niet doorgegeven: Via eenconst unique_ptrblijft het aangewezen object mutabel (omdatconstde pointer beschermt, niet het component).nullis representeerbaar: Niets voorkomt dat een null-pointer in de container of het lid terechtkomt, waardoor elke dereferentie een impliciete voorwaarde bevat die het typesysteem niet uitdrukt.
Introductie van std::polymorphic
Met het C++26 type std::polymorphic hebben we geen kloonmechanisme meer nodig om componenten polymorf te laten gedragen.
Voor het gegeven voorbeeld kunnen we de Event-klasse als volgt vereenvoudigen:
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();
};
Bijgevolg veranderen de afgeleide klassen als volgt (de clone() methode wordt verwijderd):
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;
}
};
De EventLog-klasse hoeft nu niet langer te voldoen aan de Rule of Five en alle speciale lidfuncties te implementeren. In feite kan deze de Rule of Zero volgen en geen enkele functie implementeren:
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(); }
};
De clientcode blijft ongewijzigd; de main() functie uit de vorige sectie werkt nog steeds. De gehele code is echter eenvoudiger geworden. std::polymorphic lost een reeks problemen op:
- De eigenaar is weer kopieerbaar:
std::polymorphicis zelf kopieerbaar en voert een correcte deep copy uit van het bezitnemende object. De kopieeroperaties van het samengestelde object worden door de compiler gegenereerd, wat betekent dat ze de Rule of Zero volgen. - Geen
clone()protocol: De kopieeroperatie is type-erased en wordt vastgelegd bij constructie, wanneer het concrete type statisch bekend is. De hiërarchie heeft geenclone()functie nodig, en een afgeleide klasse kan deze niet vergeten. - Schone interfaces: Zonder de noodzaak voor
clone()bevat de basisklasse alleen domeinspecifieke methoden. constwordt doorgegeven: Via eenconst std::polymorphicis de toegang tot het bezitnemende objectconst. Deep const wordt afgedwongen door het typesysteem, niet door conventie.- Nooit
null: Eenstd::polymorphicobject bezit altijd een waarde; er is geennull-status te construeren. De enige lege status is de moved-from status, expliciet detecteerbaar viavaluelessaftermove(). Elke dereferentie is dus volgens de invariant geldig.
Kortom, het std::polymorphic component gedraagt zich weer als een regulier lid: kopieerbaar, diep const en altijd aanwezig, terwijl het zijn dynamische type behoudt.
Hoe werkt dit technisch?
Je vraagt je misschien af: wanneer je een std::polymorphic<Event> kopieert die een TemperatureReadingEvent bevat, hoe weet hij dan dat hij een TemperatureReadingEvent moet kopiëren zonder een virtuele clone()? Het antwoord is type erasure van de kopieeroperatie, vastgelegd op het moment van constructie.
Conceptueel bewaart std::polymorphic<Event> zijn object achter een kleine interne controlestructuur. Wanneer je het construeert met std::inplacetype<TemperatureReadingEvent>, weet de constructor op dat moment statisch dat het type TemperatureReadingEvent is. Hij instantieert en bewaart operaties die equivalent zijn aan:
copy = [](const Event& e) { return new TemperatureReadingEvent(static_cast<const TemperatureReadingEvent&>(e)); };
destroy = [](Event* e) { delete static_cast<Event*>(e); };
Later roept de kopieerconstructor van std::polymorphic simpelweg de bewaarde operatie aan. Dit is dezelfde techniek die std::function en std::any gebruiken, maar dan toegepast op de kopieerconstructor. Dit betekent ook dat Event niet eens een virtuele destructor nodig heeft om correct door std::polymorphic vernietigd te worden, aangezien de erased destroy-operatie het concrete type al kent. Dit betekent niet dat je de virtuele destructor moet verwijderen, aangezien andere code mogelijk nog steeds via basispointers verwijdert.
De rest van de interface weerspiegelt die van het std::indirect type:
operator/operator->geven jeEvent&/Event, met correcteconst-propagatie.- Nooit
null, behalve in de moved-from status, querybaar metvaluelessaftermove(). - Allocator-aware, en in tegenstelling tot
std::indirectmogen implementaties een small-buffer optimization (SBO) gebruiken, waarbij kleine afgeleide objecten inline worden opgeslagen zonder heap-allocatie.
Er is geen manier om te vragen "welk afgeleid type zit er binnenin?". Het heeft geen type() functie zoals std::any. Als je dat nodig hebt, gebruik dan dynamic_cast op *p, zoals je dat bij elke basisreferentie zou doen. De ontwerpbedoeling is dat je interacteert via de basisinterface.
Conclusie
Gebruik std::polymorphic wanneer een klasse een component bezit waarvan het dynamische type tijdens runtime varieert en dat component deel uitmaakt van de waarde van de eigenaar: kopiëren van de eigenaar moet het component kopiëren, const moet worden doorgegeven en het component mag nooit afwezig zijn. Typische gevallen zijn strategy-leden, heterogene collecties zoals eventlogs en recursieve structuren zoals expressiebomen.
- Als het type vaststaat, gebruik
std::indirect. - Als de set van alternatieven gesloten is, gebruik
std::variant. - Als het object een gedeelde identiteit is in plaats van een waarde, blijf dan bij smart pointers.
Zie ook
Hier is een lijst met aanvullende literatuur over dit onderwerp:
std::polymorphic- Copying Polymorphic Types: How C++26 simplifies value semantics
- C++26: std::polymorphic
Groetjes,