DEV Community

Félix-Olivier Dumas
Félix-Olivier Dumas

Posted on

Static Interface Projection Pattern (SIPP): Static Control of Method Exposure in C++

Introduction and context

Nowadays, C++ is one of the most influential programming languages in the world. Over the past 40 years, it has proven itself in fields ranging from aerospace engineering to your basement at 2 a.m.

What is most striking is that we often forget the origin of this pillar of modern programming. Stroustrup originally named his project “C with Classes,” since it added object-oriented features to C without modifying the underlying language itself. It is therefore obvious that polymorphism plays a central role in the philosophy of this language.

To this day, there are two major categories when it comes to polymorphism in C++: static polymorphism and dynamic polymorphism. Although they share the same underlying philosophy, the reality is that they are extremely different from one another, both in implementation and in usage.

In today’s article, we will mainly focus on static polymorphism. Not because virtual polymorphism is uninteresting, but because the two differ so much that they would each require a full article of their own. So feel free to settle in and enjoy what follows!


Understanding polymorphism

First of all, it is important to explain what polymorphism is in C++. If you are reading this article, I assume you already understand the general concept of polymorphism, but I will still take a moment to briefly explain it.

In programming language theory and type theory, polymorphism allows a value or variable to have more than one type and allows a given operation to be performed on values of more than one type.

In object-oriented programming, polymorphism is the provision of one interface to entities of different data types. The concept is borrowed from a principle in biology in which an organism or species can have many different forms or stages.

— Source: Wikipedia, Polymorphism (programming language theory)

The explanation from the citation above is particularly effective, as it touches on one of the core ideas of the concept: interfaces. An interface can be seen as a contract, an obligation, or a structural constraint. For example, if a cat claims to be a feline, it is bound by the contract of exhibiting feline behavior in some form or another. Thus, when we see a cat, we can assume it can hunt, since felines hunt.

Let’s put felines aside and move on to a more concrete, code-oriented approach. From this point on, I will present examples in C++. “Talk is cheap, show me the code.”:

#include <iostream>

struct Felin {
    virtual ~Felin() = default;
    virtual void hunt() = 0;
};

struct Cat : Felin {
    void hunt() override {
        // hunts a mouse, an insect, etc.
        std::cout << "Cat hunts\n";
    }
};
Enter fullscreen mode Exit fullscreen mode

Here, you can see that the Felin struct defines the contract via the hunt method. Then you can notice that the Cat struct inherits from Felin, which automatically forces it to respect the contract and therefore provide its own implementation of the required method.

Finally, for the more observant among you, you may have noticed the slightly unusual syntax with the keywords virtual and override. This is not surprising, since it represents a form of virtual polymorphism. As mentioned earlier, we will not really cover this category. There are many resources available, and I encourage you to explore them if you are curious about how it works.


Static Polymorphism in C++

Static polymorphism is a programming mechanism in which the correct implementation is selected at compile time, based on the types known at that moment. In other words, a single interface can be used with different types, but the choice of which version is executed is resolved before the program runs, by the compiler.

It is important to understand that static polymorphism is not a single technique, but rather a set of techniques sharing the same fundamental constraints. To illustrate this concretely, let’s examine one of the most common uses of CRTP:

#include <iostream>
#include <utility>

struct Base {
    template <typename Self>
    void interface(this Self&& self) {
        std::forward<Self>(self).implementation();
    }
};

struct Derived : Base {
    void implementation() {
        std::cout << "Implementation from Derived\n";
    }
};
Enter fullscreen mode Exit fullscreen mode

Analysis of the CRTP Example

As you can see, the example above is significantly more complex than the one we discussed earlier. This is not accidental, since it requires replicating, entirely at compile time, the behavior that the compiler would normally handle with virtual polymorphism.

Here, we are also using the latest C++23 standard features to greatly simplify the pattern’s implementation. The use of the explicit this parameter allows us to retrieve the object from which the function is called. In this example, the interface method will always be invoked on the derived object Derived, which allows it to directly call the implementation method on that object.

template <typename Self>
void interface(this Self&& self) { // self = derived type
    std::forward<Self>(self).implementation(); // call implementation method
}
Enter fullscreen mode Exit fullscreen mode

Advantages of Static Polymorphism

Why use static polymorphism? For the simple reason that it provides significant performance gains compared to virtual polymorphism. In addition, it offers a great deal of flexibility in design, since it allows you to modify the behavior of polymorphic delegation, something that is not normally possible with the virtual approach.

#include <utility>

