类型函数
在传统的C/C++编程中,函数通常是值函数,它们接收值作为输入参数,并计算得出一个值作为输出结果。然而,C++模板允许我们定义一类特殊的函数,即类型函数,它们接收一个或多个类型作为参数,并根据这些类型生成一个新的类型或常量作为输出结果。
模板可以定义与类型相关的行为,这意味着我们可以编写模板来根据不同类型的行为特性提取相关信息。例如,sizeof是一个内置的类型函数,它接受一个类型作为参数,并返回该类型的大小(以字节为单位)的常量值,这就是一个直观的类型函数示例。sizeof 操作符可以实现如下接口:
#include <cstddef>
#include <iostream>
// 类型函数sizeof(T),在编译期就能算出常量
template<typename T>
struct TypeSize {
static std::size_t constexpr value = sizeof(T);
};
int main() {
std::cout << "TypeSize<int>::value = " << TypeSize<int>::value << "\n";
}
在C++中,sizeof
是内置的操作符,它用来计算给定类型或表达式的大小(以字节为单位)。此处提到的TypeSize<T>
是一个模板定义的类,其中T
是模板参数,TypeSize<T>::value
是一个静态成员常量,它的值等于sizeof(T)
,即类型T
的大小。
TypeSize 是一个类型, 因此可以作为类模板参数传递。 TypeSize 是一个模板,可以作为模板参数传递。
之所以说TypeSize<T>
可以作为类模板参数传递,是因为在C++模板编程中,模板参数不仅可以是基本数据类型,还可以是模板类或模板实例。当你将TypeSize<T>
用作另一个模板类或函数模板的参数时,T
会根据上下文中的实际类型被实例化,导致TypeSize
类的value
成员也被正确地计算出对应类型的大小。
举例来说,如果你有一个模板类SomeClass
,并希望在其内部根据模板参数U
的大小来决定某个成员变量的长度,你可能会这么声明:
template <typename U>
class SomeClass {
public:
std::array<char, TypeSize<U>::value> buffer; // 根据U类型大小决定buffer的大小
};
在这个例子中,当你创建一个SomeClass<int>
实例时,TypeSize<int>
会被用作模板参数传递,进而buffer
的大小将自动设置为sizeof(int)
。
另一方面,“TypeSize 是一个模板,可以作为模板参数传递”指的是整个TypeSize
模板可以作为另一个模板的参数,就像这样:
#include <cstddef>
#include <iostream>
// 类型函数sizeof(T),在编译期就能算出常量
template<typename T>
struct TypeSize {
static std::size_t constexpr value = sizeof(T);
};
// 模板模板类,这里C++14以前可能不支持这种写法
// 可以写成 template <template <typename> class TypeSize>
template <template <typename> typename TypeSize>
class AnotherClass {
public:
using SizeTypeForInt = TypeSize<int>; // SizeType在此处作为一个模板参数被传递,并实例化为SizeType<int>
};
int main() {
std::cout << "TypeSize<int>::value = " << AnotherClass<TypeSize>::SizeTypeForInt::value << "\n";
}
在这种情况下,AnotherClass
接受一个模板模板参数TypeSize
,并根据这个参数来创建新的类型别名SizeTypeForInt
,当SizeType
是TypeSize
时,就会实例化为TypeSize<int>
。
#include <iostream>
// 可以返回类型
template<typename T>
struct ElementT {
using Type = T;
using Int_Type = int;
};
int main() {
std::cout<<typeid(typename ElementT<double>::Type).name()<<std::endl; // double 类型
std::cout<<typeid(typename ElementT<double>::Int_Type).name()<<std::endl; // int 类型
}
类型函数还可以使用谓词特征来选择执行流分支,通过std::is_same<>, std::true_type 和 std::false_type实现, 这里给出这三种模板类的简易实现:
#include <iostream>
// bool型的常量模板
template<bool val>
struct BoolConstant {
using Type = BoolConstant<val>;
static bool constexpr value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;
// IsSameT 等价于 std::is_same_t
template<typename T1, typename T2>
struct IsSameT : FalseType {};
// 类模板偏特化
template<typename T>
struct IsSameT<T, T> : TrueType {};
// 由此,就可以设计出函数不同的分支了
template<typename T>
void fooImpl(T /*unused*/, TrueType /*unused*/) {
std::cout<<"use TrueType"<<std::endl;
}
template<typename T>
void fooImpl(T /*unused*/, FalseType /*unused*/) {
std::cout<<"use FalseType"<<std::endl;
}
// 只有int类型走一个分支,其他类型走另一个分支
template<typename T>
void foo(T t) {
fooImpl(t, IsSameT<T, int>{});
}
int main() {
foo(3); // int 类型,走TrueType分支
foo(3.14); // double 类型,走FalseType分支
}
std::declval
是C++标准库提供的一个模板函数,它主要用于编译时计算类型表达式的类型,而不需要实际创建该类型的对象。std::declval<T>()
表达式返回的是类型T
的一个临时右值引用(对于可引用类型而言),但请注意,std::declval
并不会真正执行任何构造函数或其他操作来生成一个实际的值。
以下是如何在模板编程中使用std::declval
的一个例子:
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
在这个例子中,PlusResult
是一个类型别名,它代表了类型T1
和T2
相加的结果类型。通过std::declval<T1>() + std::declval<T2>()
,编译器会尝试计算这个表达式的类型,即使T1
和T2
并没有默认构造函数或者根本不能创建对象实例。这种方法对于确定模板函数返回类型或者模板类的某些成员类型非常有用,特别是在元编程场景中。
举个具体应用场景,当你需要为不同类型的数组元素定义加法操作时,可以使用std::declval
来确定加法表达式的结果类型,而不需要真的去构造那些元素。这对于模板编程中需要提前知道操作结果类型以便定义函数返回类型或者进行类型推导是非常重要的。不过要注意,std::declval
仅限于编译时的类型分析,不应用于运行时代码。
基于 SFINAE 的 Traits
SFINAE(substitution failure is not an error): 替换失败不是错误
主要用途:SFINAE排除函数重载,SFINAE排除模板偏特化
SFINAE排除函数重载
比如,判断类型是否有默认构造函数:
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
template<typename T>
struct IsDefaultConstructibleT {
private:
// 回退函数模板,用于占位,将在不能成功匹配其他更具体模板的情况下被选中
template<typename>
static long test(...);
// 接受一个 void* 类型的参数
// decltype(U()) 尝试生成一个临时的 U 类型对象并获取其类型
// 如果 U 有默认构造函数,U() 就是合法的表达式,否则编译失败。
template<typename U, typename = decltype(U())>
static char test(void*);
public:
// 静态布尔成员,检查 test<T>(nullptr) 的返回类型是否为 char。
// 如果 T 有默认构造函数,那么 test<T>(nullptr) 将会选择第二个 test 版本,
// 返回类型为 char;如果 T 没有默认构造函数,则会选择第一个回退版本,返回类型为 long。
static constexpr bool value = std::is_same_v<decltype(test<T>(nullptr)), char>;
};
// 没有默认构造函数的类
struct A {
A() = delete;
};
int main() {
std::cout << std::boolalpha;
std::cout << "int: " << IsDefaultConstructibleT<int>::value << '\n';
std::cout << "std::string: " << IsDefaultConstructibleT<std::string>::value << '\n';
std::cout << "std::vector<int>: " << IsDefaultConstructibleT<std::vector<int>>::value << '\n';
std::cout << "A: " << IsDefaultConstructibleT<A>::value << '\n';
return 0;
}
为什么要重新引入一个U而不是用T?
在类模板 IsDefaultConstructibleT 中,如果直接在成员函数模板 test 中使用 T() 来检查 T 是否有默认构造函数,那么对于所有实例化 IsDefaultConstructibleT 的情况,无论 T 是否具有默认构造函数,编译器都会尝试去实例化所有的 test 函数模板。这意味着,即使对于那些没有默认构造函数的 T,编译器也会尝试 T() 这个表达式,从而导致编译错误。
然而,SFINAE机制的有效范围是模板参数列表的类型推导阶段,只有在这个阶段遇到无法成功的类型推导才会触发SFINAE,进而排除该模板函数候选。一旦进入模板实例化体内部(也就是函数实现部分),SFINAE就不再适用了。
通过引入辅助类型 U,我们可以创建一个特定的SFINAE上下文,即在 test 函数模板的参数列表中,通过 U() 来检查是否有默认构造函数。这样,当 U 与 T 相同,且 U 没有默认构造函数时,编译器在尝试类型推导的过程中会发现 decltype(U()) 的推导失败,这时候编译器会依据SFINAE原则,忽略这个 test 函数模板的重载版本,而不是导致整个模板实例化失败。
所以,将 U 替换为 T 后,我们就无法有效利用SFINAE机制来无声地排除不适用的模板函数,而可能导致整个模板实例化过程因编译错误而中断。
predicate(谓词) trait
谓词trait返回布尔值,应返回从std::true_type或std::false_type派生的值。
依托std::true_type 和 std::false_type , 上面的trait的设计可以更加简洁:
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
template<typename T>
struct IsDefaultConstructibleHelper {
private:
template<typename>
static std::false_type test(...);
template<typename U, typename = decltype(U())>
static std::true_type test(void*);
public:
using Type = decltype(test<T>(nullptr)); // 不再需要std::is_same_t来判断类型是否一致
};
// 根据 decltype(test<T>(nullptr)的类型,
// 继承std::false_type 或者 std::true_type
// template<typename T>
// struct IsDefaultConstructibleT : IsDefaultConstructibleHelper<T>::Type {};
// 当然,使用using 更加简单
template<typename T>
using IsDefaultConstructibleT = typename IsDefaultConstructibleHelper<T>::Type;
struct A {
A() = delete;
};
int main() {
std::cout << std::boolalpha;
std::cout << "int: " << IsDefaultConstructibleT<int>::value << '\n';
std::cout << "std::string: " << IsDefaultConstructibleT<std::string>::value << '\n';
std::cout << "std::vector<int>: " << IsDefaultConstructibleT<std::vector<int>>::value << '\n';
std::cout << "A: " << IsDefaultConstructibleT<A>::value << '\n';
return 0;
}
SFINAE Out Partial Specializations(偏特化)
SFINAE-based traits 的另一种实现方式是使用偏特化:
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
// 忽视任意数量的模板参数
template<typename...>
using VoidT = void;
// 判断类型是否可被默认构造
template<typename, typename = VoidT<>>
struct IsDefaultConstructibleT : std::false_type {};
// 偏特化 默认构造
template<typename T>
struct IsDefaultConstructibleT<T, VoidT<decltype(T())>> : std::true_type {};
struct A {
A() = delete;
};
int main() {
std::cout << std::boolalpha;
std::cout << "int: " << IsDefaultConstructibleT<int>::value << '\n';
std::cout << "std::string: " << IsDefaultConstructibleT<std::string>::value << '\n';
std::cout << "std::vector<int>: " << IsDefaultConstructibleT<std::vector<int>>::value << '\n';
std::cout << "A: " << IsDefaultConstructibleT<A>::value << '\n';
return 0;
}
首先,定义了一个名为 VoidT 的模板别名,它接收任意数量的模板参数并总是推导为 void 类型。这个模板别名的作用是提供一种手段来捕获可能出现的无效类型。
接着,定义了一个主模板 IsDefaultConstructibleT,它带有两个模板参数,其中第二个参数有一个默认类型 VoidT<>。
在这个主模板中,当未指定第二个模板参数时,IsDefaultConstructibleT 会继承自 std::false_type,表示默认情况下假定 T 不具有默认构造函数。
然后,对 IsDefaultConstructibleT 进行偏特化,这个偏特化版本仅在第二个模板参数能够成功推导时生效。在这里,尝试使用 decltype(T()) 创建一个 T 类型的临时对象。如果 T 具有默认构造函数,则 T() 是有效的表达式,VoidT<decltype(T())> 将推导为 void,于是这个偏特化版本会被选用,使得 IsDefaultConstructibleT 继承自 std::true_type,表示 T 具有默认构造函数。
将通用Lambdas用于SFINAE
C++17 提供了更为便捷的实现上述功能的方法,首先需要介绍两个通用Lambdas表达式
#include <iostream>
#include <type_traits>
#include <utility>
#include <string>
// 辅助函数,检查f(args...)的有效性
template<typename F, typename... Args,
typename = decltype(std::declval<F>()(std::declval<Args&&>()...))>
std::true_type isValidImpl(void*);
// 当上述辅助函数由于SFINAE被丢弃时的回退版本
template<typename F, typename... Args>
std::false_type isValidImpl(...);
// 定义一个接受lambda f并返回使用args调用f是否有效的lambda
inline constexpr
auto isValid = [](auto f){
return [](auto&&... args){
return decltype(isValidImpl<decltype(f), decltype(args)&&...>(nullptr)){};
};
};
// helper template to represent a type as a value
template<typename T>
struct TypeT {
using Type = T;
};
// helper to wrap a type as a value
template<typename T>
constexpr auto type = TypeT<T>{};
// helper to unwrap a wrapped type in unevaluated contexts
template<typename T>
T valueT(TypeT<T>); // no definition needed
int main() {
constexpr auto isDefaultConstructible
= isValid([](auto x) -> decltype((void)decltype(valueT(x))()) {
});
std::cout<<std::boolalpha<<isDefaultConstructible(type<int>)<<std::endl; // true (int is default-constructible)
std::cout<<isDefaultConstructible(type<int&>)<<std::endl;
return 0;
}
其中:
// helper template to represent a type as a value
template<typename T>
struct TypeT {
using Type = T;
};
这个模板 TypeT 的主要用途是在编译时存储并传递类型信息。它可以用来将类型转换为对象,这样就可以像操作其他对象一样操作类型。在某些场合下,特别是那些需要在模板元编程或类型查询相关的场景中,这样的结构体很有用。
例如,在编译时获取类型信息、创建类型工厂函数、类型擦除(type erasure)以及各种编译时逻辑中,可能需要将类型本身作为一个值来传递或存储。在给定的例子中,TypeT 可以用于创建类型标签对象,如 TypeT,然后通过 Type 成员获得具体的 int 类型。
// 通过 TypeT<T> 创建的实例可以提供类型 T 的引用
// helper to wrap a type as a value
template<typename T>
constexpr auto type = TypeT<T>{};
这里定义了一个名为 type 的变量模板,它接受一个模板参数 T 并返回一个 TypeT 类型的实例。这里的 constexpr 关键字保证了这个实例能够在编译时被创建和使用。
TypeT 结构体的目的是将类型 T 包装为一个类型值,它的成员类型 Type 存储了模板参数 T 的类型信息。
而这里:
// helper to unwrap a wrapped type in unevaluated contexts
template<typename T>
T valueT(TypeT<T>); // no definition needed
valueT 函数模板并没有给出具体的实现(定义),但它在这里的存在意义在于配合 TypeT 结构体和其他部分的代码来触发 SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)机制,从而在编译时期检查类型 T 的特性。
由于 valueT 函数模板并未给出实际的定义,因此,当尝试生成临时对象时,如果 T 类型具有默认构造函数,则编译器可以顺利地生成这个默认构造函数调用的表达式;反之,如果 T 类型没有默认构造函数,那么编译器无法生成这个表达式,这时会发生 SFINAE 错误。
然而,由于整个表达式是在 decltype 的范围内,按照 SFINAE 原则,编译器遇到这种错误并不会终止编译,而是简单地忽略这个模板实例化,转而去尝试其他可能的匹配。这就是为什么即使没有定义 valueT 函数模板,也可以利用它来检查类型 T 是否具有默认构造函数的原因。
isValid: 判断传入的lambda是否有效。
这种函数式的调用方法虽然十分复杂,但好处是,如果想要扩展traits,会变得相当简单,后续会介绍。