The Composite Pattern in C++: Tree Ownership, Transparent vs Safe Designs and the LSP Problem

Key takeaways

How to model trees with the Composite pattern in C++: ownership models for child nodes, file system and UI hierarchy examples, the transparent vs safe composite debate, and virtual dispatch cost in deep trees.

What is the Composite Pattern? Why is it needed?

Problem Scenario: Handling Individual Objects and Groups Differently

Problem: Handling files and folders differently makes code complex.

// Bad design: Type checking required
void printSize(FileSystemItem* item) {
    if (auto* file = dynamic_cast<File*>(item)) {
        std::cout << file->getSize() << '\n';
    } else if (auto* folder = dynamic_cast<Folder*>(item)) {
        for (auto& child : folder->getChildren()) {
            printSize(child);  // Recursion
        }
    }
}

Solution: The Composite Pattern treats leaves (files) and composites (folders) with the same interface.

// Good design: Composite
class Component {
public:
    virtual int getSize() const = 0;  // Unified interface
};
class File : public Component {
    int size_;
public:
    int getSize() const override { return size_; }
};
class Folder : public Component {
    std::vector<std::shared_ptr<Component>> children_;
public:
    int getSize() const override {
        int total = 0;
        for (const auto& child : children_)
            total += child->getSize();  // Recursion
        return total;
    }
};
flowchart TD
    client[Client]
    component["Component\n(getSize)"]
    leaf["Leaf\n(File)"]
    composite["Composite\n(Folder)"]
    
    client --> component
    leaf -.implements.-> component
    composite -.implements.-> component
    composite --> component

The diagram above hides a design decision that every C++ implementation of Composite has to answer explicitly, in a way that languages with garbage collection mostly get to postpone: who owns the children, and who is responsible for destroying them in the right order? In Java or C#, you can put add()/remove() on the base Component interface, let File throw UnsupportedOperationException, and never think about memory again. In C++, every add(std::shared_ptr<Component>) you write is also a statement about lifetime — does the composite keep the child alive as long as the composite lives, or does the caller retain shared ownership, or does raw ownership belong somewhere else entirely (a scene graph’s arena, a separate object pool)? Get this wrong and the pattern’s textbook elegance — “leaves and composites look the same to the client” — turns into a use-after-free bug that only reproduces under specific traversal orders. This is not a hypothetical concern. Composite trees are almost always mutated while they are being traversed: a UI event handler removes a child panel in response to a click that occurred during a render pass; a build system’s dependency graph adds a node while another thread is still walking it for cycle detection; a game engine’s scene graph reparents an entity in the middle of a physics tick that is iterating siblings. The rest of this guide spends more time than the average tutorial on ownership and iteration-safety precisely because that is where Composite implementations in production C++ codebases actually break, far more often than the recursive getSize() example ever does.


Basic Structure

#include <vector>
#include <memory>
#include <iostream>
class Component {
public:
    virtual void operation() const = 0;
    virtual void add(std::shared_ptr<Component>) {}
    virtual void remove(std::shared_ptr<Component>) {}
    virtual ~Component() = default;
};
// Leaf: No children
class Leaf : public Component {
    int id_;
public:
    explicit Leaf(int id) : id_(id) {}
    void operation() const override {
        std::cout << "Leaf " << id_ << '\n';
    }
};
// Composite: Holds list of children
class Composite : public Component {
    std::vector<std::shared_ptr<Component>> children_;
public:
    void add(std::shared_ptr<Component> c) override {
        children_.push_back(std::move(c));
    }
    void remove(std::shared_ptr<Component> c) override {
        children_.erase(
            std::remove(children_.begin(), children_.end(), c),
            children_.end()
        );
    }
    void operation() const override {
        std::cout << "Composite [\n";
        for (const auto& c : children_)
            c->operation();
        std::cout << "]\n";
    }
};
int main() {
    auto root = std::make_shared<Composite>();
    root->add(std::make_shared<Leaf>(1));
    
    auto branch = std::make_shared<Composite>();
    branch->add(std::make_shared<Leaf>(2));
    branch->add(std::make_shared<Leaf>(3));
    root->add(branch);
    
    root->operation();
    // Output:
    // Composite [
    // Leaf 1
    // Composite [
    // Leaf 2
    // Leaf 3
    // ]
    // ]
    return 0;
}

