Skip to content

C++ Inheritance vs Composition Before Building an ECS

Movement, visibility, and lifetime can vary independently. This chapter implements the same simulation with inheritance and composition, then checks both against the plain baseline instead of assuming one architecture is better.

C++ Inheritance vs Composition: Keep the Problem Fixed

Use the previous chapter's simulation contract, including movement-before-expiration and ordered snapshots. All three implementations live in simulation.hpp, and tests.cpp runs the same contract against each.

The experiment changes representation, not the required behaviour. It does not test runtime editing of capabilities or interactions between bodies. Those would require more tests and potentially different design decisions.

Inheritance: An Interface for Behaviour

The inheritance variant owns bodies through std::unique_ptr<Body>. The base has a virtual destructor, an advance operation, and an observation method. This is an ordinary polymorphic interface; clients do not downcast.

The two behaviour axes produce four explicit specialisations:

1
2
3
4
5
6
7
8
9
template<bool Moving, bool Expiring>
class ConfiguredBody final : public Body {
public:
    explicit ConfiguredBody(Seed seed) : Body(seed) {}
    void advance(double dt) override {
        if constexpr (Moving) move(state_.position, state_.velocity, dt);
        if constexpr (Expiring) state_.remaining -= dt;
    }
};

The factory chooses a specialisation from the input flags. Visibility remains a data flag: it does not need another subclass axis. You do not need to write a template framework to understand the example; the two booleans simply select which operations the class implements.

This avoids inventing a deep hierarchy of projectiles and decorations. It also demonstrates a cost: a new independent behaviour can require edits to both the specialisation logic and factory combinations. Runtime capability changes would need a policy, such as replacing a body or delegating its behaviour to owned values.

This is one inheritance design, not evidence that every OOP program has combination explosion. A Strategy implementation can express different choices.

Composition: Optional Owned Values

The composition variant stores:

1
2
3
4
5
6
7
8
struct Motion { Vec2 velocity; };
struct Lifetime { double remaining; };
struct Body {
    Vec2 position;
    bool visible;
    std::optional<Motion> motion;
    std::optional<Lifetime> lifetime;
};

The body owns its optional values directly. Presence of motion means there is movement data; absence means no movement behaviour. A missing lifetime means the body does not expire.

The update loop checks these values:

1
2
3
4
for (auto& body : bodies_) {
    if (body.motion) move(body.position, body.motion->velocity, dt);
    if (body.lifetime) body.lifetime->remaining -= dt;
}

This removes a separate “moving but has no movement data” flag/value pairing. It does not eliminate every invalid state: external inputs, numerical limits, and future cross-body references still need policies.

Adding optional data is not free. The record still contains optional storage, the loop still checks presence, and dependencies between behaviours still need to be expressed. None of that implies a measured speed advantage.

Why This Is Not Yet an ECS

We have reusable behaviour data, but it remains embedded in each body. There is no entity-ID registry, separate component store, or query that joins components by entity. The rendering snapshot is also not an entity handle.

For a concrete library model, the EnTT ECS documentation describes a registry and views over component combinations. Our small owned-value example deliberately stops before that machinery.

Likewise, the composition example is not a structure-of-arrays layout. Changing ownership style and changing physical storage layout are separate experiments.

Verify Equivalence, Not Superiority

Build and test using the Chapter 2 commands. The suite:

  • Runs the known movement and expiration expectations against each world.
  • Rejects invalid dt without modifying each world.
  • Creates all eight moving/visible/expiring combinations.
  • Compares state and rendering snapshots over several steps.
  • Checks expected survivor counts and a trajectory independently of the baseline.

Passing means the implementations agree for these tested cases. It is not a proof of all possible inputs, a concurrency test, or a benchmark.

Choose by the Next Requirement

Requirement Candidate to investigate Cost to examine
Small fixed workload Keep the plain record Irrelevant fields and flag maintenance
Interchangeable behaviour behind a stable interface Inheritance or Strategy Ownership, dispatch, factory maintenance
Independent per-body capabilities Owned-value composition Presence checks and dependency rules
Large batches selected by capability, stable identity Consider separate stores and ECS Lookup, structural changes, ID lifecycle

These are investigation prompts, not automatic rules. Do not rewrite a working system simply to match the last row.

Exercise: A Fourth Independent Capability

Sketch “acceleration” in each representation without implementing it first. Identify the files and functions that would change. Specify whether acceleration is applied before or after movement, and add a numerical expectation.

Then ask whether you need a new architecture or just one field and one pass. The answer should follow your workload and invariants.

Previous: Plain structs and loops. Next: Entity identity and IDs. Return to the course overview and planned next stages.

Donate