struct Base {
    template <typename Self>
    void interface(this Self&& self) {
        std::forward<Self>(self).implementationA();
        std::forward<Self>(self).implementationB();
    }
};
Enter fullscreen mode Exit fullscreen mode

Here, we call two methods sequentially from the base interface method, which is normally impossible with virtual polymorphism. As another example, it would even be possible to measure the execution time of the derived method by starting a timer before the call and stopping it afterward.


Limitations and Drawbacks

Although it may seem perfect at first glance, static polymorphism has many obvious issues that prevent it from fully replacing its virtual counterpart. I have therefore selected the main drawbacks associated with its use.

Code Complexity and Readability

First of all, there is obviously the added complexity and reduced readability of its implementation. It is well known that nothing is free, and you inevitably pay for these performance gains with increased complexity. This is also why this approach is generally used in environments where performance is critical and readability is almost secondary.

Slicing and Storage Issues

And that’s not all, because objects using this approach cannot be stored in the same way as those using virtual polymorphism. For example, you cannot store Cat objects inside a std::vector<Felin> by value, because this leads to slicing: only the part corresponding to the base class is preserved during copying, while the derived part is lost. This is what is known as “slicing”, although I will avoid going into further detail for the sake of the target audience. However, there is a solution, but it introduces a significant increase in complexity and reduced readability—you can judge for yourself:

#include <iostream>
#include <variant>
#include <vector>

struct Shape {
    template <typename Self>
    void draw(this Self&& self) {
        self.draw_impl();
    }
};

struct Circle {
    void draw_impl() {
        std::cout << "Circle\n";
    }
};

struct Square {
    void draw_impl() {
        std::cout << "Square\n";
    }
};

using AnyShape = std::variant<Circle, Square>;

int main() {
    std::vector<AnyShape> shapes = { Circle{}, Square{}, Circle{} };

    for (auto& s : shapes) {
        std::visit([](auto& shape) {
            shape.draw();
        }, s);
    }
}
Enter fullscreen mode Exit fullscreen mode

Exposure of Implementation Methods

Finally, one of the issues that bothers me the most is that implementation methods are always visible and accessible from the derived object. This exposes methods unnecessarily, bloats the public API of the object, and is generally “messy”. Here is a small example so you can see what I mean:

#include <iostream>

struct Circle {
    void draw_impl() {
        std::cout << "Circle\n";
    }
};

int main() {
    Circle c{};
    c.draw_impl(); // visible and accessible
}
Enter fullscreen mode Exit fullscreen mode

Interim Conclusion

To conclude this section, you may have noticed that I avoid going too deep into the technical details of how all these concepts work. This is mainly because that is not the focus of this article. This section is meant more as context than as a technical deep dive. That said, I have already written a large and highly technical article on static polymorphism and CRTP. In it, I also present an experimental approach called “exotic CRTP”, which may interest the more curious readers among you.


SIPP: an Experimental Approach to Static Polymorphism

Pattern Overview

Now that you understand some of the main drawbacks of static polymorphism, I can share what I have been experimenting with over the past few days. Here is the result of my experiments on the matter.

#include <iostream>

struct Implementation {
    void foo() {
        std::cout << "foo\n";
    }

    void bar() {
        std::cout << "bar\n";
    }
};

template<typename Impl>
struct Interface : private Impl {
    using Impl::foo;
    using Impl::bar;
};
Enter fullscreen mode Exit fullscreen mode

First of all, I must say that I spent a long time searching online for similar implementations to what I have been experimenting with, but unfortunately, I found nothing concrete. However, I am certain I am not the first to discover this, because it feels far too obvious.

For all these reasons, I decided to name it: Static Interface Projection Pattern, or SIPP.


Understanding How SIPP Works

At first, it may seem a bit unclear, but we will gradually go through each part of this code to better understand it.

Let’s start by looking at the code. You may notice that the typical “Derived inherits Base” model appears inverted here. In fact, the interface acts as a view over an implementation. More precisely, it filters which methods are exposed externally. So if we look at the example above, the only methods callable from an Interface<Implementation> object are foo and bar:

#include <iostream>

struct Implementation {
    void foo() {}
    void bar() {}
};

template<typename Impl>
struct InterfaceA : private Impl {
    using Impl::foo;
};

template<typename Impl>
struct InterfaceB : private Impl {
    using Impl::bar;
};

int main() {
    InterfaceA<Implementation> objA;
    InterfaceB<Implementation> objB;

    objA.foo();
    objB.bar();
}
Enter fullscreen mode Exit fullscreen mode