Ownership Models: Raw Pointers vs. unique_ptr vs. shared_ptr

The std::shared_ptr<Component> used above is a defensible default, but it is not the only option, and picking it reflexively without thinking through the alternatives is how Composite trees end up with subtle bugs. There are three realistic choices for the children container, and each one encodes a different answer to “who owns this node.”

Raw pointers (std::vector<Component*>)

This is what pre-C++11 code (and a surprising amount of code today, especially in game engines and embedded systems where allocator control matters) actually uses. The composite does not own its children in this model — some other authority (an object pool, an arena allocator, a scene manager) owns them, and the composite just holds non-owning references for traversal. This is a legitimate design when you have a centralized allocation strategy, but it pushes the lifetime problem entirely onto the programmer’s discipline: nothing in the type system stops you from destroying a node that a composite still references. The classic failure mode is removing a node from the tree (freeing it) while a sibling’s operation() call is mid-traversal and still holds a raw pointer to it from an earlier lookup, or from a cached result. The pointer doesn’t become null when the object dies — it becomes dangling, and dereferencing it is undefined behavior that may run fine in a debug build and corrupt memory in release.

std::unique_ptr<Component>

This expresses “the composite exclusively owns its children” and is the correct choice when nothing outside the tree needs to reference a node independently. It also forces you to think about move semantics for add()/remove(), since you cannot copy a unique_ptr. The tradeoff is that unique_ptr makes reparenting (moving a subtree from one composite to another) mechanically explicit but syntactically noisier — you have to std::move the pointer out of one vector and into another, and any dangling references elsewhere (e.g., a “currently selected node” pointer in a UI framework) become raw pointers again, with the same use-after-free risk described above the moment the node moves or is destroyed.

std::shared_ptr<Component>

This is what most tutorials (including this one, in the basic example) default to, because it “just works” for ad hoc tree construction — multiple containers, caches, or visitor state can hold a reference to the same node without anyone needing to reason too hard about who destroys it last. The cost is real, though: every shared_ptr copy is an atomic reference-count increment (or decrement on destruction), which shows up as measurable overhead in traversal-heavy code, and shared_ptr does not save you from the two things people assume it does. It doesn’t prevent cycles — a parent shared_ptr to a child and a child shared_ptr back to its parent creates a reference cycle that leaks until you explicitly break it with std::weak_ptr on the parent-facing edge, which is why the “circular reference” error later in this article recommends weak_ptr for the parent, not just shared_ptr all around. And it doesn’t prevent use-after-free if you also keep a raw pointer alias to the object elsewhere and that raw pointer outlives every shared_ptr that referenced it. Practical guidance: use unique_ptr when the tree has a single conceptual owner and nothing needs to alias into it (most file-system-style trees). Use shared_ptr when multiple owners are genuinely necessary — for example, a scene graph node that is simultaneously referenced by the tree and by a physics engine’s collision cache. Never mix raw-pointer aliases into a tree that is otherwise shared_ptr-owned without being deliberate about which pointer is authoritative, because that mix is exactly the setup that produces the double-free scenario described next. I hit this mixing bug once in a UI toolkit I was maintaining: the widget tree stored children as shared_ptr<Widget> inside each Panel, which was correct, but a separate “focus manager” cached the currently focused widget as a raw Widget* for fast access during input dispatch, refreshed whenever focus changed. A close-button handler removed a panel from its parent (dropping the tree’s shared_ptr, which was the last owner, so the widget was destroyed immediately), but the focus manager’s cached raw pointer wasn’t cleared because focus hadn’t explicitly changed — the widget being focused was simply removed out from under it. The next keystroke dispatched through the stale raw pointer, and we got a heap-corruption crash that only reproduced when a user closed a panel while it (or a descendant) still had focus, which made it maddening to reproduce in isolation. The fix was to make the focus manager hold a std::weak_ptr<Widget> and lock() it before every dispatch instead of caching a raw pointer — a few extra atomic operations per keystroke in exchange for never dereferencing freed memory. The lesson generalizes: any time you cache a pointer into a Composite tree from outside the tree’s own ownership structure, that cache needs to be weak, not raw, or it will eventually outlive the thing it points to.


