The Bridge Pattern in C++: Splitting Abstraction from Implementation to Avoid Class Explosion

Key takeaways

C++ Bridge pattern separates implementation (Implementor) and abstraction (Abstraction) to enable swappable platforms and drivers, with practical examples, renderer switching, and platform-independent design.

What is Bridge Pattern? Why Needed?

In the Structural Patterns Series, viewing Bridge alongside Adapter and Composite makes it easy to distinguish where “separating abstraction and implementation” applies.

Problem Scenario: Combinatorial Explosion

Problem: Combining Shape (circle, rectangle) and Renderer (OpenGL, Vulkan) with inheritance causes class explosion.

// Bad design: combinatorial explosion
class OpenGLCircle : public Shape { };
class VulkanCircle : public Shape { };
class OpenGLRectangle : public Shape { };
class VulkanRectangle : public Shape { };
// 3 Shapes × 2 Renderers = 6 classes
// Adding Color: 3 × 2 × 3 = 18...

Solution: Bridge pattern separates abstraction (Shape) and implementation (Renderer). Shape only references Renderer, allowing independent extension of both.

// Good design: Bridge
class Shape {
protected:
    std::shared_ptr<Renderer> renderer_;
public:
    explicit Shape(std::shared_ptr<Renderer> r) : renderer_(std::move(r)) {}
    virtual void draw() = 0;
};
class Circle : public Shape {
    void draw() override { renderer_->drawCircle(...); }
};
// Adding Shape: 1 class only, Adding Renderer: 1 class only

The reason inheritance alone causes this explosion is that a single class hierarchy can only vary along one axis cleanly. The moment you need Shape to vary independently along two axes — “what shape is it” and “how does it get drawn” — subclassing forces you to bake both dimensions into one inheritance tree, producing one class per combination rather than one class per variation. Bridge fixes this by giving each axis its own hierarchy (Shape/Circle/Rectangle for the “what,” Renderer/OpenGLRenderer/VulkanRenderer for the “how”) and connecting them with a plain runtime reference instead of inheritance. Because Shape only depends on the Renderer interface, adding a new renderer never touches the Shape hierarchy and vice versa — the two dimensions genuinely scale independently instead of multiplicatively.

flowchart LR
    client[Client]
    abstraction["Abstraction<br/>(Shape)"]
    implementor["Implementor<br/>(Renderer)"]
    refined["RefinedAbstraction<br/>(Circle, Rectangle)"]
    concrete["ConcreteImplementor<br/>(OpenGLRenderer, VulkanRenderer)"]
    
    client --> abstraction
    abstraction --> implementor
    refined -.extends.-> abstraction
    concrete -.implements.-> implementor

Basic Structure

Notice that Shape holds a std::shared_ptr<Renderer>, not a Renderer value or a raw Renderer* — this ownership choice matters. A shared_ptr lets a single renderer instance (say, one OpenGLRenderer that wraps an expensive-to-create graphics context) be shared across many Shape objects without each one needing its own copy or worrying about who’s responsible for destroying it. The draw() method on each concrete shape (Circle, Rectangle) does nothing but forward the call to renderer_ with shape-specific parameters — the abstraction layer contributes what to draw, and the implementation layer contributes how. This split is exactly why main() can freely mix gl and vk renderers across different shape instances at runtime: the renderer is a property of the object instance, not something baked into the class itself.

#include <memory>
#include <iostream>
// Implementation interface (Implementor)
class Renderer {
public:
    virtual void drawCircle(float x, float y, float r) = 0;
    virtual void drawRect(float x, float y, float w, float h) = 0;
    virtual ~Renderer() = default;
};
// Concrete implementation 1
class OpenGLRenderer : public Renderer {
public:
    void drawCircle(float x, float y, float r) override {
        std::cout << "[OpenGL] Circle at (" << x << "," << y << ") r=" << r << '\n';
    }
    void drawRect(float x, float y, float w, float h) override {
        std::cout << "[OpenGL] Rect at (" << x << "," << y << ") " << w << "x" << h << '\n';
    }
};
// Concrete implementation 2
class VulkanRenderer : public Renderer {
public:
    void drawCircle(float x, float y, float r) override {
        std::cout << "[Vulkan] Circle at (" << x << "," << y << ") r=" << r << '\n';
    }
    void drawRect(float x, float y, float w, float h) override {
        std::cout << "[Vulkan] Rect at (" << x << "," << y << ") " << w << "x" << h << '\n';
    }
};
// Abstraction - references implementation
class Shape {
protected:
    std::shared_ptr<Renderer> renderer_;
public:
    explicit Shape(std::shared_ptr<Renderer> r) : renderer_(std::move(r)) {}
    virtual void draw() = 0;
    virtual ~Shape() = default;
};
class Circle : public Shape {
    float x_, y_, r_;
public:
    Circle(std::shared_ptr<Renderer> r, float x, float y, float radius)
        : Shape(std::move(r)), x_(x), y_(y), r_(radius) {}
    void draw() override { renderer_->drawCircle(x_, y_, r_); }
};
class Rectangle : public Shape {
    float x_, y_, w_, h_;
public:
    Rectangle(std::shared_ptr<Renderer> r, float x, float y, float w, float h)
        : Shape(std::move(r)), x_(x), y_(y), w_(w), h_(h) {}
    void draw() override { renderer_->drawRect(x_, y_, w_, h_); }
};
int main() {
    auto gl = std::make_shared<OpenGLRenderer>();
    auto vk = std::make_shared<VulkanRenderer>();
    
    Circle c1(gl, 0, 0, 10);
    Circle c2(vk, 5, 5, 3);
    Rectangle r1(gl, 10, 10, 50, 30);
    
    c1.draw();  // [OpenGL] Circle
    c2.draw();  // [Vulkan] Circle
    r1.draw();  // [OpenGL] Rect
    return 0;
}

