On generic programming, value types and constraints
Alex Dathskovsky ?
Director of Software Engineering @ Speedata.io | C++ Guru and Speaker | ISO C++ standardization group member
Last week I have published a new riddle, it involved generic programming from C++20 and an interesting case of overloading and template type deduction
I have promised a full answer to this question but the answer is long and I need to use examples so it would be more readable and understandable so I have decided to illustrate it in a new article.
Let's start from the beginning.
The question at hand:
Breaking the question down:
The FMT library:
FMT library is an amazing print library that gives us the ability to parse strings in a python like way and can even do some of the parts in compile time, It is safe and self contained.
fmt::print:
Let's start by looking on pythons formatted strings
Here we can see python formatted string example. print function can take a "formatted string" the string contains {}, in each of this curly braces closure we can put a variable and this variable will be incorporated into the output string.
pretty nice right ?!
fmt::print gives us very similar functionality. As you can see in the example we provide something very similar to python's formatted string, but instead of putting the variables directly in the curly braces closure we are just providing the braces, and after the formatted string declaration is over we are providing the parameter that will be printed. We can provide other useful information in the curly braces, but as this article is not about FMT lib I will leave it up to the curious reader to learn more about it.
One important thing to mention about fmt lib is that it cannot print pointers that are not void*, this is important for our question.
Note 1: FMT was partially adopted into C++20 standard and we are now able to use std::format and iostream but sadly it's 2022 and there is still no implementation for it in Clang or GCC so we still need to use FMT to get all this amazing functionality.
Note 2: C++23 standard adopted the std::print and std::println that act like fmt::print but sadly I'm not sure when we will be able to use it. So FMT lib is here to stay for now.
The auto Concept:
In C++ 20 Concepts were introduced, concepts are a very powerful tool to constrain types, functions and templates (more about concepts later). One of the simplest concepts that were introduced is the auto as parameter. This is a "lightweight" concept because it implies that any type will be accepted with this function or in other words it is just like writing a templated function with type T that can be anything. With auto overloading of templated functions is simpler and we write less code.
Example of same functionality before C++20:
The functionality of this overloaded templated function is the same as with the auto concepts, but as mentioned above we need to declare the functions as templates and we loose some readability.
What happens in the question:
We now understand that auto in the functions is just simplified way to declare templated generic functions. As with templated function overloading the auto overloading will be the same, the compiler will try to deduce the best type and will select the most specialized function.
In this case we have created an lvalue of type int. int i = 10 and we are passing an prvalue reference to the print function. in this case the compiler looks at the first overload and tries to deduce the passed type. It sees that the type is int* and so it generates.
It's a valid overload because const& can implicitly accept lvalue references and rvalue references and in this case rvalue of int* is passed.
On the other hand the second instantiation it tries is:
The second overload will be ignored because its type is incorrect int* const* is a pointer to a pointer of int type where the value it points to is constant, and we are passing a pointer to int. (int* const* != int*).
The first overload is therefore used, with pointer type argument. In this overload we are not aware of the fact that a pointer was passed to us and we just try to print the value of the reference.
As I have mentioned before fmt lib cannot print pointers that are not void* and so the program doesn't compile. The library uses static assertion so the compiler is kind enough to give us a very clean error message.
How can we fix it ?
领英推荐
Things we shouldn't do:
This helps the compiler to understand that we are working with an int type and so it should generate templated versions with int. In this case it will generate this functions:
In this case only the second overload is applicable and we will work with the correct version of the function for pointers. But it beats the point of generic functions - the compiler should be able to deduce the correct type and generate the correct function for us. In this example it might seem acceptable to give the compiler a hint, but think what would happen if we had 5 types to specify or maybe this function would have a variadic pack with unknown number of elements.
2. You may want to do this:
In this case we are creating a pointer to a const memory exactly what our print function expects and we are forcing the compiler to generate this function for us:
But just as in the previous example we are telling the compiler what we want it to do and by that we are losing the generic part of the function.
Note: This is the same functionality but readability is even worst:
3. Maybe we should remove the const ?
In this case the correct function will be called because int* rvalue cannot be implicitly converted to int& lvalue reference. and so the only overload that is suitable is the one with int*.
Yay! we solved it! But did we solve it ?!
Sadly, we solved one problem by creating another one: the intent of these function signatures was to show that the arguments content will not be mutated during the execution of these functions, but we lost that guarantee by removing the constness.
Things we should do:
SFINE: When creating a candidate set for?overload resolution, some (or all) candidates of that set may be the result of instantiated templates with (potentially deduced) template arguments substituted for the corresponding template parameters. If an error occurs during the substitution of a set of arguments for any given template, the compiler removes the potential overload from the candidate set instead of stopping with a compilation error, provided the substitution error is one the C++ standard grants such treatment.?If one or more candidates remain and overload resolution succeeds, the invocation is well-formed.
To achieve SFINE here we will use std::enable_if which is a great helper tool from STL to achieve SFINE.
template<bool B, class T = void>
struct enable_if {};
?
template<class T>
struct enable_if<true, T> { using type = T; };
The first template parameter is a non-type parameter which is a Boolean condition and the second parameter is the type that will be used if the Boolean condition is satisficed. std::enable_if<B, T>::type will be well-formed if and only if the Boolean expression will be satisfied.
We will also use:
Note: During our examples we will use shorter virions of traits and enable_if and is_pointer
template <bool B, class T = void
using enable_if_t = enable_if<B, T>::type
template <typename T>
using is_pointer_v = is_pointer<T>::value
In this code snippet we see the combination of all the tools that were described above. We are telling the compiler to remove an overload if the decayed type is a pointer. Basically we are forcing the compiler to use the correct type deduction by removing the incorrect function overload.
2. Since C++20 Concepts are available and we can create better and cleaner constraints on types and help the compiler to select the correct overload.
We need to create a Concept:
For this simple concept we are creating a requires expression that accepts a parameter of type T. This concept will be satisfied only if t can be dereferenced.
Now we need to use it:
In this usage we are constraining the auto concept to accept only pointers. As mentioned above auto concept will accept any type but when we are using a concept on auto we are constraining the types that are accepted by the auto. The functionality is similar to what we saw with std::enable_if but it's more readable and maintainable.
If you would like to know more about Generic programming and Concepts you may watch my talk from CppCon 2022 "From templates to concepts : the amazing journey of metaprogramming".
Conclusions:
When working on generic code is very important to think about the edge cases and understand how the generic function work in the standard. What will be the deduced type, and which overload will be selected. It's important to express exactly what you want the compiler to do but always prefer the more generic way. We should always be as explicit as possible - the standard library has the tools for it like we saw above and much more than that. In addition since C++20 we have Concepts that make generic code in C++ readable and maintainable.
UX/UI SAAS Product Designer & Consultant ?? | Helping SAAS / AI companies and Startups Build Intuitive, Scalable Products.
4 个月???? ??? ?? ?? ???????? ??? ????? ???? ?????? ???: ?????? ????? ??? ??????? ?????? ??????, ?????? ?????? ??????,?????? ????? ????????. https://chat.whatsapp.com/IyTWnwphyc8AZAcawRTUhR
Software Technical Lead at General Motors
2 年Great article! One question: Using concepts in the last example makes sure that int* won't be deduced for the first signature?