File System Example

Calculating File and Folder Sizes

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class FileSystemItem {
public:
    virtual int getSize() const = 0;
    virtual void print(int indent = 0) const = 0;
    virtual ~FileSystemItem() = default;
};
class File : public FileSystemItem {
    std::string name_;
    int size_;
public:
    File(std::string name, int size) : name_(std::move(name)), size_(size) {}
    
    int getSize() const override { return size_; }
    
    void print(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "File: " << name_ 
                  << " (" << size_ << " bytes)\n";
    }
};
class Folder : public FileSystemItem {
    std::string name_;
    std::vector<std::shared_ptr<FileSystemItem>> children_;
public:
    explicit Folder(std::string name) : name_(std::move(name)) {}
    
    void add(std::shared_ptr<FileSystemItem> item) {
        children_.push_back(std::move(item));
    }
    
    int getSize() const override {
        int total = 0;
        for (const auto& child : children_)
            total += child->getSize();
        return total;
    }
    
    void print(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Folder: " << name_ 
                  << " (" << getSize() << " bytes total)\n";
        for (const auto& child : children_)
            child->print(indent + 2);
    }
};
int main() {
    auto root = std::make_shared<Folder>("root");
    root->add(std::make_shared<File>("readme.txt", 100));
    
    auto src = std::make_shared<Folder>("src");
    src->add(std::make_shared<File>("main.cpp", 500));
    src->add(std::make_shared<File>("utils.cpp", 300));
    root->add(src);
    
    auto docs = std::make_shared<Folder>("docs");
    docs->add(std::make_shared<File>("manual.pdf", 2000));
    root->add(docs);
    
    root->print();
    // Output:
    // Folder: root (2900 bytes total)
    //   File: readme.txt (100 bytes)
    //   Folder: src (800 bytes total)
    //     File: main.cpp (500 bytes)
    //     File: utils.cpp (300 bytes)
    //   Folder: docs (2000 bytes total)
    //     File: manual.pdf (2000 bytes)
    
    return 0;
}

Key Point: getSize() is called recursively to calculate the total size of the entire tree. Note that this recursion also means getSize() on the root is O(n) in the total node count on every call — there is no caching. For a tree that changes rarely but is queried often (a file browser’s status bar, refreshed on every render), it is worth caching the size at each Folder and invalidating the cached value up the parent chain when a child is added, removed, or resized, rather than recomputing the whole subtree each time.


UI Component Hierarchy

GUI Widget Tree

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class Widget {
public:
    virtual void render() const = 0;
    virtual void add(std::shared_ptr<Widget>) {}
    virtual ~Widget() = default;
};
class Button : public Widget {
    std::string label_;
public:
    explicit Button(std::string label) : label_(std::move(label)) {}
    
    void render() const override {
        std::cout << "[Button: " << label_ << "]\n";
    }
};
class Label : public Widget {
    std::string text_;
public:
    explicit Label(std::string text) : text_(std::move(text)) {}
    
    void render() const override {
        std::cout << "Label: " << text_ << '\n';
    }
};
class Panel : public Widget {
    std::string title_;
    std::vector<std::shared_ptr<Widget>> children_;
public:
    explicit Panel(std::string title) : title_(std::move(title)) {}
    
    void add(std::shared_ptr<Widget> widget) override {
        children_.push_back(std::move(widget));
    }
    
    void render() const override {
        std::cout << "=== Panel: " << title_ << " ===\n";
        for (const auto& child : children_)
            child->render();
        std::cout << "===================\n";
    }
};
int main() {
    auto mainPanel = std::make_shared<Panel>("Main Window");
    mainPanel->add(std::make_shared<Label>("Welcome!"));
    
    auto buttonPanel = std::make_shared<Panel>("Actions");
    buttonPanel->add(std::make_shared<Button>("OK"));
    buttonPanel->add(std::make_shared<Button>("Cancel"));
    mainPanel->add(buttonPanel);
    
    mainPanel->render();
    // Output:
    // === Panel: Main Window ===
    // Label: Welcome!
    // === Panel: Actions ===
    // [Button: OK]
    // [Button: Cancel]
    // ===================
    // ===================
    
    return 0;
}

