Composite Pattern


  • Description: Treat individual objects and compositions of objects uniformly through a shared interface — recursive tree structure where leaves and containers respond to the same operations.
  • My Notion Note ID: K2C-2-8
  • Created: 2026-05-22
  • Updated: 2026-05-22
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Core Idea

  • Part-whole hierarchies → trees of leaves + containers.
  • Clients shouldn't care if they're calling an op on a single item or a whole subtree.
  • Both leaf and composite implement the same Component interface → recursive uniformity.

Canonical examples → filesystem (file / directory), GUI widgets (button / panel), scene graphs (mesh / group), expression trees (literal / binary-op).


2. Structure

Role Responsibility
Component Common interface for leaf and composite. Declares operations and (optionally) child management.
Leaf Terminal node. Has no children. Implements operations on itself.
Composite Holds children (std::vector<std::unique_ptr<Component>> typical). Forwards / aggregates operations across children.
Client Manipulates the tree through Component* only. Doesn't know leaf vs composite.

Two design choices:

  • Transparent — child management (add, remove) declared on Component → uniform interface; leaves either no-op or throw.
  • Safe — child management only on Composite → no nonsensical calls on leaves; clients must downcast.

GoF prefers transparent; modern C++ practice usually prefers safe, or uses std::variant.


3. C++ Example

3.1 Filesystem with shared Component

#include <memory>
#include <string>
#include <vector>
#include <iostream>
#include <numeric>
#include <utility>

class FsNode {
public:
    virtual ~FsNode() = default;
    virtual std::size_t size() const = 0;          // bytes
    virtual void print(int indent = 0) const = 0;
};

class File : public FsNode {
public:
    File(std::string name, std::size_t bytes)
        : name_(std::move(name)), bytes_(bytes) {}
    std::size_t size() const override { return bytes_; }
    void print(int indent) const override {
        std::cout << std::string(indent, ' ') << name_
                  << " (" << bytes_ << "B)\n";
    }
private:
    std::string name_;
    std::size_t bytes_;
};

class Directory : public FsNode {
public:
    explicit Directory(std::string name) : name_(std::move(name)) {}

    void add(std::unique_ptr<FsNode> child) {
        children_.push_back(std::move(child));
    }
    std::size_t size() const override {
        return std::accumulate(children_.begin(), children_.end(), std::size_t{0},
            [](std::size_t acc, const auto& c) { return acc + c->size(); });
    }
    void print(int indent) const override {
        std::cout << std::string(indent, ' ') << name_ << "/\n";
        for (const auto& c : children_) c->print(indent + 2);
    }
private:
    std::string name_;
    std::vector<std::unique_ptr<FsNode>> children_;
};

int main() {
    auto root = std::make_unique<Directory>("root");
    root->add(std::make_unique<File>("readme.md", 1200));
    auto src = std::make_unique<Directory>("src");
    src->add(std::make_unique<File>("main.cpp", 800));
    src->add(std::make_unique<File>("lib.cpp", 4000));
    root->add(std::move(src));

    root->print();
    std::cout << "Total: " << root->size() << "B\n";
}

Client root->size() works the same whether root is a single file or a deep tree.

3.2 Modern std::variant approach

For closed sets of node kinds where virtuals feel heavy:

#include <variant>
#include <vector>
#include <memory>

struct FileV { std::string name; std::size_t bytes; };
struct DirV;

using NodeV = std::variant<FileV, DirV>;

struct DirV {
    std::string name;
    std::vector<std::unique_ptr<NodeV>> children;
};

std::size_t size(const NodeV& n) {
    return std::visit([](const auto& x) -> std::size_t {
        using T = std::decay_t<decltype(x)>;
        if constexpr (std::is_same_v<T, FileV>) return x.bytes;
        else {
            std::size_t s = 0;
            for (const auto& c : x.children) s += size(*c);
            return s;
        }
    }, n);
}
  • Trade-off: no inheritance, no virtual dispatch, closed set of types → adding a new node kind touches every visit.
  • unique_ptr<NodeV> indirection needed because DirV contains a NodeV recursively (forward decl + heap pointer breaks the cycle).

4. When to Use

Use when:

  • Recursive part-whole hierarchies (trees).
  • Client code should treat single items and groups uniformly.
  • New leaf / container types added often → polymorphic dispatch wins.

Avoid when:

  • Structure isn't a tree (graph with cycles → needs different abstraction; risk of infinite recursion).
  • One-level container only → just use std::vector<Leaf>.
  • Operations on leaves and composites genuinely differ → forcing them through a shared interface yields no-op methods or runtime checks.

5. Variants and Pitfalls

  • Transparent vs Safe. Transparent puts add/remove on Component → leaves throw or no-op. Safe puts them only on Composite → typesafe but loses uniformity at child-management time.
  • Parent pointers. Storing parent_ enables upward navigation but introduces back-reference lifetime hazards. Use weak_ptr or raw non-owning pointer; owner is the parent.
  • Caching aggregates. Recomputing size() walks the whole subtree every call → cache + invalidate on mutation, or accept the cost.
  • Iteration order. Tree traversal kind (pre/post/level) matters when ops have side effects.
  • Ownership. unique_ptr children → clear ownership. shared_ptr → DAGs sneak in; recursion can loop forever.
  • Cycle hazard. Composite assumes a tree. Insert a node as its own ancestor → infinite recursion. Add a sanity check on add.

6. Related Patterns

  • Composite vs Decorator. Both rely on a shared base interface and recursive composition. Decorator wraps one child to add behavior; Composite holds many children to aggregate behavior. Decorator's primary effect is behavior augmentation; Composite's is structural grouping.
  • Composite + Visitor. Visitor lets new operations be added without touching the component hierarchy — ideal for tree-shaped Composites with closed node sets.
  • Composite + Iterator. Iterator traverses Composite in a particular order without exposing structure.
  • Composite + Chain of Responsibility. Pass requests up parent links (when parent ptrs exist) until handled.
  • Composite vs Flyweight. Composite leaves can be Flyweights → e.g. text glyphs shared across many paragraphs in a document tree.

7. References