Skip to Content
Course content

123: PIMPL Idiom for ABI Stability

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're ordering a meal from a high-end restaurant. You have a menu that lists the dishes you can order and what's in them. That menu is the "public interface." Now, imagine if that menu also included a detailed blueprint of the kitchen—where the fryers are located, which brand of oven the chef uses, and exactly how the dishwasher is plumbed into the wall. If the restaurant decided to upgrade their oven or move the fridge three feet to the left, they'd have to reprint every single menu in the building and hand them out to every customer just so the customers would know the kitchen layout changed. That's absurd, right? You don't care about the oven; you just want the steak.

In C++, our header files are the menus. When you put private member variables directly in a class definition in a header, you're essentially putting the kitchen blueprint on the menu. Any time you add a new private variable or change a private data type, every single file that #includes that header has to be recompiled. Even worse, if you're shipping a compiled library (a DLL or .so file), changing the size of the class breaks the ABI (Application Binary Interface). The client's code expects the class to be X bytes, but you've suddenly made it Y bytes. Crash. Boom.

Stopping the Recompile Domino Effect

The PIMPL idiom—which stands for "Pointer to IMPLementation"—solves this by moving all the "kitchen equipment" into a separate structure that is completely hidden from the user. Instead of having five private member variables in your header, you have exactly one: a pointer to a hidden struct.

Let's look at a NetworkManager. Usually, this class would be littered with socket handles, buffer arrays, and perhaps some platform-specific headers like winsock2.h that you absolutely do not want leaking into your user's code.

// NetworkManager.h
#include <memory>
#include <string>

class NetworkManager {
public:
    NetworkManager();
    ~NetworkManager(); // We need a custom destructor for PIMPL!
    
    void connect(const std::string& ip);
    void sendData(const std::string& data);

private:
    struct Impl; // Forward declaration of the implementation
    std::unique_ptr<Impl> pImpl; // The only private member
};

Notice how the header is now incredibly "thin." The user doesn't know what's inside Impl. They don't even know it's a struct; it's just an opaque type to them. This means I can add, remove, or change any variable inside that Impl struct in the .cpp file, and as long as the public function signatures stay the same, the user never has to recompile their code.

Defining the Hidden Kitchen

The actual work happens in the implementation file. This is where we define exactly what the Impl struct looks like. I usually inherit from the public class or just make it a standalone struct that holds the state.

// NetworkManager.cpp
#include "NetworkManager.h"
#include <vector>
#include <iostream>
// Imagine a heavy, platform-specific header here
// #include <sys/socket.h> 

struct NetworkManager::Impl {
    int socketHandle = -1;
    std::vector<char> readBuffer;
    std::string currentIp;
    
    void internalLog(const std::string& msg) {
        std::cout < "[NetworkLog] " < msg < "\n";
    }
};

NetworkManager::NetworkManager() : pImpl(std::make_unique<Impl>()) {}

// This is crucial: The destructor must be defined here because 
// std::unique_ptr needs to see the full definition of Impl to delete it.
NetworkManager::~NetworkManager() = default;

void NetworkManager::connect(const std::string& ip) {
    pImpl->currentIp = ip;
    pImpl->internalLog("Connecting to " + ip);
    // Real socket logic goes here...
}

void NetworkManager::sendData(const std::string& data) {
    pImpl->internalLog("Sending: " + data);
    // Use pImpl->socketHandle to send...
}

The Cost of Hiding

I won't tell you this is a free lunch. There are two main trade-offs you need to be aware of. First, you're adding a heap allocation via std::unique_ptr and a pointer indirection every time you access a member variable. In 99% of application code, this is negligible. If you're writing a high-frequency trading engine or a physics kernel, maybe it matters. For everything else, it doesn't.

Second, you have to be careful with the "Rule of Five." Because you're using a std::unique_ptr, your class is now non-copyable by default. If you want users to be able to copy your NetworkManager, you'll have to manually implement the copy constructor and copy assignment operator to perform a "deep copy" of the Impl struct. If you don't need copying, just = delete them and call it a day.




📋 Practical Task

Refactoring the ImageProcessor for ABI Stability

You have been handed a legacy ImageProcessor class. The problem is that its header file includes a massive third-party library header <heavy_image_lib.h>, which is slowing down the compile times of the entire project and forcing users to have the library installed just to include the header.

Your Task: Refactor the following code to use the PIMPL idiom. You must move all private members and the #include <heavy_image_lib.h> out of the header and into the implementation file.

// CURRENT HEADER (ImageProcessor.h)
#pragma once
#include <string>
#include <heavy_image_lib.h> // This is the problem!

class ImageProcessor {
public:
    ImageProcessor();
    ~ImageProcessor();
    void loadFile(const std::string& path);
    void applyGrayscale();

private:
    HeavyImageBuffer buffer; // Part of the heavy library
    int qualitySettings = 90;
    std::string lastLoadedPath;
};

Requirements:

  • The ImageProcessor.h file must no longer include <heavy_image_lib.h>.
  • Use std::unique_ptr for the implementation pointer.
  • Ensure the destructor is correctly handled to avoid compilation errors regarding incomplete types.
  • The public API (loadFile and applyGrayscale) must remain unchanged.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.