Les Imbroglios d'Alexis Breust

De l'impro sans bugs et du code lâcher-prise.

Design Patterns: pimpl in C++

lava uses multiple design patterns, but any usage of those are driven by two core principles:

  • do not show more than needed ;
  • keep performances in mind.

Naive renderer

That being said, you can be sure that the singleton pattern is never used. Fact is, in lava, nothing has to be truely unique while being globally accessible.

Each of the pattern will improve a naive design of a simple renderer:

class Renderer {
public:
    void render();

    void addSquare(glm::vec2 center, float sideSize);
    void addCircle(glm::vec2 center, float radius);

protected:
    struct Square {
        glm::vec2 center;  
        float sideSize;  
    };

    struct Circle {
        glm::vec2 center;  
        float radius;  
    };

protected:
    // Internal functions
    void renderSquare(const Square& square) const;
    void renderCircle(const Circle& circle) const;

private:
    std::vector<Square> m_squares; 
    std::vector<Circle> m_circles; 
};

include/lava/renderer.hpp

#include <lava/renderer.hpp>

Renderer::render()
{
    for (const auto& square : m_squares) {
        renderSquare(square);
    }

    for (const auto& circle : m_circles) {
        renderCircle(circle);
    }
}

void Renderer::addSquare(glm::vec2 center, float sideSize)
{
    m_squares.emplace_back({ center, sideSize });
}

void Renderer::addCircle(glm::vec2 center, float radius)
{
    m_circles.emplace_back({ center, radius });    
}

// Internal functions
void Renderer::renderSquare(Square& square) const
{
    // ...
}

void Renderer::renderCircle(Circle& circle) const
{
    // ...    
}

source/renderer.cpp

This renderer allows us to add any number of squares and circles. It will render everything (doesn't matter where or how for our example) when asked. It's actually pretty clean, but we're going to see how and why it can be improved.

Pointer to implementation (pimpl)

Following the first principle truely, the pimpl pattern try to hide details of implementation behind a pointer.

We don't need to know which structure you are using to store the squares and the circles. You should try to minimize everything that is in the include folder. And, by the way, you should have detailed comments in that file.

class Renderer {
public:
    Renderer();
    ~Renderer();

    void render();

    void addSquare(glm::vec2 center, float sideSize);
    void addCircle(glm::vec2 center, float radius);

private:
    class Impl;
    Impl* m_impl = nullptr;
};

include/lava/renderer.hpp

And that's all the end user should see.

#include <lava/renderer.hpp>

#include "./renderer-impl.hpp"

Renderer::Renderer()
{
    m_impl = new Impl();
}

Renderer::Renderer()
{
    delete m_impl;
}

Renderer::render()
{
    m_impl->render();
}

void Renderer::addSquare(glm::vec2 center, float sideSize)
{
    m_impl->addSquare(center, sideSize);
}

void Renderer::addCircle(glm::vec2 center, float radius)
{
    m_impl->addCircle(center, radius); 
}

source/renderer.cpp

#include <lava/renderer.hpp>

class Renderer::Impl {
    // ... (content of the naive Renderer)
};

source/renderer-impl.hpp

#include "./renderer-impl.hpp"

// ... (content of the naive renderer implementation)
// but method are referenced like Renderer::Impl::render().

source/renderer-impl.cpp

So, you basically keep the naive implementation but it is encapsulated in the Impl class. You have now a clean interface for the user.

The drawbacks are:

  • Many files to maintain in sync, and that's harder than the naive version ;
  • Dereferencing a pointer each time (turns out it is not that costly).

But the benefits are worth it:

  • Clean API ;
  • Faster compile time on modification (as long as you don't change the API).

And, there is one other important thing. That pointer, you could make it point to anything as long as it as the right interface. Example:

#include <lava/renderer.hpp>

// Both files actually implement Renderer::Impl,
// but we choose at compile-time the implementation we want.
#if defined(PLATFORM_IS_SMARTPHONE)
    #include "./fast-renderer-impl.hpp"
#else
    #include "./beautiful-renderer-impl.hpp"
#endif

// ... (forwarded methods as before)

source/renderer.cpp

When should you use pimpl?

  • You have a class with a lot of internal methods ;
  • Or you want to be able to switch implementation during compile-time or runtime.