Logo

Blog


C++17's CTAD a sometimes underrated feature

In today's post, I like to look at C++17's Class Template Argument Deduction (CTAD) feature and see how it changes our code.

With CTAD, we can write so-called deduction guides to tell the compiler how to instantiate a class template. Thanks to CTAD, class templates can look more like function templates. There we usually don't have to state the types other than passing the parameters. The compiler then derives the types from these parameters.

A deduction guide can be seen more or less like a hint to the compiler; hey, if you call this constructor (which is a function) with a set of parameters, this is how the class gets instantiated.

Where and how does CTAD help?

Let's consider this hand-rolled version of pair. Needless to say, it's incomplete, as the focus is on CTAD.

1
2
3
4
5
template<typename F, typename S>
struct pair {
  F first;
  S second;
};

No surprise, pair is a class template taking two potentially different template type parameters. Whenever you use a pair, you have to explicitly state of which data types the pair is. Well, that was fine before C++11 since there was no auto in the language. We all grew up with this style and became used to it. Not seeing the template parameters to pair on the left makes some of you feel uncomfortable. But C++ always was about abstraction.

When auto came along, at least some of you embraced the new way of writing code, more comprehensive, repeating the types on the left where they are already named on the right became a bit frustrating.

This is the reason why since C++11, the standard library ships a lot of these tiny helper functions, like in the case of pair, std::make_pair.

The clever trick std::make_pair does is being a function instead of a class template. Remember, function templates are capable of deducing their arguments. An implementation of make_pair can look like this:

1
2
3
4
5
template<typename F, typename S>
auto make_pair(F&& f, S&& s)
{
  return pair<F, S>{std::forward<F>(f), std::forward<S>(s)};
}

Not that much code to write, but boring code that also requires maintenance over time.

Another downside is that people have to know, remember and type make_pair. Again potentially not that much, and the naming in the STL is pretty consistent, but still, knowing needs brain capacity. I prefer to keep my brain capacity for interesting things.

CTAD aims to simplify things here. No need for an additional function to write and to remember. All you need is to write a deduction guide like the following one:

1
2
template<typename F, typename S>
pair(F&& f, S&& s) -> pair<F, S>;

You know can use the nice and short form in your code:

1
pair p{3, 5.6};

The same is true for other types as well. For example, std::lock_guard. Here I don't care for the type of the lock_guard. The data type is the one I initialize the lock_guard with.

What if there is more than just a type?

The above is what you usually see and need a lot of times. But what if you face a class template that takes more than just a template type parameter, say a non-type template parameter?

Well, suppose you aim to implement something like std::array. Since C++17 std::array can be used like a regular C-array in C++, there is no need to specify the size. How is that done? Have a look at the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
template<typename T, size_t N>
struct array {
  T data[N];
};

A Deduction guide
template<typename T, typename... Args>
array(T, Args...) -> array<T, 1 + sizeof...(Args)>;

array a0{2, 3, 4, 5};

In A, you see the deduction guide. What's done there is that the second parameter, the size parameter, is calculated from the number of arguments in the parameter pack. The simplest way here is to split up the first parameter from the pack to have a type for the template type parameter. Hence, the +1 to the number of elements in the pack. Of course, there are other ways to achieve the same result. In production code, you might also add a requirement ensuring that all argument types are of the same type, making the error message understandable.

Unexpected?

Let's fast forward to what is coming with C++23, a new datatype in the STL std::expected. I have a dedicated post for std::expected C++23 - std::expected, the superior way of returning a value or an error. For this post, you need to know that std::expected is the good case. It contains either the return or the error value. However, even in an error case, the function returns a std::expected as a data type. A potential code snipped may look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::expected<int, int> OpenFile(std::string_view name)
{
  const int ret = open(name.data(), 0);

  if(-1 == ret) {
    return std::unexpected{errno};  A 
  }

  return ret;  B 
}

In A, the error case is handled. The file may not exist or isn't readable. We have to return the error code stored in the global variable errno (assuming POSIX here). If you look at the return type closely, you can see that it returns the same data type as the return value and error value. Hopefully, not all your expected type have this combination, a dedicated error type is usually better, but we have a valid case here.

This is why we cannot directly assign the error value to std::expected. The compiler would not know whether the value is the success or error value.

The trick here is to have another datatype, std::unexpected. A std::unexpected is assignable to a std::expected, given that the error datatype is the same.

Now, if we look at A again, we can see that we see nothing special for unexpected. That's because CTAD kicks in. We don't have to name the error's datatype. Like for a function template, the compiler derives that from the arguments, or in this case, the argument, to the constructor.

Andreas