Renderer Switching Example

Runtime Renderer Change

The setRenderer() method is what elevates this from “just polymorphism” to “Bridge pattern” in the specific sense that matters here: the implementation reference is a mutable member, not something fixed at construction, so the same Document object can switch its rendering strategy mid-lifetime without being reconstructed. This is fundamentally different from, say, passing a strategy object into a function call — the Article instance itself, with all its existing state (content_), persists across the switch. This matters in real applications like a document editor that offers a live preview toggle between HTML and Markdown rendering, or a UI toolkit that swaps rendering backends without recreating every widget on screen.

#include <memory>
#include <iostream>
class Renderer {
public:
    virtual void render(const std::string& content) = 0;
    virtual ~Renderer() = default;
};
class HTMLRenderer : public Renderer {
public:
    void render(const std::string& content) override {
        std::cout << "<html><body>" << content << "</body></html>\n";
    }
};
class MarkdownRenderer : public Renderer {
public:
    void render(const std::string& content) override {
        std::cout << "# " << content << "\n";
    }
};
class Document {
protected:
    std::shared_ptr<Renderer> renderer_;
    std::string content_;
public:
    Document(std::shared_ptr<Renderer> r, std::string content)
        : renderer_(std::move(r)), content_(std::move(content)) {}
    
    void setRenderer(std::shared_ptr<Renderer> r) {
        renderer_ = std::move(r);
    }
    
    virtual void display() = 0;
    virtual ~Document() = default;
};
class Article : public Document {
public:
    using Document::Document;
    
    void display() override {
        std::cout << "=== Article ===\n";
        renderer_->render(content_);
    }
};
int main() {
    auto html = std::make_shared<HTMLRenderer>();
    auto md = std::make_shared<MarkdownRenderer>();
    
    Article article(html, "Hello World");
    article.display();  // HTML rendering
    
    article.setRenderer(md);
    article.display();  // Markdown rendering
    return 0;
}

Key: Can switch implementation at runtime with setRenderer().


Platform-Independent Design

Cross-Platform File System

