In depth analysis of C + + type_traits

Posted by chaddsuk on Mon, 03 Jan 2022 07:02:01 +0100

Type of C + +_ Traits is a set of pure compile time logic, which can make some type judgment and branch selection. It is mainly used for template programming. Use type_traits is not difficult, but we hope to have a deeper understanding of its implementation. At the same time, we can further experience c + + template programming.
This article aims to guide you to realize type by yourself_ The basic code of traits.
Unlike conventional code, template programming can have flow control statements such as if else. We need to make full use of the characteristics of template, template special cases and type conversion to realize a series of judgment and type conversion during compilation.

Define base constants

In the first step, we need to define two constants, true and false, and all types_ Traits are based on this. Our goal is to use a template type to represent right and wrong, where the value is exactly these two values. Later, our higher-level judgment types are inherited from one of these two types. By obtaining the value value in this way, we can obtain true and false.
If you feel a little dizzy after listening to this explanation, it doesn't matter. Let's look at the code directly. It should be noted here that since type_traits are compile time behavior, so its members can only be static immutable members (members that can be determined at compile time).

struct true_type {
    static constexpr bool value = true;
};

struct false_type {
    static constexpr bool value = false;
};

Basic type judgment

With basic constants, we can make some simple type judgments first, such as whether the type is void. The idea here is to inherit from false for all types of templates_ Type, and for void type, we give a template special case to inherit from true_type. In this way, true is derived only when the type is void, and false is derived for others. See the routine:

template <typename>
struct is_void : false_type {};

template <>
struct is_void<void> : true_type {};

Here we can do some simple tests to determine whether the return value of the function is void:

void test1();
int test2();

int main(int argc, const char *argv[]) {
    std::cout << is_void<decltype(test1())>::value << std::endl; // 1
    std::cout << is_void<decltype(test2())>::value << std::endl; // 0
    return 0;
}

With the basic idea of judging void, it is not difficult to write other types of, for example, to judge whether it is a floating point number. You only need to deal with float, double and long double. See the code:

template <typename>
struct is_floating_point : false_type {};

template <>
struct is_floating_point<float> : true_type {};

template <>
struct is_floating_point<double> : true_type {};

template <>
struct is_floating_point<long double> : true_type {};

Integer judgment is relatively complex. It is necessary to write special cases for char, signed char, unsigned char, short, unsigned short, int, unsigned, long, unsigned long, long long, unsigned long. The methods are the same and will not be repeated.

Type processing

Write is in the previous section_ floating_ Point may find such problems:

int main(int argc, const char *argv[]) {
    std::cout << is_floating_point<const double>::value << std::endl; // 0
    std::cout << is_floating_point<double &>::value << std::endl; // 0
    return 0;
}

However, as a rule, const type and reference type should not affect the essence of floating-point numbers. Of course, we can also write template exceptions for all const and reference cases, but this is too troublesome. If there is a way to remove const and reference symbols and judge them, it will reduce a lot of our workload. At the same time, such type processing is also very useful in practical programming.
So, how to remove const? See code:

template <typename T>
struct remove_const {
    using type = T;
};

template <typename T>
struct remove_const<const T> {
    using type = T;
};

In the same way, when t is const type, we transform it into const T, and then only take out t, and directly pass through t in other types.
Similarly, references can be removed in this way:

template <typename T>
struct remove_reference {
    using type = T;
};

template <typename T>
struct remove_reference<T &> {
    using type = T;
};


template <typename T>
struct remove_reference<T &&> {
    using type = T;
};

Therefore, is_floating_point can be rewritten as follows:

// The basic judgment is degraded to helper
template <typename>
struct is_floating_point_helper : false_type {};
template <>
struct is_floating_point_helper<float> : true_type {};
template <>
struct is_floating_point_helper<double> : true_type {};
template <>
struct is_floating_point_helper<long double> : true_type {};

// remove_reference and remove_const's statement
template <typename>
struct remove_const;
template <typename>
struct remove_reference;

// Actual is_floating_point
template <typename T>
struct is_floating_point : is_floating_point_helper<typename remove_const<typename remove_reference<T>::type>::type> {};

Type selection

The main reason why we do such a series of type encapsulation is to make logical judgment in the compiler. Therefore, it is necessary to carry out a selection logic, that is, when the condition is true, select one type, and when it is not true, select another type. This function is very easy to implement. Please see the code:

template <bool judge, typename T1, typename T2>
struct conditional {
    using type = T1;
};

template <typename T1, typename T2>
struct conditional<false, T1, T2> {
    using type = T2;
};

When the first parameter is true, the type is the same as T1, otherwise it is the same as T2.

Judge whether it is the same

Sometimes we need to judge whether the two types are the same. This part is also well implemented. Please see the code:

template <typename, typename>
struct is_same : false_type {};

template <typename T>
struct is_same<T, T> : true_type {};

tips

In fact, according to these logic, we can almost write type_ All the functions in traits. STL also implements operations such as conjunction, disjunction and inversion, but it only converts logical judgment into template form, which is more convenient to use, but not necessary. If you are interested, you can read this part of the source code.

Implement is_base_of

is_base_of is used to judge whether the two types are inherited. There are already corresponding keywords in C + + to judge:

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

int main(int argc, const char *argv[]) {
    std::cout << __is_base_of(B, D) << std::endl; // 1
    std::cout << __is_base_of(B, A) << std::endl; // 0
    return 0;
}

__ is_ base_ The of keyword can do this, so we can encapsulate it as a template:

template <typename B, typename D>
struct is_base_of : conditional<__is_base_of(B, D), true_type, false_type> {};

But in addition to using the keywords provided by the compiler directly, there is another way to implement this function.
How to determine whether a class is the parent of a class? In fact, it depends on whether the pointer can be converted (polymorphic). Please see the code:

template <typename B, typename D>
true_type test_is_base(B *);

template <typename B, typename D>
false_type test_is_base(void *);

template <typename B, typename D>
struct is_base_of : decltype(test_is_base<B, D>(static_cast<D *>(nullptr))) {};

If D is a subclass of B, the first function will be called to infer that the return value is true_type, otherwise, call the second function and infer that the return value is false_type.
However, in doing so, we must add a judgment that both B and D must be classes, and factors such as const need to be removed. Readers of detailed code can try it by themselves without repeating it.

Topics: C++