Search code examples
c++typessubclassinvariance

Only allow exact type (not subclasses) in C++


Is is possible to declare types where it only allows that class and not any subclasses (I know this violates the Liskov substitution principle but I still want to know if there's a way to do it.)
For example,

#include <iostream>

struct A {};
struct B : A {};

void fn(/*I want this to be only A, not subclasses*/A arg) {
    // do stuff with arg
    std::cout << "fn called";
}

int main() {
    A a;
    fn(a);
    B b;
    fn(b);  // should raise compile-time error here
}

I want fn(b) to give a compile-time error.

Link to code: https://wandbox.org/permlink/AiLkHwp5rg7AD7gf


Solution

  • There are a lot of ways to approach this, so let's compare them:

    Constraints (since C++20)

    #include <concepts>
    
    // abbreviated function template, could also write:
    //   template <std::same_as<A> T>
    void fn(std::same_as<A> auto arg)  {
        std::cout << "fn called";
    }
    
    Diagnostic
    <source>:8:6: note: candidate template ignored: constraints not satisfied [with arg:auto = B]
    void fn(std::same_as<A> auto arg)  {
         ^
    <source>:8:9: note: because 'std::same_as<B, A>' evaluated to false
    void fn(std::same_as<A> auto arg)  {
            ^
    

    This option works because auto is deduced to B here, which is not the same as A. The constraint doesn't care that B is convertible to A (although std::convertible_to would).

    Pros and Cons
    • short and simple
    • errors are slightly verbose by comparison
    • requires fairly recent version of C++
    • intent conveyed clearly in code

    std::enable_if and SFINAE (since C++11)

    // note: convenience aliases is_same_v and enable_if_t are not available in C++11 yet
    //       this solution is C++17
    template <typename T>
    auto fn(T arg) -> std::enable_if_t<std::is_same_v<T, A>> {
        std::cout << "fn called";
    }
    
    Diagnostic
    <source>:9:6: note: candidate template ignored: requirement 'std::is_same_v<B, A>'
                        was not satisfied [with T = B]
    auto fn(T arg) -> std::enable_if_t<std::is_same_v<T, A>> {
         ^
    

    This implementation is basically the same as the C++20 version, we just do it via SFINAE instead of constraints.

    Pros and Cons
    • more verbose than C++20 counterpart, but bearable
      • solution is much uglier in C++11 because there are no convenience aliases
    • only clang has diagnostics this good for std::enable_if, other compilers will produce much worse output
    • intent conveyed clearly like in the former solution

    Deleted functions (since C++11)

    void fn(A arg) {
        std::cout << "fn called";
    }
    
    template <typename T>
    void fn(T arg) = delete;
    
    Diagnostic
    <source>:14:6: note: candidate function [with T = B] has been explicitly deleted
    void fn(T arg) = delete;
         ^
    <source>:8:6: note: candidate function
    void fn(A arg) {
         ^
    

    We are allowed to declare any function as deleted, where calling it makes the program ill-formed. This solution works because fn(T) wins in overload resolution, as no implicit conversion from B to T is required, only from B to A.

    Pros and Cons
    • requires function overloading
    • = delete on arbitrary functions is surprising, it's not a well-known feature
    • intent not conveyed as clearly in code, we must look at the overload set as a whole to understand it
    • no standard library dependency, i.e. good for compile speed potentially and very portable

    static_assert (since C++11)

    template <typename T>
    void fn(T arg) {
        // or std::is_same_v in C++17
        static_assert(std::is_same<T, A>::value, "fn must be called with A");
        std::cout << "fn called";
    }
    
    Diagnostic
    <source>:10:5: error: static assertion failed due to requirement 'std::is_same<B, A>::value':
                          fn must be called with A
        static_assert(std::is_same<T, A>::value, "fn must be called with A");
        ^ 
    

    This is a very simple, but effective solution. We check whether the type we were given is actually A. No implicit conversions from B to A would be considered.

    Pros and Cons
    • no error at the call site (bad IDE support, usually no red underline shown)
    • very clear diagnostic, customizable by us so we can convey intent

    Conclusion

    The quality of errors is alright for every solution, at least when using clang. Which solution you prefer depends in part on what requirements you have, and what version of C++ you want to be compatible with. However, personal preference also plays a role here, since none of the solutions are better than others in every regard.