This example shows the other common flavor of Bridge: instead of runtime switching, the implementation is selected once, at startup, based on the compile target (#ifdef _WIN32), and never changes again for the lifetime of the process. The value of Bridge here isn’t runtime flexibility — it’s that File and ConfigFile are written entirely against the FileSystemImpl interface and never need to know whether they’re ultimately talking to Windows or Linux system calls. This is the same structural idea PIMPL and platform-abstraction layers in real cross-platform codebases (game engines, GUI toolkits) rely on: application-level code stays platform-agnostic, and only a thin implementation layer at the bottom actually branches on the target OS.

#include <memory>
#include <iostream>
#include <string>
// Implementation: platform-specific file operations
class FileSystemImpl {
public:
    virtual bool exists(const std::string& path) = 0;
    virtual std::string read(const std::string& path) = 0;
    virtual void write(const std::string& path, const std::string& data) = 0;
    virtual ~FileSystemImpl() = default;
};
class WindowsFileSystem : public FileSystemImpl {
public:
    bool exists(const std::string& path) override {
        std::cout << "[Windows] Checking: " << path << '\n';
        return true;
    }
    std::string read(const std::string& path) override {
        return "[Windows] File content";
    }
    void write(const std::string& path, const std::string& data) override {
        std::cout << "[Windows] Writing to " << path << '\n';
    }
};
class LinuxFileSystem : public FileSystemImpl {
public:
    bool exists(const std::string& path) override {
        std::cout << "[Linux] Checking: " << path << '\n';
        return true;
    }
    std::string read(const std::string& path) override {
        return "[Linux] File content";
    }
    void write(const std::string& path, const std::string& data) override {
        std::cout << "[Linux] Writing to " << path << '\n';
    }
};
// Abstraction: platform-independent API
class File {
protected:
    std::shared_ptr<FileSystemImpl> fs_;
    std::string path_;
public:
    File(std::shared_ptr<FileSystemImpl> fs, std::string path)
        : fs_(std::move(fs)), path_(std::move(path)) {}
    
    bool exists() { return fs_->exists(path_); }
    std::string read() { return fs_->read(path_); }
    void write(const std::string& data) { fs_->write(path_, data); }
};
class ConfigFile : public File {
public:
    using File::File;
    
    void load() {
        if (exists()) {
            std::cout << "Config loaded: " << read() << '\n';
        }
    }
};
int main() {
#ifdef _WIN32
    auto fs = std::make_shared<WindowsFileSystem>();
#else
    auto fs = std::make_shared<LinuxFileSystem>();
#endif
    
    ConfigFile config(fs, "/etc/app.conf");
    config.load();
    return 0;
}

Key: Select platform implementation at compile time, abstraction layer provides same API.


Common Problems and Solutions

Problem 1: Circular Dependency

The entire value of Bridge depends on the dependency running in one direction only: abstraction knows about implementor, never the reverse. If Renderer starts holding a list of Shape*, the two hierarchies become mutually dependent, which reintroduces exactly the coupling Bridge was meant to eliminate — you can no longer change one side without the compiler needing to know about the other, and worse, you risk genuine lifetime cycles (a Renderer keeping shapes alive that keep the renderer alive) that plain shared_ptr reference counting can’t resolve on its own. If a renderer genuinely needs to know which shapes are using it (for batching draw calls, say), that’s a sign the relationship has outgrown a simple Bridge and needs an explicit registration/observer mechanism rather than a raw back-reference.

// ❌ Bad: Renderer references Shape
class Renderer {
    std::vector<Shape*> shapes_;  // Circular dependency!
};

Solution: Implementation (Implementor) should not know about abstraction (Abstraction). Maintain unidirectional dependency only.

// ✅ Good: Only Shape references Renderer
class Shape {
    std::shared_ptr<Renderer> renderer_;  // Unidirectional
};

Problem 2: Implementation Leak

Storing a OpenGLRenderer* directly instead of Renderer*/shared_ptr<Renderer> silently defeats the pattern’s whole purpose while still compiling and running correctly — which makes this mistake easy to miss in review. Once Circle is coupled to the concrete OpenGLRenderer type, you’re back to needing a VulkanCircle variant to support a different renderer, exactly the class explosion Bridge exists to prevent. This is worth watching for specifically because it tends to creep in gradually: a developer needs one OpenGL-specific method not on the Renderer interface, reaches for the concrete type “just this once,” and the abstraction boundary quietly erodes from there.

// ❌ Bad: Abstraction depends on concrete type
class Circle : public Shape {
    OpenGLRenderer* gl_;  // Concrete type!
};

Solution: Abstraction should only know Implementor interface.

// ✅ Good
class Circle : public Shape {
    std::shared_ptr<Renderer> renderer_;  // Interface only
};

Problem 3: Unnecessary Bridge

Bridge is excessive for simple cases.

Every layer of indirection has a real cost: an extra virtual call, an extra allocation for the implementor object, and — perhaps most importantly — an extra interface for a future maintainer to read through before understanding what the code actually does. If LoggerImpl will only ever have one concrete implementation and there’s no concrete plan to add a second, the indirection buys nothing and only adds a hop between the caller and the actual logging code. The pattern earns its cost specifically when there are two or more implementations that need to vary independently of the abstraction, or a documented expectation that a second one is coming (a new rendering backend, a second target platform) — “we might need it someday” without a concrete second implementation in view is usually not a strong enough reason on its own.

// ❌ Over-engineering: Only 1 implementation
class Logger {
    std::shared_ptr<LoggerImpl> impl_;  // Unnecessary
};

Solution: Use Bridge only when 2+ implementations needed or platform/driver switching expected.


Bridge, Strategy, or a compile-time choice

Bridge and Strategy look the same in code: an object holds a pointer to an interface and delegates to it. The difference is where the variation lies. Strategy swaps one algorithm inside one class. Bridge is worth its extra classes when both sides have their own hierarchy that grows independently, such as several shape types and several rendering backends, where inheritance alone would need a class for every combination.

Before introducing a runtime bridge, check whether the implementation really changes at runtime. If each build targets exactly one platform, selecting the implementation at compile time (a separate source file per platform behind one header, or the pimpl idiom) gives the same separation without a virtual call or a heap-allocated implementation object.

Related: Adapter Pattern, Decorator Pattern, Proxy Pattern, Strategy Pattern, Facade Pattern.