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

166 Views Asked by At

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

4

There are 4 best solutions below

0
On BEST ANSWER

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.

0
On

You can delete functions

template<typename T>
void fn(T arg)=delete;
#include <iostream>

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

void fn(A arg) {
    // do stuff with arg
    std::cout << "fn called";
}

template<typename T>
void fn(T arg)=delete;

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

Because the template matches everything, it is always considered in the overload resolution. Since non-templates are preferred to templates, fn(A{}); is a better candidate than the template, on the other hand fn(B{}); matches the template exactly while the non-template would require an implicit cast.

Of course, you cannot forbid someone casting B to A before the calls so the solution is brittle and confusing at best.

0
On

You might forbid other overload:

void fn(A arg) {
    // do stuff with arg
    std::cout << "fn called";
}

template <typename T> void fn(T) = delete;
6
On

You could use the std::same_as concept:

#include <concepts>

void fn(std::same_as<A> auto arg)  {
    std::cout << "fn called";
}