[2026] C++ Interface Design and PIMPL: Cut Compile Dependencies and Keep ABI Stability [#38-3]

[2026] C++ Interface Design and PIMPL: Cut Compile Dependencies and Keep ABI Stability [#38-3]

이 글의 핵심

Use the PIMPL idiom to hide implementation details, shrink rebuild graphs, and keep a stable binary layout for shared libraries and plugins. Patterns, pitfalls, and versioning.

Introduction: when the header changes, the world rebuilds

“I only changed a private field—why did everything recompile?”

If implementation details (data members, heavy includes) live in a public header, every translation unit that includes it is affected. Adding/removing private members can change object layout and break ABI. PIMPL (pointer to implementation) moves the real class into an Impl type defined only in .cpp files. The public class holds something like std::unique_ptr and forward-declares Impl in the header—like showing only the building façade while machinery stays out of sight. Including implementation headers only from .cpp cuts compile dependencies and makes binary compatibility easier. This article covers:

  • PIMPL mechanics and when to use them
  • Special members (destructor, copy, move)
  • ABI and what stays stable in public headers
  • Scenarios, full interface examples, errors, versioning, production patterns

Table of contents

  1. Scenarios
  2. PIMPL pattern
  3. Copy, move, destructor
  4. ABI and public headers
  5. Complete interface examples
  6. Common errors
  7. Versioning
  8. Production patterns
  9. Summary

1. Scenarios where PIMPL helps

1) Shipping a library: add a private cache

You ship Document and later add an internal std::unordered_map. Every user of document.h rebuilds; mixing old and new .so can crash due to ABI mismatch. Fix: hide state in DocumentImpl; document.h stays stable so many clients avoid rebuilds and ABI stays coherent.

2) Build time explosion

Widget.h is included by 200+ .cpp files. Adding #include <boost/json.hpp> to the header pulls a huge parse cost into every TU. Fix: include Boost.JSON only in widget.cpp; widget.h stays light.

3) Plugin ABI drift

Host and plugins build at different times. Adding a virtual in the middle of a base interface shifts vtable slots and crashes old plugins. Fix: stable abstract interface + PIMPL in concrete wrappers; add features via versioned APIs or new virtuals at the end.

4) Platform-specific backends

FileWatcher uses ReadDirectoryChangesW on Windows and inotify on Linux. Platform headers in a shared .h break cross-platform builds. Fix: FileWatcherImpl in .cpp behind #ifdef.

5) Circular includes

A.h includes B and B includes A; forward declarations are not enough if A stores B by value. Fix: std::unique_ptr (or PIMPL) in A.h with forward declaration; include B.h in A.cpp.

2. PIMPL pattern

Hide implementation behind a pointer

  • The public class holds a pointer to Impl (typically std::unique_ptr). Impl is forward-declared in the header and defined in .cpp.
  • TUs that include the public header do not see Impl’s size or members, so changes to Impl do not force recompilation of unrelated TUs. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 실행 예제
flowchart TB
    subgraph public["Public header (widget.h)"]
        W[Widget]
        P[pImpl_]
        W --> P
    end
    subgraph impl["Implementation (.cpp only)"]
        I[WidgetImpl]
        D[data, cache, ...]
        I --> D
    end
    P -.->|pointer only| I

Because std::unique_ptr needs a complete type in the destructor’s TU, declare ~Widget() in the header and define it in .cpp after WidgetImpl is complete. Construct with std::make_unique() and delegate doSomething() to pImpl_. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// widget.h — public API
#pragma once
#include <memory>
class WidgetImpl;
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
private:
    std::unique_ptr<WidgetImpl> pImpl_;
};

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// widget.cpp
#include "widget.h"
#include "widget_impl.h"
#include <vector>
struct WidgetImpl {
    std::vector<int> data;
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
void Widget::doSomething() {
    // use pImpl_->data
}

std::shared_ptr can avoid out-of-line destructor requirements at the cost of control blocks and atomic refcounting; unique_ptr is the default for exclusive ownership.

3. Copy, move, destructor

Rule of Five and PIMPL

