DEV Community

Pierre Gradot
Pierre Gradot

Posted on • Updated on

Let's try C++20 | explicit(bool)

From the many changes of C++20, let's focus today on explicit(bool). This feature is described in P0892R2. The explicit keyword avoids implicit conversions by unexpectedly call a constructor. With C++20, it is now possible to have boolean condition as a "parameter" to the keyword.

How it works

This simple code makes it easy to understand how explicit(bool) works:

constexpr bool ENABLE_EXPLICIT = false;

struct Foo {
    explicit(ENABLE_EXPLICIT)
    Foo(int) {}
};

Foo a = 1;
Enter fullscreen mode Exit fullscreen mode

This code compiles fine, but change ENABLE_EXPLICIT to true and you will get an error with clang-10.0.0:

error: no viable conversion from 'int' to 'Foo'
note: explicit constructor is not a candidate (explicit specifier evaluates to true)
Enter fullscreen mode Exit fullscreen mode

explicit(bool) is that simple.

Purpose

OK but the code above is quite non-sense. Why would one need explicit(bool)?

In template types, it is often desirable to make constructors explicit or implicit based on certain properties of the template parameters. The proposal P0892R2 shows an example with std::pair:

template <typename T1, typename T2>
struct pair {
    template <typename U1=T1, typename U2=T2,
        std::enable_if_t<
            std::is_constructible_v<T1, U1> &&
            std::is_constructible_v<T2, U2> &&
            std::is_convertible_v<U1, T1> &&
            std::is_convertible_v<U2, T2>
        , int> = 0>
    constexpr pair(U1&&, U2&& );

    template <typename U1=T1, typename U2=T2,
        std::enable_if_t<
            std::is_constructible_v<T1, U1> &&
            std::is_constructible_v<T2, U2> &&
            !(std::is_convertible_v<U1, T1> &&
              std::is_convertible_v<U2, T2>)
        , int> = 0>
    explicit constexpr pair(U1&&, U2&& );
};
Enter fullscreen mode Exit fullscreen mode

Depending on what are T1, T2, U1 and U2:

  • It may or may not be possible to create an instance of std::pair<T1, T2> from two objects of types U1 and U2
  • The constructor may be explicit or implicit.

Thanks to the new explicit(bool) feature, the code above can be rewritten as:

template <typename T1, typename T2>
struct pair {
    template <typename U1=T1, typename U2=T2,
        std::enable_if_t<
            std::is_constructible_v<T1, U1> &&
            std::is_constructible_v<T2, U2>
        , int> = 0>
    explicit(!std::is_convertible_v<U1, T1> ||
        !std::is_convertible_v<U2, T2>)
    constexpr pair(U1&&, U2&& );
};
Enter fullscreen mode Exit fullscreen mode

The code is clearly improved. First, there is only one constructor so it avoids code duplication. Secondly, the condition that makes the constructor explicit is separated from the condition that makes the constructor viable. See "PS" at the end of this article for more explanations about these conditions.

Other examples

This answer by Jarod42 on stackoverflow shows that it is possible to make variadic constructors explicit only if there is one parameter in the pack:

struct Foo {
    template <typename ... Ts>
    explicit(sizeof...(Ts) == 1) Foo(Ts&&...) {}
};

Foo good = {1,2}; // OK
Foo bad = {1}; // error: chosen constructor is explicit in copy-initialization
Enter fullscreen mode Exit fullscreen mode

In its article "C++20’s Conditionally Explicit Constructors", Sy Brand from Microsoft uses explicit(bool) to write wrappers for strings and takes advanges of explicit(bool) to simplify his code, in a very similar manner as in the example of the proposal. He invokes the principle of least astonishment as a motivation for wrappers to behave in a similar way as the wrapped types.

Conclusion

explicit(bool) is clearly a feature for people who write template types. It is likely that you won't use it in you everyday life but it may help you understand code written by others.

PS

I think it is interesting to understand why the constructor of pair is declared as:

template <typename T1, typename T2>
struct pair {
    template <typename U1=T1, typename U2=T2,
        std::enable_if_t<
            std::is_constructible_v<T1, U1> &&
            std::is_constructible_v<T2, U2>
        , int> = 0>
    explicit(!std::is_convertible_v<U1, T1> ||
        !std::is_convertible_v<U2, T2>)
    constexpr pair(U1&&, U2&& );
};
Enter fullscreen mode Exit fullscreen mode

