Click here to Skip to main content
15,885,984 members
Articles / Programming Languages / C++20

Enforcing a Static Interface in C++

Rate me:
Please Sign up or sign in to vote.
5.00/5 (3 votes)
2 Feb 2023MIT10 min read 12.7K   10   4
Ways to enforce an interface contract on static methods, similar to what you would expect from static virtual methods if they'd exist in C++
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:

C++
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.

C++
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.

C++
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.

C++
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:

C++
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.

C++
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.

C++
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.

C++
CSomeClass<CUtility>::CUtility t;

And if we use multiple derived classes as template argument, we need to declare them all, somewhere.

C++
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 typedefs to define the precise interface.

C++
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:

C++
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:

C++
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:

C++
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.

C++
template<typename T>
concept ImplementsContract =
    requires(T t) {
    static_cast<IContract::DoStuffFunc>(T::DoStuff);
};

The implementation then becomes:

C++
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:

C++
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:

C++
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).

C++
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:

C++
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

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Software Developer
Belgium Belgium
I am a former professional software developer (now a system admin) with an interest in everything that is about making hardware work. In the course of my work, I have programmed device drivers and services on Windows and linux.

I have written firmware for embedded devices in C and assembly language, and have designed and implemented real-time applications for testing of satellite payload equipment.

Generally, finding out how to interface hardware with software is my hobby and job.

Comments and Discussions

 
QuestionNice one! Pin
adanteny6-Feb-23 1:03
adanteny6-Feb-23 1:03 
AnswerRe: Nice one! Pin
Bruno van Dooren6-Feb-23 1:19
mvaBruno van Dooren6-Feb-23 1:19 
GeneralRe: Nice one! Pin
adanteny6-Feb-23 4:02
adanteny6-Feb-23 4:02 
GeneralRe: Nice one! Pin
Bruno van Dooren8-Feb-23 3:09
mvaBruno van Dooren8-Feb-23 3:09 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.