- Notifications
You must be signed in to change notification settings - Fork7
Bareflank/static_interface_pattern
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
SIP provides the ability to implement the S.O.L.I.D. design principles in C++ without the need for virtual inheritance.
Suppose we have the following class:
classA{public:voidfoo() { }};
Now suppose we wish to useA
in another class as a private member variable as follows:
#include"a.h"classB{public:voidbar() { m_a.foo(); }private: A m_a;};
Finally, let us instantiateB
in our application as follows:
#include"b.h"intmain(){ B b; b.bar();return0;}
The above example is a simple demonstration of a class hierarchy that most C++ programmers have implemented at one point in time or another. Simply put, this example shows a class depending on another class.
The biggest issue with the above example is it doesn't adhere to the S.O.L.I.D design principles. The S.O.L.I.D design principles are a set of 5 principles designed to address different types of common problems found in projects that leverage object oriented programming. These 5 principles are as follows:
- Single responsibility principle
- Open–closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
Of these 5, our example has issues with 3 of them.
The general problem above does not adhere to the OCP asB
is not open to extension.B
directly depends onA
, which means that any changes toA
will changeB
. In other words, there is no way to add functionality toA
withoutB
knowing about it. To fix this, we will need to provideB
with an interface toA
instead of directly relying onA
itself.
Since we do not defineA
's andB
's responsibilities, we do not know ifA
orB
provide the needed level of abstraction to prevent clients from depending on interfaces they do not need. We can, however, still state that the general problem above does not adhere to the ISP as clients ofB
are required to include the definition ofA
, sinceB
's definition includesA
's definition. The larger a project gets, the more this type of problem will result in hard to debug dependency chains. To solve this problem, bothA
andB
will need their own interfaces that do not include the "details" of their implementations.
The general problem above does not adhere to the DIP as bothA
andB
fail to depend on their own interfaces, meaning clients of bothA
andB
will have to depend on the "details" ofA
andB
instead of the interfaces ofA
andB
.
Typically in C++, abstraction is implemented using pure virtual interfaces (i.e., dynamic interfaces) that leverage runtime polymorphism to separate the details of an object from its interface. For example:
classAInterface{public:virtual~AInterface() =default;virtualvoidfoo() = 0;};
The above pure virtual interface defines the interface forA
. Using this interface,A
can be defined as the following:
#include"a_interface.h"classA : public AInterface{public:voidfoo()override { }};
As shown above,A
now inherits our interface, and overrides the foo() function. To implementB
, we must first define its interface as follows:
classBInterface{public:virtual~BInterface() =default;virtualvoidbar() = 0;};
With the above interface defined forB
, we can now defineB
as the following:
#include"b_interface.h"#include"a_interface.h"classB : public BInterface{public:B(AInterface &a) : m_a{a} { }voidbar()override { m_a.foo(); }private: AInterface &m_a;};
As shown above,B
now stores a reference to the interface ofA
and not an instance ofA
. Dynamic interfaces introduce an issue with ownership. Unlike our general problem (or static interfaces),B
cannot createA
as part of its own definition without breaking the OCP. The above example is one way to handle this, but there are many different ways to address this, including the factory pattern.
To useA
andB
, we can do the following:
#include"a.h"#include"b.h"intmain(){ A a; Bb(a); b.bar();return0;}
As shown above, we can see thatA
andB
now adhere to the S.O.L.I.D principles. SinceB
only depends on the interface ofA
,A
can change without changingB
. In addition, we can provideB
with any version ofA
we want meaning thatB
is open to extension while closed to modifications. For example, we can implement a unit test ofB
as follows:
#include"a_interface.h"#include"b.h"#include<iostream>classA_mock : public AInterface{public:voidfoo()override { std::cout <<"mocked foo\n"; }};intmain(){ A_mock a; Bb(a); b.bar();return0;}
As shown above, we can useA
's interface to mockA
, without making any modifications toB
, meaning we now adhere to OCP. We also adhere to ISP asB
no longer includes the definition ofA
, only the interface, and we also adhere to DSP as bothA
andB
only depend on interfaces.
There are some issues with dynamic interfaces however. The first issue with dynamic interfaces is they add additional overhead. For example, with some tricks (can be seen in the source code examples) to ensure inlining is controlled, we can see the main function for the general problem looks like this following:
0000000000401020 <main>: 401020:e8 fb 00 00 00 callq 401120 <_ZN1A3fooEv.isra.0> 401025:31 c0 xor %eax,%eax 401027:c3 retq
The resulting code of this same logic using dynamic interfaces results in the following:
0000000000401040 <main>: 401040:48 83 ec 18 sub $0x18,%rsp 401044:48 c7 44 24 08 60 20 movq $0x402060,0x8(%rsp) 40104b:40 00 40104d:48 8d 7c 24 08 lea 0x8(%rsp),%rdi 401052:e8 f9 00 00 00 callq 401150 <_ZN1A3fooEv> 401057:31 c0 xor %eax,%eax 401059:48 83 c4 18 add $0x18,%rsp 40105d:c3 retq
The extra logic seen above is needed to initialize the vTable ofA
and get access to the function pointer tofoo()
. Whether or not this overhead actually ends up becoming a problem for your applications depends on your application as modern compilers are amazing at removing the overhead of virtual inheritance.
The other issue with dynamic interfaces is they do not support static functions. Meaning, ifA
defines a static function thatB
needs to use, this entire scheme no longer works as there is no way to define a static function in a pure virtual interface.
The goal of the static interface pattern (SIP) is to address the issues of dynamic interfaces by implementing abstraction without the need for virtual inheritance. To accomplish this, we will use the following class:
template<template<typename>typename INTERFACE,typename DETAILS >classtype : public INTERFACE<type<INTERFACE, DETAILS>>{using details_type = DETAILS; DETAILS d;friendclassINTERFACE<type<INTERFACE, DETAILS>>;constexprstatic DETAILS*details(INTERFACE<type<INTERFACE, DETAILS>> *i) {return &(static_cast<type<INTERFACE, DETAILS> *>(i)->d); }constexprstaticconst DETAILS*details(const INTERFACE<type<INTERFACE, DETAILS>> *i) {return &(static_cast<const type<INTERFACE, DETAILS> *>(i)->d); }};
The above class implements Static Polymorphism (also called Curiously Recurring Template Pattern). The difference is, the above class provides a means to define an object's interface and implementation separately and then combine different implementations with the same interface as needed, all at compile time.
To better understand how this class works, let us first look atA
's interface as follows:
namespaceinterface{template<typename T>structA{constexprvoidfoo() {T::details(this)->foo(); }};}
As shown above, the interface is defined using a template. We wrap the interface in an "interface" namespace so that the interface can be calledinterface::A
. Each of the functions within the interface call into its own subtype using thedetails
function, which returns a pointer to the subtype's private implementation.
With this interface,A
is defined as follows:
#include"a_interface.h"namespacedetails{classA{public:voidfoo() { }};}using A = type<interface::A, details::A>;
As shown above,A
is defined the same as our general problem. The only difference is, we wrap the definition ofA
in a namespace called "details" allowing us to call the implementationdetails::A
. From here, we use ourtype
class to actually createA
withusing A = type<interface::A, details::A>
.
The next step is to define the interface forB
as follows:
namespaceinterface{template<typename T>structB{constexprvoidbar() {T::details(this)->bar(); }};}
As shown above, we use the same pattern as above to create our interface.B
is define as follows:
#include"a_interface.h"#include"b_interface.h"namespacedetails{template<typename T>classB{public:voidbar() { m_a.foo(); }private: interface::A<T> m_a;};}template<typename T>using B = type<interface::B, details::B<T>>;
As shown above, the definition ofB
is identical the the general problem with some exceptions. First,B
is now a template class so that we can provideB
with different versions ofA
as needed. Instead of directly instantiatingA
, we instantiateA
using its interface using static polymorphism. LikeA
,B
is implemented in a "details" namespace, and defined using thetype
class. The difference is thatB
must remain a template type to ensure we can give it whateverA
we want.
To use this code, we can do the following:
#include"a.h"#include"b.h"intmain(){ B<A> b; b.bar();return0;}
Compared to dynamic interfaces, static interfaces solve the ownership issues asA
is instantiated inB
withoutB
having to rely on the definition ofA
. Static interfaces also support static functions as the interface can use thedetails_type
type to access a static function, allowing the interface to override static functions (something dynamic interfaces cannot do).
With respect to performance, once again, this depends on your application. If we compare the resulting binary of our static interface with the resulting binary of our general problem (with --strip-all to remove strings), the binaries are byte-for-byte identical, meaning the compiler is able to take the above template code and reduce it to the same code as the general problem. This doesn't mean that static interfaces do not come at a cost as the applications string table is much larger with all of the additional decorations (so strip them), and the above code is far more difficult to understand without really knowing how templates work including static polymorphism, so there is a human cost to this abstraction (something C++20 will likely address with C++ Concepts)
For reference, mocking works as follows:
#include"a_interface.h"#include"b.h"#include<iostream>namespacedetails{classA_mock{public:voidfoo() { std::cout <<"mocked foo\n"; }};}using A_mock = type<interface::A, details::A_mock>;intmain(){ B<A_mock> b; b.bar();return0;}
In general, static interfaces address all of the problems that dynamic interfaces introduce when attempting to add abstraction and adhere to S.O.L.I.D at the expense of being more difficult to understand.
About
how to implement the static interface pattern
Topics
Resources
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Releases
Packages0
Uh oh!
There was an error while loading.Please reload this page.