Key Point: Panel can contain other Panels or Buttons, representing nested UI hierarchies.


Transparent vs. Safe Composite, and the LSP Problem

The Widget base class above puts add() on the interface with a default no-op body, so Button and Label inherit it but the call does nothing. This is called the transparent variant of Composite: every Component/Widget in the hierarchy exposes the full interface, including child-management methods, whether or not a concrete type can meaningfully support them. The alternative is the safe variant: only Composite (or Panel) declares add()/remove(), and Leaf types simply don’t have those methods at all. Gang of Four’s original description favors the transparent variant because it maximizes client-code uniformity — you never need to know or care whether the Component* you’re holding is a leaf or a composite before calling any method on it, which is the entire point of the pattern. But this comes at a real cost to type safety, and it is worth being explicit about what that cost is rather than treating it as a minor footnote:

  • The safe variant catches “add a child to a leaf” mistakes at compile time, because Leaf simply has no add() method to call — a call site error becomes a build error instead of a runtime surprise. The cost is that client code can no longer treat every Component uniformly; it has to dynamic_cast to Composite* before calling add(), which reintroduces exactly the type-checking branch the pattern exists to eliminate. You end up back at something close to the “bad design” example at the top of this article, just with the branch moved to a different call site.
  • The transparent variant preserves uniform treatment but pushes the type-safety problem to runtime: calling add() on a Leaf either silently does nothing (as in the Widget example, which can mask real bugs — a caller thinks it added a child and it didn’t) or throws (as in the File::add() example under Common Errors below, which is safer but means every client now has to be aware that a method declared on the interface can throw for some concrete types it’s called on). That second point is worth stating plainly: a transparent Composite that throws or no-ops on unsupported operations is a textbook Liskov Substitution Principle violation. LSP says a subtype should be usable anywhere its base type is expected without the caller needing to know which concrete subtype it got. If Leaf::add() throws where Composite::add() succeeds, then code that is correct for one concrete type is not correct for another, even though both are Component. You have not actually achieved substitutability — you have hidden a dynamic_cast-shaped decision behind a virtual call and a try/catch, which is arguably worse, because the failure surfaces at a call site far from where the misuse originated, and only if that code path executes at runtime with that particular concrete type. In practice, most production codebases accept this violation deliberately, as a pragmatic tradeoff rather than an oversight: uniform client code is worth more than compile-time safety for a mistake (adding children to a leaf) that is usually caught immediately in testing and rarely reaches production. The rule of thumb I’d suggest: choose transparent-with-throw (never transparent-with-silent-no-op — silent failures are how “I added three children but the panel is empty” bugs survive code review) when the tree is built primarily by a small amount of trusted, well-tested application code, and choose the safe variant when the tree is built or mutated by less-trusted callers — plugin code, user-scriptable configuration, or anything crossing a module boundary where a compile-time guardrail is worth the loss of uniformity.

Common Errors and Solutions

Error 1: Calling add() on Leaf

// ❌ Bad: Calling add() on Leaf is silently ignored
auto file = std::make_shared<File>("test.txt", 100);
file->add(anotherFile);  // Nothing happens

Solution: Throw an exception in Leaf’s add() or add type checking.

// ✅ Good: Throw exception
class File : public FileSystemItem {
public:
    void add(std::shared_ptr<FileSystemItem>) override {
        throw std::logic_error("Cannot add to a file");
    }
};

Error 2: Circular Reference

// ❌ Bad: Circular reference
auto folder1 = std::make_shared<Folder>("A");
auto folder2 = std::make_shared<Folder>("B");
folder1->add(folder2);
folder2->add(folder1);  // Circular!

Solution: Add parent pointer to detect cycles or use weak_ptr.

// ✅ Good: Parent checking
class Folder : public FileSystemItem {
    std::weak_ptr<Folder> parent_;
public:
    void add(std::shared_ptr<FileSystemItem> item) {
        // Cycle detection logic
        children_.push_back(std::move(item));
    }
};

Error 3: Memory Leak