Inversion of the Contract Model

As you can understand, the Implementation (Derived) no longer inherits a contract: the contract itself is what gets applied to it. In this way, the Implementation class becomes completely independent and decoupled from any contractual structure. This results in a kind of inversion, where both the polymorphic contract and the implementation must now accommodate each other for the contract to work. It is somewhat like moving from a relationship where only one person makes the decisions to a relationship where both parties have a say.

A Symmetrical Contractual Relationship

In this way, we create a symmetrical contractual polymorphic relationship. For example, if an Interface class exposes the method foo, the implementation class must necessarily provide that method in an accessible way; otherwise, the contract cannot be validated (the compiler will simply fail because the method foo does not exist in Impl). On the other hand, the implementation class itself can expose additional methods outside the contract, but only the methods defined by the contract will be accessible from the outside.

Extension to Multiple Inheritance and Contract Composition

Now, there is the topic of multiple inheritance, which becomes somewhat ambiguous. This model is primarily designed for a 1:1 relationship between interface and implementation, but it could also be extended to multiple inheritance with some caution. I have therefore prepared an example of what this could look like:

#include <iostream>

template<typename... Interfaces>
struct MultiInterface : public Interfaces... {};

struct Implementation {
    void foo() { std::cout << "foo\n"; }
    void bar() { std::cout << "bar\n"; }
};

template<typename Impl>
struct InterfaceA : private Impl {
    using Impl::foo;
};

template<typename Impl>
struct InterfaceB : private Impl {
    using Impl::bar;
};

int main() {
    MultiInterface<
        InterfaceA<Implementation>,
        InterfaceB<Implementation>
    > obj;

    obj.foo();
    obj.bar();
}

Enter fullscreen mode Exit fullscreen mode

As you can see, this quickly becomes very heavy in terms of readability. It would be better to use aliases to reduce the burden of these rather “demonic” type signatures. As for the implementation itself, it is not actually that complicated: we use a wrapper that inherits and publicly exposes all methods from the contracts that compose it. It is simple and effective, but it is not entirely 100% safe, because there is always a risk that two contracts expose the same method, which would result in an ambiguous call.

However, and I must emphasize this, this pattern is not really intended for use cases where multiple inheritance is a primary requirement.


Practical Applications of SIPP

This unusual relationship leads us to understand that this form of static polymorphism opens many doors in the design of ultra-high-performance object-oriented architectures. To illustrate this properly, here are two very interesting examples that use this pattern.

First Example: Logging System

Here is the first example: a simple implementation of a logging system where the logger can be viewed in two different states depending on the chosen contract:

#include <iostream>

struct ConsoleLogger {
    void write() {
        std::cout << "write to console\n";
    }

    void flush() {
        std::cout << "flush console\n";
    }

    void debug() {
        std::cout << "debug: console\n";
    }
};

template<typename Impl>
struct LoggingInterface : private Impl {
    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingInterface : private Impl {
    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    LoggingContract<ConsoleLogger> console_logger;

    console_logger.write();
    console_logger.flush();

    DebugLoggingContract<ConsoleLogger> debug_console_logger;

    debug_console_logger.debug(); // newly exposed method
    debug_console_logger.write();
    debug_console_logger.flush();
}

Enter fullscreen mode Exit fullscreen mode

In this example, you can see that the first view, LoggingContract, exposes only the write and flush methods. Similarly, the second view, DebugLoggingContract, exposes the same methods as the first one, but additionally exposes the debug method.

In this way, it is possible to represent a single object in two completely different ways without modifying its implementation. The ConsoleLogger class only needs to implement whatever methods it wants, and it is the views/contracts that handle the exposure. Finally, this helps keep a clean and highly extensible API, thanks to the simplicity of the contracts: you only need to add a using Impl::my_method; for it to become exposed.


Second Example: Contract Reinterpretation and Cast

This second example is a bit more technical and may be somewhat more controversial than the previous one. It is not necessarily the most important part, but it involves some slightly “hacky” manipulations that are not intended for everyone. Without further delay, here it is:

#include <iostream>
#include <type_traits>

struct ConsoleLogger {
    void write() {
        std::cout << "write\n";
    }

    void flush() {
        std::cout << "flush\n";
    }