There are 2 things to understand: the usage of std::enabled_if_t and the condition in explicit. Buckle up!

1) Why is std::enabled_if_t used here?

Let's consider a simple code like this:

template <typename T>
struct Foo {
    template<typename U>
    Foo(U u) : t(u) {}

    T t;
};

Foo<int> good = 1;
Foo<int> bad = "1";
Enter fullscreen mode Exit fullscreen mode

This code generates of an error for bad:

error: cannot initialize a member subobject of type 'int' with an lvalue of type 'const char *'
note: in instantiation of function template specialization 'Foo<int>::Foo<const char *>'
Enter fullscreen mode Exit fullscreen mode

The error occurs in the "body" of the constructor. It would be nice to catch the error earlier by rejecting the call to the constructor. This can be done thanks to std::enable_if:

#include <type_traits>

template <typename T>
struct Foo {
    template<typename U,
             std::enable_if_t<std::is_constructible_v<T, U>, int> = 0>
    Foo(U u) : t(u) {}

    T t;
};
Enter fullscreen mode Exit fullscreen mode

The error becomes:

error: no viable conversion from 'const char [2]' to 'Foo<int>'
note: candidate constructor (the implicit copy constructor) not viable: no known conversion from 'const char [2]' to 'const Foo<int> &'
note: candidate constructor (the implicit move constructor) not viable: no known conversion from 'const char [2]' to 'Foo<int> &&'
note: candidate template ignored: requirement 'std::is_constructible_v<int, const char *>' was not satisfied [with U = const char *]
Enter fullscreen mode Exit fullscreen mode

Noice! 😎

Let's understand how this works.

template<typename U, std::enable_if_t<std::is_constructible_v<T, U>, int> = 0> is equivalent to template<typename U, SOMETHING = 0> where SOMETHING is std::enable_if_t<std::is_constructible_v<T, U>, int>.

cppreference explains how std::enable_if works:

Defined in header <type_traits>

template< bool `B`, class T = void >
struct enable_if;

If B is true, std::enable_if has a public member typedef type, equal to T; otherwise, there is no member typedef.

In our case, the boolean condition B is std::is_constructible_v<T, U> and the typedef type is int.

Hence:

  • If T can be constructed from U, then SOMETHING is int. The constructor has a second template parameter of type int, which is defaulted to 0, and the constructor can be called.
  • Otherwise, the template definition is ill-formed, and thanks to SFINAE, this constructor is removed from the overloads. The other overloads are the compiler-defined move and copy constructors but there are not acceptable neither.

At the end of the day, there is a viable conversion from 1 to Foo but there isn't from "1" to Foo and we get a clear error.

We can now get back to std::pair's constructor and easily understand it: it's the same as Foo but with an additional template type.

2) How is the constructor made explicit?

std::pair's constructor is explicit if the following condition is true: !std::is_convertible_v<U1, T1> || !std::is_convertible_v<U2, T2>. It means that if U1 and/or U2 cannot be implicitly converted to T1 and U2 respectively, then the constructor is explicit.

This behavior follows the principle of least astonishment: it would be astonishing if a pair of {U1, U2} could be implicitly converted to pair of {T1, T2} if U1 and/or U2 cannot be converted to T1 and T2 implicitly.

Example with a custom Pair type:

template <typename T>
struct Foo {
    explicit Foo(T) {}
};

template <typename T>
struct Pair {
    template <typename U>
    Pair(U, U) {}
};

Pair<Foo<int>> pair = {1, 2}; // OK
Foo<int> foo1 = 1; // error: no viable conversion from 'int' to 'Foo<int>'
Foo<int> foo2 = 2; // error: no viable conversion from 'int' to 'Foo<int>'
Enter fullscreen mode Exit fullscreen mode

So... We can create a pair of Foo<int> from two ints but we can't create two Foos from two ints? It's quite weird and we can use explicit(bool) (as in std::pair) to avoid that:

template <typename T>
struct Pair {
    template <typename U>
    explicit(not std::is_convertible_v<U, T>)
    Pair(U, U) {}
};

Pair<Foo<int>> pair = {1, 2}; // error: chosen constructor is explicit in copy-initialization
Enter fullscreen mode Exit fullscreen mode

If we remove the explicit keyword from the definition of Foo::Foo(T), the error disappears and we can create pair from {1, 2}.

That's it!

I hope you enjoyed this trip into the details of the declaration of std::pair's constructor as much as I have! 😁

Discussion (0)