// ❌ Bad: Using raw pointers
class Composite {
    std::vector<Component*> children_;  // Leak risk
};

Solution: Use std::shared_ptr or std::unique_ptr.

// ✅ Good
class Composite {
    std::vector<std::shared_ptr<Component>> children_;
};

Error 4: Mutating the Child List While Iterating It

// ❌ Bad: removing a sibling from inside operation() invalidates the iterator
void Composite::operation() const {
    for (const auto& child : children_) {
        child->operation();  // if this call reaches code that calls
                              // this->remove(someOtherChild), children_
                              // is being mutated while we iterate it
    }
}

Why this happens: std::vector::erase invalidates iterators from the erase point onward. If any child’s operation() triggers a callback that reaches back into the same composite and calls remove() on a sibling — a very common shape for UI event handling, where a button’s click handler closes the panel it lives in, or removes a neighboring widget — the range-based for loop above is iterating over a container that just changed size underneath it. Depending on which element was erased and where the loop currently is, this is either a silent skip (a sibling that should have been visited gets missed) or a crash from dereferencing an invalidated iterator, and which one you get depends on the exact index removed relative to the current iterator position, which makes it look intermittent. Solution: never mutate the container you are actively iterating. Either collect removal requests during the traversal and apply them after the loop finishes, or iterate over a copy of the pointer list (cheap for shared_ptr, since you’re only copying reference-counted handles, not the nodes themselves):

// ✅ Good: snapshot the children before iterating, so removals
// during a child's operation() cannot invalidate our loop
void Composite::operation() const {
    auto snapshot = children_;  // copies shared_ptrs, not the nodes
    for (const auto& child : snapshot) {
        child->operation();
    }
}

I ran into exactly this bug in a notification-panel widget tree: each notification was a Composite child of a Panel, and clicking a notification’s dismiss button called back up to parentPanel->remove(self) from inside that child’s own operation()-equivalent click handler, which was invoked from the middle of the parent’s render loop over children_. Most of the time this worked, because the dismissed notification tended to be near the end of the vector and erase didn’t disturb the iterator’s current position enough to matter. It crashed reliably only when a user dismissed the first visible notification while several more were queued below it — the exact case that triggered a full engineering escalation, because our internal test scripts always dismissed notifications from the newest (last) one. The for loop’s iterator, mid-traversal, pointed into memory that erase had just shifted, and we read a shared_ptr that had already been destructed. The fix was the snapshot-before-iterate approach above: three lines, and the bug class disappeared entirely, along with a second related crash we hadn’t even filed yet in the same code path.


Performance: Virtual Dispatch in Deep Trees

Every call to operation(), getSize(), or render() in the examples above goes through a virtual function table, because that indirection is exactly what lets Leaf and Composite share an interface without the caller knowing which one it has. For shallow trees (a handful of UI panels, a typical file system directory) this cost is irrelevant — a vtable lookup and an indirect branch are a few nanoseconds, and branch prediction handles repeated calls to the same concrete type well. It becomes worth measuring, not just assuming, in two specific situations:

  • Deep, wide trees traversed frequently. A scene graph with tens of thousands of nodes, walked every frame at 60 fps, turns “a few nanoseconds per call” into a real percentage of your frame budget, because the indirect branch defeats speculative execution more often than a direct call would, and because each virtual call is also a potential cache miss — the vtable pointer, the vtable itself, and the target function can each live in different cache lines that are not prefetched together the way a monomorphic call site’s target would be.
  • Polymorphic mixes that break branch prediction. If your tree alternates between many different concrete Component subtypes at each level (unlike the two-type File/Folder example), the CPU’s indirect branch predictor has to track many more (call site, target) pairs, and misprediction rates rise measurably compared to a tree dominated by one or two concrete types. If profiling actually shows virtual dispatch as a bottleneck (and you should profile before optimizing this — it usually is not the bottleneck; the recursive allocation pattern or the traversal’s memory access pattern usually dominates first), the standard mitigations are: flattening hot subtrees into a std::variant<File, Folder> and dispatching with std::visit (trades pattern flexibility for a jump table the compiler can sometimes fully devirtualize), caching aggregate results like getSize() instead of recomputing them on every call as noted earlier, or restructuring the hottest traversal as an explicit stack-based loop over a flat array of nodes (a “linearized” tree) rather than recursive virtual calls, which is the approach most production game engines take for scene graphs specifically because it also improves cache locality during traversal.