    void debug() {
        std::cout << "debug\n";
    }
};

template<typename Impl>
struct LoggingContract : private Impl {
    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingContract : private Impl {
    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    LoggingContract<ConsoleLogger> console_logger;

    console_logger.write();
    console_logger.flush();

    auto& debug_console_logger =
        reinterpret_cast<DebugLoggingContract<ConsoleLogger>&>(console_logger);

    debug_console_logger.debug();

    static_assert(std::is_layout_compatible_v<
        LoggingContract<ConsoleLogger>,
        DebugLoggingContract<ConsoleLogger>
    >);
}

Enter fullscreen mode Exit fullscreen mode

As you can see, I use a reinterpret_cast to transform my initial view using LoggingContract into another view, DebugLoggingContract. At first glance, this may seem somewhat esoteric, and it is—but there is still a certain logic behind this manipulation.

The first question to ask is probably: why does this even compile in the first place? The answer is simpler than you might think. In reality, LoggingContract<ConsoleLogger> and DebugLoggingContract<ConsoleLogger> are exactly the same in memory. In fact, I verified this using the standard library utility std::is_layout_compatible_v<T, V>, and the result was true.

However, functional does not automatically mean safe. I will avoid going too deep into how ABI and class memory layout work on most compilers, but the key takeaway is that, in memory, these empty view types that inherit from the same implementation are effectively identical. This works well in practice, but it is only an ABI convention and not something guaranteed by the standard.

On the other hand, this approach allows transforming the object without performing any runtime operation, making it almost zero-cost. This mainly avoids the need for copying or assignment, which is clearly non-negligible in performance-critical systems. It is a cost to pay, and whether it is acceptable depends on your use case.

Beyond all these caveats, this cast mainly allows us to reinterpret the view of an object without changing its underlying nature. In fact, the debug_console_logger obtained from the reinterpret_cast points to the exact same object as the original LoggingContract view. This means we can reinterpret the polymorphic contract of an object at compile time, which I find extremely interesting.


Third Example: Safe Conversion Between Views

This third and final example is intended to address the safety issues of the previous example. Indeed, I chose not to place this example before the other one, because it deals with slightly different techniques compared to the base pattern. Here it is:

#include <iostream>
#include <type_traits>

struct ConsoleLogger {
    ConsoleLogger() = default;

    ConsoleLogger& operator=(const ConsoleLogger& other) {
        return *this;
    }

    void write() {
        std::cout << "write\n";
    }

    void flush() {
        std::cout << "flush\n";
    }

    void debug() {
        std::cout << "debug\n";
    }
};

template<typename Impl>
struct LoggingContract : private Impl {
    explicit LoggingContract(Impl& m) : Impl(m) {} // to assign the base at creation time

    using Impl::Impl;

    using Impl::write;
    using Impl::flush;
};

template<typename Impl>
struct DebugLoggingContract : private Impl {
    explicit DebugLoggingContract(Impl& m) : Impl(m) {} 

    using Impl::Impl;

    using Impl::write;
    using Impl::flush;
    using Impl::debug;
};

int main() {
    ConsoleLogger console_logger_impl;

    LoggingContract<ConsoleLogger> console_logger{ console_logger_impl }; // supports construction from the implementation type

    console_logger.write();
    console_logger.flush();

    DebugLoggingContract<ConsoleLogger> debug_console_logger;

    debug_console_logger = console_logger_impl; // also supports assignment from the implementation type

    debug_console_logger.debug();
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this version also primarily aims to allow assigning an object to different views. It does so by using a delegating constructor inside the view, which allows it to receive an object of type Impl in order to initialize the base. In addition, it exposes implementation-specific methods using using Impl::Impl, which in our case also enables the assignment operator to be exposed.

It is important to note that the mechanism used here is fully safe and part of the language design. However, although it may seem like a better option than the previous example, it still has a major drawback: performance cost. Indeed, passing parameters through constructors and using the assignment operator introduce additional runtime overhead. This is not catastrophic, but in a system where this pattern is used extensively, it can start to become expensive. Finally, it also introduces some additional complexity and reduces readability, which is not negligible, since the added elements in this version will be present in every view and implementation of the system.


Final Conclusion

To conclude, I would like to say a few words to those who made it all the way through this article. First of all, thank you for reading my work, I am truly grateful.

Next, I want to make it clear that I do not claim to have invented a pattern. I am simply highlighting a very interesting variation of existing ideas. I also had the intention of giving this technique a name, and I am not entirely sure how the C++ community reacts to this kind of thing, but I do it with good intentions.

I see a future in this pattern, and I truly believe there is something much bigger to be explored here. And with that, this concludes my second article ever. It is much less technical than my first one, and that was intentional. Simplifying complex ideas is an art, and it is key to learning.


Further Reading and Resources

Top comments (0)