In C++, you can have abstract base classes that are similar to interfaces in dotNET. Only it is not possible to declare static methods that way. This makes it impossible to enforce a class having static methods with a specific parameters list. Using standard C++ behavior, there are some ways we can work around that.
Introduction
The C++ language does not have the dotNET concept of interfaces. Instead, you can make abstract
classes which contain method signatures without implementation, like this:
class IContract
{
public:
virtual void DoStuff() = 0;
}
There are a couple of reasons why this is useful. For starters, the obvious reason is that you want to enforce that when base is called, an implementation dependent DoStuff
is executed.
class CUtility : public IContract
{
public:
void DoStuff(int &val);
}
In this implementation, an object can be accessed through the derived object or the base object, and in both cases, the same method will be executed.
Now suppose you want DoStuff
to be a static
method because it's a helper function that does something with the supplied parameter. Then it becomes a problem because static
methods cannot be virtual. The code below will not compile.
class IContract
{
public:
static virtual void DoStuff(int &val) = 0;
};
class CUtility : public IContract
{
public:
static void DoStuff(int &val);
};
class CSomeClass
{
private:
int m_val = 0;
public:
void Something(void) {
CUtility ::DoStuff(m_val);
}
};
int main()
{
CSomeClass a;
a::Something();
}
At this point, you may be wondering why you'd even need this.
In my case, it surfaced because I was writing a memory allocator class for memory management. That requires various functions (allocate
, deallocate
, ...) with a specific signature. They belong together (so they should logically be in a class) and they don't depend on specific object state (so they can be static
).
I was working on a template class where different types of allocator could be provided, but they all have to have the correct method signatures. Deriving from IContract
is one way to ensure this. It's not the only way and in fact with static
methods, it's not even possible. That doesn't work because the standard doesn't allow it.
In this article, I highlight various other approaches. Note that some of them are a bit contrived. I just want to explore the different options.
Why Do We Need an Interface Contract
What happens if we let go of the idea of defining an interface contract.
class CUtility
{
public:
static void DoStuff(int &val);
};
class CSomeClass
{
private:
int m_val = 0;
public:
void Something(void) {
CUtility::DoStuff(m_val);
};
int main()
{
CSomeClass a;
a.Something();
}
In this scenario, we have no explicit interface definition that is implemented. In general, this is ok because we're purposely using CUtility::DoStuff
in our code, which means that we probably checked that CUtility
is implementing whatever we need in CSomeClass
. Both classes are concrete types so after testing, you're pretty much covered.
But in my case, CSomeClass
is a template class, and CUtility
is the template argument. There are multiple implementations of the methods of IContract
that all implement methods with the same signature:
class CUtility
{
public:
static void DoStuff(int &val);
};
template<typename T>
class CSomeClass
{
private:
int m_val = 0;
public:
void Something(void) {
T::DoStuff(m_val);
}
};
int main()
{
CSomeClass<CUtility> a;
a.Something();
}
based on which implementation is supplied, a specific DoStuff
is called.
This means that throughout various points in time, other IContract
implementations can be made, long after CSomeClass
was developed. Now you could argue that if DoStuff
doesn't have the correct signature, the code won't compile. But that's not entirely true. In this simple case, we pass an int
by reference.
If someone accidentally provides the following implementation, it will compile just fine. It just won't do what is expected because the int
is passed by value.
class CUtility2
{
public:
static void DoStuff(int val);
};
So clearly, the approach of just forgetting about an interface contract is less than ideal.
Not-Really-A-Workaround
There is not a whole lot we can do if we stick to basic C++ except to leave the interface in place, make the methods instance methods, and have a static
instance in our class.
I want to stress that this is not a workaround because we don't have static
methods any more. We have instance methods on an instance without internal state.
class IContract
{
public:
virtual void DoStuff(int &val) = 0;
};
class CUtility: public IContract
{
public:
void DoStuff(int &val);
};
template<typename T>
class CSomeClass
{
private:
static T t;
int m_val = 0;
public:
void Something(void) {
static_cast<IContract&>(t).DoStuff(m_val);
}
};
int main()
{
CSomeClass<CUtility> a;
a.Something();
}
That works too. By casting t
to a IContract&
, we enforce the call to happen through IContract::DoStuff.
The only very annoying thing is that even though our static
variable still needs to be declared in a cpp file somewhere.
CSomeClass<CUtility>::CUtility t;
And if we use multiple derived classes as template argument, we need to declare them all, somewhere.
CSomeClass<CUtility>::CUtility t;
CSomeClass<CUtility2>::CUtility2 t;
For non-template classes, you can do that in the cpp file for that class. So if we have a caller.h and a caller.cpp, then that goes in the cpp file and we can forget about it. But because caller is a template class, not only do we not have a cpp file for it but even if we did, it would not know which static
variables need to be declared.
This is annoying because it means that we cannot just change template types without also changing static
variable declarations. We could, of course, also turn the static
variable into an instance variable. That works too. But of course, if we do that, there is nothing static
about the solution anymore.
And arguably, while we use a static
variable, the contract implementation itself is non-static. Now admittedly, when I was facing this problem, I simply decided to make IContract
a non-static
interface contract on an empty class which is the simplest solution and has no real downsides, but for curiosity's sake, I fiddled around until I had the next workarounds.
An Enforcement Mechanism
If we want to make sure that a method is implemented with a specific signature, we need an enforcement mechanism. I've found an elegant solution that ensures the correct implementation of the contract.
First, we slightly modify the interface contract. Instead of virtual
functions, we use function pointer typedef
s to define the precise interface.
class IContract
{
public:
typedef void (*DoStuffFunc)(int& val);
};
A function pointer typedef
is just like any other type what can be assigned to, which means we can do something like this:
IContract::DoStuffFunc funcdummy = T::DoStuff;
This is great, because the compiler will attempt to compile and if the two are not an exact match, we have what we need. Now it's just a matter of putting this in the code someplace to tie CSomeClass
to this constraint.
Workaround 0: Casting Every Method Call
The simplest way without much fuss is to typecast every method call:
template<typename T>
class CSomeClass
{
private:
int m_val = 0;
public:
void Something(void) {
static_cast<IContract::DoStuffFunc>(T::DoStuff)(m_val);
}
};
We simply cast the method to a function pointer which is then invoked. This works but let's be honest, it doesn't exactly look clean. Also, because the check is implemented where the method is invoked, it requires programmers to remember to implement this whenever they use static
methods which are supposed to have an interface contract. So it is error prone, and something you need to remember as CSomeClass
is developed during the lifecycle.
Workaround 1: Static Inline Variable
A very simple and straightforward way to set this up as a prerequisite is to do this:
template<typename T>
class CSomeClass
{
static inline IContract::DoStuffFunc funcdummy = T::DoStuff;
private:
int m_val = 0;
public:
void Something(void) {
T::DoStuff(m_val);
}
};
In CSomeClass
, we have a static
variable that is a function pointer of the type which was typedef
'ed in our contract. This is initialized with a pointer to the DoStuff
method that is implemented by the supplied template type.
Any DoStuff
implementation which does not have the exact same signature will cause compiler errors. And what's really nifty here is that we don't even have to call the static
method through funcdummy
. We can continue to call it through T::DoStuff
. funcdummy
's only purpose is simply to exist for checking if T::DoStuff
can be assigned.
We need C++17 for this, because otherwise it is not possible to initialize the static
variable inline and we would be back to the problem of the previous solution where we needed an explicit static
variable.
Workaround 2: Template Concepts
In this workaround, we enforce the contract through C++ template concepts. This is also why C++20 is needed. Template concepts are a C++20 feature.
A concept that checks if the conversion is possible can be written like this. The static_cast
is not evaluated. The compiler only checks if the code compiles or not.
template<typename T>
concept ImplementsContract =
requires(T t) {
static_cast<IContract::DoStuffFunc>(T::DoStuff);
};
The implementation then becomes:
template<typename T> requires ImplementsContract<T>
class CSomeClass
{
};
Which is nice and readable. An additional benefit over the previous solution is that there doesn't have to be a member variable.
I did investigate whether it is possible to define constraints on the parameter list of a function directly in the concept without needing the static_cast<IContract::DoStuffFunc>(T::DoStuff)
typecast but could not find a solution. It is possible to check whether T::DoStuff
takes an int
as parameter:
template<typename T>
concept ImplementsContract =
requires(T t, int& i) {
T::DoStuff(i);
};
However, what this really checks is not whether T::DoStuff
takes an int
parameter by reference, but whether T::DoStuff
can be called when we supply an int
as parameter. That is fundamentally a very different question!
If we supply implementations with the signature T::DoStuff( int i)
or T::DoStuff(float f)
instead of T::DoStuff(int& i)
, it will compile without error because an int
can be passed as parameter and the compiler will decide that the concept is validated. So for now, it seems that using a function pointer typedef
is the only real way to guarantee that a static
method has the correct signature.
Workaround 3: Template Parameterization
As I mentioned, concepts only work in C++20. However, we can still do something similar, but more ugly if we're stuck with C++14 by making the function pointer a part of the type definition:
template<typename T,
IContract::DoStuffFunc f = T::DoStuff>
class CSomeClass
{
};
Basically, we have a second parameter in our template which is our function pointer type. If the correct DoStuff
method is implemented, it will compile just fine. If T
does not have the correct DoStuff
implemented, if will fail in the way of C++: with a whole lot of errors and no real explanation.
The reason I don't really like this approach is that these constructions make code much less readable and intuitive, especially when you need to hunt down the source of the problem.
Workaround 4: SFINAE
The previous example works because it will cause compilation failure if the wrong signature is supplied, and cause a bunch of compiler errors. Wouldn't it be nice if -in the absence of C++20 concepts because we're stuck with C++14- we at least get a clean compiler error telling us what's wrong?
We can do that using static_assert
. Basically, static_assert
allows us to generate a compiler error if a condition is met. In our case, if T::DoStuff
is not static_cast
-eable to IContract::DoStuffFunc
. In order to evaluate that condition, we need SFINAE to do the type evaluation. There is no standard 'is_static_castable
' type evaluation, but we can make it ourselves. And when I say 'make it ourselves' I really mean 'use someone else's pattern' (Thanks, Pavel).
template <class F, class T, class = T>
struct is_static_castable : std::false_type
{};
template <class F, class T>
struct is_static_castable<F, T, decltype(static_cast<T>
(std::declval<F>()))> : std::true_type
{};
Basically, is_static_castable
defaults to deriving from std::false_type
, and there is a partial specialization that derives from std::true_type
for specializations where a value of type F
can be cast to a value of type T
. The compiler cannot do the static_cast
directly because we're still in the compilation stage, but it can check the type of the static_cast
operation if it should be performed. And if the operation cannot be compiled, the type evaluation fails.
Using this pattern, we can do something like this:
template<typename T>
class CSomeClass
{
static_assert(
is_static_castable<decltype(T::DoStuff), IContract::DoStuffFunc>::value,
"Interface contract IContract not implemented");
};
Now we can simply compile CSomeClass
and if we supply T::DoStuff(int i)
then even though the code compiles, there will still be a clean compiler error and not a dumptruck full of template compilation errors.
Note that is_static_castable
takes two type arguments so we cannot directly supply T::DoStuff
as an argument, but we can supply 'the type of T::DoStuff
' by using the decltype
keyword.
Points of Interest
C++ and template programming in particular are very powerful and as I described in this article, we can use it to enforce interface contracts for static
methods in various ways. With the previous examples, I hope to have covered the fundamentals of the various different options. Undoubtedly, there are many more variations possible in the same vein.
That said, it may sometimes be best / simplest / easiest to not deal with a real solution and simply use instance methods on an empty class. The cost of that is negligible and you can ignore all those problems. When you need to get something done in a hurry, it may be a good idea to not get too creative. Especially since someone else who is perhaps not experienced with template meta programming may end up maintaining the code.
Still, it's always good to have another tool in your toolbox for the rare occasion when you really need to check that a static
method is implemented with a specific signature.
History
- 2nd February, 2023: First version