Production Patterns

Pattern 1: Combined with Visitor

class Visitor {
public:
    virtual void visitFile(File* file) = 0;
    virtual void visitFolder(Folder* folder) = 0;
};
class SizeCalculator : public Visitor {
    int total_ = 0;
public:
    void visitFile(File* file) override { total_ += file->getSize(); }
    void visitFolder(Folder* folder) override {
        for (auto& child : folder->getChildren())
            child->accept(this);
    }
    int getTotal() const { return total_; }
};

Pattern 2: Combined with Iterator

class Composite {
    std::vector<std::shared_ptr<Component>> children_;
public:
    auto begin() { return children_.begin(); }
    auto end() { return children_.end(); }
};
// Usage
for (auto& child : composite) {
    child->operation();
}

Complete Example: Organization Chart System

#include <vector>
#include <memory>
#include <iostream>
#include <string>
class Employee {
public:
    virtual void showDetails(int indent = 0) const = 0;
    virtual int getSalary() const = 0;
    virtual void add(std::shared_ptr<Employee>) {}
    virtual ~Employee() = default;
};
class Developer : public Employee {
    std::string name_;
    int salary_;
public:
    Developer(std::string name, int salary) 
        : name_(std::move(name)), salary_(salary) {}
    
    void showDetails(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Developer: " << name_ 
                  << " ($" << salary_ << ")\n";
    }
    
    int getSalary() const override { return salary_; }
};
class Manager : public Employee {
    std::string name_;
    int salary_;
    std::vector<std::shared_ptr<Employee>> team_;
public:
    Manager(std::string name, int salary) 
        : name_(std::move(name)), salary_(salary) {}
    
    void add(std::shared_ptr<Employee> emp) override {
        team_.push_back(std::move(emp));
    }
    
    void showDetails(int indent = 0) const override {
        std::cout << std::string(indent, ' ') << "Manager: " << name_ 
                  << " ($" << salary_ << ") - Team size: " << team_.size() << '\n';
        for (const auto& emp : team_)
            emp->showDetails(indent + 2);
    }
    
    int getSalary() const override {
        int total = salary_;
        for (const auto& emp : team_)
            total += emp->getSalary();
        return total;
    }
};
int main() {
    auto ceo = std::make_shared<Manager>("Alice", 150000);
    
    auto engManager = std::make_shared<Manager>("Bob", 120000);
    engManager->add(std::make_shared<Developer>("Charlie", 80000));
    engManager->add(std::make_shared<Developer>("David", 85000));
    ceo->add(engManager);
    
    auto salesManager = std::make_shared<Manager>("Eve", 110000);
    salesManager->add(std::make_shared<Developer>("Frank", 70000));
    ceo->add(salesManager);
    
    ceo->showDetails();
    std::cout << "\nTotal company payroll: $" << ceo->getSalary() << '\n';
    
    // Output:
    // Manager: Alice ($150000) - Team size: 2
    //   Manager: Bob ($120000) - Team size: 2
    //     Developer: Charlie ($80000)
    //     Developer: David ($85000)
    //   Manager: Eve ($110000) - Team size: 1
    //     Developer: Frank ($70000)
    //
    // Total company payroll: $615000
    
    return 0;
}

When a tree needs the Composite pattern

Composite pays off when the set of node types is open: other modules or plugins add new kinds of nodes, and client code should treat all of them through one interface. File browsers, UI widget trees and scene graphs fit that description. When the node types are few and fixed, such as a file and a directory, or the nodes of an expression tree, a std::variant of the node types with a recursive visit keeps everything in one place, needs no virtual functions, and makes the compiler check that every operation handles every node type.

If you do use the pattern, settle ownership first: parents own children through std::unique_ptr, and any link back to the parent is a non-owning raw pointer. Making both directions std::shared_ptr creates a cycle that never frees.

Related Posts: Adapter Pattern, Decorator Pattern, Iterator Guide, Visitor Pattern, Flyweight Pattern.