Tutorial: Conditionally disabling non-template functions
Consider that you have a function template that takes a parameter on type T
.
If the function template has a rather generic name like operator==
, is a constructor,
or anything whose existence might be queried with type traits to further constrain other functions,
it is often beneficial if you can conditionally disable the function if the type does not have some required properties.
Otherwise the function will be “greedy” and accept more than it should - making some traits near useless,
as they only check for existence and the error only occurs later.
Conditionally removing functions if their template parameters don’t fulfill certain properties is done with SFINAE. But what if you have member functions of a class template that are not templates themselves?
Consider a modified - and very simplified - std::unique_ptr
that takes an additional parameter AllowNull
.
If AllowNull
is true
it behaves like the regular version,
but if it is false
, the pointer must not be null.
This is just a dumb example, you wouldn’t bother with the techniques here.
template <bool AllowNull, typename T>
class unique_ptr
{
public:
unique_ptr() noexcept
: ptr_(nullptr) {}
explicit unique_ptr(T* ptr) noexcept
: ptr_(ptr)
{
assert(ptr_);
}
unique_ptr(unique_ptr&& other) noexcept
: ptr_(other.ptr_)
{
other.ptr_ = nullptr;
}
~unique_ptr() noexcept
{
delete ptr_; // delete works with nullptr
}
unique_ptr& operator=(unique_ptr&& other) noexcept
{
unique_ptr tmp(std::move(other));
swap(*this, tmp);
return *this;
}
friend void swap(unique_ptr& a, unique_ptr& b) noexcept
{
std::swap(a.ptr_, b.ptr_);
}
explicit operator bool() const noexcept
{
return ptr_ != nullptr;
}
T& operator*() const noexcept
{
assert(ptr_);
return *ptr_;
}
T* operator->() const noexcept
{
assert(ptr_);
return ptr_;
}
T* get() const noexcept
{
return ptr_;
}
void reset() noexcept
{
delete ptr_;
ptr_ = nullptr;
}
private:
T* ptr_;
};
This is a complete implementation of a simple unique_ptr
,
but it completely ignores the AllowNull
parameter.
Let’s consider the problematic operations that could make it null. Those are:
- the
reset()
member function - the default constructor
- move constructor and assignment operator
The only other functions modifying the pointer are safe,
because the constructor asserts a non-null pointer,
the destructor doesn’t matter,
and swap()
only accepts unique_ptr
objects of the exact same type,
so you can only swap to non-null unique_ptr
s which will keep both non-null.
So we only have to conditionally remove those four member functions. And we don’t want to use a specialization because this might involve a lot of code duplication (it doesn’t in this example though).
Some might argue that we don’t need to remove the move operations: Even though they leave the object in a null state, you’re not supposed to re-use a moved from object, so its safe. I disagree because we want a strong type invariant, that no object ever contains a
nullptr
, and this requires the removal of move semantics. But I don’t want to go down that rabbit hole again, so if you disagree, let’s just say I make it for didactic reasons: to show how to disable special member functions.
Part 1: How to disable member functions
The first function we tackle is reset()
.
If AllowNull == false
, this function must not exist.
While we could use a
static_assert(AllowNull, "verboten!")
in the body, this breaks SFINAE checks for the existence ofreset()
.
If you’re familiar with SFINAE, you might try changing the reset()
signature to something like this:
auto reset() noexcept
-> std::enable_if_t<AllowNull>
{
…
}
That arrow part is just C++11’s trailing return syntax.
The return type of reset()
has been changed to std::enable_if_t<AllowNull>
.
This type is only well-formed if we pass it true
as template parameter and will be the type of the second parameter (void
is default).
But if AllowNull
is false, the type is not well-formed, so the function is disabled.
If that sound confusing to you, check out my tutorial on SFINAE. Or any other tutorial on SFINAE.
But this approach won’t work.
As soon as you instantiate the unique_ptr<false, T>
,
the compiler will complain over the ill-formed signature.
SFINAE does stand for substitution failure is not an error,
but substitution failure of the function,
not of the class.
And for substitution failure of a function,
we need a function template.
reset()
is not, however, so here we have an error.
So let’s make it a template:
template <typename Dummy = void>
auto reset() noexcept
-> std::enable_if_t<AllowNull>
{
…
}
We’ve made reset()
a template by adding a Dummy
template parameter.
As it is not actually needed, we give it a default value.
Nothing changes for the caller,
but now we have a template so everything should be fine, right?
No, because the compiler can eagerly substitute the AllowNull
value and so detect that the type is ill-formed.
What we need to do is to make the type dependent on the Dummy
parameter.
We could make it for example the type:
template <typename Dummy = void>
auto reset() noexcept
-> std::enable_if_t<AllowNull, Dummy>
{
…
}
std::enable_if_t<Cond, Type>
is actually an alias for typename std::enable_if<Cond, Type>::type
.
The latter is a class template, which can be specialized for own types.
So some user could give Dummy
the value some user-defined type which has a specialized std::enable_if
.
This means that the compiler can’t eagerly detect that it’s ill-formed,
so SFINAE will work.
Even though a) nobody should pass
Dummy
a different type and b) nobody should specializestd::enable_if
, but the compiler does not know that.
We now have used SFINAE to conditionally disable that member function.
It will only be an error if we try to call it,
but it will be a “no matching function to call” error,
aka an overload resolution error,
so others can use SFINAE to detect the presence of reset()
.
Note: because
std::enable_if
specializations are not allowed, an instatiation of the class template withAllowNull == false
, would make the member function template ill-formed, whatever parameters are passed to it. This is technically not allowed, but no compiler does such advanced checks here. See the reddit discussion for more info.
Part 2: How to disable a default constructor
We also want to disable the default constructor if AllowNull == false
.
So let’s try to do the same we did for reset()
:
template <typename Dummy = void, typename Dummy2 = std::enable_if_t<AllowNull, Dummy>>
unique_ptr()
…
A constructor does not have a return type,
so we use std::enable_if_t
as type for a second dummy template parameter.
We can also omit the name of
Dummy2
and just writetypename = …
.
And this works!
A default constructor is anything callable with 0 arguments.
This constructor is - because everything is defaulted.
Furthermore it is a template with std::enable_if_t
dependent on its parameters,
so no eager substitution but instead SFINAE.
Part 3: How to disable copy/move constructor/assignment
The only functions we still need to remove are the move constructor and assignment operator. The previous technique worked so well, so let’s apply it on the move constructor:
template <typename Dummy = void, typename = std::enable_if_t<AllowNull, Dummy>>
unique_ptr(unique_ptr&& other)
…
And also on the assignment operator, but you should now know how to do that.
So let’s try it out:
unique_ptr<false, int> a(new int(4));
auto b = std::move(a); // should not compile
But this code compiles, surprisingly. So let’s run it and you might get an output like this:
*** Error in `./a.out': double free or corruption (fasttop): 0x00000000014f5c20 ***
======= Backtrace: =========
/usr/lib/libc.so.6(+0x70c4b)[0x7f0f6c501c4b]
/usr/lib/libc.so.6(+0x76fe6)[0x7f0f6c507fe6]
/usr/lib/libc.so.6(+0x777de)[0x7f0f6c5087de]
./a.out[0x4006d2]
./a.out[0x400658]
/usr/lib/libc.so.6(__libc_start_main+0xf1)[0x7f0f6c4b1291]
./a.out[0x40053a]
======= Memory map: ========
[…]
Aborted (core dumped)
Hm, that’s weird.
clang gives the following warning when compiling it:
warning: definition of implicit copy constructor for
'unique_ptr<false, int>' is deprecated because it has a user-declared
destructor [-Wdeprecated]
~unique_ptr() noexcept
Apparently - because there was no move constructor available - the compiler was so kind and has generated a copy constructor for us. This would also explain the double free error.
So let’s delete
copy operations:
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
Now the above sample code won’t compile.
But that’s the error message:
error: call to deleted constructor of 'unique_ptr<false, int>'
auto b = std::move(a);
^ ~~~~~~~~~~~~
file.cpp:34:1: note: 'unique_ptr' has been explicitly marked deleted here
unique_ptr(const unique_ptr&) = delete;
It still tries to call the copy constructor, not the move constructor, and then complains that copy has been deleted! The reason is this paragraph of the C++ standard:
[class.copy]/2: A non-template constructor for class
X
is a copy constructor if its first parameter is of typeX&
,const X&
,volatile X&
, orconst volatile X&
[…].[class.copy]/3: A non-template constructor for class
X
is a move constructor if its first parameter is of typeX&&
,const X&&
,volatile X&&
, orconst volatile X&&
[…].
So we can’t make a copy/move constructor/assignment operator a template, because then it’s not a copy/move constructor/assignment operator anymore. But if we can’t make it a template, we can’t use SFINAE.
What are we going to do? Do we have to resolve to partial specialization?
Yes, we have,
but we don’t need to partially specialize the entire unique_ptr
.
Adding an extra layer of indirection worked so well in the last post, let’s do it again.
We outsource the move constructor/assignment/destructor to separate class, unique_ptr_storage
:
namespace detail
{
template <typename T>
class unique_ptr_storage
{
public:
unique_ptr_storage(T* ptr) noexcept
: ptr_(ptr) {}
unique_ptr_storage(unique_ptr_storage&& other) noexcept
: ptr_(other.ptr_)
{
other.ptr_ = nullptr;
}
~unique_ptr_storage() noexcept
{
delete ptr_;
}
unique_ptr_storage& operator=(unique_ptr_storage&& other) noexcept
{
unique_ptr_storage tmp(std::move(other));
swap(tmp, *this);
return *this;
}
friend void swap(unique_ptr_storage& a, unique_ptr_storage& b) noexcept
{
std::swap(a.ptr_, b.ptr_);
}
T* get_pointer() const noexcept
{
return ptr_;
}
private:
T* ptr_;
};
}
The actual unique_ptr
now stores this class instead of the pointer.
As unique_ptr_storage
defines the special member functions,
unique_ptr
do not need their definitions anymore,
the default versions do just fine.
But now we are able to trick the compiler into not generating them. For that we just need a simple helper base class:
namespace detail
{
template <bool AllowMove>
struct move_control;
template <>
struct move_control<true>
{
move_control() noexcept = default;
move_control(const move_control&) noexcept = default;
move_control& operator=(const move_control&) noexcept = default;
move_control(move_control&&) noexcept = default;
move_control& operator=(move_control&&) noexcept = default;
};
template <>
struct move_control<false>
{
move_control() noexcept = default;
move_control(const move_control&) noexcept = default;
move_control& operator=(const move_control&) noexcept = default;
move_control(move_control&&) noexcept = delete;
move_control& operator=(move_control&&) noexcept = delete;
};
}
Base class because
move_control
does not have any members, but it still has a size of 1 (because all objects must have at least size 1). But base classes can have size 0, that’s called the empty base optimization.
Then unique_ptr
needs to inherit from either move_control<true>
or move_control<false>
,
depending on AllowNull
:
template <bool AllowNull, typename T>
class unique_ptr
: detail::move_control<AllowNull>
{
…
};
Now if AllowNull == true
, the compiler can generate the move operations.
But if it is false
, it can’t, because the base class is not moveable.
So the member function will not be available.
Conclusion
If you have a non-templated member function of a class template and you want to conditionally remove it, you can’t use SFINAE directly. You have to make the function a template first, by adding a dummy template parameter and making the SFINAE expression somehow dependent on it.
This approach works for all member functions except for copy/move operations, because they can never be templates. If you need custom copy/move operations, you have to write them in a separate helper class, so that they are automatically generated in your class. To disable them, simply inherit from a non-copy/moveable type. The compiler can’t generate them automatically anymore, and will delete them.
Even though in this example here partial template specializations
(or even a completely separate type) would have solved the problem better,
sometimes this would lead to too much code duplication.
An example where similar techniques have to be used are the upcoming std::optional
and std::variant
.
They must not provide copy/move operations if the underlying types are not copy/moveable.
Disclaimer: Self promotion ahead, you can stop reading here.
Appendix: Documentation generation
But now we have a bunch of weird member functions with defaulted templates which look like this:
template <typename Dummy = void, typename = std::enable_if_t<AllowNull, Dummy>>
void reset();
If we use a documentation generate that extracts signatures and use them in the output, it will add all this noise!
Thankfully, I’ve been working on a standardese, a documentation generator designed for C++. With it you can add add the following markup:
/// Here be documentation.
/// \param Dummy
/// \exclude
/// \param 1
/// \exclude
template <typename Dummy = void, typename = std::enable_if_t<AllowNull, Dummy>>
void reset();
This will exclude the two template parameters from the output. As the function does not have any template parameters then, standardese will silently hide the fact that it is a template and only document it with the intended signature:
void reset();
If you need an advanced C++ documentation generator, try standardese or read more about its latest features.
This blog post was written for my old blog design and ported over. If there are any issues, please let me know.