  • Destructor: define where Impl is complete so unique_ptr can delete it—usually = default in .cpp.
  • Copy: default copy shallow-copies the pointer—wrong. Implement deep copy in .cpp by cloning *other.pImpl_ into a new Impl.
  • Move: often = default with noexcept so containers prefer move on reallocation. 다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// widget.h
class Widget {
    // ...
    Widget(const Widget& other);
    Widget& operator=(const Widget& other);
    Widget(Widget&&) noexcept = default;
    Widget& operator=(Widget&&) noexcept = default;
};
// widget.cpp
Widget::Widget(const Widget& other)
    : pImpl_(std::make_unique<WidgetImpl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
    if (this != &other) {
        *pImpl_ = *other.pImpl_;
    }
    return *this;
}

4. ABI and public headers

Why ABI matters

ABI covers layout, calling conventions, and name mangling across compiled boundaries. Changing public class layout breaks consumers who were built against an older .so. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 실행 예제
flowchart LR
    subgraph stable[PIMPL — stable ABI]
        A1[Widget] --> A2[one pointer]
        A2 --> A3[Impl can evolve]
    end
    subgraph broken[Exposed members — fragile ABI]
        B1[Widget] --> B2[inline data]
        B2 --> B3[layout change ⇒ crash]
    end

PIMPL keeps the public object size fixed (typically one pointer). You can still break ABI by adding virtual functions in the wrong place or changing existing public data members—follow a versioning policy.

5. Complete examples

Plugin-style C API

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// plugin_interface.h — stable ABI
#pragma once
#include <memory>
#include <string>
#include <cstdint>
extern "C" {
struct PluginAPI {
    uint32_t version;
    void* (*create)(const char* config);
    void (*destroy)(void* handle);
    int (*process)(void* handle, const void* input, void* output);
};
}

Host wrapper with PIMPL

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#pragma once
#include <memory>
#include <string>
class PluginImpl;
class PluginHost {
public:
    explicit PluginHost(const std::string& plugin_path);
    ~PluginHost();
    PluginHost(const PluginHost&) = delete;
    PluginHost& operator=(const PluginHost&) = delete;
    PluginHost(PluginHost&&) noexcept = default;
    PluginHost& operator=(PluginHost&&) noexcept = default;
    int process(const void* input, void* output);
private:
    std::unique_ptr<PluginImpl> pImpl_;
};

Implementation loads dlopen/ PluginAPI, holds instance pointer, and calls process through the vtable-like function pointers.

Document class (ABI-friendly public API)

The full Document / DocumentImpl listing keeps an std::unordered_map cache in the .cpp—only the stable interface ships in headers.

FileWatcher (platform PIMPL)

Public header stays neutral; FileWatcherImpl contains HANDLE vs inotify fds behind #ifdef.

6. Common errors

  1. sizeof on incomplete type: defining ~Widget() = default inline in the header while Impl is incomplete—move ~Widget() to .cpp.
  2. Self-assignment in copy-assign without if (this != &other) when reusing Impl storage.
  3. Using moved-from object after std::move—treat as invalid unless reset.
  4. Exposing STL containers in public ABI—prefer PIMPL or stable opaque handles.
  5. Inserting virtuals in the middle of a polymorphic interface—append new virtuals at the end for compatibility.
  6. Publishing widget_impl.h to clients—defeats compile isolation.
  7. Non-noexcept move—can force copy in std::vector reallocation.

7. Versioning

  • MAJOR: ABI breaks (layout, incompatible vtables).
  • MINOR: additive changes hidden in Impl or new trailing virtuals.
  • PATCH: implementation-only fixes. Use semantic version fields in C APIs, symbol versioning on Linux, and ABI checker tools in CI.

8. Production patterns

  • Factory + PIMPL for plugin-like creation.
  • Lazy PIMPL: construct Impl on first use.
  • unique_ptr by default; shared_ptr only if shared ownership is real.
  • extern “C” exports for maximum cross-toolchain stability.
  • Measure indirection cost on hot paths before micro-optimizing away PIMPL.

9. Summary

TopicTakeaway
PIMPLHide implementation; stabilize public header & size
Special membersDestructor/copy/move defined with full Impl visible
ABIPublic surface + vtable plan + versioning discipline
ErrorsIncomplete destructor type, bad copy, virtual order
Series #38 moves from clean interfaces → composition → PIMPL/ABI as a foundation for maintainable large codebases.

When PIMPL is essential

Libraries (Qt/Boost-style), plugin SDKs, and large codebases where header churn dominates build time—often 5–10 hot headers refactored to PIMPL yield large CI savings.

Checklist

  • Hot include graph?
  • Private members change often?
  • Heavy third-party includes in header?
  • Need stable .so for out-of-tree users?
    Skip PIMPL for tiny header-only types, templates (different trade-offs), or proven nanosecond hot paths—profile first.

FAQ

When is this useful? Shipping shared libraries, plugins, and any API where compile time and ABI stability matter. What to read next? Series index, then cache / data-oriented design #39-1. Previous: Polymorphism & variant #38-2
Next: Data-oriented design #39-1

Keywords (SEO)

PIMPL, C++ ABI, binary compatibility, opaque pointer, unique_ptr, library design, compile time

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3