C++之旅(学习笔记)第8章 概念和泛型编程
C++之旅(学习笔记)第8章 概念和泛型编程
8.1 引言
模板第一个最常用的应用是泛型编程,泛型编程主要关注通用算法的设计、实现和使用。
这里“通用”的含义是该算法能支持多种数据类型,只要类型符合算法对参数的要求即可。
模板提供了以下功能:
- 在不丢失信息的情况下将类型(以及值和模板)作为参数传递的能力。这意味着表达的内容具有很大的灵活性以及具有内联的绝佳机会。
- 有机会在实例化时将来自不同上下文的信息捏合在一起,这意味着有进行针对性优化的可能。
- 把值作为模板参数传递的能力,也就是在编译时计算的能力。
8.2 概念
template<typename Seq, typename Value>
Value sum(Seq s, Value v) {
for(const auto& x : s)
v+=x;
return v;
}
这个sum函数需要保证
- 它的第一个模板参数是某种元素序列,Seq,它支持begin()和end(),从而允许范围for语句正常工作。
- 第二个参数是某种形式的数字,Value,支持+=,因此元素可以被累加。
这种需求叫作概念。
满足作为序列要求的大致有:标准库vector、list、map
满足作为算术类型的大致有:int、double、Matrix(所有合理定义的矩阵都支持算术运算)。
从以下两个维度看,sum属于通用算法:
- 数据结构的类型(序列存储方式)维度;
- 数据元素的类型维度。
8.2.1 概念的运用
大多数模板参数必须符合特定需求才能被正常编译和运行。也就是说,绝大多数模板都应当是受限模板。
类型名称指示符typename是限定程度最低的,他仅仅要求该参数是一个类型。
template<Sequence Seq, Number Num>
Num sum(Seq s, Num v){
for(const auto& x : s)
v+=x;
return v;
}
- 这里的sum定义了Sequence和Number这两个概念的实际含义,编译器就可从sum的接口中直接识别出无效的实例化调用而无需等到编译或者运行时才能报告错误。
以下这些巴拉巴拉一大堆也不知道讲的什么,做个标记,以后再看
先看看GPT的解释:
强调了使用概念来对模板参数进行约束的重要性,以提高代码的清晰度和安全性。通过使用概念,可以在编译期间发现一些错误,而不是等到运行时才暴露问题。
在C++中,
requires
子句是用于指定模板参数的一组要求(constraints)的关键字。它用于在模板定义中对模板参数进行约束,以确保只有符合指定条件的类型或值才能被接受。在模板中,
requires
子句通常用于requires
关键字之后,用于指定一组布尔表达式,这些表达式描述了模板参数必须满足的条件。如果这些条件不满足,编译器将拒绝对该模板的实例化,并在编译时生成错误消息。例如,在上面提到的代码中,
requires Arithmetic<range_value_t<Seq>, Num>
表达了对于类型range_value_t<Seq>
和Num
,必须满足Arithmetic
概念。这样的约束有助于确保在模板函数中对这些类型进行算术运算时是安全和合法的。
requires
子句的使用使得模板的错误能够更早地在编译期间被发现,提高了代码的可读性和安全性。在概念引入之前,开发者通常通过模板的SFINAE(Substitution Failure Is Not An Error)机制来实现类似的效果,但概念提供了更为直观和清晰的方式来表达对模板参数的要求。
但是,sum接口的技术规格不太完整:应该允许将整个Sequence的元素累加到Number。
template<Sequence Seq, Number Num>
requires Arithmetic<range_value_t<Seq>,Num>
Num sum(Seq s, Num s);
- 序列的range_value_t是序列中的元素类型;它来自标准库中range的类型名称。
- Arithmetic<X,Y>则是一个概念,它表明X与Y可以进行算术运算。这样可避免
vector<string>
或vector<int>
的sum()这样的操作。同时也可以正常支持vector<int>
与vector<complex<double>>
这样的参数。 - 在这个例子中,我们只需要+=操作符。但不需要限制的那么死,或许某一天我们需要用+和=两个操作符来替代+=操作符,就会庆幸使用了更通用的概念(Arithmetic),而不是单纯限制为“有用+=操作符”。
requires Arithmetic<range_value_t<Seq>,Num>
被称作requirements子句。其中记法template<Sequence Seq>
就是比requires Sequence<Seq>
更简单的写法。
复杂点则等价于:
template<Sequence Seq, Number Num>
requires Arithmetic<Seq> && Number<Num> && Arithmetic<range_value_t<Seq>,Num>
Num sum(Seq s, Num s);
同时,写成如下简写形式也具有等价的效果:
template<<Sequence Seq, Arithmetic<range_value_t<Seq>> Num>
Num sum(Seq s, Num n);
8.2.2 基于概念的重载
一旦我们正确地指定了模板地接口,就可以根据它们地属性进行重载,如同函数一样。
例如:标准库advance()函数向前移动迭代器,简化版本如下:
template<forward_iterator Iter>
void advance(Iter p, int n) { // 将p向前移动n个元素
while(n--)
++p; // 前向迭代器拥有 ++ 操作符,但没有+或者+=操作符
}
template<random_access_iterator Iter>
void advance(Iter p, int n) { // 将p向前移动n个元素
p += n; // 随机访问迭代器拥有 += 操作符
}
编译器会选择满足最严格参数需求的版本。list只提供了向前迭代器,vector提供了随机访问迭代器。
因此:
void user(vector<int>::iterator vip, list<string>::iterator lsp)
{
advance(vip,10); // 使用快速版本的advance()
advance(lsp,10); // 使用慢速版本的advance()
}
如同其他的重载,这是编译时机制,没有任何开销;
如果编译器无法找到最佳选择,会报二义性错误。
考虑具有一个参数并且提供多个版本的模板函数:
- 如果参数不能匹配特定概念,那么那个版本不会被选择。
- 如果参数可以匹配概念并且存在唯一匹配,那么选择那个版本。
- 如果参数可以同时匹配两个版本的概念,但其中一个概念比另外一个更严格(其中一个概念时另外一个概念的完整子集),那么选择更严格的那一个。
- 如果参数匹配两个概念,并且无法判断两个概念谁更严格,那么会报二义性错误。
选择某个特定版本的模板,必须满足这些条件:
- 匹配所有参数,并且
- 至少有一个参数与其他版本的匹配度均等,并且
- 至少有一个参数是最佳匹配。
8.3 泛型编程
C++直接支持的泛型编程形式围绕着这样的思想:从具体、高效的算法中抽象出来,从而获得可以与不同数据表示相结合的泛型算法,以生成各种有用的软件。
表示基本操作和数据结构的抽象被称为概念。
…较为复杂先不管,以后有能力再看
8.4 可变参数模板
定义模板时可以令其接受任意数量、任意类型的实参,这样的模板被称为可变参数模板。
假设我们需要实现一个简单的函数,输出任意可以被 << 操作符输出的数据:
void user() {
print("first: ", 1, 2.2, "hello\n"s); // 输出first: 1 2.2 hello
printf("\nsecond: ", 0.2, 'c', "yuck!"s, 0, 1, 2, '\n'); // 输出second: 0.2 c yuck! 0 1 2
}
传统方法是:实现一个可变参数模板,将第一个参数剥离出来,然后用递归调用的办法处理所有剩下的参数:
template<typename T>
concept Printable = requires(T t) { std::cout << t; } // 只有一个操作
void print()
{
// 处理无参数的情况:什么都不做
}
template<Printable T, Printable... Tail>
void print()
{
cout << head << ' '; // 首先对head进行操作
print(tail...); // 然后操作tail
}
- 这里加了省略号的Printable…表示Tail包含多个类型的序列。
- 而Tail…则表示tail本身是这个序列的值。参数声明后面加了省略号…,这叫作参数包。
- 这里的tail是由函数参数组成的参数包,其元素类型对应的是Tail模板参数包中指定的类型。
- 使用这样的机制,print()可以接受任意数量、任意类型的参数。
每次调用print()都把参数分成头元素以及其他(尾)元素。对头元素调用了打印命令,然后对其他元素调用print()。最终,tail变为空,所以我们一定需要一个无参数的版本来处理空参数的情况。如果不需要处理无参数的情况,可以通过编译时if来消除这种情况。
template<Printable T, Printable... Tail>
void print(T head, Tail... tail)
{
cout << head << ' ';
if constexpr(sizeof...(tail) > 0)
print(tail...);
}
这里使用编译时if而不是运行时if,可以避免生成对空参数print()函数的调用。这也就无须定义空参数版本的print()。
可变参数模板的强大之处在于,它们可以接受任意参数。缺点包括:
- 递归实现需要一些技巧,容易出错。
- 很可能需要一个精心设计的模板程序,才能方便地对接口地类型进行有效检查。
- 类型检查代码是临时地,而不是被标准定义地。
- 递归实现在编译时地开销可能非常昂贵,也会占用大量地编译器内存。
8.4.1 折叠表达式
template<Number... T>
int sum(T... v)
{
return (v + ... + 0); // 将v中所有元素与0累和
}
这里,(v + … + 0)表示把v中的所有元素加起来,从0开始。首先做加法的元素是最右边的那个(也就是索引最大的那个):(v[0]+(v[1]+(v[2]+(v[3]+(v[4]+0))))。从右边开始的叫作右折叠。
这个sum()函数可以接受任意数量、任意类型的参数:
int x = sum(1,2,3,4,5); // x变成15
int y = sum('a', 2.4, x); // y变成114(2.4被取整,‘a'的值是97
反之,左折叠:
template<Number... T>
int sum(T... v)
{
return (0 + ... + v); // 将v中所有元素与0累和
}
(((((0+v[0])+v[1])+v[2])+v[3])+v[4])
除此之外,折叠表达式不仅限于算术操作。
template<Printable ...T>
void print(T&&... args)
{
(std::cout << ... << args) << '\n'; // 打印输出所有参数
}
// (((((std::cout << "Hello!"s) << ' ') << "World ") << 2017) << '\n');
print("Hello!"s,' ',"World ",2017);
出现2017,是因为fold()的特性实在C++2017标准中被添加的。
8.4.2 完美转发参数
使用可变参数模板时,保证参数在通过接口传递的过程中完全不变,有时非常有用。
8.5 建议
。。。