类模板和嵌套
当一个类嵌套在类模板中时,它会自动成为一个类模板。嵌套类可以使用外围类的模板参数,如下例所示。类 PtrVector
中定义了一个嵌套类 iterator
。嵌套类从其外围类 PtrVector<Type>
中获取信息。由于这个外围类应是唯一构造其迭代器的类,因此 iterator
的构造函数被定义为私有,并且外围类 PtrVector<Type>
被授予对 iterator
私有成员的访问权限。以下是 PtrVector
类接口的初始部分:
template <typename Type>
class PtrVector: public std::vector<Type *>
{
// ...
};
这表明 std::vector
基类存储指向 Type
值的指针,而不是值本身。由于现在需要一个析构函数来释放 (外部分配的) Type
对象的内存,因此 PtrVector
的析构函数将在稍后实现。或者,分配可能是 PtrVector
的任务的一部分,当它存储新元素时。这里假设 PtrVector
的客户端会进行必要的分配,析构函数将在稍后实现。
嵌套类将其构造函数定义为私有成员,并允许 PtrVector<Type>
对象访问其私有成员。因此,只有 PtrVector<Type>
类型的对象才能构造其迭代器对象。然而,PtrVector<Type>
的客户端可以构造 PtrVector<Type>::iterator
对象的副本。下面是嵌套类 iterator
的定义,使用了(所需的)友元声明。请注意 typename
关键字的使用:由于 std::vector<Type *>::iterator
依赖于模板参数,它尚未实例化为类。因此 iterator
成为一个隐式的 typename
。如果省略 typename
,编译器会发出警告。以下是类接口:
class iterator
{
friend class PtrVector<Type>;
typename std::vector<Type *>::iterator d_begin;
iterator(PtrVector<Type> &vector);
public:
Type &operator*();
};
成员的实现显示基类的 begin
成员被调用以初始化 d_begin
。PtrVector<Type>::begin
的返回类型必须再次由 typename
修饰:
template <typename Type>
PtrVector<Type>::iterator::iterator(PtrVector<Type> &vector)
:
d_begin(vector.std::vector<Type *>::begin())
{}
template <typename Type>
Type &PtrVector<Type>::iterator::operator*()
{
return **d_begin;
}
类的其余部分很简单。省略了所有可能实现的其他函数,begin
函数返回一个新构造的 PtrVector<Type>::iterator
对象。它可以调用构造函数,因为 iterator
类将其外围类声明为友元:
template <typename Type>
typename PtrVector<Type>::iterator PtrVector<Type>::begin()
{
return iterator(*this);
}
以下是一个简单的骨架程序,显示如何使用嵌套类 iterator
:
int main()
{
PtrVector<int> vi;
vi.push_back(new int(1234));
PtrVector<int>::iterator begin = vi.begin();
std::cout << *begin << '\n';
}
嵌套的枚举和嵌套的 typedef
及 using
声明也可以由类模板定义。之前提到的类 Table
继承了枚举 TableType::FillDirection
。如果 Table
被实现为一个完整的类模板,那么这个枚举将定义在 Table
本身中,如下所示:
template <typename Iterator>
class Table: public TableType
{
public:
enum FillDirection {
HORIZONTAL,
VERTICAL
};
// ...
};
在这种情况下,引用 FillDirection
值或其类型时必须指定模板类型参数的实际值。例如(假设 iter
和 nCols
按照第22.11.3节定义):
Table<istream_iterator<string>>::FillDirection direction =
argc == 2 ?
Table<istream_iterator<string>>::VERTICAL
:
Table<istream_iterator<string>>::HORIZONTAL;
Table<istream_iterator<string>> table(iter, istream_iterator<string>(), nCols, direction);
构造迭代器
第18.2节介绍了用于通用算法的迭代器。我们已经看到,迭代器有几种类型:输入迭代器(InputIterators)、前向迭代器(ForwardIterators)、输出迭代器(OutputIterators)、双向迭代器(BidirectionalIterators)、随机访问迭代器(RandomAccessIterators)和连续迭代器(ContiguousIterators)。
为了确保一个类的对象被解释为特定类型的迭代器,该类必须设计成符合该迭代器类型的要求。实现迭代器的类的接口使用 iterator_tags
,这些标签在包含 <iterator>
头文件时可用。
迭代器类应提供以下 using
声明:
iterator_category
,指定迭代器的标签,例如:using iterator_category = std::random_access_iterator_tag;
difference_type
,定义表示指针之间差异的类型,通常是:using difference_type = std::ptrdiff_t;
value_type
,定义解引用迭代器的数据类型。例如:using value_type = std::string;
pointer
,定义迭代器的指针类型,也可以定义为const
指针。例如:using pointer = value_type *; // 或者: using pointer = value_type const *;
reference
,定义迭代器的引用类型,也可以定义为const
引用。例如:using reference = value_type &; // 或者: using reference = value_type const &;
这些 using
声明通常出现在迭代器的公共部分,因此迭代器类通常定义为 struct
。以下是一个迭代器接口的设计示例:
struct Iterator
{
using iterator_category = std::random_access_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = std::string;
using pointer = value_type *;
using reference = value_type &;
friend bool operator==(iterator const &lhs, iterator const &rhs);
friend auto operator<=>(Iterator const &lhs, Iterator const &rhs);
friend int operator-(Iterator const &lhs, Iterator const &rhs);
friend Iterator operator+(Iterator const &lhs, int step);
friend Iterator operator-(Iterator const &lhs, int step);
private:
// 私有数据成员
public:
// 公共成员
private:
// 私有支持成员
};
在第18.2节中讨论了迭代器的特性。所有迭代器都应支持(以 Iterator
作为设计的迭代器类的通用名称,Type
代表迭代器对象引用的数据类型):
- 前缀递增运算符:
Iterator &operator++();
- 解引用运算符:
Type &operator*();
- “指针到”运算符:
Type *operator->();
- 比较运算符测试两个迭代器对象的(不)相等性(最好是自由运算符,如:
bool operator==(Iterator const &lhs, Iterator const &rhs);
和bool operator!=(Iterator const &lhs, Iterator const &rhs);
)。此外,可以使用“飞船运算符”(operator<=>
),允许对迭代器进行排序。
在通用算法的上下文中使用迭代器时,迭代器还必须满足额外的要求。这些要求是因为通用算法会检查它们接收到的迭代器的类型。简单的指针通常是可以接受的,但如果使用迭代器对象,通用算法可能要求迭代器指定它代表的迭代器类型。
实现的迭代器类型由类的 iterator_category
指示。六种标准迭代器类别包括:
-
std::input_iterator_tag
:当迭代器类定义为输入迭代器时使用此标签。此类型的迭代器允许读取操作,从迭代器所引用的系列的第一个元素迭代到最后一个元素。输入迭代器的解引用运算符可以声明为:value_type const &operator*() const;
除了标准运算符外,输入迭代器没有进一步的要求。
-
std::output_iterator_tag
:当迭代器类定义为输出迭代器时使用此标签。此类型的迭代器允许对其解引用的迭代器进行赋值。因此,输出迭代器的解引用运算符通常声明为:value_type &operator*();
除了标准运算符外,输出迭代器没有进一步的要求。
-
std::forward_iterator_tag
:当迭代器类定义为前向迭代器时使用此标签。此类型的迭代器允许读取和赋值操作,从迭代器所引用的系列的第一个元素迭代到最后一个元素。因此,前向迭代器的解引用运算符应声明为:value_type &operator*();
并且也可以声明一个重载的
operator*() const
:value_type const &operator*() const;
除了标准运算符外,前向迭代器没有进一步的要求。
-
std::bidirectional_iterator_tag
:当迭代器类定义为双向迭代器时使用此标签。此类型的迭代器允许读取和赋值操作,逐步迭代,可能在系列的任意方向。双向迭代器应允许对解引用操作的数据进行赋值,并且应允许向后步进。因此,双向迭代器除了标准运算符外,还应提供以下运算符:value_type &operator*(); // 及其 const 重载 Iterator &operator--();
-
std::random_access_iterator_tag
:当迭代器类定义为随机访问迭代器时使用此标签。这些迭代器类似于双向迭代器,但还允许通过int
步长来步进到下一个迭代器(结果迭代器可能指向有效迭代器范围之外的位置)。除了双向迭代器提供的成员外,随机访问迭代器还提供以下运算符:Type operator-(Iterator const &rhs) const
,返回当前迭代器与rhs
迭代器之间的数据元素数量(如果rhs
引用的数据元素在当前迭代器所引用的数据元素之后,则返回负值);Iterator operator+(int step) const
,返回一个指向当前迭代器所引用的数据元素之后step
数据元素的迭代器;Iterator operator-(int step) const
,返回一个指向当前迭代器所引用的数据元素之前step
数据元素的迭代器;bool operator<(Iterator const &rhs) const
,返回true
如果当前迭代器所引用的数据元素位于rhs
迭代器所引用的数据元素之前。
-
std::contiguous_iterator_tag
:当迭代器类定义为连续迭代器时使用此标签。这些迭代器提供与随机访问迭代器相同的功能,但软件工程师需要确保迭代器所引用的元素在内存中是连续存储的。
每个迭代器标签假设提供了一定的操作集。随机访问迭代器和连续迭代器是最复杂的,因为其他迭代器仅提供它们功能的子集。
请注意,迭代器总是定义在某个范围内([begin, end))。递增和递减操作可能会导致迭代器的未定义行为,如果结果的迭代器值会引用此范围之外的位置。
通常,迭代器类仅访问这些类的数据元素。内部,迭代器可能使用普通指针,但通常不需要迭代器分配内存。因此,迭代器通常不需要定义拷贝构造函数和赋值运算符。出于同样的原因,迭代器通常不需要析构函数。
大多数提供返回迭代器成员的类通过构造所需的迭代器并将其作为对象返回来实现这些成员。由于调用者只需要使用或有时复制返回的迭代器对象,通常没有必要提供公开可用的构造函数,除了拷贝构造函数(它是默认可用的)。因此,额外的构造函数通常定义为私有或受保护的成员。为了允许外部类创建迭代器对象,迭代器类通常将外部类声明为其友元。
在接下来的章节中,将讨论一个随机访问迭代
器以及一个反向随机访问迭代器。用于开发随机访问迭代器的容器类可能以多种方式存储其数据元素(例如,使用容器或指向指针的指针)。第25.5节包含了一个自制模板迭代器类的示例。开发的随机访问迭代器通过指针访问数据元素。这个迭代器类被设计为一个从字符串指针的向量派生的类的内部类。
实现 RandomAccessIterator
在容器(第12章)中提到,容器拥有其包含的信息。如果容器包含对象,这些对象会在容器销毁时被销毁。由于指针不是对象,因此不鼓励在容器中使用它们(尽管 STL 的 unique_ptr
和 shared_ptr
类型对象可以使用)。尽管不鼓励,但在特定情况下我们可能能够使用指针数据类型。在下面的 StringPtr
类中,一个普通类从 std::vector
容器派生,使用 std::string*
作为其数据类型:
#ifndef INCLUDED_STRINGPTR_H_
#define INCLUDED_STRINGPTR_H_
#include <string>
#include <vector>
class StringPtr : public std::vector<std::string*> {
public:
StringPtr(StringPtr const& other);
~StringPtr();
StringPtr& operator=(StringPtr const& other);
};
#endif // INCLUDED_STRINGPTR_H_
这个类需要一个析构函数:因为对象存储了字符串指针,所以需要一个析构函数以在 StringPtr
对象本身被销毁时销毁字符串。同样,复制构造函数和重载赋值运算符也是必要的。其他成员(特别是构造函数)在这一节中并未明确声明,因为它们与主题无关。
假设我们想使用 sort
泛型算法与 StringPtr
对象。这个算法(见第19.1.54节)需要两个 RandomAccessIterators
。虽然这些迭代器是可用的(通过 std::vector
的 begin
和 end
成员),但它们返回的是指向 std::string*
的迭代器。在比较 string*
值时比较的是指针值,而不是字符串内容,这不是我们想要的。
为了解决这个问题,定义了一个内部类型 StringPtr::iterator
,它不是返回指向指针的迭代器,而是返回指向这些指针所指向的对象的迭代器。一旦这个迭代器类型可用,我们可以向 StringPtr
类接口添加以下成员,隐藏其基类中的同名但无用的成员:
struct StringPtr : public std::vector<std::string*> {
class iterator;
using reverse_iterator = std::reverse_iterator<iterator>;
iterator begin();
iterator end();
reverse_iterator rbegin();
reverse_iterator rend();
struct iterator {
using iterator_category = std::random_access_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = std::string;
using pointer = value_type*;
using reference = value_type&;
// 以下为实现
};
};
接下来,我们来看一下 StringPtr::iterator
的特性:
iterator
定义了StringPtr
作为友元,使得iterator
的构造函数可以是私有的。只有StringPtr
类本身可以构造迭代器。拷贝构造和迭代器赋值是可能的,但这是默认行为,不需要特别声明或实现。此外,由于StringPtr
的基类已经提供了迭代器,我们可以使用该迭代器访问StringPtr
对象中存储的信息。StringPtr::begin
和StringPtr::end
可以简单地返回iterator
对象。它们的实现如下:inline StringPtr::iterator StringPtr::begin() { return iterator(std::vector<std::string*>::begin()); } inline StringPtr::iterator StringPtr::end() { return iterator(std::vector<std::string*>::end()); }
iterator
的所有剩余成员都是公开的。实现这些成员非常简单,主要是操作和解引用可用的迭代器d_current
。一个RandomAccessIterator
需要一系列操作符。这些操作符通常有非常简单的实现,通常可以很好地进行内联实现:iterator& operator++();
前缀递增运算符:inline StringPtr::iterator& StringPtr::iterator::operator++() { ++d_current; return *this; }
iterator operator++(int);
后缀递增运算符:inline StringPtr::iterator StringPtr::iterator::operator++(int) { return iterator(d_current++); }
iterator& operator--();
前缀递减运算符:inline StringPtr::iterator& StringPtr::iterator::operator--() { --d_current; return *this; }
iterator operator--(int);
后缀递减运算符:inline StringPtr::iterator StringPtr::iterator::operator--(int) { return iterator(d_current--); }
iterator& operator=(iterator const& other);
重载赋值运算符。由于迭代器对象本身不分配任何内存,可以使用默认赋值运算符。bool operator==(iterator const& rhv) const;
测试两个迭代器对象的相等性:inline bool operator==(StringPtr::iterator const& lhs, StringPtr::iterator const& rhs) { return lhs.d_current == rhs.d_current; }
auto operator<=>(iterator const& rhv) const;
测试两个迭代器对象的顺序:inline auto operator<=>(StringPtr::iterator const& lhs, StringPtr::iterator const& rhs) { return lhs.d_current <=> rhs.d_current; }
int operator-(iterator const& rhv) const;
返回左侧迭代器和右侧迭代器之间的元素数量(即,要使左侧迭代器的值等于右侧迭代器的值所需的值):inline int operator-(StringPtr::iterator const& lhs, StringPtr::iterator const& rhs) { return lhs.d_current - rhs.d_current; }
Type& operator*() const;
返回对当前迭代器指向的对象的引用。对于输入迭代器和所有const_iterators
,此重载操作符的返回类型应为Type const&
。该操作符返回对字符串的引用。此字符串通过解引用解引用d_current
的值获得。由于d_current
是指向std::string*
元素的迭代器,因此需要两次解引用操作才能到达字符串本身:inline std::string& StringPtr::iterator::operator*() const { return **d_current; }
iterator operator+(int stepsize) const;
该操作符将当前迭代器向前推进stepsize
:inline StringPtr::iterator operator+(StringPtr::iterator const& lhs, int step) { StringPtr::iterator ret{lhs}; ret.d_current += step; return ret; }
iterator operator-(int stepsize) const;
该操作符将当前迭代器向后推进stepsize
:inline StringPtr::iterator operator-(StringPtr::iterator const& lhs, int step) { StringPtr::iterator ret{lhs}; ret.d_current -= step; return ret; }
std::string* operator->() const
是一个额外添加的操作符。这里只需要一个解引用操作,返回指向字符串的指针,允许我们通过其指针访问字符串的成员:inline std::string* StringPtr::iterator::operator->() const { return *d_current; }
- 额外添加的
operator+=
和operator-=
操作符。它们在RandomAccessIterators
中不是正式要求的,但还是很有用:inline StringPtr::iterator& StringPtr::iterator::operator+=(int step) { d_current += step; return *this; } inline StringPtr::iterator& StringPtr::iterator::operator-=(int step) { d_current -= step; return *this; }
其他迭代器类型所需的接口更简单,只需 RandomAccessIterator
所需的一个子集。例如,前向迭代器不会被递减,也不会以任意步长递增。因此,在这种情况下,所有递减运算符和 operator+(int step)
可以省略。相应地,迭代器标签(和所需的操作符集)会有所不同。
实现 reverse_iterator
一旦我们实现了一个迭代器,reverse_iterator
的实现就变得相当简单。为了实现 reverse_iterator
,我们可以使用 std::reverse_iterator
,它可以很方便地实现逆向迭代器,只要我们定义了一个正常的迭代器。其构造函数仅需要一个迭代器类型的对象来构造逆向迭代器。
对于 StringPtr
实现逆向迭代器时,我们只需要在类的接口中定义 reverse_iterator
类型。这只需要一行代码,如下所示,该代码应该在 iterator
类的接口后面插入:
using reverse_iterator = std::reverse_iterator<iterator>;
此外,我们还需要在 StringPtr
的接口中添加常见的成员函数 rbegin
和 rend
。这些函数可以很容易地在线实现:
inline StringPtr::reverse_iterator StringPtr::rbegin()
{
return reverse_iterator(end());
}
inline StringPtr::reverse_iterator StringPtr::rend()
{
return reverse_iterator(begin());
}
注意,reverse_iterator
构造函数的参数是 end()
和 begin()
的返回值:正向迭代器范围的终点和起点。reverse_iterator
的构造函数使用 end()
返回的值作为反向迭代器的起点,使用 begin()
返回的值作为反向迭代器的终点。
以下程序演示了如何使用 StringPtr
的 RandomAccessIterator
:
#include <iostream>
#include <algorithm>
#include "stringptr.h"
using namespace std;
int main(int argc, char **argv)
{
StringPtr sp;
while (*argv)
sp.push_back(new string{ *argv++ });
sort(sp.begin(), sp.end());
copy(sp.begin(), sp.end(), ostream_iterator<string>(cout, " "));
cout << "\n======\n";
sort(sp.rbegin(), sp.rend());
copy(sp.begin(), sp.end(), ostream_iterator<string>(cout, " "));
cout << '\n';
}
假设程序运行时传递的参数为:
a.out bravo mike charlie zulu quebec
输出将会是:
bravo charlie mike quebec zulu
======
zulu quebec mike charlie bravo
尽管我们可以从正常的迭代器构造一个反向迭代器,但反向迭代器不能反向初始化正常迭代器。
假设我们希望处理 vector<string>
中的所有行,直到遇到任何尾随的空行(或仅包含空格的行)。一种方法是从 vector
中的第一行开始处理,直到遇到第一个空行。然而,一旦遇到一个空行,它不一定是尾随空行的第一个行。这时,我们可以使用以下算法:
- 首先,使用:
rit = find_if(lines.rbegin(), lines.rend(), NonEmpty());
获取一个 reverse_iterator
rit
,指向最后一个非空行。
- 然后,使用:
for_each(lines.begin(), --rit, Process());
处理所有行,直到第一个空行。
然而,我们不能在使用通用算法时混合使用迭代器和反向迭代器。因此,我们如何使用可用的反向迭代器初始化第二个迭代器呢?解决方案并不复杂,因为迭代器可以从指针初始化。尽管反向迭代器不是指针,但 &*rit
是一个指针。因此,我们使用:
for_each(lines.begin(), &*--rit, Process());
处理所有行,直到遇到第一个尾随空行。一般来说,如果 rit
是一个指向某个元素的反向迭代器,而我们需要一个迭代器来指向该元素,我们可以使用 &*rit
来初始化迭代器。在这里,先对 rit
应用解引用操作符,以访问反向迭代器指向的元素。然后,应用取地址操作符来获取其地址,从而初始化迭代器。
当定义 const_reverse_iterator
(例如,与 const_iterator
类匹配)时,const_iterator
的 operator*
成员应该返回一个不可修改的值或对象。由于 const_reverse_iterator
使用了迭代器的 operator--
成员,我们遇到一个小的概念冲突。一方面,std::input_iterator_tag
不合适,因为我们必须允许递减迭代器。另一方面,std::bidirectional_iterator_tag
也不合适,因为我们不允许修改数据。
迭代器标签主要是概念性的。如果 const_iterators
和 const_reverse_iterators
仅允许递增操作,那么 input_iterator_tag
是最合适的标签。因此,下面使用了 input_iterator_tag
。
此外,为了保持 const_iterator
的常量性,可以在 const_iterator
类中添加如下规范:
using const_pointer = value_type const *;
using const_reference = value_type const &;
然后其 operator*
可以返回 const_reference
。
下面是定义 iterator
、const_iterator
、reverse_iterator
和 const_reverse_iterator
的示例,这个示例只需要定义 iterator
和 const_iterator
类,因为这些类型可以传递给 reverse_iterator
模板来获得相应的反向迭代器:
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
struct Data
{
class iterator;
class const_iterator;
using reverse_iterator = std::reverse_iterator<iterator>;
using const_reverse_iterator = std::reverse_iterator<const_iterator>;
private:
std::string* d_data;
size_t d_n;
public:
// Other methods...
struct iterator
{
using iterator_category = std::bidirectional_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = std::string;
using pointer = value_type*;
using reference = value_type&;
friend class Data;
private:
pointer d_current;
public:
iterator() = default;
iterator &operator++();
iterator &operator--();
std::string &operator*();
private:
iterator(std::string *data, size_t idx);
friend class std::reverse_iterator<iterator>;
};
bool operator==(Data::iterator const &lhs, Data::iterator const &rhs);
struct const_iterator : public Data::iterator
{
using const_pointer = value_type const*;
using const_reference = value_type const&;
friend class Data;
friend class std::reverse_iterator<const_iterator>;
const_iterator() = default;
const_iterator &operator++();
const_iterator &operator--();
const_reference operator*() const;
private:
const_iterator(std::string const *data, size_t idx);
};
bool operator==(Data::const_iterator const &lhs, Data::const_iterator const &rhs);
};
void demo()
{
Data::iterator iter;
Data::reverse_iterator riter(iter);
std::cout << *riter << '\n';
Data::const_iterator citer;
Data::const_reverse_iterator criter(citer);
std::cout << *criter << '\n';
}
以上代码展示了如何定义 iterator
和 const_iterator
类,并使用 std::reverse_iterator
实现 reverse_iterator
和 const_reverse_iterator
。
高级模板使用
模板的主要目的是提供类和函数的泛型定义,然后可以针对特定类型进行定制。但模板不仅仅止于此。如果不是因为编译器实现的限制,模板几乎可以在编译时完成我们使用计算机进行的任何操作。这种独特的能力,是当前其他编程语言所不具备的,它源于模板在编译时可以完成以下三项任务:
- 模板允许我们进行整数运算(并以符号方式保存计算结果);
- 模板允许我们在编译时做出决策;
- 模板允许我们重复执行某些操作。
当然,让编译器为我们计算素数是一回事,但以一种获奖的方式进行计算则完全是另一回事。当编译器为我们执行复杂计算时,不要指望能打破速度记录。但这并不是重点。最终,我们可以使用 C++ 的模板语言在编译时计算几乎任何东西,包括素数。
本章将讨论模板的这些显著特性。简要介绍与模板相关的一些细微差别后,将引入模板元编程的主要特性。
除了模板类型参数和模板非类型参数外,还有第三种模板参数,即模板模板参数。本章将简要介绍这种模板参数,为讨论Trait类(trait classes)和策略类(policy classes)奠定基础。
本章最后将讨论几种模板的其他有趣应用:调整编译器错误消息、向类类型转换以及一个详尽的编译时列表处理示例。
本章的灵感大部分来源于以下两本强烈推荐的书籍:Andrei Alexandrescu 的 2001 年著作《Modern C++ Design》(Addison-Wesley)和 Nicolai Josutis 与 David Vandevoorde 的 2003 年著作《Templates》(Addison-Wesley)。
细微之处
在第 22.2.1 节中,讨论了 typename
关键字的一种特殊应用。在那里我们了解到,它不仅用于定义(复杂)类型的名称,还用于区分类模板定义的类型与类模板定义的成员。在本节中,将介绍 typename
的另一种应用。在第 23.1.1 节中,我们将探讨如何从派生类模板中引用基类模板的问题。
除了 typename
的特殊应用外,第 23.1.2 节还介绍了一些与 typename
关键字扩展使用相关的新语法:::template
、.template
和 ->template
,这些用法用于通知编译器在模板内使用的某个名称本身就是一个类模板。
基类成员的类型解析
下面我们看到两个类模板 Base
和 Derived
,其中 Base
是 Derived
的基类:
#include <iostream>
template <typename T>
class Base
{
public:
void member();
};
template <typename T>
void Base<T>::member()
{
std::cout << "This is Base<T>::member()\n";
}
template <typename T>
class Derived : public Base<T>
{
public:
Derived();
};
template <typename T>
Derived<T>::Derived()
{
member();
}
这个示例不会编译,编译器会给出类似以下的错误信息:
error: there are no arguments to 'member' that depend on a template
parameter, so a declaration of 'member' must be available
这个错误会引起一些困惑,因为普通(非模板)的基类会直接将其 public
和 protected
成员提供给派生类使用。这对于类模板来说也是一样的,但前提是编译器能够理解我们的意图。在上述示例中,编译器无法理解,因为它不知道在 Derived<T>::Derived
中调用 member
时需要为 T
初始化哪种类型的 member
函数。为了理解这一点,考虑我们定义了一个特化的情况:
template <>
void Base<int>::member()
{
std::cout << "This is the int-specialization\n";
}
由于编译器在调用 Derived<SomeType>::Derived
时并不知道 member
是否已经有一个特化版本,所以它无法在编译 Derived<T>::Derived
时决定要为哪个类型实例化 member
。在 Derived<T>::Derived
中编译时无法决定这一点,因为在 Derived::Derived
中调用 member
时并不需要模板类型参数。
在这种情况下,如果没有可用的模板类型参数来确定要使用的类型,就必须告知编译器推迟其关于使用何种模板类型参数(因此也就推迟关于调用哪个具体函数,如这里的 member
)的决策,直到实例化时再做决定。
这可以通过两种方式实现:要么使用 this
,要么显式地提到基类,为派生类的模板类型实例化的基类。当使用 this
时,编译器会明白我们引用的是为模板实例化的类型 T
。对于使用哪个成员函数(派生类成员或基类成员)的混淆将优先选择派生类成员。或者,也可以显式地提到基类或派生类(使用 Base<T>
或 Derived<T>
),如下例所示。请注意,对于 int
模板类型,将使用 int
的特化版本。
#include <iostream>
template <typename T>
class Base
{
public:
virtual void member();
};
template <typename T>
void Base<T>::member()
{
std::cout << "This is Base<T>::member()\n";
}
template <>
void Base<int>::member()
{
std::cout << "This is the int-specialization\n";
}
template <typename T>
class Derived : public Base<T>
{
public:
Derived();
void member() override;
};
template <typename T>
void Derived<T>::member()
{
std::cout << "This is Derived<T>::member()\n";
}
template <typename T>
Derived<T>::Derived()
{
this->member(); // 使用 `this` 表示使用为 T 实例化的类型
Derived<T>::member(); // 同样:调用派生类的成员
Base<T>::member(); // 同样:调用基类的成员
std::cout << "Derived<T>::Derived() completed\n";
}
int main()
{
Derived<double> d;
Derived<int> i;
}
/*
生成的输出:
This is Derived<T>::member()
This is Derived<T>::member()
This is Base<T>::member()
Derived<T>::Derived() completed
This is Derived<T>::member()
This is Derived<T>::member()
This is the int-specialization
Derived<T>::Derived() completed
*/
上述示例还演示了虚成员模板的使用(尽管虚成员模板并不常用)。在该示例中,Base
声明了一个虚函数 void member
,而 Derived
定义了其覆盖函数 member
。在这种情况下,由于 member
的虚函数性质,在 Derived::Derived
中调用 this->member()
会调用 Derived::member
。然而,语句 Base<T>::member()
总是调用基类的成员函数,可用于绕过动态多态性。
::template
、.template
和 ->template
通常情况下,编译器能够确定名称的真实含义。如前所述,这并不总是如此,有时我们必须提醒编译器。typename
关键字通常用于此目的。
在解析源码时,编译器会接收到一系列标记(tokens),这些标记代表程序源码中出现的有意义的文本单元。标记可以表示标识符、数字等。其他标记则表示运算符,如 =
、+
或 <
。正是最后一个标记可能会引起问题,因为它可能有非常不同的含义。编译器并不总能从上下文中确定 <
的正确含义。在某些情况下,编译器知道 <
不是表示小于运算符,例如当模板参数列表跟在 template
关键字后面时,如:
template <typename T, int N>
显然,在这种情况下,<
并不表示“小于”运算符。<
在 template
前面出现时具有特殊意义,这构成了本节讨论的语法结构的基础。
假设定义了以下类:
template <typename Type>
class Outer
{
public:
template <typename InType>
class Inner
{
public:
template <typename X>
void nested();
};
};
类模板 Outer
定义了一个嵌套的类模板 Inner
。Inner
又定义了一个成员模板函数。
接下来定义一个类模板 Usage
,它提供了一个成员函数 caller
,该函数期望一个上述 Inner
类型的对象。Usage
的初始设置如下:
template <typename T1, typename T2>
class Usage
{
public:
void caller(Outer<T1>::Inner<T2> &obj);
...
};
编译器不会接受这一写法,因为它将 Outer<T1>::Inner
解释为一个类类型。但实际上并没有类 Outer<T1>::Inner
。在这种情况下,编译器会生成类似以下的错误:
error: 'class Outer<T1>::Inner' is not a type
有两种方法可以告诉编译器 Inner
本身是一个模板,使用模板类型参数 <T2>
。一种方法是用 typename
前缀修饰 caller
的参数说明:
void caller(typename Outer<T1>::Inner<T2> &obj)
尽管这种方法可行,但指定 Outer<T1>::Inner<T2>
是一个 typename
可能看起来有点奇怪。因此,另一种方式是使用 ::template
结构:
void caller(Outer<T1>::template Inner<T2> &obj)
::template
告诉编译器下一个 <
不应解释为“小于”符号,而是模板类型参数。
当然,caller
本身不仅仅是声明,还必须实现。假设其实现应调用 Inner
的成员 nested
,并为另一个类型 X
实例化。类模板 Usage
因此接收第三个模板类型参数,称为 T3
。假设它已由 Usage
定义,然后要实现 caller
,我们写道:
template <typename T1, typename T2, typename T3>
void caller(typename Outer<T1>::template Inner<T2> &obj)
{
obj.nested<T3>();
}
在函数体中,我们再次遇到问题。编译器在这里将 <
解释为“小于”,认为这是一个逻辑表达式,其右侧是一个主表达式,而不是一个指定模板类型 T3
的函数调用。
为了告诉编译器应将 <T3>
解释为要实例化的类型,使用 template
关键字,如编译器提示的那样:
expected `template' keyword before dependent template name
虽然这是作为警告发出的,但随后会跟随错误消息,原因是编译器将 <
解释为“小于”。要消除警告和错误消息,我们写作 .template
,告知编译器后面的内容不是“小于”运算符,而是类型说明。因此,函数的最终实现如下:
template <typename T1, typename T2, typename T3>
void caller(typename Outer<T1>::template Inner<T2> &obj)
{
obj.template nested<T3>();
}
除了定义值或引用参数外,函数也可以定义指针参数。如果 obj
被定义为指针参数,则实现必须使用 ->template
结构,而不是 .template
结构。例如:
template <typename T1, typename T2, typename T3>
void caller(typename Outer<T1>::template Inner<T2> *ptr)
{
ptr->template nested<T3>();
}
正如我们所见,类模板可以派生自基类模板。基类模板可以声明一个静态成员模板,该模板对于从此基类派生的类是可用的。这样的基类可能如下所示:
template <typename Type>
struct Base
{
template <typename Tp>
static void fun();
};
通常,当基类定义了静态成员时,我们可以通过在其类名之前加上成员名来调用该成员。例如:
int main() {
Base<int>::fun<double>();
}
如果一个类 Derived
派生自 Base
,并为特定类型实例化,这也可以正常工作:
struct Der : public Base<int>
{
static void call()
{
Base<int>::fun<int>(); // 可以
fun<int>(); // 也可以
}
};
但当派生类本身是一个类模板时,这种调用 fun
的方式将不再编译,因为它将 Base<Type>::fun
解释为类型,要为 int
实例化。通过表明 fun
本身是模板,可以推翻这种解释。为此,使用 ::template
前缀:
template <typename Type>
struct Der : public Base<Type>
{
void call()
{
// fun<int>(); // 无法编译
// Base<Type>::fun<int>(); // 无法编译
Base<Type>::template fun<int>(); // 可以
Base<Type>::template fun<Tp>(); // 如果 call 是成员模板则可以
}
};
模板元编程
通过模板计算值
在模板编程中,通常用枚举值来表示常量值。相比于 int const
这样的值,枚举更受青睐,因为枚举值从不需要任何链接,它们是纯粹的符号值,没有任何内存表示。
考虑一个需要使用 reinterpret_cast
的场景。reinterpret_cast
的问题在于,它是关闭所有编译器检查的终极方式。所有规则都不再适用,我们可以写出极端但完全无意义的 reinterpret_cast
语句,例如:
int intVar = 12;
ostream &ostr = reinterpret_cast<ostream &>(intVar);
如果编译器能通过生成错误信息来警告我们这种奇怪的行为,那不是很好吗?
如果这是我们想要编译器做到的,那么必须有某种方法来区分疯狂和奇怪。假设我们同意以下区分:如果目标类型比表达式(源)类型占用更大的内存,那么 reinterpret_cast
是绝对不被允许的,因为这会立即导致超出目标类型实际可用的内存。因此,很明显,reinterpret_cast<double *>(&intVar)
是荒谬的,但 reinterpret_cast<char *>(&intVar)
可能是可以接受的。
现在的目标是创建一种新的类型转换方式,我们称之为 reinterpret_to_smaller_cast
。只有当目标类型占用的内存比源类型少时,才允许执行 reinterpret_to_smaller_cast
(注意,这与 Alexandrescu 在其 2001 年的书中第 2.1 节的逻辑完全相反)。
首先,我们构造如下模板:
template<typename Target, typename Source>
Target &reinterpret_to_smaller_cast(Source &source)
{
// 确定 Target 是否比 Source 小
return reinterpret_cast<Target &>(source);
}
在注释处插入一个枚举定义,定义一个具有暗示性名称的符号。如果不满足所需条件,就会产生编译时错误,并且错误消息会显示符号的名称。由于除零显然是不允许的,并且注意到 false
值表示零值,所以可以使用如下条件:
1 / (sizeof(Target) <= sizeof(Source));
有趣的是,这个条件不会产生任何代码。枚举的值是编译器在评估表达式时计算出来的纯粹的值:
template<typename Target, typename Source>
Target &reinterpret_to_smaller_cast(Source &source)
{
enum
{
the_Target_size_exceeds_the_Source_size =
1 / (sizeof(Target) <= sizeof(Source))
};
return reinterpret_cast<Target &>(source);
}
当使用 reinterpret_to_smaller_cast
从 int
转换到 double
时,编译器会产生一个错误,如下所示:
error: enumerator value for 'the_Target_size_exceeds_the_Source_size'
is not an integer constant
然而,如果请求 reinterpret_to_smaller_cast<int>(doubleVar)
,且 doubleVar
被定义为 double
,则不会报告错误。
在上述示例中,使用枚举来在编译时计算一个值,如果假设不成立,该值是非法的。创造性部分在于找到合适的表达式。
枚举值非常适合这种情况,因为它们不消耗任何内存,并且它们的计算不会产生可执行代码。它们还可以用于累积值:最终的枚举值包含一个由编译器而非可执行代码计算得出的最终值,正如下一节所示。一般来说,程序不应该在运行时做那些可以在编译时完成的事情,执行复杂的计算并生成常量值就是这种原则的一个典型例子。
将整数类型转换为types
将模板中的值与整数类型关联的另一种用途是将简单的标量 int
值“模板化”。这种技术在以下情况下很有用:当需要一个类型来基于选择进行特化时,有一个标量值(通常是布尔值)可以用于选择特化。这种情况将在后面的 23.2.2 节中简要提到。
将整型值转换为模板类型的基础是类模板和其模板参数共同定义了一个类型。例如,vector<int>
和 vector<double>
是不同的类型。
将整型值转换为模板非常简单。只需定义一个模板(它不需要包含任何内容),并在枚举中存储这个整型值:
template <int x>
struct IntType
{
enum { value = x };
};
由于 IntType
没有任何成员,我们可以将其定义为 struct IntType
,这样我们就不必键入 public:
。
定义枚举值 value
允许我们在实例化时检索所使用的值,而不会占用存储空间。枚举值既不是变量也不是数据成员,因此没有地址。它们仅仅是值。
使用 struct IntType
非常简单。可以通过为其 int
非类型参数指定一个值来定义一个匿名或命名对象。例如:
int main()
{
IntType<1> it;
cout << "IntType<1> 对象的值是: " << it.value << "\n"
<< "IntType<2> 对象是不同的类型,其值是 " << IntType<2>().value << '\n';
}
实际上,既不需要命名对象也不需要匿名对象。由于枚举被定义为一个简单的值,并与 struct IntType
关联,我们只需指定 struct IntType
定义的特定 int
即可检索其 value
,如下所示:
int main()
{
cout << "IntType<100>, 无对象, 定义了 `value': " << IntType<100>::value << "\n";
}
使用模板选择替代方案
编程语言的一个基本特征是它们允许条件执行代码。为此,C++ 提供了 if
和 switch
语句。如果我们希望能够“编程”编译器,这个功能也必须由模板提供。
与存储值的模板一样,进行选择的模板不需要在运行时执行任何代码。选择完全由编译器在编译时完成。模板元编程的本质在于,我们并没有使用或依赖任何可执行代码。模板元程序的结果通常是可执行代码,但这些代码只是编译器做出的一系列决策的函数。
模板(成员)函数只有在实际使用时才会被实例化。因此,我们可以定义互斥的函数特化。这使得我们能够在一种情况下编译一种特化,而在另一种情况下无法编译这种特化,并在第二种情况下编译另一种特化。使用特化,可以生成针对特定情况需求的代码。
这种特性无法在运行时的可执行代码中实现。例如,在设计一个通用存储类时,软件工程师可能希望在最终的存储类中既存储值类对象,也存储多态类对象。因此,软件工程师可能会得出结论,存储类应该包含对象的指针,而不是对象本身。初步的实现尝试可能如下所示:
template <typename Type>
void Storage::add(Type const &obj)
{
d_data.push_back(
d_ispolymorphic ?
obj.clone() :
new Type{obj}
);
}
意图是:如果 Type
是一个多态类,那么使用 clone
成员函数;如果 Type
是值类,则使用标准的拷贝构造函数。
不幸的是,这种方案通常会失败,因为值类没有定义 clone
成员函数,而多态基类应该删除其拷贝构造函数(参见第 7.6 节)。编译器并不关心 clone
函数从未被值类调用,或者拷贝构造函数仅在值类中可用而在多态类中不可用。编译器只是简单地尝试编译这些代码,而当缺少必要的成员时,它就无法完成编译。就是这么简单。
定义重载成员
模板元编程在这种情况下可以派上用场。我们知道类模板成员函数只有在使用时才会被实例化,因此我们的计划是设计重载的 add
成员函数,并且只有一个会被调用(因此也只有一个会被实例化)。我们的选择将基于一个额外的(除了 Type
本身之外的)模板非类型参数,该参数指示我们是否将 Storage
用于多态类或非多态类。我们的 Storage
类的开始如下:
template <typename Type, bool isPolymorphic>
class Storage
最初定义了 add
成员的两个重载版本:一个用于存储多态对象的 Storage
对象(使用 true
作为其模板非类型参数),另一个用于存储值类对象(使用 false
作为其模板非类型参数)。
不幸的是,我们遇到了一个小问题:函数不能通过它们的参数值来重载,只能通过它们的参数类型来重载。不过,这个小问题是可以解决的。我们可以利用模板名称和模板参数的组合来定义类型,并将 true
和 false
值转换为类型,这样就可以应用在之前(23.2.1.1 节)提到的将整型值转换为类型的知识。
我们将为 add
成员提供一个带有 IntType<true>
参数的私有函数(实现多态类),以及另一个带有 IntType<false>
参数的私有函数(实现非多态类)。
除了这两个私有成员外,还定义了第三个(公共的)add
成员,它通过提供从 Storage
的模板非类型参数构造的 IntType
参数来调用适当的私有 add
成员。
以下是三个 add
成员的实现:
// 在 Storage 的 private 区域中声明:
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj, IntType<true>)
{
d_data.push_back(obj.clone());
}
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj, IntType<false>)
{
d_data.push_back(new Type(obj));
}
// 在 Storage 的 public 区域中声明:
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj)
{
add(obj, IntType<isPolymorphic>());
}
适当的 add
成员会被实例化并调用,因为基本值可以转换为类型。因此,每个可能的模板非类型值都被用于定义一个重载的类模板成员函数。
由于类模板成员只有在使用时才会被实例化,因此只有一个重载的私有 add
成员会被实例化。由于另一个永远不会被调用(因此永远不会被实例化),编译错误也因此被避免。
类结构作为模板参数的函数
一些软件工程师在考虑使用指针来存储值类对象的 Storage
类时会有一些保留意见。他们的论点是,值类对象完全可以通过值来存储,而不是通过指针。他们宁愿将值类对象按值存储,将多态类对象按指针存储。
这种区分在模板元编程中经常出现,以下的 IfElse
结构可以根据布尔选择器值来获取两种类型中的一种。
首先定义模板的一般形式:
template<bool selector, typename FirstType, typename SecondType>
struct IfElse
{
using type = FirstType;
};
然后定义一个部分特化。该特化表示特定的选择器值(例如,false),并将剩余类型留待进一步指定:
template<typename FirstType, typename SecondType>
struct IfElse<false, FirstType, SecondType>
{
using type = SecondType;
};
前者(一般)定义将 FirstType
与 IfElse::type
类型定义关联,后者(部分特化为逻辑值 false
)将 SecondType
与 IfElse::type
类型定义关联。
IfElse
模板允许我们定义类模板,其数据组织依赖于模板的参数。使用 IfElse
,Storage
类可以定义指针来存储多态类类型对象的副本,并定义值来存储值类类型对象:
template <typename Type, bool isPolymorphic>
class Storage
{
using DataType = typename IfElse<isPolymorphic, Type *, Type>::type;
std::vector<DataType> d_data;
private:
void add(Type const &obj, IntType<true>);
void add(Type const &obj, IntType<false>);
public:
void add(Type const &obj);
};
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj, IntType<true>)
{
d_data.push_back(obj.clone());
}
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj, IntType<false>)
{
d_data.push_back(obj);
}
template <typename Type, bool isPolymorphic>
void Storage<Type, isPolymorphic>::add(Type const &obj)
{
add(obj, IntType<isPolymorphic>());
}
上述例子中使用了 IfElse
的 type
,该 type
由 IfElse
定义为 FirstType
或 SecondType
。IfElse
的 type
定义了用于 Storage
的向量数据类型的实际数据类型。
该示例中的显著结果是 Storage
类的数据组织现在依赖于其模板参数。由于 isPolymorphic == true
情况使用不同的数据类型于 isPolymorphic == false
情况,因此重载的私有 add
成员可以立即利用这种差异。例如,add(Type const &obj, IntType<false>)
使用直接复制构造来将 obj
的副本存储在 d_vector
中。
IfElse
结构可以嵌套,从而允许从多个类型中进行选择。请注意,使用 IfElse
对最终可执行程序的大小或执行时间没有任何影响。最终的程序仅包含根据最终选择的类型的适当类型。
说明示例
下一个示例定义了 MapType
,这是一个具有平面类型或指针的映射,用于键类型或值类型。这说明了这一方法:
template <typename Key, typename Value, int selector>
class Storage
{
using MapType =
typename IfElse<
selector == 1,
// 如果 selector == 1:
std::map<Key, Value>,
// 使用 std::map<Key, Value>
typename IfElse<
selector == 2,
std::map<Key, Value *>,
// 如果 selector == 2:
// 使用 std::map<Key, Value *>
typename IfElse<
selector == 3,
// 如果 selector == 3:
std::map<Key *, Value>,
// 使用 std::map<Key *, Value>
// 否则:
std::map<Key *, Value *> // 使用 std::map<Key *, Value *>
>::type
>::type
>::type;
MapType d_map;
public:
void add(Key const &key, Value const &value);
private:
void add(Key const &key, Value const &value, IntType<1>);
// 其他重载的 add 函数
};
模板中的 MapType
是一个条件类型,它依据 selector
的值选择不同的 std::map
类型:
- 如果
selector == 1
,则MapType
为std::map<Key, Value>
。 - 如果
selector == 2
,则MapType
为std::map<Key, Value *>
。 - 如果
selector == 3
,则MapType
为std::map<Key *, Value>
。 - 否则,
MapType
为std::map<Key *, Value *>
。
Storage
类具有一个私有 add
函数的重载版本,根据 IntType
进行优化,这使得每种类型组合都有一个特定的 add
实现。公共的 add
函数会调用适当的私有 add
函数,并传递合适的 IntType
参数。
template <typename Key, typename Value, int selector>
inline void Storage<Key, Value, selector>::add(Key const &key, Value const &value)
{
add(key, value, IntType<selector>());
}
上述示例中的原理是:如果类模板使用的类型依赖于模板的非类型参数,可以使用 IfElse
结构来选择合适的数据类型。对各种数据类型的知识也可以用来定义重载的成员函数。这些重载成员函数可以针对各种数据类型进行优化。在程序中,只会实例化一个替代函数(即针对实际使用的数据类型优化的那个函数)。
私有的 add
函数定义了与公共 add
包装函数相同的参数,但增加了一个特定的 IntType
类型,从而允许编译器基于模板的非类型选择参数来选择适当的重载版本。
模板中的迭代:通过递归实现
由于模板元编程中没有变量,因此没有类似于 for
或 while
语句的模板等效物。然而,迭代可以始终被重写为递归。模板支持递归,因此迭代总是可以通过(尾递归)实现。
要实现通过(尾递归)的迭代,按如下步骤操作:
- 定义一个实现结束条件的特化;
- 使用递归定义所有其他步骤;
- 将中间值存储为枚举值。
编译器会优先选择更专用的模板实现而不是更通用的实现。编译器在到达结束条件时停止递归,因为特化版本不再使用递归。
许多读者可能对数学上的“阶乘”运算的递归实现比较熟悉,阶乘通常由感叹号(!)表示:n!
返回连续的乘积 n * (n - 1) * (n - 2) * ... * 1
,表示 n
个对象的排列方式。值得注意的是,阶乘运算通常由递归定义:
n! = (n == 0) ? 1 : n * (n - 1)!
要从模板中计算 n!
,可以定义一个 Factorial
模板,使用 int n
作为模板非类型参数。为 n == 0
的情况定义一个特化。通用实现则使用递归来根据阶乘定义进行计算。此外,Factorial
模板定义了一个枚举值 value
,包含其阶乘值。以下是通用实现的定义:
template <int n>
struct Factorial
{
enum { value = n * Factorial<n - 1>::value };
};
注意,在 value
的赋值表达式中使用了可以由编译器确定的常量值。提供了值 n
,Factorial<n - 1>
使用模板元编程进行计算。Factorial<n-1>
进一步结果是可以由编译器确定的值(即 Factorial<n-1>::value
)。Factorial<n-1>::value
表示由类型 Factorial<n - 1>
定义的值,而不是该类型对象的值。这里没有对象,仅仅是由类型定义的值。
递归在特化中结束。编译器会选择特化版本(为终止值 0 提供)而不是通用实现。以下是特化的实现:
template <>
struct Factorial<0>
{
enum { value = 1 };
};
Factorial
模板可以用来在编译时确定固定数量对象的排列方式。例如:
int main()
{
cout << "The number of permutations of 5 objects = " << Factorial<5>::value << "\n";
}
再次说明,Factorial<5>::value
在运行时不会被计算,而是在编译时计算。上述 cout
语句的运行时等效代码是:
int main()
{
cout << "The number of permutations of 5 objects = " << 120 << "\n";
}
用户定义字面量
除了第 11.14 节讨论的字面量操作符外,C++ 还提供了一个函数模板字面量操作符,匹配原型:
template <char ...Chars>
Type operator "" _identifier()
这个可变非类型参数函数模板定义了一个没有参数的模板,只包含一个可变的非类型参数列表。其参数必须是一个整数常量,正如字面量操作符定义的无符号长整型参数一样。所有整数常量的字符作为单独的 char
非类型模板参数传递给字面量操作符。
例如,如果 _NM2km
是一个字面量操作符函数模板,它可以被调用为 80_NM2km
。此时函数模板实际调用为 _NM2km<'8', '0'>()
。如果这个函数模板仅使用模板元编程技术并且只处理整数数据,那么它的操作可以完全在编译时完成。为了说明这一点,假设 NM2km
只处理并返回无符号值。
函数模板 _NM2km
可以将其参数转发到一个类模板,该模板定义一个枚举常量值并执行所需的计算。以下是 NM2km
变长字面量操作符函数模板的实现:
template <char ... Chars>
size_t constexpr operator "" _NM2km()
{
return static_cast<size_t>(
// 转发 Chars 到 NM2km
NM2km<0, Chars ...>::value * 1.852);
}
类模板 NM2km
定义了三个非类型参数:acc
用于累积值,c
是变长非类型参数的第一个字符,而 ...Chars
代表剩余的非类型参数,包含在一个非类型参数包中。由于 c
是每次递归调用中的下一个字符,所以值乘以 10 加上下一个字符的值作为下一个累积值传递给递归调用,同时传递参数包中的剩余元素 Chars ...
:
template <size_t acc, char c, char ...Chars>
struct NM2km
{
enum
{
value = NM2km<10 * acc + c - '0', Chars ...>::value
};
};
最终,参数包将为空。对于这种情况,有一个 NM2km
的部分特化可用:
template <size_t acc, char c> // 空的参数包
struct NM2km<acc, c>
{
enum { value = 10 * acc + c - '0' };
};
这工作得很好,但当然不能处理需要解释的二进制、八进制或十六进制值。在这种情况下,我们必须首先确定第一个字符(或字符序列)是否表示一个特殊的数字系统。这可以通过类模板 NM2kmBase
来确定,现在从 _NM2km
字面量操作符调用:
template <char ... Chars>
size_t constexpr operator "" _NM2km()
{
return static_cast<size_t>(
// 转发 Chars 到 NM2kmBase
NM2kmBase<Chars ...>::value * 1.852);
}
NM2kmBase
类模板通常假定十进制数字系统,将基值 10 和初始和 0 传递给 NM2km
。NM2km
类模板提供了一个额外的(第一个)非类型参数,表示要使用的数字系统的基值。以下是 NM2kmBase
:
template <char ...Chars>
struct NM2kmBase
{
enum
{
value = NM2km<10, 0, Chars ...>::value
};
};
部分特化处理不同的数字系统,通过检查第一个字符(或两个字符):
template <char ...Chars>
struct NM2kmBase<'0', Chars ...> // "0..."
{
enum
{
// 八进制值:基数 8
value = NM2km<8, 0, Chars ...>::value
};
};
template <char ...Chars>
struct NM2kmBase<'0', 'b', Chars ...> // "0b..."
{
enum
{
// 二进制值:基数 2
value = NM2km<2, 0, Chars ...>::value
};
};
template <char ...Chars>
struct NM2kmBase<'0', 'x', Chars ...> // "0x..."
{
enum
{
// 十六进制值:基数 16
value = NM2km<16, 0, Chars ...>::value
};
};
NM2km
的实现如前所述,但现在它可以处理各种数字系统。字符到数值的转换由一个小的支持函数模板 cVal
完成:
template <char c>
int constexpr cVal()
{
return '0' <= c && c <= '9' ? c - '0' : 10 + c - 'a';
}
template <size_t base, size_t acc, char c, char ...Chars>
struct NM2km
{
enum
{
value = NM2km<base, base * acc + cVal<c>(), Chars ...>::value
};
};
template <size_t base, size_t acc, char c>
struct NM2km<base, acc, c>
{
enum { value = base * acc + cVal<c>() };
};
模板模板参数
考虑以下情况:软件工程师被要求设计一个存储类 Storage
。存储在 Storage
对象中的数据可能要么复制数据并存储副本,要么以接收到的形式存储数据。Storage
对象还可能使用 vector
或 linked list
作为其底层存储介质。工程师应该如何处理这个请求?是否应该设计四个不同的 Storage
类?
工程师的第一反应可能是开发一个全面的 Storage
类。它可能有两个数据成员,一个是列表(list
),一个是向量(vector
),它的构造函数可能接受一个枚举值,指示是存储数据本身还是存储新的副本。该枚举值可用于初始化一系列指向成员函数的指针,这些函数执行所需的任务(例如,使用 vector
存储数据或使用 list
存储副本)。
这虽然复杂,但可以实现。然后工程师被要求修改类:在存储新副本的情况下,应使用自定义分配方案,而不是标准的 new
操作符。他们还被要求允许使用另一个容器类型,除了已经包含的 vector
和 list
之外。也许更喜欢使用 deque
,甚至是 stack
。
很明显,试图在一个类中实现所有功能和所有可能组合的方法是不切实际的。Storage
类很快会变成一个庞大的、难以理解、维护、测试和部署的巨型类。
一个原因是,庞大的、包罗万象的类难以部署和理解,因为一个设计良好的类应该强制执行约束:类的设计应该本身禁止某些操作,违反这些操作的情况应该由编译器而不是可能在运行时终止的程序来检测。
考虑一下上述请求。如果类提供了访问 vector
数据存储的接口和访问 list
数据存储的接口,那么类很可能会提供一个重载的 operator[]
成员来访问 vector
中的元素。然而,当选择了不支持 operator[]
的 list
数据存储时,这个成员在语法上会存在,但在语义上是无效的。这将导致用户在选择 list
作为底层数据存储时不小心使用 operator[]
,虽然编译器无法检测到这个错误,只有在程序运行时才会出现,从而使用户感到困惑。
问题是:当面临上述问题时,工程师应该如何继续?是时候引入策略(policies)了。
Policy类 - I
Policy定义(在某些上下文中:规定)一种特定的行为方式。在C++中,Policy 类定义了类接口的某一部分。它还可能定义内部类型、成员函数和数据成员。
在前一节中,介绍了创建一个可能使用一系列分配方案的类的问题。这些分配方案都依赖于实际使用的数据类型,因此应该应用模板的“反射”。
分配方案可能应该定义为模板类,将适当的分配程序应用于当前的数据类型。当这些分配方案被熟悉的STL容器(如std::vector
, std::stack
等)使用时,这样自制的分配方案可能应该从std::allocator
派生,以满足这些容器的要求。类模板std::allocator
由<memory>
头文件声明,这里开发的三个分配方案都是从std::allocator
派生的。
为了简洁起见,以下的分配类可以在类内部实现:
- 没有特殊的分配,数据按原样使用:
template <typename Data>
class PlainAlloc: public std::allocator<Data>
{
template<typename IData>
friend std::ostream &operator<<(std::ostream &out,
PlainAlloc<IData> const &alloc);
Data d_data;
public:
PlainAlloc() {}
PlainAlloc(Data const &data) :
d_data(data)
{}
PlainAlloc(PlainAlloc<Data> const &other) :
d_data(other.d_data)
{}
};
- 第二种分配方案使用标准的
new
操作符来分配数据的新副本:
template <typename Data>
class NewAlloc: public std::allocator<Data>
{
template<typename IData>
friend std::ostream &operator<<(std::ostream &out,
NewAlloc<IData> const &alloc);
Data *d_data;
public:
NewAlloc() :
d_data(0)
{}
NewAlloc(Data const &data) :
d_data(new Data(data))
{}
NewAlloc(NewAlloc<Data> const &other) :
d_data(new Data(*other.d_data))
{}
~NewAlloc()
{
delete d_data;
}
};
- 第三种分配方案使用定位
new
操作符(参见9.1.5节),从公共池中请求内存(成员函数request
的实现,获取所需的内存量,留作读者练习):
template<typename Data>
class PlacementAlloc: public std::allocator<Data>
{
template<typename IData>
friend std::ostream &operator<<(std::ostream &out,
PlacementAlloc<IData> const &alloc);
Data *d_data;
static char s_commonPool[];
static char *s_free;
public:
PlacementAlloc() :
d_data(0)
{}
PlacementAlloc(Data const &data) :
d_data(new(request()) Data(data))
{}
PlacementAlloc(PlacementAlloc<Data> const &other) :
d_data(new(request()) Data(*other.d_data))
{}
~PlacementAlloc()
{
d_data->~Data();
}
private:
static char *request();
};
上述三个类定义了用户可以在前一节介绍的类Storage
中选择的Policy 。除了这些类之外,用户还可以实现其他分配方案。
要将适当的分配方案应用于类Storage
,Storage
本身应设计为一个类模板。该类还需要一个模板类型参数,允许用户指定数据类型。
当然,可以在指定要使用的分配方案时指定所需的数据类型。然后类Storage
将具有两个模板类型参数,一个用于数据类型,一个用于分配方案:
template <typename Data, typename Scheme>
class Storage ...
要使用类Storage
,我们可以这样写,例如:
Storage<string, NewAlloc<string>> storage;
以这种方式使用Storage
相当复杂且可能容易出错,因为它要求用户两次指定数据类型。相反,应该使用一种新的模板参数类型来指定分配方案,不需要用户指定分配方案所需的数据类型。这种新的模板参数类型(除了大家熟知的模板类型参数和模板非类型参数之外)称为模板模板参数。
从C++14标准开始,在模板模板参数的语法形式(template <parameter specifications> class Name
)中,不再需要关键字class
。从该标准开始,关键字typename
也可以使用(例如,template <parameter specifications> typename Name
)。
Policy类 - II: 模板模板参数
模板模板参数允许我们将类模板作为模板参数进行指定。通过指定类模板,可以为现有的类模板添加某种行为(称为Policy)。
为了指定一个分配Policy ,而不是为Storage
类指定分配类型,我们可以重新定义其类模板的头文件:定义如下所示:
template <typename Data, template <typename> class Policy>
class Storage...
第二个模板参数是新的,它是一个模板模板参数。它包含以下元素:
- 关键字
template
,用于启动模板模板参数; - 关键字
template
后面(用尖括号括起来)跟着模板参数列表,这些参数必须为模板模板参数指定。这些参数可以被命名,但通常省略名称,因为这些名称不能在随后的模板定义中使用。另一方面,提供正式名称可能有助于模板的读者理解必须使用模板模板参数指定的模板类型。 - 模板模板参数必须在数量和类型上(即模板类型参数、模板非类型参数、模板模板参数)与为Policy 必须指定的模板参数匹配。这可能会有些棘手,因为某些模板使用了几乎从未改变的默认参数(如容器的分配方案)。在指定模板模板参数时,将使用这些默认类型。如果它们应该是可指定的,那么必须将它们添加到模板模板参数的类型中。在下面的例子中,
fun
只能使用默认的分配器,gun
必须在定义vd
时指定分配器,而hun
定义了一个默认的分配器类型,提供了指定分配器类型或使用默认类型的选项:
#include <vector>
using namespace std;
template<typename Data, template <typename> class Vect>
void fun()
{
// Vect<Data, std::allocator<Data>> vd; // fails
Vect<Data> vd; // OK, uses std::allocator
}
template<typename Data,
template <typename, typename> class Vect>
void gun()
{
Vect<Data, std::allocator<Data>> vd; // allocator type is required
}
template<typename Data,
template <typename, typename = std::allocator<Data>> class Vect>
void hun()
{
Vect<Data> vd; // uses default allocator type
}
int main()
{
fun<int, vector>();
gun<int, vector>();
hun<int, vector>();
}
或者,引入于23.5节的别名模板(alias templates)可以用于使用预定义的参数类型来为模板定义名称。
- 在方括号列表之后必须指定关键字
class
或typename
。 - 所有参数都可以提供默认参数。这在下一个假设类模板的示例中进行了展示:
template <
template <
typename = std::string,
int = 12,
template <typename = int> class Inner = std::vector
>
class Policy
>
class Demo
{
...
};
这里,类模板Demo
期望一个名为Policy
的模板模板参数,Policy
期望三个模板参数:一个模板类型参数(默认为std::string
);一个模板非类型参数(默认值为12);Policy
本身期望一个名为Inner
的模板模板参数,默认使用int
作为其模板类型参数。
Policy 类通常是所考虑类的组成部分。正因为如此,它们通常被用作基类。在示例中,类Policy
可以用作类Storage
的基类。
Policy 操作的是类Storage
的数据类型。因此,Policy 类也会被告知数据类型。我们的类Storage
现在如下所示:
template <typename Data, template <typename> class Policy>
class Storage: public Policy<Data>
这使我们在实现类Storage
的成员时可以自动使用Policy
的成员。
我们自制的分配类实际上并没有提供太多有用的成员。除了提取操作符之外,它们没有提供直接访问数据的方法。这可以通过添加更多成员轻松修复。例如,类NewAlloc
可以通过添加操作符来访问和修改存储的数据:
operator Data &()
{
return *d_data;
}
NewAlloc &operator=(Data const &data)
{
*d_data = data;
}
其他分配类也可以添加类似的成员。
让我们在一些实际代码中使用这些分配方案。下一个示例展示了如何使用一些数据类型和分配方案定义Storage
。我们再次从一个类Storage
开始:
template <typename Data, template <typename> class Allocate>
class Storage: public std::vector<Data, Allocate<Data>>
{};
这就是我们所需要做的全部。请注意,std::vector
正式有两个模板参数。第一个是向量的数据类型,它总是被指定;第二个是向量使用的分配器。通常,分配器未被指定(在这种情况下,使用默认的STL分配器),但这里明确提到它,允许我们将自己的分配Policy 传递给Storage
。
所有所需的功能都从向量基类中继承,而Policy 则通过模板模板参数“引入到方程式中”。下面的示例展示了如何做到这一点:
Storage<std::string, NewAlloc> storage;
copy(istream_iterator<std::string>(cin), istream_iterator<std::string>(), back_inserter(storage));
cout << "Element index 1 is " << storage[1] << '\n';
storage[1] = "hello";
copy(storage.begin(), storage.end(), ostream_iterator<NewAlloc<std::string>>(cout, "\n"));
由于Storage
对象也是std::vector
对象,因此STL的copy
函数可以与back_inserter
迭代器结合使用,将一些数据添加到storage
对象中。可以使用索引操作符访问和修改其元素。然后NewAlloc<std::string>
对象被插入到cout
中(同样使用copy
函数)。
有趣的是,这并不是故事的结束。请记住,我们的初衷是创建一个允许我们指定存储类型的类。那么如果我们不想使用向量,而是想使用列表怎么办?
更改Storage
的设置以便可以根据请求使用完全不同的存储类型(如双端队列)是很容易的。为实现这一点,存储类也被参数化,并使用了另一个模板模板参数:
template <typename Data, template <typename> class AllocationPolicy,
template <typename, typename> class Container = std::vector>
class Storage: public Container<Data, AllocationPolicy<Data>>
{};
上面示例中使用的Storage
对象可以再次使用,而无需进行任何修改(除了上述重新定义)。显然,它不能与列表容器一起使用,因为列表缺少operator[]
。尝试这样做会立即被编译器识别,如果尝试在列表上使用operator[]
,编译器将生成错误。然而,仍然可以指定列表容器作为要使用的容器。在这种情况下,Storage
将作为列表实现,提供给用户的是列表的接口,而不是向量的接口。
策略类的析构函数
在上一节中,策略类被用作模板类的基类。这引出了一个有趣的现象:策略类可以作为派生类的基类。由于策略类可以充当基类,因此可以使用指向或引用该策略类的指针或引用来指向或引用使用该策略的派生类。
尽管这种情况是合法的,但出于各种原因,应当避免:
- 使用基类的析构函数销毁派生类对象需要实现虚析构函数;
- 虚析构函数为通常没有数据成员、仅定义行为的类引入了额外的开销:突然间需要一个虚函数表(vtable)以及一个指向该虚函数表的指针数据成员;
- 虚成员函数会在某种程度上降低代码的效率;虚成员函数使用动态多态性,这在原则上会抵消模板所提供的静态多态性的优势;
- 模板中的虚成员函数可能导致代码膨胀:一旦需要实例化类的成员,类的虚函数表及其所有虚成员都必须实现。
为了避免这些缺点,良好的做法是防止使用策略类的引用或指针来指向或引用派生类对象。这可以通过为策略类提供非虚的受保护析构函数来实现。使用非虚析构函数时不会有性能损失,并且由于其析构函数是受保护的,用户不能使用指向策略类的指针或引用来引用从策略类派生的类。
通过策略定义结构
策略类通常定义的是行为,而不是结构。策略类通常用于参数化从它们派生的类的某些行为方面。然而,不同的策略可能需要不同的数据成员,这些数据成员也可以由策略类定义。因此,策略类既可以用来定义行为,也可以用来定义结构。
通过提供一个定义良好的接口,从策略类派生的类可以利用策略类的不同结构来定义成员的特化。例如,一个基于指针的简单策略类可以通过使用C风格的指针操作来提供其功能,而基于vector
的策略类则可以直接使用vector
的成员。
在这个例子中,可以设计一个通用的模板类Size
,它期望一个类容器类型的策略,使用常见于容器中的特性来定义策略中指定的容器的数据(从而定义结构)。例如:
template <typename Data, template <typename> class Container>
struct Size: public Container<Data>
{
size_t size() {
// 依赖于容器的 `size()` 方法
// 注意: 不能使用 `this->size()`
return Container<Data>::size();
}
};
现在,可以为一个更简单的存储类定义一个特化,例如,使用简单的指针(实现依赖于std::pair
的first
和second
数据成员,参见本节末尾的示例):
template <typename Data>
struct Size<Data, Plain>: public Plain<Data>
{
size_t size() {
// 依赖于指针的数据成员
return this->second - this->first;
}
};
根据模板作者的意图,还可以实现其他成员。为了简化上述模板的实际使用,可以构建一个通用的包装类:它使用Size
模板来匹配实际使用的存储类型(例如std::vector
或一些简单的存储类)以定义其结构:
template <typename Data, template <typename> class Store>
class Wrapper: public Size<Data, Store>
{};
上述类现在可以如下使用(顺便展示一个极其简单的Plain
类):
#include <iostream>
#include <vector>
template <typename Data>
struct Plain: public std::pair<Data *, Data *> {};
int main() {
Wrapper<int, std::vector> wiv;
std::cout << wiv.size() << "\n";
Wrapper<int, Plain> wis;
std::cout << wis.size() << "\n";
}
wiv
对象现在定义了vector
数据,而wis
对象则仅定义了std::pair
对象的数据成员。
别名模板
除了函数和类模板,C++ 还可以使用模板为一组类型定义别名,这称为别名模板。别名模板可以被特化。别名模板的名称是一个类型名称。
别名模板可以作为模板模板参数的参数使用。这使我们能够避免使用模板模板参数时可能遇到的“意外默认参数”(例如,在使用 C++23 标准之前的 C++ 标准时)。定义一个模板 template <typename> class Container
是可以的,但是(在 C++23 之前)将 vector
或 set
这样的容器作为模板模板参数进行指定是不可能的,因为 vector
和 set
容器还定义了第二个模板参数,用来指定它们的分配策略。
别名模板的定义方式类似于 using
声明,为一个现有的(可能部分或完全特化的)模板类型指定一个别名。在下面的示例中,Vector
被定义为 vector
的别名:
template <typename Type>
using Vector = std::vector<Type>;
现在你可以像这样使用 Vector
:
Vector<int> vi; // 与 std::vector<int> 相同
std::vector<int> vi2(vi); // 拷贝构造:OK
那么,这样做的意义是什么呢?看看 vector
容器,我们可以看到它定义了两个模板参数,而不是一个,第二个参数是分配策略 _Alloc
,默认设置为 std::allocator<_Tp>
:
template<typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector { ... };
现在定义一个类模板 Generic
,它定义了一个模板模板参数:
template <typename Type, template <typename> class Container>
class Generic: public Container<Type>
{
...
};
Generic
类最有可能提供由实际用于创建 Generic
对象的容器所提供的成员,并在此基础上添加一些自身的成员。然而,像 std::vector
这样的简单容器无法使用,因为 std::vector
不匹配 template <typename> class Container
参数;它需要一个 template <typename, typename> class Container
模板模板参数。
然而,Vector
别名模板被定义为一个具有一个模板类型参数的模板,并且它使用 vector
的默认分配器。因此,将 Vector
传递给 Generic
是可以的:
Generic<int, Vector> giv; // OK
Generic<int, std::vector> err; // 编译错误:第二个参数不匹配
借助一个小型别名模板,还可以将完全不同类型的容器(如 map
)与 Generic
一起使用:
template <typename Type>
using MapString = std::map<Type, std::string>;
Generic<int, MapString> gim; // 使用 map<int, string>
Trait类
在 std
命名空间中可以找到分散的Trait类。 例如,大多数 C++ 程序员在对 std::string
对象执行非法操作时,都会看到编译器提到 std::char_traits<char>
,如在 std::string s(1)
中。
Trait类用于在编译时对类型做出决策。 Trait类允许我们将适当的代码应用于适当的数据类型,无论是指针、引用还是普通值,这些都可能与 const
组合使用。 使用模板时,可以从指定的实际类型(或隐含类型)推断出要使用的数据的特定类型。 这可以完全自动化,不需要模板编写者做出任何决定。
Trait类允许我们开发一个 template <typename Type1, typename Type2, ...>
,而无需为所有组合(例如,值、(const)指针或(const)引用)指定许多特化,这很快会导致模板特化的指数爆炸(例如,当使用两个模板类型参数时,允许这些五种不同类型的每种模板参数已经会导致 25 种组合:每种组合必须由潜在的不同特化来覆盖)。
使用Trait类时,实际类型可以在编译时推断出来,允许编译器推断出实际类型是指针、成员指针还是 const
指针,并在实际类型为左值引用或右值引用类型的情况下做出类似的推断。 这反过来允许我们编写定义类型的模板,如 argument_type
、first_argument_type
、second_argument_type
和 result_type
,这些类型在几个泛型算法中是必需的(例如,count_if()
)。
Trait类通常不执行任何行为。 也就是说,它没有构造函数,也没有可以调用的成员。 相反,它定义了一系列类型和枚举值,这些类型和枚举值根据传递给Trait类模板的实际类型具有特定值。 编译器使用一组可用的特化中的一个来选择适合实际模板类型参数的特化。
定义特性模板时的出发点是一个简单的结构体,定义像 int
这样的普通值类型的特性。 这为特定的特化设置了舞台,修改了可以为模板指定的任何其他类型的特性。
为了使问题具体化,假设我们打算创建一个 BasicTraits
Trait类,告诉我们类型是普通值类型、指针类型、左值引用类型还是右值引用类型(所有这些类型可能是或可能不是 const
类型)。
无论提供的实际类型是什么,我们都希望能够确定“普通”类型(即不带任何修饰符、指针或引用的类型)、“指针类型”和“引用类型”,允许我们在所有情况下定义,例如,即使我们传递了一个指向该类型的 const
指针,我们仍然可以定义它的内置类型的右值引用。
正如前面提到的,我们的出发点是定义所需参数的简单结构体。 也许像这样:
template <typename T>
struct Basic {
using Type = T;
enum {
isPointer = false,
isConst = false,
isRef = false,
isRRef = false
};
};
尽管可以通过组合各种枚举值得出一些结论(例如,普通类型不是指针、引用、右值引用或 const
),但最好提供Trait类的完整实现,而不是要求用户自己构造这些逻辑表达式。
因此,Trait类中的基本决策通常由嵌套的Trait类做出,创建适当逻辑表达式的任务留给周围的Trait类。
因此,Basic
结构定义了我们内部Trait类的通用形式。 特化处理具体细节。 例如,可以通过以下特化来识别指针类型:
template <typename T>
struct Basic<T *> {
using Type = T;
enum {
isPointer = true,
isConst = false,
isRef = false,
isRRef = false
};
};
而指向 const
类型的指针与下一个特化匹配:
template <typename T>
struct Basic<T const *> {
using Type = T;
enum {
isPointer = true,
isConst = true,
isRef = false,
isRRef = false
};
};
还应定义其他几个特化:例如,识别 const
值类型或(右值)引用类型。 最终,所有这些特化都实现为外部类 BasicTraits
的嵌套结构,提供公共Trait类接口。 外部Trait类的大纲是:
template <typename TypeParam>
class BasicTraits {
// 在这里定义模板 `Basic` 的特化
public:
BasicTraits(BasicTraits const &other) = delete;
using ValueType = Basic<TypeParam>::Type;
using PtrType = ValueType *;
using RefType = ValueType &;
using RvalueRefType = ValueType &&;
enum {
isPointerType = Basic<TypeParam>::isPointer,
isReferenceType = Basic<TypeParam>::isRef,
isRvalueReferenceType = Basic<TypeParam>::isRRef,
isConst = Basic<TypeParam>::isConst,
isPlainType = not (isPointerType or isReferenceType or isRvalueReferenceType or isConst)
};
};
Trait类的公共接口明确删除了其拷贝构造函数。 因此它没有定义任何构造函数,并且没有静态成员,因此它不提供任何运行时可执行代码。 因此,Trait类的所有功能都必须在编译时使用。
Trait类模板可用于获取正确的类型,而不管提供的模板类型参数如何。 它还可用于选择依赖于模板类型 const
的适当特化。 示例:
cout << "int: plain type? " << BasicTraits<int>::isPlainType << "\n"
<< "int: ptr? " << BasicTraits<int>::isPointerType << "\n"
<< "int: const? " << BasicTraits<int>::isConst << "\n"
<< "int *: ptr? " << BasicTraits<int *>::isPointerType << "\n"
<< "int const *: ptr? " << BasicTraits<int const *>::isPointerType << "\n"
<< "int const: const? " << BasicTraits<int const>::isConst << "\n"
<< "int: reference? " << BasicTraits<int>::isReferenceType << "\n"
<< "int &: reference? " << BasicTraits<int &>::isReferenceType << "\n"
<< "int const &: ref? " << BasicTraits<int const &>::isReferenceType << "\n"
<< "int const &: const? " << BasicTraits<int const &>::isConst << "\n"
<< "int &&: r-reference? " << BasicTraits<int &&>::isRvalueReferenceType << "\n"
<< "int &&: const? " << BasicTraits<int &&>::isConst << "\n"
<< "int const &&: r-ref? " << BasicTraits<int const &&>::isRvalueReferenceType << "\n"
<< "int const &&: const? " << BasicTraits<int const &&>::isConst << "\n"
<< "\n";
BasicTraits<int *>::ValueType value = 12;
BasicTraits<int const *>::RvalueRefType rvalue = int(10);
BasicTraits<int const &&>::PtrType ptr = new int(14);
cout << value << ' ' << rvalue << ' ' << *ptr << '\n';
区分类类型与非类类型
在前一节中,我们开发了 BasicTraits
特性类。通过嵌套的结构体类型(Type)的特化版本,可以区分类型修饰符、指针、引用和值类型。
了解某个类型是否为类类型(例如,该类型表示原始类型)对模板开发者来说也是一个有用的信息。模板类开发者可能希望在模板的类型参数表示类类型时定义一个特化(可能使用一些应该可用的成员函数),并在非类类型时定义另一个特化。
本节讨论如何通过特性类区分类类型和非类类型。
为了区分类与非类类型,必须找到一个可以在编译时使用的区分特征。找到这样一个区分特征可能需要一些思考,但最终发现了一个好候选:成员指针语法构造。成员指针仅适用于类。使用成员指针构造作为区分特征,可以开发一个使用成员指针的特化。当成员指针不可用时,另一种特化(或通用模板)可以执行其他操作。
如何从“通用情况”中区分出一个成员指针,不是成员指针?幸运的是,这是可能的。可以定义一个函数模板特化,它的参数是一个成员函数指针。然后,通用函数模板接受任何其他参数。当提供的类型是类类型时,编译器会选择前者(特化版本),因为类类型可能支持成员指针。这里有趣的词是“可能”:类不一定要定义一个成员指针。
此外,编译器实际上不会调用任何函数:我们在讨论的是编译时。编译器所做的只是通过评估一个常量表达式来选择适当的函数。
因此,我们的预期函数模板现在看起来像这样:
template <typename ClassType>
static `some returntype' fun(void (ClassType::*)());
函数的返回类型(‘some returntype’)将很快定义。让我们首先仔细看看函数的参数。函数的参数定义了一个返回 void
的成员指针函数。对于使用该函数的具体类类型,可能不需要存在这样的函数。实际上,没有提供实现。函数 fun
仅在特性类中声明为一个静态成员。它没有实现,也不需要通过特性类对象来调用它。
那么,它的用途是什么呢?
要回答这个问题,我们现在来看当模板的参数不是类类型时应使用的通用函数模板。语言提供了一个“最坏情况”的参数,那就是省略号参数列表。省略号是编译器在一切都失败时可以使用的最后手段。通用函数模板在参数列表中指定了一个简单的省略号:
template <typename NonClassType>
static `some returntype' fun(...);
如果定义通用替代函数为一个期望 int
类型的函数,那将是一个错误。当面对多个选项时,编译器更倾向于选择最简单、最明确的选项,而不是更复杂、通用的选项。因此,当提供 fun
一个参数时,它会选择 int
(如果可能),并且不会选择 fun(void (ClassType::∗)())
。在 fun(void (ClassType::∗)())
和 fun(...)
之间选择时,它会选择前者,除非它无法做到。
现在问题是:什么样的参数可以同时用作成员指针和省略号?实际上,确实有一个“通用”的参数:0。值 0 可以作为省略号参数传递给函数,也可以作为成员指针参数传递给函数。
但 0 并不指定特定的类。因此,fun
必须指定一个显式的模板参数,在代码中表现为 fun<Type>(0)
,其中 Type
是特性类的模板类型参数。
现在谈谈返回类型。函数的返回类型不能是简单的值(如 true
或 false
)。我们最终的目的是为特性类提供一个枚举,以告诉我们特性类的模板参数是否表示类类型。该枚举将变为类似于:
enum { isClass = some class/non-class distinguishing expression };
区分表达式不能是
enum { isClass = fun<Type>(0) };
因为 fun<Type>(0)
不是一个常量表达式,而枚举值必须由常量表达式定义,以便在编译时确定其值。
要确定 isClass
的值,我们必须找到一个表达式,允许在编译时区分 fun<Type>(...)
和 fun<Type>(void (Type::∗)())
。
在这种情况下,sizeof
运算符通常是我们选择的工具,因为它在编译时计算。通过定义两个 fun
声明不同大小的返回类型,我们能够在编译时区分编译器选择的两个 fun
选项中的哪一个。
char
类型在定义上大小为 1。通过定义包含两个连续 char
值的类型,可以获得更大的类型。char[2]
当然不是一个类型,但可以将 char[2]
定义为一个结构体的成员,而结构体确实定义了一个类型。然后,该结构体的大小将超过 1。例如:
struct Char2 {
char data[2];
};
Char2
可以定义为特性类的嵌套类型。两个 fun
函数模板声明变为:
template <typename ClassType>
static Char2 fun(void (ClassType::*)());
template <typename NonClassType>
static char fun(...);
由于 sizeof
表达式在编译时计算,我们现在可以确定 isClass
的值:
enum { isClass = sizeof(fun<Type>(0)) == sizeof(Char2) };
这个表达式有几个有趣的含义:
- 没有任何
fun
函数模板会被实例化; - 编译器考虑
Type
并选择fun
的函数模板特化,如果Type
是类类型,否则选择通用函数模板; - 从选定的函数中确定返回类型,从而确定返回类型的大小;
- 最终正确地评估
isClass
。
无需任何实例化,特性类现在可以回答模板类型参数是否表示类类型的问题。真是妙极了!
可用的 type traits
C++ 提供了许多设施来识别和修改类型的特性。在使用这些设施之前,必须包含 <type_traits>
头文件。
type_traits
提供的所有设施都定义在 std
命名空间中(在下面的示例中省略),允许程序员确定各种类型和数值的特性。
在描述可用的类型特征时,遇到以下概念:
- 算术类型:任何整型或浮点型。
- 类类型:不是联合类型、没有非静态数据成员、没有虚拟成员、没有虚拟或非空基类。
- 复合类型:
- 对象的数组;
- 函数,其参数为给定类型,返回
void
或对象; - 指向
void
、对象、函数或非静态类成员的指针; - 指向对象或函数的引用;
- 类、联合或枚举类型。
- 基本类型:内置类型。
- 整型:所有值表示整数的类型,以及
bool
和所有表示(可能是 Unicode)字符的内置类型。 - 字面量类型:字面量类型是标量类型;平凡类类型;或字面量类型元素的数组。
is_nothrow_...
类型特征:用于确定其模板参数是否支持指定的不抛出异常的成员。这样的类型特征除非使用noexcept(true)
在函数声明中,否则返回false
。例如:
struct NoThrow {
NoThrow &operator=(SomeType const &rhs) noexcept(true);
};
- 平凡类型:平凡类型包括标量类型、平凡类类型、这些类型的数组,以及它们的
cv
限定版本。 - 平凡类类型:一个类类型,如果它具有平凡的拷贝构造函数、没有非平凡的移动构造函数、平凡的析构函数、平凡的默认构造函数或至少一个非拷贝或非移动构造函数的
constexpr
构造函数,并且只有非静态数据成员和字面量类型的基类,那么它就是平凡类类型。 - 平凡成员函数:平凡成员函数在类接口中从未声明(默认成员函数除外),且(对于默认构造函数或赋值运算符)只执行逐字节操作。以下是两个示例:
Pod
结构体只有平凡成员,因为它没有显式声明任何成员函数且它的数据成员是旧式数据;Nonpod
结构体不是旧式数据。尽管它也没有显式声明任何成员函数,但它的数据成员是std::string
,而std::string
本身不是旧式数据,因为std::string
具有非平凡的构造函数。
struct Pod {
int x;
};
struct Nonpod {
std::string s;
};
- 当
type-condition
应用于类型时,它必须是完全类型、void
或未知大小的数组。 - 提供了以下类型特征:
add_const<typename Type>::type
:为类型Type
添加const
。add_cv<typename Type>::type
:为类型Type
添加const volatile
。add_lvalue_reference<typename Type>::type
:为类型Type
添加左值引用。add_pointer<typename Type>::type
:为类型Type
添加指针。add_rvalue_reference<typename Type>::type
:为类型Type
添加右值引用。add_volatile<typename Type>::type
:为类型Type
添加volatile
。conditional<bool cond, typename TrueType, typename FalseType>::type
:如果cond
为真则使用TrueType
,否则使用FalseType
。template <typename Type> struct decay
:定义从Type
中移除所有cv
限定符和引用后的类型。它还会将左值类型转换为右值类型,并将数组和函数转换为指针。这类似于将参数传递给值类型参数时发生的情况。template <typename Type> decay_t
是typename decay<Type>::type
的简写。enable_if<bool cond, typename Type>::type
:如果cond
为真则有条件地定义Type
。is_abstract<typename Type>::value
:判断Type
是否为抽象类型(例如,抽象基类)(type-condition
适用)。is_arithmetic<typename Type>::value
:判断Type
是否为算术类型。is_array<typename Type>::value
:判断Type
是否为数组类型。is_assignable<typename To, typename From>::value
:判断类型From
的对象是否可以分配给类型To
的对象(type-condition
适用)。is_base_of<typename Base, typename Derived>::value
:判断Base
是否为Derived
类型的基类。is_class<typename Type>::value
:判断Type
是否为类类型。is_compound<typename Type>::value
:判断Type
是否为复合类型。is_const<typename Type>::value
:判断Type
是否为const
类型。is_constructible<typename Type, typename ...Args>::value
:判断是否可以从Args
参数包中的参数构造Type
对象(type-condition
适用于Args
中的所有类型)。is_convertible<typename From, typename To>::value
:判断是否可以使用static_cast
将类型From
转换为类型To
。is_copy_assignable<typename Type>::value
:判断Type
是否支持拷贝赋值(type-condition
适用)。is_copy_constructible<typename Type>::value
:判断Type
是否支持拷贝构造(type-condition
适用)。is_default_constructible<typename Type>::value
:判断Type
是否支持默认构造函数(type-condition
适用)。is_destructible<typename Type>::value
:判断Type
是否具有非删除的析构函数(type-condition
适用)。is_empty<typename Type>::value
:判断Type
是否为类类型(不是联合类型),且没有非静态数据成员、虚拟成员、虚拟或非空基类(type-condition
适用)。is_enum<typename Type>::value
:判断Type
是否为枚举类型。is_floating_point<typename Type>::value
:判断Type
是否为浮点类型。is_function<typename Type>::value
:判断Type
是否为函数类型。is_fundamental<typename Type>::value
:判断Type
是否为基本类型。is_integral<typename Type>::value
:判断Type
是否为整型。is_literal_type<typename Type>::value
:判断Type
是否为字面量类型(type-condition
适用)。is_lvalue_reference<typename Type>::value
:判断Type
是否为左值引用。is_member_function_pointer<typename Type>::value
:判断Type
是否为指向非静态成员函数的指针。is_member_object_pointer<typename Type>::value
:判断Type
是否为指向非静态数据成员的指针。is_member_pointer<typename Type>::value
:判断Type
是否为指向成员函数的指针。is_move_assignable<typename Type>::value
:判断Type
是否支持移动赋值(type-condition
适用)。is_move_constructible<typename Type>::value
:判断Type
是否支持移动构造(type-condition
适用)。is_nothrow_assignable<typename To, typename From>::value
:判断Type
是否支持不会抛出异常的赋值运算符(type-condition
适用)。is_nothrow_constructible<typename Type, typename ...Args>::value
:判断是否可以从Args
参数包中的类型构造一个不会抛出异常的Type
对象(type-condition
适用)。is_nothrow_copy_assignable<typename Type>::value
:判断Type
是否支持不会抛出异常的拷贝赋值运算符(type-condition
适用)。is_nothrow_copy_constructible<typename Type>::value
:判断Type
是否支持不会抛出异常的拷贝构造(type-condition
适用)。is_nothrow_default_constructible<typename Type>::value
:判断Type
是否支持不会抛出异常的默认构造函数(type-condition
适用)。is_nothrow_destructible<typename Type>::value
:判断Type
是否支持不会抛出异常的析构函数(type-condition
适用)。is_nothrow_move_assignable<typename Type>::value
:判断Type
是否支持不会抛出异常的移动赋值(type-condition
适用)。is_nothrow_move_constructible<typename Type>::value
:判断Type
是否支持不会抛出异常的移动构造(type-condition
适用)。is_object<typename Type>::value
:判断Type
是否为对象类型(与标量相对)。is_pod<typename Type>::value
:判断Type
是否为聚合(旧式数据,type-condition
适用)。is_pointer<typename Type>::value
:判断Type
是否为指针类型。is_polymorphic<typename Type>::value
:判断Type
是否为多态类型(type-condition
适用)。is_reference<typename Type>::value
:判断Type
是否为引用(左值或右值)。is_rvalue_reference<typename Type>::value
:判断Type
是否为右值引用。is_same<typename First, typename Second>::value
:判断类型First
和Second
是否相同。is_scalar<typename Type>::value
:判断Type
是否为标量类型(与对象类型相对)。is_signed<typename Type>::value
:判断Type
是否为有符号类型。is_standard_layout<typename Type>::value
:判断Type
是否提供标准布局(type-condition
适用)。is_trivial<typename Type>::value
:判断Type
是否为平凡类型(`type
-condition` 适用)。
is_trivially_assignable<typename To, typename From>::value
:判断Type
是否提供平凡赋值运算符(type-condition
适用)。is_trivially_constructible<typename Type, typename ...Args>::value
:判断Type
是否可以平凡构造(type-condition
适用)。is_trivially_copy_assignable<typename Type>::value
:判断Type
是否提供平凡拷贝赋值运算符(type-condition
适用)。is_trivially_copy_constructible<typename Type>::value
:判断Type
是否提供平凡拷贝构造函数(type-condition
适用)。is_trivially_default_constructible<typename Type>::value
:判断Type
是否提供平凡默认构造函数(type-condition
适用)。is_trivially_destructible<typename Type>::value
:判断Type
是否提供平凡析构函数(type-condition
适用)。is_trivially_move_assignable<typename Type>::value
:判断Type
是否提供平凡移动赋值运算符(type-condition
适用)。is_trivially_move_constructible<typename Type>::value
:判断Type
是否提供平凡移动构造函数(type-condition
适用)。is_union<typename Type>::value
:判断Type
是否为联合类型。is_unsigned<typename Type>::value
:判断Type
是否为无符号类型。is_void<typename Type>::value
:判断Type
是否为void
。is_volatile<typename Type>::value
:判断Type
是否为volatile
。
定义 ErrorCodeEnum
和 ErrorConditionEnum
枚举
在第 4.3.2 节中介绍了 std::error_code
类。其构造函数之一接受 ErrorCodeEnum
值,其中 ErrorCodeEnum
是我们可以自己定义的枚举类型模板,用于包含用作错误码值的符号。另一个构造函数期望一个整数值的错误码和一个使用这些错误码的错误类别的说明。虽然 C++ 预定义了几个错误码枚举和错误类别,但也可以定义新的 ErrorCodeEnum
和错误类别。本节将介绍如何构造新的 ErrorCodeEnum
,下一节将介绍如何设计新的错误类别。
当使用 error_code
对象时,如果标准错误码值(如 enum class errc
定义的值)不合适,则定义新的错误码枚举是一种选择。例如,当设计一个交互式计算器时,可能会遇到与用户输入表达式的方式相关的几种错误。在这种情况下,你可能想要开发自己的错误码枚举。
在本节和下一节中,我们采用了一种简单的方法来定义错误码枚举和错误类别。没有开发具体的现实生活中的类。我认为这种方法的优点在于,这样更容易将原则应用于新的现实生活情境,而不是首先需要自己抽象一个现实生活中的示例。接下来:
-
定义自己的枚举:
我们的第一步是定义自己的枚举。这个枚举包含列出错误原因的符号。新定义的错误码枚举不应将值 0 与任何符号关联,因为按照约定,值 0 表示“无错误”。enum class CatErr
列出了与我们(尚未设计的)错误类别相关的错误原因:enum class CatErr { Err1 = 1, // 错误原因 Err2, Err3 };
-
将枚举提升为
error_code_enum
:
单独定义一个enum class
并不能使其值传递给error_code
构造函数。在我们能够这样做之前,必须将枚举“提升”到error_code_enum
。这种“提升”通过特化std::is_error_code_enum
结构体实现,之后error_code(ErrorCodeEnum)
成员模板和make_error_code
函数可以接受CatErr
枚举值。有趣的是,这需要我们向std
命名空间添加代码。通常情况下,这是不允许的,但在这种情况下是允许的。C++ 标准规定:20.5.4.2.1 Namespace std 如果程序向命名空间 `std` 或 `std` 命名空间内的其他命名空间添加声明或定义,除非另有规定,否则程序的行为是未定义的。程序只能在模板特化时添加用户定义的类型,并且特化符合标准库对原始模板的要求,并且没有明确禁止的情况下,添加标准库模板的特化。
下面是
is_error_code_enum
结构体的特化:namespace std { template <> struct is_error_code_enum<CatErr> : public true_type {}; }
这完成了我们自己错误码枚举的定义,其符号现在被
error_code
的构造函数接受。 -
定义
ErrorConditionEnum
:
在设计自己的错误类别之前,我们还必须查看“高级”错误原因,这些错误由std::error_condition
类的对象表示(参见第 10.9.2 节)。错误条件表示平台无关的错误,如语法错误或不存在的请求。在我们简单实现的错误类别中,这些高级错误原因在enum class Cond
枚举中列出。它的定义类似于CatErr
。我们的第一步是定义枚举。与错误码枚举一样,其符号不应赋值为 0。下面是
Cond
枚举,其符号可能表示平台无关的错误原因:enum class Cond { NoCond = -1, Cond1 = 1, Cond2, Cond3 };
-
将枚举提升为
error_condition_enum
:
单独定义enum class
并不能使其值传递给error_condition
构造函数。在我们能够这样做之前,必须将枚举“提升”到error_condition_enum
。同样地,这种“提升”通过特化std::is_error_condition_enum
结构体实现。下面是is_error_condition_enum
结构体的特化:namespace std { template <> struct is_error_condition_enum<Cond> : public true_type {}; }
我们现在已经准备好设计自己的 error_category
类。
从 std::error_category
派生类
在上一节中,我们开发了错误码枚举 CatErr
和 Cond
。这些枚举的值分别指定了在本节中开发的新错误类别中可能遇到的直接和平台无关的错误原因。本节将介绍如何从 std::error_category
派生一个类,并实现其接口。
派生自 std::error_category
的类被设计为单例类,实现了其自身的 name
、message
和 equivalent
成员。我们的类 Category
还声明了一个静态成员实例,返回对该类单例对象的引用,该对象在编译时初始化,并在实例调用时可用。另一种方法是定义一个专门的函数(类似于 generic_category
函数),该函数返回对 Category
对象的引用。
CatErr
值、Cond
值以及 CatErr
值的文本描述被组合在一个 std::unordered_map
中,使用 CatErr
作为键,POD
结构体作为值类型。这个映射允许我们检索平台无关的错误类型及其与 CatErr
值相关联的描述。
以下是 Category
类的接口:
class Category: public std::error_category
{
static Category s_instance;
struct POD
{
Cond cond;
char const *msg;
};
static std::unordered_map<CatErr, POD> s_map;
public:
Category(Category const &other) = delete;
static Category &instance();
bool equivalent(std::error_code const &ec, int condNr) const noexcept override;
bool equivalent(int ev, std::error_condition const &condition) const noexcept override;
std::error_condition default_error_condition(int ev) const noexcept override;
std::string message(int ce) const override;
char const *name() const noexcept override;
private:
Category() = default;
template <typename Enum>
static constexpr Enum as(int err);
};
它的 unordered_map
s_map
提供了 Cond
值和 CatErr
值的文字描述,如下所示:
std::unordered_map<CatErr, Category::POD> Category::s_map =
{
{ CatErr::Err1, { Cond::Cond1, "Err1" } },
{ CatErr::Err2, { Cond::Cond2, "Err2" } },
{ CatErr::Err3, { Cond::Cond1, "Err3" } },
};
函数 make_error_code
和 make_error_condition
分别返回 error_code
和 error_condition
对象,使用 CatErr
值和 Cond
值。这些函数的声明可以在 Category
类接口下面提供,它们的实现将 Category
对象传递给构造函数:
std::error_code make_error_code(CatErr ce)
{
return { static_cast<int>(ce), Category::instance() };
}
std::error_condition make_error_condition(Cond ec)
{
return { static_cast<int>(ec), Category::instance() };
}
派生自 error_category
的类必须定义 name
成员。它简单地返回一个短字符串,命名类别(例如,对于 Category
类返回 “Category”)。同样,成员函数 message
也必须重新定义。它的实现通常比 name
的实现稍微复杂一些:它期望一个(转换为整数的)CatErr
值,并使用该值在 s_map
中查找相应的文本描述。如果找到,则返回描述;如果未找到,则返回一个简短的后备消息:
std::string Category::message(int ce) const
{
auto iter = s_map.find(static_cast<CatErr>(ce));
return iter != s_map.end() ? iter->second.msg : "No CatErr value";
}
成员 default_error_condition
接收一个(转换为整数的)CatErr
值。该值用于查找关联的 Cond
值。如果函数接收到的整数值不代表有效的 CatErr
值,则使用后备值 Cond::NoCond
。该函数返回一个由 make_error_condition
创建的 error_condition
对象,该对象接收确定的 Cond
值作为参数:
std::error_condition Category::default_error_condition(int ev) const noexcept
{
auto iter = s_map.find(as<CatErr>(ev));
return make_error_condition(
iter == s_map.end() ? Cond::NoCond : iter->second.cond
);
}
剩下的就是实现两个 equivalent
成员。第一个 equivalent
成员(接收 error_code
对象的引用和(转换为整数的)Cond
值)确定与 error_code
对象关联的 Cond
值和作为函数第二个参数传递的 Cond
值的等价性。如果这些值相等并且 error_code
对象的类别等于 Category
,则已经确定了等价性,并返回 true
。其实现如下:
bool Category::equivalent(std::error_code const &ec, int condNr) const noexcept
{
if (*this != ec.category())
return false;
if (ec.value() == 0)
return condNr == 0;
auto iter = s_map.find(as<CatErr>(ec.value())); // 获取与 ec 的 CatErr 关联的信息
return iter == s_map.end() ? false // 未找到
: iter->second.cond == as<Cond>(condNr); // 比较 Cond 值
}
第二个 equivalent
成员(接收(转换为整数的)CatErr
值和 error_condition
对象)确定由传递的 CatErr
值构造的 Cond
值的 error_condition
对象与作为第二个参数传递的 error_condition
对象的等价性。在得出等价性的前提条件是错误条件的类别是 Category
。如果条件满足,则如果其整数参数等于零,并且条件对象也指示无错误,函数返回 true
。否则,如果条件对象等于由传递给函数的 CatErr
值构造的 error_condition
对象,则等价性也已经确定,并返回 true
。其实现如下:
bool Category::equivalent(int ev, error_condition const &condition) const noexcept
{
if (ev == 0) // 无错误?
return condition.category() == *this && !static_cast<bool>(condition);
auto iter = s_map.find(as<CatErr>(ev)); // 查找 ev 的 Cond
return iter == s_map.end() ? false // 没有这样的 CatErr
: condition == make_error_condition(iter->second.cond); // 比较条件
}
因此,为了定义自己的类别:
- 定义一个枚举,并将其“提升”为
error_code_enum
; - 定义一个枚举,并将其“提升”为
error_condition_enum
; - 通过从
std::error_category
派生一个类来定义新的error_category
类,定义为单例类,并重写其default_error_condition
、equivalent
、message
和name
成员。
在提供“强保证”时使用 noexcept
当尝试实现强保证时,一个函数的操作通常分为两个部分:
- 首先,在一个临时对象上执行所有可能会抛出异常的操作(这不会影响目标对象)。
- 然后,使用提供
nothrow
保证的操作修改目标对象。
在第一步中进行的操作可能需要通过 std::move
来使其支持移动(例如,将源对象的值分配给(可能是临时的)目标对象)。然而,使用 std::move
很容易影响源对象(例如,当扩展源对象的内存时,将现有数据移动到新的位置),这会破坏第一步的假设,因为目标对象现在已经被修改。
在这种情况下(以及通常情况下),移动操作不应抛出异常。这意味着,如果移动构造函数使用(外部)数据类型,而这些数据类型的移动构造函数不可控,那么编写必须提供非抛出移动构造函数的代码就变得困难。例如:
template <typename Type>
class MyClass
{
Type d_f;
public:
MyClass() = default;
MyClass(MyClass &&tmp)
: d_f(std::move(tmp.d_f))
{}
};
在这里,MyClass
的作者无法控制 Type
的设计。如果 MyClass
被实例化为类型 Foreign
,并且 Foreign
具有一个(可能会抛出异常的)拷贝构造函数,则以下代码会破坏移动构造函数的无异常假设:
MyClass<Foreign> s2{ std::move(MyClass<Foreign>()) };
如果模板能够检测 Type
是否具有非抛出的移动构造函数,则可以通过在需要使用更昂贵的拷贝构造函数的情况下调用这些移动构造函数来优化其实现。
noexcept
关键字被引入,以便模板能够进行这样的优化。与抛出列表一样,检查 noexcept
是一个运行时检查,但违反 noexcept
的后果比违反抛出列表更严重:违反 noexcept
会调用 std::terminate
,终止程序,可能不会进行栈展开。在前面的例子中,以下代码被编译器接受,演示了没有对 noexcept
的编译时检查:
class Foreign
{
public:
Foreign() = default;
Foreign(Foreign const &other) noexcept
{
throw 1;
}
};
然而,当调用这个类的拷贝构造函数时,执行将中止并出现以下消息:
terminate called after throwing an instance of 'int'
Abort
请记住,noexcept
的当前目的是允许模板通过使用移动操作来优化代码,而这些代码还必须能够提供强异常保证。由于 noexcept
也提供了条件 noexcept(condition)
语法(其中 noexcept(true)
和 noexcept
具有相同的语义),noexcept
可以根据模板类型的“无抛出”性质进行条件性设置。请注意,这在抛出列表中是不可能的。
以下是使用 noexcept
的一些经验法则:
- 一般规则:不要使用
noexcept
(这与抛出列表的建议相同); - 如果编译器可以推断出组成类型也提供
noexcept(true)
,则默认实现的构造函数、拷贝和移动赋值运算符以及析构函数都应提供noexcept(true)
,以允许模板优化使用移动操作; - 带有
noexcept
声明的函数仍然可能抛出异常(见上例)。最终,noexcept
仅表示如果此类函数抛出异常,则调用std::terminate
而不是std::unexpected
; - 之前提供空的抛出列表(
throw()
)的函数应提供noexcept
; - 当使用以下标准特性(在
<type_traits>
头文件中声明)时,必须提供noexcept
规范:is_nothrow_constructible
is_nothrow_default_constructible
is_nothrow_move_constructible
is_nothrow_copy_constructible
is_nothrow_assignable
is_nothrow_move_assignable
is_nothrow_copy_assignable
is_nothrow_destructible
这些类型特性提供了成员常量值,如果类(及其参数类型列表)匹配特性命名的特征,则为 true
。例如,如果 MyClass(string const &) noexcept
是一个构造函数,则 std::is_nothrow_constructible<MyClass, string>::value
等于 true
。对于命名成员(如 is_nothrow_move_constructible
),参数类型不需要指定,因为它们是隐含的。例如,std::is_nothrow_move_constructible<MyClass>::value
返回 true
,如果移动构造函数具有 noexcept
修饰符。
更多的类类型转换
类型到类型
虽然类模板可以进行部分特化,但函数模板却不能。这有时会带来困扰。例如,假设有一个函数模板实现了某种一元操作符,可以与 transform
泛型算法一起使用:
template <typename Return, typename Argument>
Return chop(Argument const &arg)
{
return Return{ arg };
}
假设当 Return
是 std::string
时,上述实现不应该被使用。相反,当 Return
是 std::string
时,应该始终提供第二个参数 1。如果 Argument
是一个 C++ 字符串,这将允许我们从 arg
中返回一个去掉了第一个字符的副本。
由于 chop
是一个函数,不能定义部分特化如下:
template <typename Argument>
// 这将无法编译!
std::string chop<std::string, Argument>(Argument const &arg)
{
return std::string{ arg, 1 };
}
尽管函数模板不能进行部分特化,但可以使用重载来实现,定义一个第二个虚拟的字符串参数:
template <typename Argument>
std::string chop(Argument const &arg, std::string)
{
return std::string{ arg, 1 };
}
除了提供一个字符串虚拟参数之外,还可以使用 IntType
模板(参见 23.2.1.1 节)来选择正确的重载版本。例如,可以将 IntType<0>
定义为第一个重载 chop
函数的第二个参数类型,将 IntType<1>
用于第二个重载函数。从程序效率的角度来看,这是一个很有吸引力的选项,因为提供的 IntType
对象非常轻量。IntType
对象不包含任何数据。然而,也有一个明显的缺点,即没有直观明确的值与预期类型之间的关联。
与定义任意 IntType
类型相比,更有吸引力的是使用另一种轻量级解决方案,即使用自动类型到类型的关联。TypeType
结构体是一个轻量级的类型封装器,非常类似于 IntType
。以下是其定义:
template <typename T>
struct TypeType
{
using Type = T;
};
TypeType
也是一个轻量级类型,因为它没有任何数据字段。TypeType
允许我们为 chop
的第二个参数使用自然的类型关联。例如,可以这样定义重载函数:
template <typename Return, typename Argument>
Return chop(Argument const &arg, TypeType<Argument> )
{
return Return{ arg };
}
template <typename Argument>
std::string chop(Argument const &arg, TypeType<std::string> )
{
return std::string{ arg, 1 };
}
使用上述实现,可以为 Result
指定任何类型。如果它恰好是 std::string
,则自动选择适当的重载版本。以下是 chop
函数的额外重载:
template <typename Result>
Result chop(char const *txt)
// char const * 也可以是第二个模板类型参数
{
// 使用模板类型参数
return chop(std::string{ txt }, TypeType<Result>{});
}
使用第三个 chop
函数,以下语句将产生文本“ello world”:
std::cout << chop<std::string>("hello world") << '\n';
模板函数不支持部分特化,但可以进行重载。通过提供依赖于其他参数的虚拟类型参数的重载,并从不需要虚拟类型参数的重载函数中调用这些重载函数,可以实现类似于类模板部分特化的情况。
空类型
有时(参见第 23.10 节),一个空的结构体是一个有用的工具。它可以作为一种类型,类似于 NTBS(Null-terminated Byte Strings)中的最后一个 0 字节。它可以简单地定义为:
struct NullType
{};
类型可转换性
在什么情况下类型 T 可以作为类型 U 的“替代品”使用?由于 C++ 是一种强类型语言,这个问题的答案出奇简单:当类型 T 可以在需要类型 U 的地方作为参数使用时,T 就可以用作 U 的替代品。这一推理基础上构建了一个类,用于确定类型 T 是否可以在期望类型 U 的地方使用。值得注意的是,这里的决策完全由编译器完成,没有实际的代码被生成或执行。
在本节的第二部分,我们将展示如何使用第一部分开发的代码来检测一个类 B 是否是另一个类 D 的基类(is_base_of
模板也提供了这个问题的答案)。这里开发的代码紧密跟随了 Alexandrescu(2001,第 35 页)提供的示例。
首先,设计一个接受类型 U 的函数 test
。这个函数 test
返回一个尚未知类型 Convertible
的值:
Convertible test(U const &);
函数 test
永远不会被实现,仅仅声明而已。如果一个类型 T 可以代替一个类型 U,那么 T 也可以作为参数传递给上述 test
函数。
另一方面,如果替代类型 T 不能在期望 U 的地方使用,那么编译器将无法使用上述 test
函数。相反,编译器将使用一个选择优先级较低的替代函数,该函数可以与任何 T 类型一起使用。
C(以及 C++)提供了一个非常通用的参数列表,这个参数列表总是被认为是可接受的。这个参数列表就是我们熟悉的省略号(ellipsis),它表示编译器可能遇到的最糟糕的情况。如果其他所有选项都失败了,那么定义省略号作为参数列表的函数将被选中。
通常这不是一个有效的选择,但在当前情况下,它正是我们需要的。当面临两个候选函数时,其中一个定义了省略号参数,编译器只有在其他候选函数无法使用时才选择定义省略号参数的函数。
按照上述推理,还声明一个替代函数 test(...)
。这个替代函数不返回 Convertible
类型的值,而是返回 NotConvertible
类型的值:
NotConvertible test(...);
如果 test
的参数是类型 T 并且 T 可以转换为 U,则 test
的返回类型为 Convertible
。否则返回 NotConvertible
。
这个情况显然与第 23.6.1 节中遇到的情况类似,在那里需要在编译时确定 isClass
的值。这里需要解决两个相关的问题:
- 如何获取 T 参数?这比最初预期的要困难,因为可能无法定义 T。如果类型 T 没有定义任何构造函数,则无法定义 T 对象。
- 如何区分
Convertible
和NotConvertible
?
第一个问题的解决方法是认识到实际上不需要定义 T。毕竟,目的是在编译时决定类型是否可转换,而不是定义 T 值或对象。定义对象不是编译时的任务,而是运行时的任务。
通过简单地声明一个返回 T 的函数,我们可以告诉编译器它应该假设有一个 T 对象从中出来。然而,这个函数需要一点小修改才能实际满足我们的需求。如果 T 恰好是一个数组,那么 T makeT()
函数会让编译器感到困惑,因为函数不能返回数组。然而,这很容易解决,因为函数可以返回数组的引用。所以上述声明被改为:
T const &makeT();
接下来,我们将 T const &
传递给 test
:
test(makeT());
现在编译器看到 test
被调用时带有 T const &
参数,它决定如果确实可以进行转换,则返回值为 Convertible
。否则,编译器决定返回 NotConvertible
(因为在这种情况下,编译器选择了 test(...)
)。
第二个问题,区分 Convertible
和 NotConvertible
,的解决方法与第 23.6.1 节中确定 isClass
的方法相同,即使它们的大小不同。完成后,以下表达式可以确定 T 是否可以从 U 转换:
isConvertible = sizeof(test(makeT())) == sizeof(Convertible);
通过使用 char
作为 Convertible
和 Char2
(参见第 23.6.1 节)作为 NotConvertible
,可以进行区分。
上述内容可以总结为一个类模板 LconvertibleToR
,它有两个模板类型参数:
template <typename T, typename U>
class LconvertibleToR
{
struct Char2
{
char array[2];
};
static T const &makeT();
static char test(U const &);
static Char2 test(...);
public:
LconvertibleToR(LconvertibleToR const &other) = delete;
enum { yes = sizeof(test(makeT())) == sizeof(char) };
enum { sameType = 0 };
};
template <typename T>
class LconvertibleToR<T, T>
{
public:
LconvertibleToR(LconvertibleToR const &other) = delete;
enum { yes = 1 };
enum { sameType = 1 };
};
由于类模板删除了其拷贝构造函数,因此无法创建对象。只能查询其枚举值。以下示例代码在 main
函数中运行时输出 1 0 1 0
:
cout <<
LconvertibleToR<ofstream, ostream>::yes << " " <<
LconvertibleToR<ostream, ofstream>::yes << " " <<
LconvertibleToR<int, double>::yes << " " <<
LconvertibleToR<int, string>::yes <<
"\n";
确定继承关系
现在已经能够确定类型的可转换性,接下来可以很容易地确定一个类型 Base
是否是另一个类型 Derived
的(公有)基类。
继承关系可以通过检查(const)指针的可转换性来确定。如果 Derived const *
可以转换为 Base const *
,则满足以下条件:
- 两个类型完全相同;
Base
是Derived
的公有且明确的基类;- 通常不打算的情况是
Base
是void
。
假设不使用最后一种转换,继承关系可以通过以下特性类 LBaseRDerived
来确定。LBaseRDerived
提供了一个枚举 yes
,如果左侧类型是右侧类型的基类且两种类型不同,则 yes
为 1:
template <typename Base, typename Derived>
struct LBaseRDerived
{
LBaseRDerived(LBaseRDerived const &) = delete;
enum {
yes =
LconvertibleToR<Derived const *, Base const *>::yes &&
not LconvertibleToR<Base const *, void const *>::sameType
};
};
如果代码不应将类视为其自身的基类,则可以使用特性类 LBaseRtrulyDerived
进行严格测试。此特性类添加了类型相等性的测试:
template <typename Base, typename Derived>
struct LBaseRtrulyDerived
{
LBaseRtrulyDerived(LBaseRtrulyDerived const &) = delete;
enum {
yes =
LBaseRDerived<Base, Derived>::yes &&
not LconvertibleToR<Base const *, Derived const *>::sameType
};
};
示例:以下语句执行时显示:
cout << "\n" <<
"1: " << LBaseRDerived<ofstream, ostream>::yes << ", " <<
"2: " << LBaseRDerived<ostream, ofstream>::yes << ", " <<
"3: " << LBaseRDerived<void, ofstream>::yes << ", " <<
"4: " << LBaseRDerived<ostream, ostream>::yes << ", " <<
"5: " << LBaseRtrulyDerived<ostream, ostream>::yes <<
"\n";
输出结果为:
1: 0, 2: 0, 3: 1, 4: 1, 5: 0
模板 TypeList 处理
本节有两个目的。一方面,它展示了各种模板元编程技术的能力,这些技术可以作为开发自己模板时的灵感来源;另一方面,它提供了一个具体的示例,说明了这些技术所提供的一些强大功能。
本节的灵感来自于 Andrei Alexandrescu(2001)《现代 C++ 设计》一书。它在 Alexandrescu 的书中使用的算法的基础上进行了扩展,采用了在他著作中尚不可用的变参模板。尽管如此,Alexandrescu 使用的算法在使用变参模板时仍然非常有用。
C++ 提供了 tuple
来存储和检索多种类型的值。在这里,重点仅仅是处理类型。我们将使用一个简单的 TypeList
结构体作为即将到来的子节的工作工具。其定义如下:
template <typename ...Types>
struct TypeList
{
TypeList(TypeList const &) = delete;
enum { size = sizeof ...(Types) };
};
TypeList
允许我们存储任意数量的类型。以下是一个例子,存储了 char
、short
、int
三种类型到一个 TypeList
中:
TypeList<char, short, int>
TypeList 的长度
由于参数包中的类型数量可以通过 sizeof
操作符获得(参见第 22.5 节),因此很容易获得指定的 TypeList
中类型的数量。例如,以下语句会显示值 3:
std::cout << TypeList<int, char, bool>::size << '\n';
然而,如果没有 sizeof
,了解如何确定 TypeList
中指定的类型数量是很有意义的。为了获得 TypeList
中指定的类型数量,可以使用以下算法:
- 如果
TypeList
不包含任何类型,则其大小等于零; - 如果
TypeList
包含类型,则其大小等于 1 加上其第一个类型之后的类型数量。
这个算法使用递归来定义 TypeList
的长度。在可执行的 C++ 代码中,递归也可以用于类似的情况。例如,递归可以用来确定 NTBS(Null-Terminated Byte String)的长度:
size_t c_length(char const *cp)
{
return *cp == 0 ? 0 : 1 + c_length(cp + 1);
}
虽然 C++ 函数通常使用迭代而不是递归,但迭代在模板元编程算法中不可用。在模板元编程中,重复必须使用递归实现。此外,尽管 C++ 运行时代码可以使用条件决定是否开始下一个递归,但模板元编程不能这样做。模板元编程算法必须依靠(部分)特化来选择替代方案。
可以使用以下替代实现来计算 TypeList
中的类型数量,该实现使用了通用的结构声明和两个特化:一个用于空的 TypeList
,另一个用于非空的 TypeList
(参见上述算法描述):
template <typename ...Types>
struct TypeList;
template <typename Head, typename ...Tail>
struct TypeList<Head, Tail...>
{
enum { size = 1 + TypeList<Tail...>::size };
};
template <>
struct TypeList<>
{
enum { size = 0 };
};
搜索 TypeList
要确定某个特定类型(以下称为 SearchType
)是否存在于给定的 TypeList
中,可以使用一个算法,该算法要么将 index
定义为 -1(如果 SearchType
不是 TypeList
的元素),要么将 index
定义为 SearchType
在 TypeList
中第一次出现的索引。使用的算法如下:
- 如果
TypeList
为空,index
为 -1; - 如果
TypeList
的第一个元素等于SearchType
,index
为 0; - 否则,
index
为:- 如果在
TypeList
的尾部搜索SearchType
的结果是index
== -1,则index
为 -1; - 否则(
SearchType
在TypeList
的尾部被找到),index
被设置为 1 + 在TypeList
的尾部搜索SearchType
时获得的index
。
- 如果在
该算法通过使用一个变参模板结构 ListSearch
来实现,该结构接受一个参数包:
template <typename ...Types>
struct ListSearch
{
ListSearch(ListSearch const &) = delete;
};
特化处理上述算法中提到的各种情况:
- 如果
TypeList
为空,index
为 -1:
template <typename SearchType>
struct ListSearch<SearchType, TypeList<>>
{
ListSearch(ListSearch const &) = delete;
enum { index = -1 };
};
- 如果
TypeList
的头部元素等于SearchType
,index
为 0。请注意,SearchType
被明确提到作为TypeList
的第一个元素:
template <typename SearchType, typename ...Tail>
struct ListSearch<SearchType, TypeList<SearchType, Tail...>>
{
ListSearch(ListSearch const &) = delete;
enum { index = 0 };
};
- 否则,对
TypeList
的尾部进行搜索。搜索结果的索引存储在tmp
枚举值中,然后用来确定index
的值:
template <typename SearchType, typename Head, typename ...Tail>
struct ListSearch<SearchType, TypeList<Head, Tail...> >
{
ListSearch(ListSearch const &) = delete;
enum { tmp = ListSearch<SearchType, TypeList<Tail...>>::index };
enum { index = tmp == -1 ? -1 : 1 + tmp };
};
以下是一个示例,展示了如何使用 ListSearch
:
std::cout <<
ListSearch<char, TypeList<int, char, bool>>::index << "\n" <<
ListSearch<float, TypeList<int, char, bool>>::index << "\n";
从 TypeList 中选择类型
确定 TypeList
中某个类型的索引的反向操作是根据索引检索类型。本节介绍了如何实现这一操作。
该算法通过结构体 TypeAt
实现。TypeAt
通过 using
声明来定义与给定索引匹配的类型。但索引可能会超出范围。在这种情况下,我们有几种选择:
-
使用
static_assert
来停止编译。如果索引不应该超出范围,这是合适的做法; -
定义一个本地类型(例如
Null
),该类型不应该作为TypeList
中的类型使用。当索引超出范围时,这个类型将被返回。将这个本地类型作为TypeList
中的一种类型被视为错误,因为它与Null
作为无效索引返回的特殊含义冲突。为了防止
Null
类型被TypeAt
返回,可以使用static_assert
来捕获在TypeAt
评估时遇到的Null
类型; -
TypeAt
结构体可以定义一个enum
值validIndex
,当索引有效时设置为true
,当索引无效时设置为false
。
以下是第一种替代方案的实现。其他替代方案也不难实现,留给读者作为练习。以下是 TypeAt
的实现方式:
- 基础是一个变参模板结构
TypeAt
,接受一个索引和一个TypeList
:
template <size_t index, typename Typelist>
struct TypeAt;
- 如果
TypeList
为空,static_assert
会结束编译:
template <size_t index>
struct TypeAt<index, TypeList<>>
{
static_assert(index < 0, "TypeAt index out of bounds");
using Type = TypeAt;
};
- 如果搜索的索引等于 0,定义
Type
为TypeList
中的第一个类型:
template <typename Head, typename ...Tail>
struct TypeAt<0, TypeList<Head, Tail...>>
{
using Type = Head;
};
- 否则,
Type
定义为TypeAt<index - 1>
在TypeList
的尾部上的结果:
template <size_t index, typename Head, typename ...Tail>
struct TypeAt<index, TypeList<Head, Tail...>>
{
using Type = typename TypeAt<index - 1, TypeList<Tail...>>::Type;
};
以下是如何使用 TypeAt
的示例。取消注释第一个变量定义会导致编译错误:TypeAt index out of bounds:
using list3 = TypeList<int, char, bool>;
// TypeAt<3, list3>::Type invalid; // 会导致编译错误
TypeAt<0, list3>::Type intVariable = 13;
TypeAt<2, list3>::Type boolVariable = true;
std::cout << "The size of the first type is " <<
sizeof(TypeAt<0, list3>::Type) << ", "
"the size of the third type is " <<
sizeof(TypeAt<2, list3>::Type) << "\n";
if (typeid(TypeAt<1, list3>::Type) == typeid(char))
std::cout << "The typelist's 2nd type is char\n";
if (typeid(TypeAt<2, list3>::Type) != typeid(char))
std::cout << "The typelist's 3rd type is not char\n";
从 TypeList 中删除类型
从 TypeList
中删除类型也是可能的。这里有几种不同的可能性,每种可能性都导致不同的算法:
- 删除指定类型的第一次出现:删除
TypeList
中的第一个出现的指定类型; - 删除指定索引位置的类型:删除
TypeList
中索引位置上的类型; - 删除所有出现的指定类型:删除
TypeList
中所有出现的指定类型; - 变种:删除
TypeList
中所有重复出现的类型,仅保留每种类型一次。
毫无疑问,还有其他方式可以从 TypeList
中删除类型。最终实现的方式取决于具体情况。由于模板元编程非常强大,大多数(如果不是所有)算法都可能得到实现。以下是从 TypeList
中删除类型的算法示例,这些算法将在接下来的小节中进行开发。
删除第一次出现
为了从 TypeList
中删除指定类型 EraseType
的第一次出现,我们再次使用递归算法。这个模板元编程程序使用了一个通用的 Erase
结构体和几个特化版本。这些特化版本定义了一个 List
类型,其中包含了删除后的 TypeList
。以下是算法的步骤:
-
算法的基础:包含一个模板结构体
Erase
,接受要删除的类型和一个TypeList
:template <typename EraseType, typename TypeList> struct Erase;
-
如果
TypeList
为空:没有类型需要删除,因此结果是一个空的TypeList
:template <typename EraseType> struct Erase<EraseType, TypeList<>> { using List = TypeList<>; };
-
如果
TypeList
的头部匹配要删除的类型:List
变成原始TypeList
的尾部类型组成的TypeList
:template <typename EraseType, typename ...Tail> struct Erase<EraseType, TypeList<EraseType, Tail...>> { using List = TypeList<Tail...>; };
-
在其他情况下:删除操作应用于
TypeList
的尾部。结果是一个TypeList
,需要在前面加上原始TypeList
的头部。前缀操作返回的TypeList
被作为Erase::List
返回:template <typename EraseType, typename Head, typename ...Tail> struct Erase<EraseType, TypeList<Head, Tail...>> { using List = typename Prefix<Head, typename Erase<EraseType, TypeList<Tail...>>::List >::List; };
以下是如何使用 Erase
的示例语句:
cout <<
Erase<int, TypeList<char, double, int>>::List::size << '\n' <<
Erase<char, TypeList<int>>::List::size << '\n' <<
Erase<int, TypeList<int>>::List::size << '\n' <<
Erase<int, TypeList<>>::List::size << "\n";
这些语句展示了在不同情况下使用 Erase
删除类型的效果。
按索引删除类型
为了通过索引从 TypeList
中删除类型,我们再次使用递归模板元编程。EraseIdx
结构体接受一个 size_t
类型的索引值和一个 TypeList
,其目的是从 TypeList
中删除第 idx
个(0 基础)类型。EraseIdx
定义了一个包含结果 TypeList
的类型 List
。以下是算法的步骤:
-
算法的基础:包含一个模板结构体
EraseIdx
,接受要删除的类型的索引和一个TypeList
:template <size_t idx, typename TypeList> struct EraseIdx;
-
如果
TypeList
为空:没有类型需要删除,因此结果是一个空的TypeList
:template <size_t idx> struct EraseIdx<idx, TypeList<>> { using List = TypeList<>; };
-
当
idx
为 0 时:递归结束,此时忽略TypeList
的第一个类型,List
被初始化为一个包含原始TypeList
尾部类型的TypeList
:template <typename EraseType, typename ...Tail> struct EraseIdx<0, TypeList<EraseType, Tail...>> { using List = TypeList<Tail...>; };
-
在其他情况下:
EraseIdx
被应用于TypeList
的尾部,同时索引idx
减 1。结果的TypeList
需要在前面加上原始TypeList
的头部。前缀操作返回的TypeList
被作为EraseIdx::List
返回:template <size_t idx, typename Head, typename ...Tail> struct EraseIdx<idx, TypeList<Head, Tail...>> { using List = typename Prefix< Head, typename EraseIdx<idx - 1, TypeList<Tail...>>::List >::List; };
以下是如何使用 EraseIdx
的示例语句:
if (
typeid(TypeAt<2,
EraseIdx<1,
TypeList<int, char, size_t, double, int>>::List
>::Type
)
== typeid(double)
)
cout << "the third type is now a double\n";
这段代码展示了在 EraseIdx
删除操作后,如何使用 TypeAt
确认删除操作是否成功。例如,检查 TypeAt<2>
是否返回 double
类型,表明第三个类型现在是 double
。
删除所有出现的类型
从 TypeList
中删除所有 EraseType
类型可以通过将删除操作应用于 TypeList
的头部和尾部来轻松完成。以下是这个算法的步骤,描述的顺序与 Erase
算法稍有不同:
-
如果
TypeList
为空:没有类型需要删除,因此结果是一个空的TypeList
。这与Erase
的处理方式相同,因此可以通过继承来避免重复编写模板元程序的部分内容:template <size_t idx> struct EraseIdx<idx, TypeList<>> { using List = TypeList<>; };
-
算法的基础:包含一个结构体模板
EraseAll
,接受要删除的类型和一个TypeList
,该模板继承自Erase
,因此已经提供了空TypeList
处理的特化版本:template <typename EraseType, typename TypeList> struct EraseAll: public Erase<EraseType, TypeList> {};
-
如果
TypeList
的头部匹配EraseType
:EraseAll
也会应用于TypeList
的尾部,从而删除TypeList
中所有出现的EraseType
类型:template <typename EraseType, typename ...Tail> struct EraseAll<EraseType, TypeList<EraseType, Tail...>> { using List = typename EraseAll<EraseType, TypeList<Tail...>>::List; };
-
在其他情况下(即
TypeList
的头部不匹配EraseType
):EraseAll
应用到TypeList
的尾部。返回的TypeList
包含原始TypeList
的头部类型和通过递归EraseAll
调用返回的TypeList
的类型:template <typename EraseType, typename Head, typename ...Tail> struct EraseAll<EraseType, TypeList<Head, Tail...>> { using List = typename Prefix< Head, typename EraseAll<EraseType, TypeList<Tail...>>::List >::List; };
以下是如何使用 EraseAll
的示例语句:
cout <<
"After erasing size_t from "
"TypeList<char, int, size_t, double, size_t>\n"
"it contains " <<
EraseAll<size_t,
TypeList<char, int, size_t, double, size_t>
>::List::size << " types\n";
这段代码展示了在 EraseAll
删除操作后,TypeList<char, int, size_t, double, size_t>
中 size_t
被删除后,剩下的类型数量。
删除重复项
要从 TypeList
中删除所有重复的类型,需要将 TypeList
的第一个元素从 TypeList
的尾部删除,并递归地对 TypeList
的尾部应用此过程。下面是算法的步骤:
-
首先,声明通用的
EraseDup
结构体模板。EraseDup
结构体定义了一个类型List
,表示它们生成的TypeList
。EraseDup
调用期望TypeList
作为模板类型参数:template <typename TypeList> struct EraseDup;
-
如果
TypeList
为空,可以直接返回空的TypeList
,处理完成:template <> struct EraseDup<TypeList<>> { using List = TypeList<>; };
-
在其他情况下:
- 首先对原始
TypeList
的尾部应用EraseDup
。根据定义,这会返回一个删除了所有重复项的TypeList
。 - 上一步返回的
TypeList
可能包含原始TypeList
的第一个类型。如果是这样,就需要通过对返回的TypeList
应用Erase
来删除原始TypeList
的第一个类型。 - 返回的
TypeList
包含原始TypeList
的第一个类型,接着将上一步生成的TypeList
的类型附加到其后。
- 首先对原始
这段特化实现如下:
template <typename Head, typename ...Tail>
struct EraseDup<TypeList<Head, Tail...>>
{
using UniqueTail = typename EraseDup<TypeList<Tail...>>::List;
using NewTail = typename Erase<Head, UniqueTail>::List;
using List = typename Prefix<Head, NewTail>::List;
};
以下是如何使用 EraseDup
的示例:
cout <<
"After erasing duplicates from "
"TypeList<double, char, int, size_t, int, double, size_t>\n"
"it contains " <<
EraseDup<
TypeList<double, char, int, size_t, int, double, size_t>
>::List::size << " types\n";
这段代码展示了在 EraseDup
删除重复项操作后,TypeList<double, char, int, size_t, int, double, size_t>
中剩下的类型数量。
使用 TypeList
在前面的部分中,我们讨论了 TypeList
的定义及其一些特性。大多数 C++ 程序员认为 TypeList
既令人兴奋又具有智力挑战性,它们在递归编程领域磨练了技能。
但是,TypeList
不仅仅是一个智力挑战。在本章的最后部分,将涉及以下主题:
-
从
TypeList
创建类:
这里的目标是构建一个新类,该类由对每个TypeList
中提到的类型进行实例化的现有基本模板组成。 -
通过索引而非名称访问构造出的聚合类的数据成员:
再次说明,这些部分的很多材料都受到 Alexandrescu(2001)书籍的启发。
Wrap 和 Multi 类模板
为了说明模板元编程的概念,下面开发了模板类 Multi
。该类模板 Multi
从一个定义了数据存储策略的模板模板参数 Policy
和一系列类型中创建一个新类。Multi
将这些模板参数传递给其基类 MultiBase
,后者最终创建一个完整的类继承树。由于我们不知道要使用多少个类型,Multi
被定义为一个使用模板包 ...Types
的变参类模板。
实际上,指定给 Multi
的类型并不那么重要。它们主要用于“播种”类 Policy
。因此,Multi
的类型不会直接传递给 MultiBase
,而是传递给 Policy
,然后将 Policy<Type>
类型的序列转发给 MultiBase
。Multi
的构造函数期望初始化其各种 Policy<Type>
的值,并将这些值完美地转发给 MultiBase
。
类 Multi
(将其构造函数内联以节省空间)展示了如何将模板包包装到策略中。以下是 Multi
的定义:
template <template <typename> class Policy, typename ...Types>
struct Multi : public MultiBase<0, Policy<Types>...>
{
using PlainTypes = TypeList<Types...>;
using Base = MultiBase<0, Policy<Types>...>;
enum { size = PlainTypes::size };
Multi(Policy<Types> &&...types)
: MultiBase<0, Policy<Types>...>(std::forward<Policy<Types>>(types)...)
{}
};
不幸的是,如所述设计存在一些缺陷:
- 由于
Policy
模板模板参数被定义为template <typename> class Policy
,它只能接受一个类型参数。相对而言,std::vector
是一个期望两个模板参数的模板,第二个参数定义了std::vector
使用的分配方案。这个分配方案很少被更改,大多数应用程序仅定义像vector<int>
、vector<string>
等类型。然而,模板模板参数必须指定正确数量和类型的模板参数,因此vector
不能作为Multi
的策略。这可以通过将更复杂的模板包装在更简单的包装模板中来解决,例如:
template <class Type>
struct Vector : public std::vector<Type>
{
Vector(std::initializer_list<Type> iniValues)
: std::vector<Type>(iniValues)
{}
};
现在 Vector
提供 std::vector
的第二个参数,使用其默认模板参数。或者,可以使用模板使用声明。
-
如果
TypeList
包含两个类型,如int
和double
,且策略类为Vector
,则MultiBase
类最终继承自vector<int>
和vector<double>
。但如果TypeList
包含相同的类型,例如两个int
类型规范,MultiBase
将会从两个vector<int>
类继承。类不能从相同的基类派生,因为这会使其成员无法区分。对此,Alexandrescu(2001)写道(第67页):有一个主要的烦恼来源...:当 `TypeList` 中有重复的类型时,你不能使用它。 .... 没有简单的方法来解决歧义,因为最终派生的类会从相同的基类继承两次。
解决重复基类类型的问题的一种方法是,如果不直接从基类继承,而是将这些基类首先包装在唯一类型定义类中,那么这些唯一的类可以使用继承原则来访问基类。由于这些唯一类型定义的包装类仅仅是从“真实”基类继承的类,因此它们继承了基类的功能。唯一类型定义的包装类可以设计成类似于之前定义的
IntType
类。我们寻找的包装类结合了类继承与IntType
提供的唯一性。类模板UWrap
具有两个模板参数:一个非类型参数idx
和一个类型参数。通过确保每个UWrap
定义使用唯一的idx
值,可以创建唯一的类类型。这些唯一的类类型随后用作派生类MultiBase
的基类:
template <size_t nr, typename Type>
struct UWrap : public Type
{
UWrap(Type const &type)
: Type(type)
{}
};
使用 UWrap
可以轻松区分,例如两个 vector<int>
类:UWrap<0, vector<int>>
可以指代第一个 vector<int>
,UWrap<1, vector<int>>
可以指代第二个 vector<int>
。
各种 UWrap
类型的唯一性由类模板 MultiBase
确保,如下一节所述。
还必须能够初始化 Multi
类对象。因此,它的构造函数期望所有 Policy
值的初始化值。所以如果定义了 Multi
为 Vector
、int
、string
,则其构造函数可以接收匹配的初始化值。例如:
Multi<Vector, int, string> mvis({1, 2, 3}, {"one", "two", "three"});
MultiBase 类模板
类模板 MultiBase
是 Multi
的基类。它定义了一个类,该类最终从一系列 Policy
类型派生,这些 Policy
类型由 Multi
使用传递的额外类型创建。
MultiBase
本身并不具备 Policy
的概念。对 MultiBase
来说,世界仅仅由一个简单的模板包组成,这些类型用于定义一个类。除了 PolicyTypes
模板包,MultiBase
还定义了一个 size_t nr
的非类型参数,用于创建唯一的 UWrap
类型。以下是 MultiBase
的通用类声明:
template <size_t nr, typename ...PolicyTypes>
struct MultiBase;
有两个特化处理所有可能的 MultiBase
调用。其中一个特化是递归模板。这个模板处理 MultiBase
模板参数包中的第一个类型,并递归地使用自身来处理其余的类型。第二个特化在模板参数包耗尽时被调用,并不做任何事情。以下是后者特化的定义:
template <size_t nr>
struct MultiBase<nr>
{};
递归定义的特化是有趣的部分。它执行以下任务:
- 它从一个唯一的
UWrap
类型继承。通过在定义UWrap
时使用MultiBase
的nr
参数来保证唯一性。除了nr
外,UWrap
类还接收作为模板参数包第一个类型的PolicyT1
。 - 它还递归地从自身继承。递归的
MultiBase
类型使用递增的nr
值作为第一个模板参数(从而确保递归MultiBase
类型定义的UWrap
类型的唯一性)。它的第二个模板参数是MultiBase
可用的模板参数包的尾部。
以下是 MultiBase
的递归定义:
template <size_t nr, typename PolicyT1, typename ...PolicyTypes>
struct MultiBase<nr, PolicyT1, PolicyTypes...> :
public UWrap<nr, PolicyT1>,
public MultiBase<nr + 1, PolicyTypes...>
{
using Type = PolicyT1;
using Base = MultiBase<nr + 1, PolicyTypes...>;
MultiBase(PolicyT1 &&policyt1, PolicyTypes &&...policytypes)
: UWrap<nr, PolicyT1>(std::forward<PolicyT1>(policyt1)),
MultiBase<nr + 1, PolicyTypes...>(std::forward<PolicyTypes>(policytypes)...)
{}
};
MultiBase
的构造函数简单地接收那些最初传递给 Multi
对象的初始化值。使用完美转发来完成这一过程。MultiBase
的构造函数将第一个参数值传递给其 UWrap
基类,也使用了完美转发。
支持模板
Multi
类模板定义了 PlainTypes
作为包含其所有参数包类型的 TypeList
。每个从 UWrap
类型派生的 MultiBase
也定义了一个 Type
类型,表示用于定义 UWrap
类型的策略类型,以及一个 Base
类型,表示其嵌套 MultiBase
类的类型。
这三种类型定义使我们能够访问从 Multi
对象创建的类型及其值。
类模板 typeAt
是一个纯模板元编程类模板(它没有运行时可执行代码)。它接受一个 size_t idx
模板参数,指定在 Multi
类型对象中的策略类型的索引,以及一个 Multi
类类型。它定义了 Type
类型作为 Multi
的 MultiBase<idx, ...>
基类定义的类型。例如:
typeAt<0, Multi<Vector, int, double>>::Type // Type 是 vector<int>
类模板 typeAt
定义(并使用)一个嵌套的类模板 PolType
来完成所有工作。PolType
的通用定义指定两个模板参数:一个用于指定请求类型的索引,一个是由 MultiBase
类型参数初始化的 typename
。PolType
的递归定义递归地减少其索引非类型参数,将 MultiBase
继承树中的下一个基类传递给递归调用。由于 PolType
最终将 Type
定义为请求的策略类型,因此递归定义将其 Type
定义为递归调用定义的类型。最终的(非递归)特化定义了 MultiBase
类型的初始策略类型作为 Type
。以下是 typeAt
的定义:
template <size_t index, typename Multi>
class typeAt
{
template <size_t idx, typename MultiBase>
struct PolType;
template <size_t idx, size_t nr, typename PolicyT1, typename ...PolicyTypes>
struct PolType<idx, MultiBase<nr, PolicyT1, PolicyTypes...>>
{
using Type = typename PolType<idx - 1, MultiBase<nr + 1, PolicyTypes...>>::Type;
};
template <size_t nr, typename PolicyT1, typename ...PolicyTypes>
struct PolType<0, MultiBase<nr, PolicyT1, PolicyTypes...>>
{
using Type = PolicyT1;
};
public:
typeAt(typeAt const &) = delete;
using Type = typename PolType<index, typename Multi::Base>::Type;
};
使用 plainTypeAt
类模板也可以检索由 Multi
参数包指定的类型。例如:
plainTypeAt<0, Multi<Vector, int, double>>::Type // Type 是 int
类模板 plainTypeAt
使用与 typeAt
相似(但更简单)的实现。它也是一个纯模板元编程类模板,定义了一个嵌套的类模板 At
。At
的实现方式类似于 typeAt
,但它访问原始模板包中的类型,这些类型传递给 Multi
并通过 Multi
的 PlainTypes
类型提供。以下是 plainTypeAt
的定义:
template <size_t index, typename Multi>
class plainTypeAt
{
template <size_t idx, typename List>
struct At;
template <size_t idx, typename Head, typename ...Tail>
struct At<idx, TypeList<Head, Tail...>>
{
using Type = typename At<idx - 1, TypeList<Tail...>>::Type;
};
template <typename Head, typename ...Tail>
struct At<0, TypeList<Head, Tail...>>
{
using Type = Head;
};
public:
plainTypeAt(plainTypeAt const &) = delete;
using Type = typename At<index, typename Multi::PlainTypes>::Type;
};
可以说,最巧妙的支持模板是 get
。这是一个函数模板,定义了 size_t idx
作为第一个模板参数,以及 typename Multi
作为第二个模板参数。函数模板 get
定义了一个函数参数:对 Multi
的引用,以便可以自行推断 Multi
的类型。知道这是一个 Multi
对象,我们推理它也是一个 UWrap<nr, PolicyType>
,因此也是一个 PolicyType
,因为后者类被定义为 UWrap
的基类。
由于类类型对象可以初始化对其基类的引用,因此 PolicyType &
可以由适当的 UWrap
引用初始化,而 UWrap
引用可以由 Multi
对象初始化。由于我们可以使用 typeAt
确定 PolicyType
(注意评估 typename typeAt<idx, Multi>::Type
是一个纯编译时的操作),因此 get
函数可以非常简单地通过单个返回语句实现:
template <size_t idx, typename Multi>
inline typename typeAt<idx, Multi>::Type &get(Multi &multi)
{
return static_cast<UWrap<idx, typename typeAt<idx, Multi>::Type> &>(multi);
}
中间的 UWrap
转换是为了消除相同策略类型(如两个 vector<int>
类型)之间的歧义。由于 UWrap
是通过其 nr
模板参数唯一确定的,因此可以轻松避免歧义。
使用 Multi
现在 Multi
和其支持模板已经开发完成,我们该如何使用 Multi
呢?需要提醒的是,为了减少开发的类的大小,Multi
的设计是极简的。例如,get
函数模板不能用于 Multi
的 const
对象,并且 Multi
类型没有默认构造函数或移动构造函数。Multi
的设计旨在展示模板元编程的一些可能性,希望 Multi
的实现能很好地服务于这个目的。但它真的能使用吗?如果可以,那么怎么使用呢?
本节提供了一些注释示例。这些示例可以连接起来,定义一系列语句,放置在 main
函数的主体中,从而得到一个可工作的程序。
-
可以定义一个简单的
Policy
:template <typename Type> struct Policy { Type d_type; Policy(Type &&type) : d_type(std::forward<Type>(type)) {} };
Policy
定义了一个数据成员,可以用来定义Multi
对象:Multi<Policy, std::string> ms{ Policy<std::string>{ "hello" } }; Multi<Policy, std::string, std::string> ms2s{ Policy<std::string>{ "hello" }, Policy<std::string>{ "world" } }; using MPSI = Multi<Policy, std::string, int>; MPSI mpsi{ std::string{ "hello" }, 4 };
-
要获得
Multi
类或对象中定义的类型数量,可以使用::size
枚举值(使用Multi
类)或.size
成员(使用Multi
对象):std::cout << "There are " << MPSI::size << " types in MPSI\n" << "There are " << mpsi.size << " types in mpsi\n";
-
可以使用
plainTypeAt
定义构成类型的变量:plainTypeAt<0, MPSI>::Type sx = "String type"; plainTypeAt<1, MPSI>::Type ix = 12;
-
可以使用原始静态转换来获得构成类型:
std::cout << static_cast<Policy<std::string> &>(mpsi).d_type << '\n' << static_cast<Policy<int> &>(mpsi).d_type << '\n';
-
然而,当模板参数包包含相同的类型时,这种方法不起作用,因为转换不能区分相同的
Policy<Type>
类型。在这种情况下,get
函数仍然有效:using MPII = Multi<Policy, int, int>; MPII mpii{ 4, 18 }; std::cout << get<0>(mpii).d_type << ' ' << get<1>(mpii).d_type << '\n';
-
以下是将
std::vector
包装在Vector
中的示例:using MVID = Multi<Vector, int, double>; MVID mi{ {1, 2, 3}, {1.2, 3.4, 5.6, 7.8} };
-
可以通过
Multi
类型定义这样的向量:typeAt<0, Multi<Vector, int>>::Type vi = {1, 2, 3};
-
知道
Vector
是std::vector
之后,get
返回的引用支持索引运算符,可以用作左值或右值操作数:std::cout << get<0>(mi)[2] << '\n'; get<1>(mi)[3] = get<0>(mi)[0]; std::cout << get<1>(mi)[3] << '\n';
表达式模板
假设我们正在处理 std::vector
对象。向量之间可以相互赋值,但除此之外没有其他操作。我们已经看到(参见第12.4.2节),其成员函数通常对当前向量进行操作,但算术运算如加法、减法、乘法等不能应用于一对向量。
实现向量的加法运算符并不困难。如果 VecType
是我们的向量类型,那么实现像 VecType &&operator+(VecType const &lhs, VecType const &rhs)
和 VecType &&operator+(VecType &&lhs, VecType const &rhs)
这样的自由函数来进行加法运算是一个简单的练习(参见第11章)。
现在考虑一个表达式,如 one + two + three + four
。计算这个和需要四个步骤:首先计算 tmp = one
,创建最终的返回值。向量 tmp
成为最终的返回值。一旦它可用,就计算 tmp += two
,然后是 tmp += three
,最后是 tmp += four
(当然,我们不应该实现 std::vector::operator+=
,因为 std
命名空间对我们来说是不可接触的,我们也不应该从 std::vector
派生出一个类提供 operator+=
,根据 Liskov 替代原则(参见第14.7节),但我们可以绕过这一点)。
这里我们简单地假设 operator+=
是可用的。
下面是我们可能如何实现 VecType
的 operator+=
:
VecType &VecType::operator+=(VecType const &rhs) {
for (size_t idx = 0, end = size(); idx != end; ++idx)
(*this)[idx] += rhs[idx];
return *this;
}
考虑这种实现:一旦我们对 VecType
对象进行加法运算,且这些对象有 N
个元素,我们就必须执行 2 * N
次索引评估。当添加 k
个 VecType
对象时,这将累积到 2 * N * k
次索引表达式评估(因为最终我们还需要将结果临时对象的元素赋值给目标对象):很多索引表达式评估。
如果我们能够实现“逐行”评估,那么我们只需访问每个向量元素一次(这特别适用于临时对象)。在这种情况下,当添加 k
个对象时,将它们各自元素的和赋值给目标向量,我们需要计算 N * (k + 1)
次索引表达式(k
次用于每个向量,1
次用于目标向量)。
对于 k == 1
的情况,两种方法在索引计算方面的效率相同。但这不是加法,而是赋值。因此,当添加任意数量的向量时,使用表达式模板将它们的和赋值给目标向量比普通的加法运算符实现更高效。我们将在接下来的章节中查看表达式模板的设计和实现。
设计表达式模板
正如我们所看到的,当使用标准的实现来处理像 one + two + three + four
这样的表达式时,如果这些对象是具有 n
个元素的向量,那么如果有 k
个向量,我们需要执行总共 k * 2 * n
次索引评估。
表达式模板允许我们避免许多这样的评估。在使用表达式模板时,这些模板可能会访问向量,但在加法操作过程中不会实际访问它们的元素。
假设我们的表达式模板名为 ET
,如果我们想要计算 one + two + three
,那么第一个 +
运算符仅仅创建 ET(one, two)
。注意,此时并不会实际执行加法操作,ET
仅仅存储对 one
(成为 ET
的 lhs
数据成员)和 two
(成为 ET
的 rhs
数据成员)的(常量)引用。一般而言,ET
存储对传递给其构造函数的两个参数的引用。
在下一个加法操作时,将创建另一个 ET
。其构造函数的参数分别是刚刚为 one
和 two
构造的 ET
对象,以及向量 three
。同样,ET
对象不会执行任何加法操作。
这种算法可以很容易地推广到任意数量的向量。括号也可以使用。例如,(one + two) + (three + four)
结果为 ET(ET(one, two), ET(three, four))
。
假设在某个时刻我们想要获得向量的和。为此,表达式模板提供了一个转换运算符,将 ET
对象转换为向量,或者可能提供一个赋值运算符来完成相同的操作。
转换运算符看起来是这样的:
operator ET::VecType() const {
VecType retVal;
retVal.reserve(size());
for (size_t ix = 0, end = size(); ix != end; ++ix)
new(&retVal[ix]) value_type((*this)[ix]);
return retVal;
}
这里使用了定位 new
以提高效率:没有必要先用默认值初始化 retVal
。真正有趣的部分隐藏在 (*this)[idx]
表达式后面:在这一点上,真正的加法发生了。
ET
的索引运算符简单地将其 lhs
和 rhs
数据成员的相应索引表达式返回的值相加。如果一个数据成员引用了一个向量,则使用对应的向量元素,将其加到其他数据成员的值上。如果一个数据成员本身引用了一个 ET
对象,则该嵌套的 ET
对象的索引运算符在其自身的数据成员上执行相同的加法,返回它们的和。因此,像 (*this)[0]
这样的表达式会返回 first[0] + second[0] + third[0]
,然后将计算出的和存储到 retVal[0]
中,使用定位 new
。
在这种情况下,所需的索引表达式评估次数是 n * k
(对于 k
个向量的 n
个元素)加上 n
(对于 retVal
的 n
个元素,总共是 (k + 1) * n
)。
由于 (k + 1) * n < 2 * k * n
对于 k > 1
,表达式模板比传统的 operator+
实现更高效。使用表达式模板的另一个好处是,当使用带括号的表达式时,它们不会创建额外的临时向量对象。
实现表达式模板
在本节中,我们以 IntVect = std::vector<int>
为例,说明如何构建一个表达式模板。
起点是一个简单的 main
函数,其中添加了几个 IntVect
对象。例如:
int main() {
IntVect one;
IntVect two;
IntVect three;
IntVect four;
// ... 假设这些 IntVect 对象以某种方式接收值
four = one + two + three + four;
}
在这一点上,代码并没有显示出将使用表达式模板。然而,operator+
的实现是特殊的:它是一个模板,仅返回一个由 operator+
构造的对象:
template<typename LHS, typename RHS>
BinExpr<LHS, RHS, std::plus> operator+(LHS const &lhs, RHS const &rhs) {
return BinExpr<LHS, RHS, std::plus>{ lhs, rhs };
}
我们的表达式模板称为 BinExpr
。它有三个模板类型参数:两个对象类型和一个模板模板参数,用于执行请求的操作。它的声明如下:
template<typename LHS, typename RHS, template<typename> class Operation>
struct BinExpr;
由于 LHS
和 RHS
可以是由表达式模板处理的数据类型,或是 BinExpr
,因此需要两个不同的类型。Operation
是表达式模板执行的操作。通过使用模板模板参数,我们可以使用 BinExpr
执行任何我们想要的操作,而不仅仅是加法。像 std::plus
这样的预定义函数模板可以用于标准的算术运算符;对于其他操作符,我们可以定义自己的函数模板。
BinExpr
的构造函数初始化了对 lhs
和 rhs
的常量引用。它的类内实现如下:
BinExpr(LHS const &lhs, RHS const &rhs)
: d_lhs(lhs), d_rhs(rhs) {}
为了检索结果的 IntVect
,定义了一个转换运算符。我们在前一节中已经遇到过它的实现。这里是它作为 BinExpr
成员的类内实现:
operator ObjType() const {
ObjType retVal;
retVal.reserve(size());
for (size_t idx = 0, end = size(); idx != end; ++idx)
new(&retVal[idx]) value_type((*this)[idx]);
return retVal;
}
我们稍后会回到 ObjType
类型。在此时,它可以被认为是 IntVect
。成员函数 size()
仅仅返回 d_lhs.size()
:在任何 IntVect
相加的序列中,LHS
最终是一个 IntVect
,因此每个 BinExpr
定义了一个有效的 size()
,如下所示:
size_t size() const {
return d_lhs.size();
}
唯一剩下的成员需要实现的是 operator[]
。由于它接收一个索引,它只需要对 d_lhs
和 d_rhs
数据成员的对应索引元素执行请求的操作。表达式模板的美妙之处在于,如果其中任何一个自身是 BinExpr
,则该表达式模板会调用其 operator[]
,最终在所有 IntVect
对象的所有对应元素上执行请求的操作。它的实现如下:
value_type operator[](size_t ix) const {
static Operation<value_type> operation;
return operation(d_lhs[ix], d_rhs[ix]);
}
这个实现使用了另一个类型:value_type
,它是由表达式模板处理的向量类型的元素类型。像之前的 ObjType
,它的定义会在下面进行说明。静态数据成员 operation
只是指定在构造 ExprType
对象时的 Operation
类型的实例化。
在下一节中,我们将更详细地讨论 ObjType
和 value_type
。
基本类型特征类和排序类
BinExpr
表达式模板在实例化对象之前需要了解两种类型。首先,必须知道 ObjType
,因为这是由表达式模板处理的对象类型。ObjType
对象包含值,我们要求这些值的类型可以被确定为 ObjType::value_type
。例如,对于我们的 IntVect
数据类型,value_type
是 int
。
在像 one + two + three
这样的表达式中,BinExpr
表达式模板接收两个 IntVect
对象。这总是正确的:首先构造的 BinExpr
接收两个 IntVect
对象。在这种情况下,ObjType
就是 LHS
,并且 ObjType::value_type
也可以得到:要么 value_type
已经由 LHS
定义,要么 BinExpr
要求它定义 type value_type
。
由于 BinExpr
对象的参数不总是基本的 ObjType
类型(BinExpr
对象在下一个嵌套级别中至少接收一个 BinExpr
参数),我们需要一种方法来从 BinExpr
中确定 ObjType
。为此,我们使用特征类 BasicType
。BasicType
接收一个 typename
模板参数,并将其类型 ObjType
等同于接收到的模板类型参数:
template<typename Type>
struct BasicType
{
using ObjType = Type;
};
一个特化处理了 Type
实际上是 BinExpr
的情况:
template<typename LHS, typename RHS, template<typename> class Operation>
struct BasicType<BinExpr<LHS, RHS, Operation>>
{
using ObjType = typename BinExpr<LHS, RHS, Operation>::ObjType;
};
由于 BinExpr
要求 ObjType::value_type
是一个已定义的类型,value_type
已经自动处理好了。由于 BinExpr
引用 BasicType
,而 BasicType
又引用 BinExpr
,我们必须提供一个前向声明。由于 BinExpr
的声明已经提供,因此我们可以从该声明开始,得到以下结果:
template<typename LHS, typename RHS, template<typename> class Operation>
class BinExpr
{
LHS const &d_lhs;
RHS const &d_rhs;
public:
using DataType = BasicType<RHS>::DataType;
using value_type = DataType::value_type;
// 所有 BinExpr 成员函数
};
概念(Concepts)
C++ 是一种强类型语言:函数 add(int lhs, int rhs)
不接受 std::string
类型的参数,尽管 lhs + rhs
的实际操作在 int
和 string
中是相同的。
模板的引入是为了设计编译器的“配方”,允许它在保持类型安全的同时构建类型安全的函数和类的重载版本。
一个基本的加法函数模板如下:
template <typename Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
当这个函数模板被调用时,如果参数类型不支持 operator+
,编译器会注意到这一点并产生错误。例如,当调用:
add(std::cerr, std::cout);
时,g++ 编译器会产生大约 140 行的错误信息。编译器注意到 std::ostream
对象没有 operator+
,并告诉我们可能的正确用法(比如两个 int
的相加),以及函数模板的定义出了问题。实际上,140 行错误信息已经算是比较少的了,几百行的错误信息也很常见,有时错误的位置没有在顶部,而是在错误信息的末尾。
C++23 标准引入了概念(concepts),允许我们为模板类型指定要求。通过将合适的概念应用于 add
函数模板的定义,编译器可以立即指出错误,告诉我们错误发生的位置和原因,从而将错误信息减少到 15 行左右,而不是 140 行。
减少错误信息的行数本身就是一种好处。但概念允许我们有意识地开发模板,明确其使用的具体要求,这同样重要:它提高了模板的文档化,从而改善了对模板的理解。
概念可以被看作是模板对强类型语言哲学的回答。通过将概念应用于模板,我们可以指定类型要求,而不是使用传统的“散弹式经验主义”方法,即直接使用模板,知道编译器会在出错时提示我们。从这个意义上说,概念提供了类型的定义。概念有名称,并且可以在模板头文件中使用,其中概念名称取代了传统的 typename
关键字。
作为一个开端示例,假设存在一个名为 Addable
的概念,指定 operator+
必须为模板的类型定义。上面的函数模板 add
现在可以表述为:
template<Addable Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
从现在开始,传递给 add
的每种类型都必须满足 Addable
的要求。
以下是使用 add
的两个表达式:
add("first"s, "second"s); // (1)
add(map<int, int>{}, map<int, int>{}); // (2)
表达式 (1) 可以顺利编译,因为 string
对象可以相加;而表达式 (2) 编译失败,编译器报告类似以下错误信息:
error: use of function `Type add(const Type&, const Type&)
[with Type = std::unordered_map<int, int>]' with unsatisfied constraints
add(unordered_map<int, int>{}, unordered_map<int, int>{});
note: constraints not satisfied
Type add(const Type&, const Type&)
...
note: the required expression `(lhs + rhs)' is invalid
错误信息的最终“note”明确指出了问题的原因:你不能对 map
进行相加。
使用概念和不使用概念的编译器报告差异是显著的。当使用传统的 typename Type
规范时,编译器会生成大约 17 KB 的错误信息,分布在超过 200 行的错误信息中。
在接下来的部分,我们将介绍如何定义概念,能够提出什么样的要求,以及它们在实际中的使用方法。
定义概念(Concepts)
在实际查看如何定义概念之前,需要注意的是,概念名称应该像类名、类型名、函数名和变量名一样,能够暗示其用途。不要将概念命名为 Constraint
或 Concept
,而是使用像 Addable
和 HasValueType
这样的名称。
概念是模板。它们以模板头开始(模板头部的示例定义了单个模板类型参数,但也可以使用多个模板参数)。在前面的部分中,我们使用了概念 Addable
。下面是如何定义它的示例:
template <typename Type>
concept Addable =
requires(Type lh, Type rh)
{
lh + rh;
};
概念的模板头后跟 concept
关键字、概念名称和赋值操作符。赋值操作符后面是要求的规格说明。概念定义以分号结束。这个概念使用了一个简单的要求(参见第 23.13.2.1 节),表明 operator+
必须为 Addable
模板的类型定义。
要求有多种形式。一种非常简单的形式是仅包含一个布尔值,这在开发概念时有时是有用的。这样的概念如下:
template <typename Type>
concept IsTrue =
true;
但在大多数情况下,会使用 requires
规格说明。它们类似于具有参数列表的函数定义,参数列表可以选择性地定义概念模板头部中指定的类型的变量,并且复合语句中指定要求。
概念从未被实例化。它们在编译时用于验证模板类型是否满足施加的要求。因此,不需要在 requires
规格说明的参数列表中使用引用。概念 Addable
只是使用了:
requires(Type lh, Type rh)
而不需要指定:
requires(Type const &lh, Type const &rh)
(通常不需要这样做。在第 23.13.2.4 节中,我们将遇到一个情况,在这种情况下,可能需要更具体的参数定义。)
以下是两个使用概念 Addable
的模板示例。第一个示例在指定模板头时使用了 Addable
,第二个示例将概念规格说明附加到模板头本身:
template<Addable Type>
Type add2(Type const &x, Type const &y)
{
return x + y;
}
template<typename Type>
requires Addable<Type>
Type add(Type const &x, Type const &y)
{
return x + y;
}
使用概念的模板声明也相应地指定。只需将函数模板的主体替换为分号即可。
概念也可以通过扩展或组合现有的概念来定义。概念的嵌套将在第 23.13.2.4 节中讨论。
尽管概念是模板,但它们不能被特化。如果一个概念应该识别特化,则这些特化必须通过概念的定义来处理。有关示例,请参见第 23.13.2.3 节。
Requirements
requires
声明的主体包含应用于模板参数的约束条件。rrequirement有四种类型:
- 简单requirements:对功能的约束(例如,要求
operator+
的可用性); - 类型requirements:requirement存在(子)类型(例如,
value_type
子类型),这些类型必须在使用标准的push_back
函数时可用; - 复合requirements:requirement通过应用运算符或调用(成员)函数返回的类型;
- 嵌套requirements:将概念定义为现有概念的组合。
约束条件必须在编译时可验证。
当指定多个约束时,所有约束都必须在编译时可验证,并且只有当所有requirement都得到满足时,实际类型才会被编译器接受。
简单requirements
我们已经遇到过各种简单requirements的示例:它们指定了 requires
规格说明中的变量必须支持的操作。当要求涉及单个变量时,使用单一的 Type
参数即可;当要求涉及不同类型时,概念的模板头声明这些不同的类型,requires
参数列表通常会定义这些不同类型的变量。概念 BasicMath
指定了两个类型,并使用四个简单要求来指定四种基本的算术操作:
template <typename LhsType, typename RhsType>
concept BasicMath =
requires(LhsType lhs, RhsType rhs)
{
lhs + rhs; // 必须支持加法
lhs - rhs; // 必须支持减法
lhs * rhs; // 必须支持乘法
lhs / rhs; // 必须支持除法
};
指定约束条件并不一定意味着这些约束条件在运行时的情况中会字面地应用。例如,为了要求存在索引操作符,可以使用以下简单requirements:
template <typename Type>
concept HasIndex =
requires(Type tp)
{
tp[0]; // 索引操作符必须可用
};
template <HasIndex Type>
auto idx(Type const &obj, size_t idx)
{
return obj[idx];
}
在这里,参数 0
用于指定索引操作符的参数。虽然在简单requirements规格说明中使用了参数 0
,但实际上使用的是参数 5
:
std::string str;
idx(str, 5);
除了 int
类型的索引外,还可以使用类似的方式指定其他类型的索引。以下是一个示例,展示如何定义和使用requirement std::string
索引操作符的概念 HasStrIndex
:
template <typename Type>
concept HasStrIndex =
requires(Type tp)
{
tp[std::string{}]; // 索引操作符必须支持 std::string 类型
};
template <HasStrIndex Type>
auto value(Type &obj, std::string const &key)
{
return obj[key];
}
int main(){
std::map<std::string, double> msd;
value(msd, "hi"); // 使用 std::string 作为索引
}
复合要求
当操作的返回类型必须满足某些要求时,应使用复合要求。复合要求定义了对表达式的类型约束,这些表达式嵌入在复合语句中。C++23 标准定义了几个可以用于指定此类要求的概念(参见第 23.13.3 节)。以下是一个示例:
template <typename Type, typename ReturnType>
concept Return =
requires(Type par)
{
// par[..] 必须返回 `ReturnType`
{ par[0] } -> std::same_as<ReturnType>;
};
现在可以使用这个概念来指定模板类型参数的要求。例如:
template <typename Type, typename RetType>
requires Return<Type, RetType>
RetType fun(Type tp)
{
return tp[0];
}
在这里,传递给 fun
的参数必须满足两个要求:
- 必须提供一个接受整数参数的索引操作符;
- 其索引操作符必须返回
std::string
类型的值。
你可能注意到 std::same_as
概念只接收一个模板类型参数,它(仿佛神奇般地)将其与 par[0]
表达式返回的类型进行比较。当查看第 23.13.3 节中可用的概念时,你会发现其中一些概念实际上定义了两个模板类型参数。当在复合要求中使用这些概念时,编译器将复合表达式的类型(即上述示例中的 par[0]
的类型)传递给概念的第一个类型,将显式指定的类型传递给概念的第二个类型。
知道这一点后,我们可以定义自己的概念以用于复合表达式。我们可以像下面这样定义自己的 same_as
概念,使用一个单独的类模板 SameTypes
。SameTypes
定义了一个布尔值 value
,用于决定概念的要求。类模板 SameTypes
使用一个特化来处理两个类型相等的情况。请注意,概念本身不能被特化:
template <typename Lhs, typename Rhs>
struct SameTypes // 通用:任意两个类型
{
static bool const value = false;
};
template <typename Lhs>
struct SameTypes<Lhs, Lhs> // 特化:相等的类型
{
static bool const value = true;
};
template<typename Compound, typename Specified>
concept Same = SameTypes<Compound, Specified>::value;
现在可以使用 Same
概念代替 std::same_as
,只需指定所需的类型:
template <typename Type, typename ReturnType>
concept Return =
requires(Type par)
{
// par[..] 必须返回 `ReturnType`
{ par[0] } -> Same<ReturnType>;
};
虽然在这种情况下实际使用哪个类型作为概念类型参数的参数并不重要,但编译器将复合表达式的类型指定为 Same
的 Compound
参数的模板参数,而 ReturnType
被用作 Same
的 Specified
参数的模板参数。
可以通过提供多个复合要求来指定多个类型要求,如以下示例所示:
template <typename Type>
concept MultiArgs =
requires(Type lhs, Type rhs)
{
{ lhs + rhs } -> std::same_as<Type>;
{ lhs += rhs } -> std::same_as<Type &>;
{ lhs.c_str() } -> std::same_as<char const *>;
};
如果需要确保复合操作不会抛出异常,则可以在复合要求的延迟返回类型箭头(->
)之后立即写上 noexcept
。noexcept
规范本身可以选择性地跟随一个类型约束。
最后,延迟返回类型规格说明本身是可选的,在这种情况下,复合要求就像简单要求一样:它要求存在复合语句中指定的表达式。在这种情况下,不要忘记在复合要求的闭括号后添加分号:
template <typename Type>
concept Increment =
requires(Type par)
{
{ ++par };
// 同等于:
++par;
};
嵌套要求
概念可以嵌套。能够嵌套概念非常有用,因为它允许我们按层次结构组织概念,并在现有概念的基础上定义新概念。
在第 18 章中介绍了迭代器(第 18.2 节)。通常区分五种概念上不同的迭代器类型:
- 输入迭代器是可递增的,并且它们支持对常量值的解引用;
- 输出迭代器类似于输入迭代器,但它们引用非常量值;
- 前向迭代器结合了输入和输出迭代器的特点;
- 双向迭代器类似于前向迭代器,但它们还支持递减操作符;
- 随机访问迭代器类似于双向迭代器,但这些迭代器还支持任意步长的加法和减法。
所有迭代器类型都支持(不等)检查和递增操作符。因此,在所有迭代器的基础上,我们可以发现迭代器必须是可比较和可递增的。覆盖这些要求的概念可以轻松构建(参见图 23.2):
template <typename Type>
concept Comparable =
requires (Type lhs, Type rhs)
{
lhs == rhs;
lhs != rhs;
};
template <typename Type>
concept Incrementable =
requires (Type type)
{
++type;
type++;
};
请注意,在 lhs == rhs
和 lhs != rhs
的要求之后没有指定类型,因为这些类型由操作符隐含。
再定义两个概念,一个允许解引用指针返回常量引用,另一个返回可修改的引用。为了允许编译器验证这些要求,我们还隐式要求存在(通常遇到的)typename Type::value_type
:
template <typename Type>
concept Dereferenceable =
requires(Type type)
{
{ *type } -> std::same_as<typename Type::value_type &>;
};
template <typename Type>
concept ConstDereferenceable =
requires(Type type)
{
{ *type } -> std::same_as<typename Type::value_type const &>;
};
到目前为止的层次结构不多,但这在我们定义迭代器概念时将发生变化。输入迭代器是一个可比较、可递增并且可常量解引用的迭代器。为每个这些要求定义了概念,可以使用布尔运算符将它们组合起来定义 InIterator
概念。请注意,概念的模板参数必须使用 typename
关键字。概念的模板参数不能通过将它们定义为现有概念来限制(这在定义函数和类模板时是可能的)。
以下是 InIterator
概念的定义。函数模板 inFun
(在 InIterator
概念下方)演示了如何在模板头中指定约束的模板参数类型:
template <typename Type>
concept InIterator =
Comparable<Type> and Incrementable<Type> and
ConstDereferenceable<Type>;
template <InIterator Type>
void inFun(Type tp)
{}
输出迭代器的概念(及其用法,如函数模板 outFun
中)类似地定义。这次要求解引用类型,而不是常量解引用类型:
template <typename Type>
concept OutIterator =
Comparable<Type> and Incrementable<Type> and
Dereferenceable<Type>;
template <OutIterator Type>
void outFun(Type tp)
{}
对于前向迭代器,定义了 FwdIterator
概念。前向迭代器结合了输入和输出迭代器的特性,我们可以通过要求 InIterator
和 OutIterator
概念的要求来定义前向迭代器。
然而,有一个小问题。以下类(结构体)定义了常量和非常量解引用操作符,因此可以传递给期望输入或输出迭代器的函数:
struct Iterable
{
using value_type = int;
Iterable& operator++();
operator++(int);
int const & operator*() const;
int & operator*();
};
bool operator==(Iterable const & lh, Iterable const & rh);
bool operator!=(Iterable const & lh, Iterable const & rh);
int operator-(Iterable const & lh, Iterable const & rh);
但当函数模板要求 ConstDereferenceable
参数时,编译器会注意到重载的成员 int &operator*()
不返回 int const &
。即使 int const &operator*() const
是可用的,编译仍会失败。这个问题可以通过两种方式解决:注意到 int &
可以转换为 int const &
,可以使用预定义的概念 std::convertible_to
代替 std::same_as
在 ConstDereferenceable
中;或者其 requires
子句可以指定 Type const &type
而不是仅 Type type
。以下是 ConstDereferenceable
的定义,当定义 FwdIter
时可以与 Dereferenceable
组合使用:
template <typename Type>
concept ConstDereferenceable =
requires(Type const &type)
{
{ *type } -> std::same_as<typename Type::value_type const &>;
};
最后两种迭代器类型没有问题:BiIterator
概念要求 FwdIterator
概念的约束以及递减操作符,最终 RndIterator
概念要求 BiIterator
的约束,并且要求迭代器对任意步长的递增递减和迭代器减法:
template <typename Type>
concept BiIterator =
FwdIterator<Type> and
requires(Type type)
{
--type;
type--;
};
template <typename Type>
concept RndIterator =
BiIterator<Type> and
requires(Type lhs, Type rhs)
{
lhs += 0;
lhs -= 0;
lhs + 0;
lhs - 0;
{ lhs - rhs } -> std::same_as<int>;
};
预定义概念
在上一节中,我们讨论了如何定义概念要求。在指定一些要求时,我们已经使用了诸如 std::same_as
等现有概念。C++23 标准提供了大约 30 个预定义概念,可以用来指定类型要求、转换要求以及更复杂的要求,有时接受可变参数模板。以下小节将介绍当前定义的预定义概念。
指定一个模板类型参数的概念
以下概念只指定一个模板类型参数。它们的通用形式是:
template <typename Type>
concept Name =
... requirements ...
;
在复合要求中使用时,只需指定它们的名称。例如(使用 std::boolean
概念),要要求一个接受某种类型 Type
的函数 fun
返回一个布尔值,可以定义如下概念:
template<typename Type>
concept BoolFun =
requires(Type param)
{
{ fun(param) } -> std::boolean;
};
以下是一些预定义概念及其要求:
- boolean: 要求其类型可以用于布尔表达式。
- copy_constructible: 要求其类型的对象支持拷贝构造和移动构造。
- copyable: 要求其类型的对象支持拷贝构造、移动构造和赋值,并且可以交换两个对象。
- default_initializable: 要求其类型的对象支持默认构造函数。
- destructible: 要求其类型的析构函数被定义为
noexcept(true)
。 - equality_comparable: 要求提供
operator==
以比较两个相同类型的对象。 - floating_point: 要求其类型是浮点类型。
- integral: 要求其类型是整型。
- movable: 要求其类型支持移动和交换。完全支持移动需要类型支持移动构造和移动赋值。
- move_constructible: 要求其类型支持移动构造。
- regular: 要求其类型满足
semiregular
和equality_comparable
概念的要求。 - semiregular: 要求其类型支持默认构造、拷贝、移动和交换。
- signed_integral: 要求其类型是有符号整型。
- swappable: 要求同一类型的两个对象可以交换。通用变体名为
swappable_with
。 - unsigned_integral: 要求其类型是无符号整型。
- totally_ordered: 要求两个相同类型的对象可以使用操作符
==
,!=
,<
,<=
,>
, 和>=
进行排序。排序要求是严格的:对于任何两个对象,要么one < two
,要么one == two
,要么one > two
为真。通用变体名为totally_ordered_with
。
指定两个模板类型参数的概念
以下概念定义了两个模板类型参数。它们的通用形式是:
template <typename LHS, typename RHS>
concept Name =
... requirements ...
;
在复合要求中,编译器会推导出复合表达式的类型,然后将该类型用作 LHS
。在复合语句后的类型要求中,只指定 RHS
类型。例如(使用概念 std::same_as
),要要求一个函数 fun
,接受某种类型 Type
的参数并返回 std::string
,可以定义如下概念:
template<typename Type>
concept StringFun =
requires(Type param)
{
{ fun(param) } -> std::same_as<std::string>;
};
以下是一些预定义概念及其要求:
- assignable_from: 要求
RHS
类型的表达式可以赋值给LHS
类型的表达式。 - common_reference_with: 要求两种类型都可以转换为相同的(引用)类型。这个概念适用于两个完全相同的类型,但也适用于一个类型是另一个类型的派生类型的情况。例如:
template <typename LHS, typename RHS> concept CommonRef = std::common_reference_with<LHS, RHS>; template <typename T1, typename T2> requires CommonRef<T1, T2> void fun(T1 &&t1, T2 &&t2) {} struct {}; struct D1: public B { }; int main() { fun(4, 'a'); fun(4.5, 'a'); D1 d1; B b; fun(b, d1); // 对象,右值引用: fun(D1{}, B{}); // 全部 OK }
- common_with: 其工作方式类似于上一个概念。
- convertible_to: 要求
LHS
类型可以自动转换为RHS
类型:template <typename LHS, typename RHS> concept Convertible = requires(LHS lhs) { { lhs } -> std::convertible_to<RHS>; }; template <typename RHS, typename LHS> requires Convertible<LHS, RHS> void fun(LHS lhs) {} int main(){ // 注意:LHS 是 <...> 的类型 fun<double>(12); // 从 int 到 double fun<int>(12.5); // 从 double 到 int fun<std::string>("a"); // 从 NTBS 到 string fun<std::string>(12); // 不满足约束 }
- derived_from: 要求
LHS
类型是RHS
类型的派生类型。 - equality_comparable_with: 要求
operator==
和operator!=
操作符可以用于比较LHS
和RHS
类型的变量(无论顺序如何)。 - same_as: 要求
LHS
类型与RHS
类型完全相同。请注意,这个概念要求比较严格。例如,std::same_as<long int, int>
不满足要求。如果不需要这么严格的相等性,convertible_to
可能是一个可行的替代方案。 - swappable_with: 要求可能不同类型的两个对象可以交换。更具限制性的变体要求对象类型相同,名为
swappable
。 - totally_ordered_with: 要求可能不同类型的两个对象可以使用操作符
==
,!=
,<
,<=
,>
, 和>=
进行排序。排序要求是严格的:对于任何两个对象,要么one < two
,要么one == two
,要么one > two
为真。更具限制性的变体要求对象类型相同,名为totally_ordered
。
指定多个模板类型参数的概念
大多数期望多个模板参数的预定义概念都是变参的(参见第 23.13.4 节)。
-
constructible_from: 要求
LHS
类型和一个变参模板参数,表示可以用这些类型构造LHS
类型(如果支持默认构造函数,则可以为空)。例如:template <typename LHS, typename ...Args> concept Constructible = std::constructible_from<LHS, Args...>; template <typename T1, typename ...Args> requires Constructible<T1, Args...> T1 fun(Args &&...t2) { return T1(std::forward<Args>(t2)...); } int main() { std::string s{ fun<std::string>(5, 'a') }; std::string s2{ fun<std::string>() }; // 有点奇怪... }
-
equivalence_relation: 该概念定义了三个模板类型参数,是概念
std::relation
的同义词。 -
invocable: 要求
LHS
类型和一个变参模板参数,表示作为invocable
第一个参数指定的函数或函数对象所转发的参数类型。例如:template <typename Function, typename ...Params> void fun(Function &&fun, Params &&...params) requires std::invocable<Function, Params ...> { fun(std::forward<Params>(params)...); } void hello(int value, char const *txt, std::string const &str) { std::cout << value << ' ' << txt << ' ' << str << '\n'; } int main() { fun(hello, 1, "text", "string"); // fun(hello, 1); // 不符合要求 }
-
predicate: 要求
LHS
类型是一个谓词(函数对象或函数返回布尔值),期待变参模板参数RHS
的参数。例如:template <typename Container, typename Predicate> concept Find_ifConcept = std::predicate<Predicate, typename Container::value_type> and requires(Container container) { { container.begin() } -> Iter; { container.end() } -> Iter; { container.size() } -> std::same_as<size_t>; }; template <typename Container, typename Predicate> size_t findIdx_if(Container const &cont, size_t from, Predicate const &pred) requires Find_ifConcept<Container, Predicate> { auto iter = std::find_if(cont.begin() + from, cont.end(), pred); return iter == cont.end() ? cont.size() : iter - cont.begin(); } int main() { std::cout << "Index at " << findIdx_if(std::string{ "hello world" }, 0, [&](int ch) { return ch == ' '; } ) << '\n'; }
-
regular_invocable: 是
invocable
的同义词。 -
relation: 该概念定义了三个模板类型参数。第一个参数是一个谓词,其函数调用操作符期望两个参数。这两个参数的类型是第二和/或第三个模板类型参数(可以是任意组合,任意顺序)。例如,
std::relation
概念的要求可以通过以下方式满足:template <typename LHS, typename RHS> struct Less { bool operator()(LHS lhs, RHS rhs) const { return lhs < rhs; } }; template <template<typename LHS, typename RHS> typename Pred, typename LHS, typename RHS> bool cmp(LHS lhs, RHS rhs) requires std::relation<Pred<LHS, RHS>, LHS, RHS> { return Pred<LHS, RHS>{}(lhs, rhs); } int main() { std::cout << cmp<Less>(5, 4.9) << '\n'; std::cout << cmp<Less>(5, "hello world") << '\n'; // 不符合约束,因为 int 和 NTBS 不能比较 }
-
strict_weak_order: 该概念定义了三个模板类型参数。第一个参数是一个谓词,其函数调用操作符期望两个参数。这两个参数的类型是第二和/或第三个模板类型参数(可以是任意组合,任意顺序)。如果谓词能验证其参数类型应用了严格弱排序,则该概念得到满足。严格弱排序的条件是:
- 谓词对同一对象的比较返回 false;
- 关系是传递的:如果
pred(one, two)
和pred(two, three)
都为真,则pred(one, three)
也为真。
应用概念于模板参数包
正如我们在第 23.13.3.3 节中所见,概念可以处理模板参数包。这些概念被称为变参概念。定义概念保护的变参函数或类模板时,并不总是需要变参概念。考虑以下函数:
template <HasSize ...Types>
void fun(Types &&...obj)
{
sum(std::forward<Types &&>(obj)...);
}
这里我们看到一个变参模板,但通过简单地提到概念 HasSize
,而不是仅仅使用 typename
,来定义所有参数的约束类型。HasSize
概念非常基础:它仅要求类型具有 size()
成员,返回一个 size_t
:
template <typename Types>
concept HasSize =
requires (Types type)
{
{ type.size() } -> std::same_as<size_t>;
};
一旦 fun
函数验证了所有其参数类型都满足 HasSize
约束,则不再需要额外检查。fun
函数模板只是将其参数转发给 sum
,这是一个变参模板,它简单地将所有参数的 size()
成员的返回值相加:
size_t sum()
{
return 0;
}
template <typename First, typename ...Types>
size_t sum(First &&first, Types &&...types)
{
return first.size() + sum(std::forward<Types>(types)...);
}
包装函数 fun
并不是必需的。也可以直接定义变参模板函数来汇总各种 size()
值,并要求这些类型必须满足 HasSize
概念。以下是变参函数模板 sum2
的定义,正好满足这个要求:
size_t sum2()
{
return 0;
}
template <HasSize First, HasSize ...Types>
size_t sum2(First &&first, Types &&...types)
{
return first.size() + sum2(std::forward<Types>(types)...);
}
以下是调用 fun
和 sum2
的 main
函数:
int main()
{
fun(queue<int>{}, vector<int>{}, string{});
std::cout << sum2(queue<int>{}, vector<int>{}, string{}) << '\n';
}
另一方面,预定义概念 std::constructible_from
是一个变参概念,因为它接受一个 LHS
模板参数和一个 RHS
参数包。该概念在 LHS
参数可以从 RHS
参数包中指定的类型构造时得到满足。包含类型特征后,定义和使用这样的概念并不困难:
template <typename Class, typename ...Params>
concept Constructible = std::is_constructible<Class, Params ...>::value;
template <typename Class, typename ...Params>
requires Constructible<Class, Params ...>
void fun(Class &&type, Params &&...params)
{}
编写变参概念的步骤并不复杂:
- 从定义一个指定参数包的模板头开始。
- 将参数传递给处理该参数包的类型特征。
要在函数或类模板中使用变参概念,只需将其模板参数简单地转发给概念(如上例所示)。
当没有预定义的变参类型特征可用时,变参概念必须使用其他方法来确定其约束是否得到满足。在这些情况下,定义自己的变参类型特征。为了说明,假设我们需要一个变参概念,用于验证传递给变参函数模板的所有参数是否都是整数类型。在这种情况下,我们需要自己定义类型特征。我们将 IntegralOnly
概念定义为一个变参概念,使用自定义类型特征 allIntegralTypes
,然后在定义一个要求所有参数都是整数值的函数时使用它:
template <typename ...Types>
concept IntegralOnly = allIntegralTypes<Types ...>::value;
template <IntegralOnly ...Types>
void fun(Types ...types)
{}
通用类型特征 allIntegralTypes
只是指定接受任意数量的类型参数,并使用特化来处理特定情况。一个特定的情况是没有指定类型,这仅定义一个 true
的静态布尔常量值:
template <typename ...Types>
struct allIntegralTypes;
template <>
struct allIntegralTypes<>
{
static bool const value = true;
};
类型特征的部分特化完成了主要工作:它确定第一个类型是否是整数,并将其与 allIntegralTypes
结构处理剩余类型的值结合(使用 and
):
template <typename First, typename ...Types>
struct allIntegralTypes<First, Types ...>
{
static bool const value = std::is_integral<First>::value and
allIntegralTypes<Types ...>::value;
};
现在,函数 fun
可以用任何数量的参数进行调用。只要参数是整数类型,编译就会成功,fun
就可以安全地执行其工作。
将概念应用于自由函数
概念通常与类一起使用,但它们也可以与普通的函数模板结合使用,以限制其参数类型。一旦概念应用于一个函数,该函数自动成为一个函数模板。通常情况下,这是明确的,因为使用了模板头,但一个函数也可以在没有提供模板头的情况下定义受限的参数类型。
为了说明在定义函数模板时可以使用概念的各种方式,以下示例使用了概念 Addable
(参见第 23.13.1 节)。
-
要求可以直接在模板头后面指定:
template <typename Type> requires Addable<Type> auto add(Type const &lhs, Type const &rhs) { return lhs + rhs; }
-
要求也可以直接在函数头后面指定:
template <typename Type> auto add(Type const &lhs, Type const &rhs) requires Addable<Type> { return lhs + rhs; }
这些变体允许我们以最灵活的方式指定要求。例如,如果参数还应该是整数值,那么仅 Addable
要求是不够的,我们还需要 std::integral
要求,这将导致如下函数定义:
template <typename Type>
requires Addable<Type> and std::integral<Type>
auto add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
(这也可以使用尾随的 requires
规范。)
如果 Addable
概念完全覆盖了参数的要求,则可以使用以下简化的定义:
-
模板头使用概念名称而不是
typename
:template <Addable Type> auto add(Type const &lhs, Type const &rhs) { return lhs + rhs; }
-
概念名称本身作为参数类型。注意,在这种形式中,模板头未被使用,关键字
auto
跟随概念的名称:auto
通知编译器Addable
不是普通类型的名称,而是概念的名称:auto add(Addable auto const &lhs, Addable auto const &rhs) { return lhs + rhs; }
实现受限的类成员
在定义类模板的成员函数时,若成员函数的定义位于类接口之外,其模板头必须与类模板的模板头匹配。这在使用概念时也不例外。
在以下示例中,概念 Addable
被用于定义类模板 Data
。类 Data
声明了一个成员函数 process
,该成员函数在类接口下方实现。像类本身一样,其成员函数的模板头也必须指定 Addable
(参见第 23.13.1 节):
template <Addable Type>
class Data {
void process();
};
template <Addable Tp> // 概念必须被指定
void Data<Tp>::process() // 但形式参数名不必是 `Type`
{
// 实现
}
类似地,如果类模板的成员函数只能在满足某个约束时使用(但其他类成员没有额外约束),类模板的头部可以使用 typename
,而附加的约束可以仅应用于相关成员:
template <typename Type>
class Data
{
void process() requires Addable<Type>; // 额外的要求
};
template <typename X>
void Data<X>::process() requires Addable<X>
{
// 实现
}
成员模板本身的类型也可以被约束。在这种情况下,同样的规则适用,即成员实现的模板头必须与其声明的模板头匹配:
template <typename Type>
class Data
{
template <Addable Tp>
void process(Tp par);
};
// 对成员模板应用约束
template <typename Type>
template <Addable Tp>
void Data<Type>::process(Tp par)
{
// 实现
}
受限的部分特化
类模板可以被(部分)特化。特化常用于为特定类型调整实现。当定义特化时,也可以使用概念。考虑一个结构体 Handler
具有以下通用实现:
template <typename Tp>
struct Handler
{
Handler()
{
std::cout << "Generic Handler\n";
}
};
除了可能的类型相关特化(如 Handler<Tp*>
等),还可以定义一个要求 Tp
支持加法操作符的特化,通过要求概念 Addable
来实现:
template <Addable Tp>
// 限制 Tp 为可加类型
struct Handler<Tp>
{
Handler()
{
std::cout << "Handler for types supporting operator+\n";
}
};
在以下程序中使用时(假设所有必需的头文件已经包含),第一行输出显示 “Generic Handler”,而第二行显示 “Handler for types supporting operator+”:
int main()
{
Handler<std::vector<int>>{};
Handler<int>{}; // 通用特化
}
编译器在编译 main
的第一个语句时,首先查找 Handler
的特化版本。虽然它找到了一个特化版本,但该特化要求 operator+
可用。由于 std::vector
不支持该操作符,因此编译器不使用该特化版本。如果这是唯一可用的实现,则编译器会报告约束不满足的错误。然而,仍然存在通用定义可以用于 std::vector
。因此,编译器使用了通用定义(这也很好地说明了 SFINAE 原则)。
在实例化第二个 Handler
对象时,加法操作符是可用的,因此在这种情况下,编译器选择了特化版本:在可用时使用特化版本,如果不可用,则使用通用模板定义。
函数模板和类模板声明
受限的函数模板或类模板可以像往常一样进行声明:使用分号代替实现部分。声明没有约束规格的函数或类模板时,该函数或类模板是无约束的,不会匹配现有的受限重载版本。另一方面,概念不能被声明。因此,如果概念定义必须在多个源文件或头文件中使用,那么通常会将概念定义放在单独的头文件中,并在使用该概念的文件中包含它。
以下是一些简单的示例,展示了如何声明受限的函数模板:
template <typename Type>
concept Addable = // 建议将概念定义在单独的头文件中
{
lh + rh;
};
template <typename Type>
// 声明一个无约束的函数模板
void fun();
template <Addable Type>
// 声明一个受限的重载函数模板
void fun();
template <typename Type>
// 同上,要求跟随 fun
void fun() requires Addable<Type>;
template <typename Type>
// 同上,要求在 fun 之前
requires Addable<Type> void fun();
在声明类模板时,它们的 requires
子句必须出现在类名之前。此外,当存在无约束的类模板时,受限的类模板实际上是特化模板,并必须相应地声明:
template <typename Type>
// 无约束的类模板声明
struct Data;
template <Addable Type>
// 受限的类模板声明
struct Data<Type>;
template <typename Type>
// 同上,要求添加在 Data<Type> 前
requires Addable<Type> struct Data<Type>;
多个约束也可以被声明:
template <typename Type>
concept C1 = true;
template <typename Type>
concept C2 = true;
template <C1 Type>
// 多重约束的函数模板
requires C2<Type> void fun();
template <typename Type>
// 同上,使用 'and'
requires C1<Type> and C2<Type> void fun();
template <typename Type>
// 同上,尾随 'requires'
void fun() requires C1<Type> and C2<Type>;
template <typename Type>
struct Multi;
template <C1 Type>
// 多重约束的类模板
requires C2<Type> struct Multi<Type>;
尽管特化可以定义不同的约束(例如,可能还会有一个概念 Subtractable
),例如为可减法的类型定义一个 Data
的特化:
template <Subtractable Type>
struct Data<Type>
{};
但这可能不是你想要的:当定义 Data<vector<int>>{}
时,其中 template <typename Type> Data
仅被声明,编译器会因为无法使用 Addable
或 Subtractable
的特化而报告不完整类型 struct Data<std::vector<int>>
的错误。因此,它回退到通用模板,但对于这个模板没有可用的实现,因此它是不完整的。
定义一个需要两个类型的模板,第一个类型为 Addable
,第二个模板参数为不受限制的类型,而特化定义要求一个 Subtractable
类型和一个 int
,这也不能按预期工作。在这种情况下,模板可能是:
template <typename t1, typename t2> requires Addable<t1>
struct Data
{};
template <Subtractable Type>
struct Data<Type, int>
{};
在这里,如果第一个模板参数不是可减法类型(如 vector<int>
),而第二个参数是 int
,编译器将不会使用这个特化,因为第一个参数不是可减法类型。因此,它会回退到第一个(通用的)模板定义。然而,这个定义也无法工作,因为第一个参数也不是可加的,你会收到关于 (lh + rh)
形式不正确的错误。
现在,虽然你指定了 int
作为模板的第二个参数,但你可能期望的是关于 (lh - rh)
形式不正确的错误,但这并没有发生。换句话说:使用概念仍然需要你理解发生了什么。概念帮助编译器确定编译失败的原因,但最终还是你需要理解你在做什么,以便掌握编译器试图告诉你的内容。
绑定到自由运算符
之前,在第22.10.2.1节中,介绍了如何构造模板类的嵌套类的自由运算符。在那里,嵌套类定义了类型名,这些类型名随后被用来通过定义模板参数来选择适当的自由运算符。
概念提供了另一种方式来定义绑定到嵌套类模板类型的自由运算符。当使用概念时,类模板及其嵌套类可以以其最基本的形式定义,如下所示:
template <typename Data>
struct String
{
struct iterator
{
using value_type = Data;
std::string::iterator d_iter;
// 注意 <>: operator== 是一个函数模板特化,
// 因为 'iterator' 是一个类模板
friend bool operator==<>(iterator const &lhs, iterator const &rhs);
};
iterator begin()
{
return iterator{};
}
};
一旦指定了类接口(struct String
),可以定义概念。它只要求自由运算符的参数是 String<Data>::iterator
对象:
template<typename Type>
concept StringIterator =
std::same_as<Type, typename String<typename Type::value_type>::iterator>;
现在可以使用简化的 StringIterator
auto 类型说明来定义自由运算符:
inline bool operator==(StringIterator auto const &lhs, StringIterator auto const &rhs)
{
return lhs.d_iter == rhs.d_iter;
}
通过使用概念来定义嵌套类的自由运算符,我们实现了这些运算符绑定到那些类模板的模板类型,并且自由运算符与这些(嵌套)类完美匹配。此外,在设计类模板时,软件工程师可以专注于类的基本特性,而不必考虑使用第22.10.2.1节中讨论的SFINAE方法所需的特殊类型定义。
协程
考虑以下任务:设计一个程序,提供一个 next
函数,在随后的调用中返回下一个斐波那契数。
以下是一个可能的设计示例:它定义了一个 Fibo
类,并在 main
中调用 Fibo::next
以计算下一个斐波那契数(为了简洁,程序使用了一个源文件):
#include <iostream>
#include <string>
using namespace std;
class Fibo
{
size_t d_return = 0;
size_t d_next = 1;
public:
size_t next();
};
size_t Fibo::next()
{
size_t ret = d_return; // 返回下一个斐波那契数
d_return = d_next; // 下次调用时:返回 d_next;
d_next += ret; // 准备 d_next 作为 d_return 和 d_next 的和
return ret;
}
int main(int argc, char **argv)
{
Fibo fibo; // 创建一个 Fibo 对象
size_t sum = 0;
// 使用其 'next' 成员获取斐波那契数列
for (size_t begin = 0, end = argc == 1 ? 10 : stoul(argv[1]);
begin != end;
++begin)
{
sum += fibo.next();
}
cout << sum << '\n';
}
虽然 next
成员函数并不复杂,但每次调用时都会执行一些与计算斐波那契数无关的操作:
- 调用一个函数涉及(忽略复制和销毁值类型参数对象和局部对象的复杂性):
- 将函数的参数推送到栈上(注意
next
是成员函数,因此它确实有一个参数:调用next
的对象的地址); - 将这些参数所需的字节数推送到栈上;
- 将完成
next
函数调用后的下一条指令的位置推送到栈上; - 将当前函数的栈帧位置推送到栈上;
- 将当前栈指针的值复制到寄存器(通常称为基指针),然后使用该寄存器访问
next
的参数和局部变量; - 从
next
的第一条指令继续执行; - 这首先在栈上为所有的
next
局部变量腾出空间。
- 将函数的参数推送到栈上(注意
- 一旦
next
的返回语句被执行:- 将栈指针重置为基指针中存储的值,从而将局部变量从栈上移除;
- 弹出基指针,从而恢复对调用者栈帧的访问;
- 从栈上弹出调用者下一条指令的位置;
- 通过栈顶当前值减少栈的大小,该值持有函数参数的大小。
这些步骤虽然看起来很多,但实际上并不需要太多时间,因为大多数步骤都是通过非常快速的寄存器操作执行的,而且计算机的架构通常对此类操作进行了高度优化。
然而,在函数本身很短且简单(如 Fibo::next
)的情况下,这些步骤可能被认为是不必要的,提出了是否可以避免它们的问题。
C++ 协程允许我们避免执行调用普通函数时所需的步骤。接下来的部分将深入介绍协程,但这里是协程的 Fibo::next
等效实现:
#include "main.ih"
Fibo fiboCoro()
{
size_t returnFibo = 0;
size_t next = 1;
while (true)
{
size_t ret = returnFibo;
returnFibo = next;
next += ret;
co_yield ret;
}
}
我们可以观察到 fiboCoro
协程的一些特点:
- 虽然协程可以定义为类成员,但这个协程不是。它是一个自由函数,因此没有指向调用协程的对象的(隐式)指针;
- 更重要的是:它定义了局部变量(尤其是第 5 行和第 6 行中的变量),这些局部变量在连续的调用之间保持其值。这些局部变量类似于静态变量,但它们不是。每次调用协程时都会接收到其局部变量的实例;
- 第 8 行到第 16 行定义了一个连续的循环,其中每个循环周期计算下一个斐波那契数;
- 一旦计算出斐波那契数,协程会在第 15 行挂起,而不会丢失刚刚计算出的值,通过
co_yield
将ret
传递给协程的调用者(co_yield
是协程可以使用的三个新关键字之一;其他两个是co_await
和co_return
)。
虽然 fiboCoro
为获得斐波那契数列奠定了基础,但恢复协程并不是像调用成员 Fibo::next
那样调用函数:没有参数;没有局部变量的准备;没有栈处理。相反,仅仅是直接跳转到协程挂起点之后的指令。当协程的代码遇到下一个挂起点(在 fiboCoro
中是下一个周期,当它再次达到 co_yield
语句时),程序的执行仅仅跳转回到恢复协程的指令之后的指令。
使用协程的 main
函数看起来非常类似于使用类 Fibo
的 main
函数:
#include "main.ih"
int main(int argc, char **argv)
{
Fibo fibo = fiboCoro();
size_t sum = 0;
for (size_t begin = 0, end = argc == 1 ? 10 : stoul(argv[1]);
begin != end;
++begin)
{
sum += fibo.next();
}
cout << sum << '\n';
}
在第 5 行,调用 fiboCoro
,返回一个(尚未介绍的)fibo
对象。这个 fibo
对象被称为协程处理程序,当在第 14 行调用 fibo.next()
时,它会恢复 fiboCoro
,然后再次在其 co_yield
语句处挂起,通过处理程序的 next
函数返回下一个可用的值。
协程的核心特性是它们可以挂起其执行,同时保持其当前状态(变量的值、要执行的下一条指令的位置等)。通常,一旦挂起,协程的调用者会在返回协程的下一个值之后继续其工作,因此这两个函数(协程和其调用者)紧密合作以完成实现的算法。因此,协程也被称为协作例程,不应与并发(多线程)例程混淆。
整个过程的工作方式及其特点将在接下来的部分中详细介绍。
定义协程
在定义协程时,必须包含 <coroutine>
头文件。一个函数只有在使用 co_yield
、co_await
或 co_return
关键字时,才被称为协程。协程不能使用 return
关键字,不能定义可变参数,其返回类型必须是定义了协程处理程序的现有类型。
尽管协程似乎返回对象(如前面部分定义的 Fibo
类中的 Fibo fiboCoro()
协程所示),但实际上它们并不会。相反,协程返回的是所谓的“处理程序”(handlers)。例如,在前面的示例中,fibo
是处理程序:
int main(int argc, char **argv)
{
auto fibo = fiboCoro();
...
sum += fibo.next();
...
}
Fibo
类本身定义了允许编译器生成代码来存储协程的参数、局部变量、协程返回或挂起时的下一条指令的位置,以及所谓的 promise_type
对象到堆上的特征。这只发生一次,因此当协程被激活(如 sum += fibo.next()
)时,通常在调用函数时采取的步骤会被避免,相反,协程会立即执行,使用其已存在的局部变量和参数。协程的处理程序类有时称为 Future
,它们的嵌套状态类必须作为处理程序的 promise_type
被公开。这里的 future
和 promise_type
名称与在多线程上下文中使用的 std::future
(参见第 20.8 节)和 std::promise
(参见第 20.12 节)类型完全无关。实际上,协程与多线程无关,被称为协作例程(cooperating routines)。由于协程的处理程序和状态类与本章中多线程上下文中的 future
和 promise
类无关,因此通常使用术语 Handler 和 State。
定义协程是一回事,但在使用协程时,还必须定义处理程序类(在当前示例中是 Fibo
类)。此外,该处理程序类必须定义一个嵌套类,其名称必须作为处理程序的 promise_type
公共可用。名称 promise_type
并不能很好地描述其目的,使用更具描述性的类名可能更为合适。在这种情况下,可以在处理程序类的公共部分使用简单的 using
声明,如下所示:
#include <coroutine>
class Fibo
{
class State // 跟踪协程的状态
{
// ...
};
std::coroutine_handle<State> d_handle;
public:
using promise_type = State;
explicit Fibo(std::coroutine_handle<State> handle);
~Fibo();
size_t next();
};
协程的处理程序类具有以下特点:
- 它有一个嵌套类(在这里是
State
),用于跟踪协程的状态; - 通常定义一个私有数据成员,类型为
std::coroutine_handle<State>
,例如std::coroutine_handle<State> d_handle
,其成员将在下面介绍; - 除非处理程序类的嵌套类称为
promise_type
,否则必须指定using
声明,以使嵌套类名称也被知道为promise_type
; - 其他成员是可选的,但通常至少有一个成员返回协程状态中可用的值,如示例中的
next
成员:
size_t Fibo::next()
{
d_handle.resume();
return d_handle.promise().value();
}
next
成员的当前实现会恢复协程。这并不意味着第一次调用 next
时,它是协程的第一次激活:首先是 auto fibo = fiboCoro()
调用,构造并返回协程的处理程序(从而在协程的第一条语句处挂起)是自动完成的。在此时,调用者接收到协程的处理程序对象,而构造和返回处理程序对象在协程代码中不可见(更多细节将在下一节中介绍)。
一旦挂起在 co_yield
,协程的 State
中可用的值将被返回,并提供给协程的调用者。
可以通过处理程序的 d_handle
数据成员调用以下成员:
void* address()
:返回处理程序的State
对象的地址;void destroy()
:将State
对象的内存返回给操作系统,结束State
对象的生命周期。通常,处理程序类的析构函数会调用d_handle.destroy()
;bool done()
:当协程已经返回时返回true
,如果协程当前挂起则返回false
;coroutine_handle from_address(void* address)
:返回与处理程序的State
对象地址对应的coroutine_handle
,该地址作为参数传递给函数。也可以传递nullptr
给from_address
;explicit operator bool()
:如果d_handle
不是空指针则返回true
。通常在处理程序类的析构函数中使用if (d_handle)
语句。它在将0
(或nullptr
)分配给d_handle
后返回false
。此操作符和static_cast<bool>(d_handle.address())
行为相同(注意d_handle.address()
在将0
分配给d_handle
后仍然有效,在这种情况下它返回0
);State& promise()
:返回对处理程序的State
类的引用;void resume()
(或void operator()()
):恢复挂起的协程的执行。恢复协程仅在协程实际挂起时定义。
处理程序的 State
类跟踪协程的状态。其基本元素将在下一节中介绍。
协程的 State
类(promise_type
)
Handler::State
类跟踪协程的状态。它必须作为 Handler::promise_type
公共可用,可以通过 using
声明将更合适的类名与 promise_type
关联。
在当前示例中,类名为 State
,其接口如下:
class State
{
size_t d_value;
public:
Fibo get_return_object();
std::suspend_always yield_value(size_t value);
static std::suspend_always initial_suspend();
static std::suspend_always final_suspend() noexcept;
static void unhandled_exception();
static void return_void();
size_t value() const;
};
这个 State
类没有声明构造函数,因此使用默认构造函数。也可以声明和定义默认构造函数。或者,声明和定义一个与协程参数相同的构造函数(或可以由协程参数初始化的构造函数),该构造函数在协程返回其处理对象时被调用。例如,如果协程的签名是:
Handler coro(int value, string const &str);
而 State
类有一个构造函数:
Handler::State::State(int value, string const &str);
那么该构造函数将被调用。如果没有这样的构造函数,则调用 State
的默认构造函数。此外,协程还可以使用 awaiter 将参数传递给处理程序的 State
类。此方法在第 24.5 节中介绍。
数据成员 d_value
和成员函数 value()
被 Fibo
类特别使用,其他协程状态类可能声明其他成员。剩余成员是必需的,但返回 std::suspend_always
的成员也可以声明为返回 std::suspend_never
的成员。
通过返回(空的)suspend_always
结构体,协程的操作会挂起,直到恢复。在实践中,使用 suspend_always
,因此可以将 ..._suspend
成员声明为静态,使用以下基本实现:
inline std::suspend_always Fibo::State::initial_suspend()
{
return {};
}
inline std::suspend_always Fibo::State::final_suspend() noexcept
{
return {};
}
类似地,当 unhandled_exception
成员仅重新抛出协程可能抛出的异常时,可以将其声明为静态:
inline void Fibo::State::unhandled_exception()
{
// 不要忘记包含 <future>
std::rethrow_exception(std::current_exception());
}
(必需的)成员 Fibo::State::get_return_object
返回协程的处理程序对象(即 Fibo
)。其实现如下:
inline Fibo Fibo::State::get_return_object()
{
return Fibo{ std::coroutine_handle<State>::from_promise(*this) };
}
Fibo::State::yield_value
成员可以重载以支持不同的参数类型。在我们的 Fibo::State
中,只有一个 yield_value
成员,它将其参数值存储在 State::d_value
数据成员中,并且它还通过返回 std::suspend_always
挂起协程的执行:
inline std::suspend_always Fibo::State::yield_value(size_t value)
{
d_value = value;
return {};
}
现在,我们已经覆盖了协程的处理程序类及其 State
子类,让我们更详细地看看在执行前面介绍的 main
函数时发生了什么。再次展示 main
函数:
int main(int argc, char **argv)
{
auto fibo = fiboCoro();
size_t sum = 0;
for (
size_t begin = 0, end = argc == 1 ? 10 : stoul(argv[1]);
begin != end;
++begin
)
{
sum += fibo.next();
}
cout << sum << '\n';
}
当使用参数 ‘2’ 调用时,发生以下情况:
- 在第 2 行,程序开始执行;
- 在第 3 行,似乎调用了
fiboCoroutine
(参见第 24 节的定义),但在此之前:- 调用
State::get_return_object
,返回一个Fibo
对象。注意,在fiboCoroutine
中并没有直接返回Fibo
对象,即使它的定义表明返回了一个Fibo
对象。get_return_object
成员确实调用了Fibo
的构造函数,这个Fibo
对象在第 3 行被返回; - 构造
Fibo
对象后,协程的执行会被挂起(Fibo::State::initial_suspend
被自动调用)。
- 调用
接下来,在第 3 行,返回的 Fibo
对象被赋值给 fibo
。当前实现使用 auto fibo = ...
,但也可以使用 Fibo fibo = ...
。使用 auto
可能很吸引人,尤其是当协程处理类的类型比较复杂时;
main
的执行继续在第 12 行调用fibo.next()
;fibo.next()
通过调用d_handle.resume()
恢复协程。由于这是第一次显式调用协程,因此它现在从协程作者编写的第一条语句开始执行;- 协程继续执行,直到达到
co_yield
、co_await
或co_return
关键字。在这种情况下是co_yield
,返回当前的斐波那契值(size_t ret
); - 由于存在一个与
ret
类型匹配的State::yield_value
成员,该成员被调用,并接收ret
的值作为其参数。由于yield_value
返回std::suspend_always
,协程再次被挂起; - 挂起后,控制返回到
Fibo::next
的下一条语句,在那里:Fibo
的d_handle
使用其成员promise()
访问处理程序的State
对象,使next
能够返回State::value()
(这是最近计算的斐波那契值,在调用State::yield_value
时存储在State
对象中)。Fibo::next()
返回该值;
- 完成第 12 行后,
for
循环继续在第 7 行,再次到达第 12 行; - 在后续的迭代中,协程继续从它被挂起的语句(即
co_yield
语句)之后的语句开始执行。因此,它不会从协程的第一条语句继续,而是继续执行while (true)
语句中的语句,直到再次到达co_yield
语句,如前所述; - 在两次迭代后,
for
循环结束,就在程序结束之前(第 15 行),Fibo
的析构函数被自动调用,将为协程的数据分配的内存返回到通用池。
如果使用 suspend_never
会怎样?
如果在 State
类的成员中返回 std::suspend_never
代替 std::suspend_always
,则在协程启动后,协程将永远不会被挂起。如果用参数 2
调用计算斐波那契数的程序,会发生以下情况:
- 在第 2 行,程序开始执行;
- 在第 3 行,看似调用了
fiboCoroutine
,但在此之前:- 调用
State::get_return_object
,在第 3 行返回一个Fibo
对象; - 在构造
Fibo
对象后,协程的执行会继续,因为这次Fibo::State::initial_suspend
不会挂起。然而,fibo
对象已经被构造,因为auto fibo = ...
不是赋值,而是fibo
对象的初始化;
- 调用
- 由于协程的执行没有被挂起,它开始迭代,因此它会调用
co_yield
。但尽管在co_yield
调用时Fibo::State::yield_value
被调用,协程并不会被挂起,因为yield_value
现在返回std::suspend_never
; - 因此,协程继续其循环,在每次迭代时将下一个斐波那契数赋值给
State::d_value
。由于循环没有被挂起,并且没有其他退出循环的方式,程序会继续运行,直到被某个信号(如 Ctrl-C)终止。
简化状态类
由于协程处理程序的状态类通常可以使用最小化实现,因此将这些成员定义在一个独立的基类中,以简化状态类的接口和实现,可能会很有吸引力。
考虑到 Fibo::State
类,其成员 initial_suspend
、final_suspend
和 unhandled_exception
是适合放入基类的候选者。通过将基类定义为类模板,并将协程处理程序的类名和处理程序的状态类名作为模板类型参数传递给基类,这样基类还可以提供处理程序的 get_return_object
成员。
以下是如何定义这个基类的示例。它被用在本章开发的协程处理程序的状态类中:
#include <coroutine>
#include <cstddef>
#include <future>
template <typename Handler, typename State>
struct PromiseBase
{
Handler get_return_object();
static std::suspend_always initial_suspend();
static std::suspend_always final_suspend() noexcept;
static void unhandled_exception();
static void return_void();
};
template <typename Handler, typename State>
inline void PromiseBase<Handler, State>::return_void()
{}
template <typename Handler, typename State>
inline std::suspend_always PromiseBase<Handler, State>::initial_suspend()
{
return {};
}
template <typename Handler, typename State>
inline std::suspend_always PromiseBase<Handler, State>::final_suspend() noexcept
{
return {};
}
template <typename Handler, typename State>
inline void PromiseBase<Handler, State>::unhandled_exception()
{
std::rethrow_exception(std::current_exception());
}
template <typename Handler, typename State>
inline Handler PromiseBase<Handler, State>::get_return_object()
{
return Handler{ std::coroutine_handle<State>::from_promise(
static_cast<State &>(*this))
};
}
这个基类提供了协程处理程序状态类的常见实现,简化了状态类的定义,并使得协程处理程序的实现更加模块化。
将协程嵌入到类中
协程不一定非得是自由函数(即,类外的函数)。它们也可以作为类的成员定义,在这种情况下,它们可以完全访问其类的成员。在本节中,我们将开发一个 Floats
类,该类可以将二进制浮点值写入或从文件中读取。该类的对象在 main
函数中被使用,调用其 run
成员函数来进行写入或读取操作:
int main(int argc, char **argv)
{
Floats floats(argc, argv);
floats.run();
}
该程序接受两个参数:一个是 ‘r’(读取)或 ‘w’(写入),另一个是二进制文件的名称。
Floats::run
成员函数使用成员指针来调用 read
或 write
函数:
class Reader;
class Writer;
class Floats
{
enum Action
{
READ,
WRITE,
ERROR,
};
Action d_action;
std::string d_filename; // 二进制文件的名称
static void (Floats::*s_action[])() const; // 读取和写入的指针
public:
Floats(int argc, char **argv);
void run() const;
private:
void read() const;
Reader coRead() const;
void write() const;
static Writer coWrite();
};
read
成员函数通过协程 coRead
读取二进制文件。当调用 coRead
时,执行以下操作:
- 隐式调用协程
Reader
的State
成员的get_return_object
来获取协程的处理程序,并且协程会被挂起。 - 然后,
get_return_object
返回的处理程序作为read
函数的reader
对象可用:
void Floats::read() const
{
Reader reader = coRead(); // coRead: 协程, reader: 协程的处理程序
while (auto opt = reader.next()) // 获取下一个值
cout << opt.value() << ' ';
cout << '\n';
}
一旦 reader
对象可用,read
函数进入一个 while
循环,重复调用 reader.next()
。此时发生以下情况:
- 协程被恢复,从二进制文件中读取下一个值(如果有的话);
- 协程再次挂起,返回刚刚获得的值(或者表示所有值已经处理完);
- 一旦
next
返回,read
函数继续执行,可能结束其while
语句或显示获取的浮点值。
当第一次恢复协程(即第一次调用 reader.next()
)时,coRead
协程打开文件,然后在 while
语句中确定下一个可用值。如果成功,则协程再次挂起,使用 co_yield
将刚刚读取的值传递给 read
,或者(如果无法获取值)协程通过调用 co_return
结束。以下是 Floats::coRead
协程的实现:
Reader Floats::coRead() const
{
ifstream in{ d_filename };
while (true)
{
float value;
in.read(reinterpret_cast<char *>(&value), sizeof(float));
if (!in)
co_return; // 如果没有更多数据:结束协程
co_yield value;
}
}
类似地,成员函数 write
使用协程 coWrite
重新写入二进制文件,遵循与 read
相同的程序来获取写入协程的处理程序:
void Floats::write() const
{
ofstream out{ d_filename };
Writer writer = coWrite(); // coWrite: 协程, writer: 协程的处理程序
cout << "Enter values (one per prompt), enter 'q' to quit\n";
while (true)
{
cout << "? ";
auto opt = writer.next(); // 获取下一个值
if (!opt) // 如果没有更多值则停止
break;
out.write(&opt.value()[0], sizeof(float));
}
}
Floats::coWrite
的行为类似于 Floats::coRead
,但它将值写入而不是读取。以下是 coWrite
的定义,它是一个普通的(非静态的)成员,因为它使用了 Floats::d_filename
:
Writer Floats::coWrite()
{
while (true)
{
float value;
if (!(cin >> value)) // 获取下一个值
co_return; // 如果没有更多值:结束协程
Writer::ValueType ret; // 返回的值
ret = Writer::ValueType{
string{
reinterpret_cast<char const *>(&value),
reinterpret_cast<char const *>(&value + 1)
}
};
co_yield ret;
}
}
Reader
和 Writer
处理程序类将在接下来的部分中介绍。
Reader
协程处理程序
Reader
类的核心是其 State
子类在协程的 co_yield
语句处接收一个值。Reader::State
通过其 yield_value
成员函数接收传递给 co_yield
的值,并将收到的浮点值存储在其 std::optional<float> d_value
数据成员中。
Reader
类本身必须定义一个构造函数,接收指向其 State
类的句柄,并且应该定义一个析构函数。其 next
成员函数简单地将存储在 State
类中的值返回给 next
的调用者。以下是 Reader
的完整头文件:
#include <coroutine>
#include <iostream>
#include <optional>
#include "../../promisebase/promisebase.h"
struct Reader
{
using ValueType = std::optional<float>;
private:
class State : public PromiseBase<Reader, State>
{
ValueType d_value;
public:
std::suspend_always yield_value(float value);
void return_void();
ValueType const &value() const;
};
std::coroutine_handle<State> d_handle;
public:
using promise_type = State;
explicit Reader(std::coroutine_handle<State> handle);
~Reader();
ValueType const &next();
};
Reader
和 Reader::State
的成员函数(除了 Reader::next
)都有非常简短的实现,可以内联定义:
inline std::suspend_always Reader::State::yield_value(float value)
{
d_value = value;
return {};
}
inline void Reader::State::return_void()
{
d_value = ValueType{};
}
inline Reader::ValueType const &Reader::State::value() const
{
return d_value;
}
inline Reader::Reader(std::coroutine_handle<State> handle)
: d_handle(handle)
{}
inline Reader::~Reader()
{
if (d_handle)
d_handle.destroy();
}
Reader::next
执行两个任务:恢复协程,然后在协程再次挂起(在 co_yield
语句处),返回存储在 Reader::State
对象中的值。它通过 d_handle.promise()
访问其 State
类对象,从中返回存储的值:
Reader::ValueType const &Reader::next()
{
d_handle.resume();
return d_handle.promise().value();
}
Writer
协程处理程序
Writer
类与 Reader
类非常相似。它使用不同的值类型,因为它必须将浮点值的二进制表示写入输出流,但除此之外,与 Reader
类的区别不多。以下是 Writer
的接口以及与 Reader
类不同的 yield_value
成员的实现:
struct Writer
{
using ValueType = std::optional<std::string>;
private:
class State : public PromiseBase<Writer, State>
{
ValueType d_value;
public:
std::suspend_always yield_value(ValueType &value);
void return_void();
ValueType const &value() const;
};
std::coroutine_handle<State> d_handle;
public:
using promise_type = State;
explicit Writer(std::coroutine_handle<State> handle);
~Writer();
ValueType const &next();
};
Writer
的 State
类中的 yield_value
成员函数与 Reader
类不同,具体实现如下:
inline std::suspend_always Writer::State::yield_value(ValueType &value)
{
d_value = std::move(value);
return {};
}
解释
Writer
类使用std::optional<std::string>
作为ValueType
,因为它需要处理字符串的二进制表示。yield_value
成员函数接收ValueType
的引用,将其值移动到d_value
中,并返回std::suspend_always
,以暂停协程执行。
Awaitables
、Awaiters
和 co_await
到目前为止,我们已经遇到过 co_yield
和 co_return
。那 co_await
呢?“await” 这个动词比 “wait” 更正式,但这两个动词的意思是一样的。Merriam-Webster 字典对“await”提供了第二种描述:在某种状态下保持不变,“abeyance”的意思也回到了我们最初的理解:暂时的无效或暂停状态。因此,当使用 co_await
时,协程进入一种暂时的非活动状态,即它被挂起。就这个意义而言,co_yield
并没有不同,因为它也会挂起协程,但与 co_yield
不同的是,co_await
期望一个所谓的 Awaitable 表达式。即,一个结果是 Awaitable 对象的表达式,或者能够转换为 Awaitable 对象的表达式(参见图 24.1)。
图 24.1 显示了传递给 co_await
的表达式可以是一个 Awaitable 对象,或者如果协程处理程序的 State 类中有一个 await_transform
成员,该成员接受某个表达式的类型作为参数,那么 await_transform
返回的值就是 Awaitable(参见图 24.2)。这些 await_transform
成员可以被重载,因此在具体情况下可能会使用多种 Awaitable 类型。
最终使用的 Awaiter 类型要么是 co_await
表达式的类型的对象,要么是协程处理程序的 State::await_transform(expr) 成员函数的返回类型。因此,Awaitable 对象只是一个中介,用于获取 Awaiter 对象。Awaiter 是 co_await
的实际工作马。
Awaitable 类可以定义一个成员函数 Awaiter Awaitable::operator co_await()
,这个函数也可以作为自由函数提供(Awaiter operator co_await(Awaitable &&)
)。如果这样的 co_await
转换运算符可用,那么它将用于从 Awaitable 对象中获取 Awaiter 对象。如果没有这样的转换运算符,那么 Awaitable 对象本身就是 Awaiter 对象。顺便提一下:在这里,Awaitable 和 Awaiter 被用作正式的类名,在实际程序中,软件工程师可以使用其他(也许更具描述性的)名称。
类 Awaiter
一旦 Awaiter
对象可用,它的成员函数 bool await_ready()
就会被调用。如果 await_ready()
返回 true
,则协程不会被挂起,而是继续执行 co_await
语句之后的代码(这表明 Awaitable 对象和 Awaiter::await_ready
显然能够避免挂起协程)。
如果 await_ready()
返回 false
,则会调用 Awaiter::await_suspend(handle)
。其 handle
参数是当前协程处理程序对象的句柄(例如 d_handle
)。注意,此时协程已经被挂起,协程的句柄甚至可能被转移到另一个线程(在这种情况下,当前线程当然不能恢复当前协程)。成员函数 await_suspend
可以返回 void
、bool
或某个协程的句柄(可选地是其自身的句柄)。如图 24.3 所示,当返回 void
或 true
时,协程被挂起,协程的调用者会在激活协程的语句之后继续执行。如果返回 false
,则协程不会被挂起,而是继续执行 co_await
语句之后的代码。如果返回一个协程的句柄(不是引用返回类型,而是值返回类型),则返回的协程句柄会被恢复(假设返回的是不同于当前协程的句柄,那么当前协程会被挂起,之前挂起的其他协程会被恢复;在下一节中,这一过程被用来实现一个使用协程的有限状态自动机)。
在 await_suspend
之后,如果当前协程再次恢复,则在此之前,Awaiter
对象会调用 Awaiter::await_resume()
,并且 await_resume
的返回值将由 co_await
表达式返回。await_resume
通常定义为 void
返回类型,如下所示:
static void Awaiter::await_resume() const
{}
在下一节中,将使用协程实现一个有限状态自动机。它们的处理程序类也是 Awaiter
类型,await_ready
返回 false
,await_resume
不做任何操作。因此,它们的定义可以由一个充当协程处理程序类基类的 Awaiter
类提供。Awaiter
只需要一个简单的头文件:
struct Awaiter
{
static bool await_ready();
static void await_resume();
};
inline bool Awaiter::await_ready()
{
return false;
}
inline void Awaiter::await_resume()
{}
从协程内部访问 State
如我们所见,当协程开始时,它会构造并返回一个处理程序类的对象。该处理程序类包含一个子类,该子类的对象跟踪协程的状态。在本章中,该子类被命名为 State
,并使用 using
声明将其标记为 promise_type
,这是协程所需的标准设施提供的。
当协程在 co_yield
语句处挂起时,生成的值会传递给 State
类的 yield_value
成员,其参数类型与生成的值的类型相匹配。
在本节中,我们将从另一个角度讨论一种方法,允许协程访问 State
类的功能。我们已经遇到了一种将信息从协程传递到 State
类的方法:如果 State
类的构造函数定义了与协程本身相同的参数,那么该构造函数会被调用,接收协程的参数作为参数。
但是,假设协程执行一个包含多个(可能是条件性的) co_yield
语句的循环,并且我们希望通知 State
类当前的迭代周期。在这种情况下,使用参数是不太合适的,因为跟踪周期数实际上是协程的一个局部变量的工作,这看起来像这样:
Handler coroutine()
{
size_t cycleNr = 0;
// 使 cycleNr 对 tt(State) 类可用
while (true)
{
++cycleNr;
// 现在 cycleNr 也为 tt(State) 知道
...
// 协程在工作,使用各种 co_yield 语句
}
}
在这些情况下,Awaiters
也可以被用来设置协程和其处理程序类对象的 State
类之间的通信线路。以下是原始 fibocoro
协程的一个稍微修改版的示例:
1: Fibo fiboCoroutine()
2: {
3: size_t returnFibo = 0;
4: size_t next = 1;
5: size_t cycle = 0;
6:
7: co_await Awaiter{ cycle };
8: cerr << "Loop starts\n";
9:
10: while (true)
11: {
12: ++cycle;
13:
14: size_t ret = returnFibo;
15:
16: returnFibo = next;
17: next += ret;
18:
19: co_yield ret;
20: }
21: }
- 在第 5 行定义了
size_t cycle
,用于跟踪协程的迭代周期; - 第 7 行包含一个
co_await
语句,将一个接收cycle
的对象传递给co_await
,这是Handler
的State
应该知道的变量; - 在第 12 行
cycle
被递增,因此它包含当前的迭代周期。
由于没有 State::await_transform
成员,Awaiter
对象是一个 awaitable。Awaiter
也没有类型 operator co_await()
,所以匿名的 Awaiter
对象确实是一个 Awaiter
。
作为 Awaiter
,它定义了三个成员:await_ready
,仅返回 false
,因为协程的执行必须在 co_await
语句处挂起;await_suspend(handle)
,接收协程 Handler
的 State
对象的句柄;以及 await_resume
,它不需要做任何操作:
class Awaiter
{
size_t const &d_cycle;
public:
Awaiter(size_t const &cycle);
bool await_suspend(Fibo::Handle handle) const;
static bool await_ready();
static void await_resume();
};
inline Awaiter::Awaiter(size_t const &cycle)
: d_cycle(cycle)
{}
inline bool Awaiter::await_ready()
{
return false;
}
inline void Awaiter::await_resume()
{}
成员函数 await_suspend
使用接收到的句柄访问 State
对象,将 cycle
传递给 State::setCycle
:
bool Awaiter::await_suspend(Fibo::Handle handle) const
{
handle.promise().setCycle(d_cycle);
return false;
}
在下一节(24.6)中,我们将使用 await_suspend
从一个协程切换到另一个协程,但这里并不需要。所以成员函数返回 false
,因此在将 cycle
传递给 State::setCycle
后继续执行。通过这种方式,协程可以将信息传递给 Handler
的 State
对象,后者可以定义一个数据成员 size_t const *d_cycle
和一个成员函数 setCycle
,在 yield_value
中使用 d_cycle
:
inline void Fibo::State::setCycle(size_t const &cycle)
{
d_cycle = &cycle;
}
std::suspend_always Fibo::State::yield_value(size_t value)
{
std::cerr << "Got " << value << " at cycle " << *d_cycle << '\n';
d_value = value;
return {};
}
通过协程实现有限状态自动机
有限状态自动机(FSA)通常通过状态 x 输入矩阵来实现。例如,在使用 Flexc++ 来识别字母、数字或其他字符时,它定义了三个输入类别和 4 个状态(第一个状态为初始状态,根据从扫描器输入流中读取的字符确定下一个状态,其他三个状态则处理来自特定类别的字符)。
通过协程实现有限状态自动机
有限状态自动机(FSA)也可以使用协程来实现。当使用协程时,每个协程处理特定的输入类别,并根据当前输入类别确定下一个使用的类别。图 24.4 展示了一个简单的 FSA:在 Start
状态下,数字将我们带到 Digit
状态,字母将我们带到 Letter
状态,对于其他字符,我们保持在 Start
状态,而在文件末尾(EOF)时,我们在 Done
状态结束 FSA。Digit
和 Letter
的行为类似。
这个 FSA 使用了四个协程:coStart
、coDigit
、coLetter
和 coDone
,每个协程返回它们自己的处理程序(例如,coStart
返回一个 Start
处理程序,coDigit
返回一个 Digit
处理程序,等等)。下面是 coStart
的实现:
Start coStart()
{
char ch;
while (cin.get(ch))
{
if (isalpha(ch))
{
cout << "at `" << ch << "' from start to letter\n";
co_await g_letter;
}
else if (isdigit(ch))
{
cout << "at `" << ch << "' from start to digit\n";
co_await g_digit;
}
else
{
cout << "at char #" << static_cast<int>(ch) << ": remain in start\n";
}
}
co_await g_done;
}
这段协程的流程可能很容易理解,但注意第 9、14 和 20 行的 co_await
语句:在这些行中,co_await
实现了从当前协程切换到另一个协程。如何实现这一点将在随后描述。
协程 coDigit
和 coLetter
执行类似的操作,而 coDone
在遇到 EOF 时被调用,它简单地使用 co_return
来结束协程的处理。以下是 coDone
的实现:
Done coDone()
{
cout << "at EOF: done\n";
co_return;
}
现在来看这个要处理的短输入文件:
a
1
a1
1a
在处理这个输入时,程序展示了它的状态变化:
at `a' from start to letter
at char #10: from letter to start
at `1' from start to digit
at char #10: from digit to start
at `a' from start to letter
at `1' from letter to digit
at char #10: from digit to start
at `1' from start to digit
at `a' from digit to letter
at char #10: from letter to start
at EOF: done
由于协程通常在激活后会被挂起,Start
处理程序提供了一个成员函数 go
来通过恢复协程来启动 FSA:
void Start::go()
{
d_handle.resume();
// 可以使用 'if (d_handle)' 来保护
}
主函数仅激活 Start
协程,但协程当然也可以嵌入到类似 FSA
的类中,主函数可能提供一个选项来处理文件参数,而不是使用重定向。以下是主函数的实现:
int main()
{
g_start.go();
}
Start
处理程序类
如图所示,从 co_await
表达式语句中获得 Awaiter 的方式有多种。最简单的方法如下:
- 根据图 24.1,如果当前协程的 State 类没有
await_transform
成员,那么expr
就是 Awaitable。 - 根据图 24.2,如果
expr
的类型没有operator co_await
成员,那么expr
的类型就是 Awaiter。
因此,嵌套在 Start
处理程序类下的 State
类只需要提供协程处理程序的标准成员。这些成员都由通用的 PromiseBase
类提供(第 24.1.2 节),因此 State
不需要额外的成员:
// 嵌套在 Start 处理程序类下:
struct State : public PromiseBase<Start, State>
{};
其他三个处理程序类也是如此,它们的 State
类也都继承自 PromiseBase<Handler, State>
。不过,由于 coDone
协程也使用了 co_return
,因此 Done::State
状态类必须有自己的 return_void
成员:
// 嵌套在 Done 处理程序类下:
struct State : public PromiseBase<Done, State>
{
void return_void() const;
};
// 在 done.h 中的实现:
inline void Done::State::return_void() const
{}
由于我们的 FSA 允许从 Digit
和 Letter
状态回到 Start
状态,因此 Start
处理程序类本身也是一个 Awaiter(Digit
、Letter
和 Done
也是如此)。第 24.4 节描述了 Awaiter 类的要求和基本定义。
从 FSA 的角度来看,最有趣的部分是如何在协程之间切换。如图 24.3 所示,这需要一个成员 await_suspend
,它接收使用 co_await
语句的协程的句柄,并返回某个协程的句柄。因此:
- 使用
co_await expr
,其中expr
是一个协程的处理程序,它也是一个 Awaiter; - 当前协程将自己的句柄传递给
expr
的await_suspend
成员; expr
的await_suspend
成员返回的句柄决定了哪个协程被恢复,从而挂起了使用co_await
的协程;expr
返回它自己的句柄,从而实现了 FSA 的状态从当前协程切换到expr
的协程。
以下是 coStart
的处理程序类的接口以及其 await_suspend
成员的定义。由于 coStart
协程可能由其他多个协程恢复,因此不清楚传递给 Start::await_suspend
的是哪个协程的句柄,因此 await_suspend
是一个成员模板,它只是返回 Start
的句柄。
class Start : public Awaiter
{
struct State : public PromiseBase<Start, State> {};
std::coroutine_handle<State> d_handle;
public:
using promise_type = State;
using Handle = std::coroutine_handle<State>;
explicit Start(Handle handle);
~Start();
void go();
};
// 这是 Awaiter 所需的
template <typename HandleType>
std::coroutine_handle<State> await_suspend(HandleType &handle);
template <typename HandleType>
inline std::coroutine_handle<Start::State>
Start::await_suspend(HandleType &handle)
{
return d_handle;
}
由于 Start
的 await_suspend
成员返回 Start
的 d_handle
,因此包含 co_await g_start
语句的协程会被挂起,而 co_start
协程会被恢复(参见图 24.3)。
Start
处理程序类的构造函数和析构函数的实现是直接的:构造函数将协程的句柄存储在其 d_handle
数据成员中,析构函数使用语言提供的 destroy
成员来正确结束 Start::State
的协程句柄。以下是它们的实现:
Start::Start(Handle handle)
: d_handle(handle)
{}
Start::~Start()
{
if (d_handle)
d_handle.destroy();
}
完成有限状态自动机
Digit
和 Letter
协程的处理程序类的实现与 Start
类似。像 coStart
一样,当接收到非数字和非字母字符时,coDigit
会继续执行,只要接收到数字字符,coLetter
会继续执行,只要接收到字母字符。
正如在第 24.6 节中所见,coDone
的实现略有不同:它不需要做任何事情,coDone
协程只需在 co_return
语句处结束。协程的执行(以及 FSA 程序)会在 main
函数的 g_start.go()
调用之后结束。
完整的基于协程的 FSA 程序实现可以在 Annotation 的发行版中找到,目录路径为 yo/coroutines/demo/fsa
。
递归协程
与普通函数一样,协程也可以递归调用。协程的一个基本特征是,当它们被使用时,看起来与普通函数没有什么不同。只是实现上,协程与普通函数有所不同。
首先,考虑一个非常简单的交互式程序,该程序生成一系列数字,直到用户结束程序或输入 q
为止:
1: int main()
2: {
3: Recursive rec = recursiveCoro(true);
4:
5: while (true)
6: {
7: cout << rec.next() << "\n"
8: "? ";
9:
10: string line;
11: if (not getline(cin, line) or line == "q")
12: break;
13: }
14: }
在第 3 行,recursiveCoro
协程被调用,返回其处理程序 rec
。在第 7 行调用其成员 next
,返回 recursiveCoro
生成的下一个值。函数 recursiveCoro
可以是任何返回一个具有 next
成员的类的对象的函数。暂时忽略递归,recursiveCoro
可能看起来像这样:
1: namespace
2: {
3: size_t s_value = 0;
4: }
5:
6: Recursive recursiveCoro(bool recurse)
7: {
8: while (true)
9: {
10: for (size_t idx = 0; idx != 2; ++idx)
11: co_yield ++s_value;
12:
13: // 这里将进行递归调用
14:
15: for (size_t idx = 0; idx != 2; ++idx)
16: co_yield ++s_value;
17: }
18: }
该协程仅生成从 0 开始的非负整数序列。它的两个 for
循环(第 10 行和第 15 行)仅用于演示,递归调用将放置在这两个循环之间。变量 s_value
定义在协程之外(而不是在内部使用 static s_value = 0
),因为递归调用的协程必须都访问同一个 s_value
变量。这没有什么魔法:只是两个在不断循环的 while
语句中的 for
语句。
返回的 Recursive
对象的接口也不复杂:
1: class Recursive
2: {
3: class State: public PromiseBase<Recursive, State>
4: {
5: size_t d_value;
6:
7: public:
8: std::suspend_always yield_value(size_t value);
9: size_t value() const;
10: };
11:
12: private:
13: using Handle = std::coroutine_handle<State>;
14: Handle d_handle;
15:
16: public:
17: using promise_type = State;
18:
19: explicit Recursive(Handle handle);
20: ~Recursive();
21:
22: size_t next();
23: bool done() const;
24: };
State
类所需的成员在 PromiseBase
中可用(参见第 24.1.2 节),无需修改。由于 recursiveCoro
使用 co_yield
生成值,State::yield_value
成员将这些值存储在其 d_value
数据成员中:
std::suspend_always Recursive::State::yield_value(size_t value)
{
d_value = value;
return {};
}
其成员 value
是一个访问器,返回 d_value
。当使用递归时,递归调用在某个点结束。当 recursiveCoro
函数结束时,会调用 State::return_void
。它不需要做任何事情,因此 PromiseBase
的空实现完全可以胜任。
Recursive
处理类的接口从第 12 行开始。其 d_handle
数据成员(第 14 行)由其构造函数(第 19 行)初始化,这就是构造函数需要做的全部工作。处理程序的析构函数只需调用 d_handle.destroy()
来释放 State
对象使用的内存。
其余成员 next
和 done
的实现也很简单。成员 done
将在递归实现 recursiveCoro
时很快被使用,它仅返回 d_handle.done()
的值。
当调用 next
成员时,协程处于挂起状态(这发生在它最初被调用时(参见上面 main
函数的第 3 行)以及在它使用 co_yield
时(参见上面 recursiveCoro
实现的第 11 行和第 16 行))。因此,它恢复协程,当协程再次挂起时,返回处理程序的 State
对象中存储的(下一个可用)值:
size_t Recursive::next()
{
d_handle.resume();
return d_handle.promise().value();
}
递归调用 recursiveCoro
现在,我们将非递归的 recursiveCoro
协程改成一个递归调用的协程。为了启用递归,我们在第 8 行下方添加了一些额外的语句来修改 recursiveCoro
:
1: Recursive recursiveCoro(bool recurse)
2: {
3: while (true)
4: {
5: for (size_t idx = 0; idx != 2; ++idx)
6: co_yield ++s_value;
7:
8: // 这里将进行递归调用
9:
10: if (not recurse)
11: break;
12:
13: Recursive rec = recursiveCoro(false);
14:
15: while (true)
16: {
17: size_t value = rec.next();
18: if (rec.done())
19: break;
20:
21: co_yield value;
22: }
23:
24: for (size_t idx = 0; idx != 2; ++idx)
25: co_yield ++s_value;
26: }
27: }
当参数 recurse
为 true
时,递归被激活,这个参数在 main
中首次调用 recursiveCoro
时传递。然后,在第 13 行递归调用 recursiveCoro
,此时参数为 false
。考虑递归调用时发生的情况:首先进入 while
循环,然后执行第 5 行的 for
循环,co_yield
两个值。接着,在第 10 行,循环结束,递归终止。这会隐式地调用 co_return
。也可以显式地做到这一点,如下所示:
if (not recurse)
co_return;
回到初始调用:一旦 rec
(第 13 行)可用,就进入一个嵌套的 while
循环(第 15 行),接收递归调用获得的下一个值(第 17 行)。这个下一个调用恢复了嵌套的协程,该协程在执行第 5 行的 for
循环时返回两个值。但当它第三次恢复时,它实际上不会 co_yield
一个新计算的值,而是调用 co_return
(因为第 10 行和第 11 行),从而结束递归调用。此时协程的 State
类的 done
成员返回 true
,这个值可以通过 rec.done()
(第 18 行)获得。一旦发生这种情况,第 15 行的 while
循环结束,非递归协程继续在第 24 行。如果递归调用的协程确实计算了一个值,rec.done()
返回 false
,由 rec
生成的值被 co_yield
到非递归调用的协程中,使其对 main
可用。因此,在这种情况下,递归调用协程 co_yield
的值被最初调用的协程 co_yield
,最终值在 main
中被收集:存在从最深层嵌套的协程到由 main
调用的协程的 co_yield
语句序列,在此时值最终在 main
中被收集。
next..done
的实现方式类似于读取流:首先尝试从流中提取信息。如果成功,则使用该值;如果没有,则做其他事情:
while (true)
{
cin >> value;
if (not cin)
break;
process(value);
}
像 getline
和重载提取运算符这样的函数可以将提取和测试结合起来。使用协程时,这当然也是可能的。将 next
定义为:
bool Recursive::next(size_t *value)
{
d_handle.resume();
if (d_handle.done()) // no more values
return false;
*value = d_handle.promise().value();
return true;
}
允许我们将第 15 行的 while
循环改为:
size_t value;
while (rec.next(&value))
co_yield value;
超越单一递归调用
在介绍部分中介绍了 fiboCoro
协程。在本节中,fiboCoro
协程将被 recursiveCoro
协程使用,使用多级递归。
为了集中于递归过程,fiboCoroutine
的处理程序在 main
中定义为全局对象,因此可以被每个递归调用直接使用。以下是 main
函数,使用 bool Recursive::next(size_t *value)
,并定义了全局对象 g_fibo
:
Fibo g_fibo = fiboCoro();
int main()
{
Recursive rec = recursiveCoro(0);
size_t value;
while (rec.next(&value))
{
cout << value << "\n"
"? ";
string line;
if (not getline(cin, line) or line == "q")
break;
}
}
Recursive
类的接口与前面开发的接口相同,除了 Recursive::done
成员(不再使用,因此被删除)和 next
成员的签名,如下所示。它的实现也相应地被修改:
bool Recursive::next(size_t *value)
{
d_handle.resume();
if (d_handle.done())
return false;
*value = d_handle.promise().value();
return true;
}
实际上,唯一需要修改的就是处理更深层递归的 recursiveCoro
协程。其修改后的版本如下:
1: Recursive recursiveCoro(size_t level)
2: {
3: while (true)
4: {
5: for (size_t idx = 0; idx != 2; ++idx)
6: co_yield g_fibo.next();
7:
8: if (level < 5)
9: {
10: Recursive rec = recursiveCoro(level + 1);
11: size_t value;
12: while (rec.next(&value))
13: co_yield value;
14: }
15:
16: for (size_t idx = 0; idx != 2; ++idx)
17: co_yield g_fibo.next();
18:
19: if (level > 0)
20: break;
21: }
22: }
这个实现与 1 级递归协程非常相似。现在允许多级递归,最大递归级别设置为 5。协程通过其 size_t level
参数知道自己的递归级别,并且只要 level
小于 5(第 8 行),它就会递归。每一级计算两个 fibonacci
值序列(在第 5 行和第 16 行的 for
语句中)。在第二个 for
语句之后,协程结束,除非它是从 main
调用的协程,此时 level
为 0。结束递归调用协程的决定在第 19 行做出。
在这个实现中,最大递归级别被设置为固定值。当然,也可以让协程自己决定进一步递归是否有意义。考虑检查目录项的情况,以及递归处理子目录的情况。递归目录访问协程可能具有如下实现:
Recursive recursiveCoro(string const &directory)
{
chdir(directory.c_str()); // 更改到该目录
while ((entry = nextEntry())) // 访问所有条目
{
string const &name = entry.name();
co_yield name; // 生成条目的名称
if (entry.type() == DIRECTORY) // 是目录吗?
{ // 获取完整路径
string path = pathName(directory, name);
co_yield path; // 生成完整路径
auto rec = recursiveCoro(path); // 访问子目录及其
string next; // 子目录的条目
while (rec.next(&next)) // 子目录条目
co_yield next; // 生成条目
}
}
}
在这个变体中(未在此处实现的)nextEntry
函数生成所有目录条目序列,如果条目表示一个目录(第 10 行),则递归执行相同的过程(第 15 行),将其条目生成到当前协程的调用者(第 18 行)。
协程迭代器
前面的示例主要使用 while
语句来获取协程返回的值,而许多通用算法(以及基于范围的 for
循环)依赖于 begin
和 end
成员函数返回迭代器。
协程(实际上是它们的处理类)也可以定义 begin
和 end
成员函数来返回迭代器。在实践中,这些迭代器通常是输入迭代器(参见第 18.2 节),提供对协程 co_yield
的值的访问。第 22.14 节规定了这些迭代器的要求。对于简单类型(如 size_t
,它是由 Fibo::next
co_yield
的),迭代器应提供以下成员:
- 前缀递增运算符:
Iterator &operator++();
- 解引用运算符:
size_t operator*() const;
- 比较运算符:
bool operator==(Iterator const &other)
和operator!=
(返回其补集)。
Iterator
类是一个值类。然而,除了复制和移动构造外,Iterator
对象只能由 Recursive
的 begin
和 end
成员函数构造。它有一个私有构造函数,并将 Recursive
声明为其友元:
class Iterator
{
friend bool operator==(Iterator const &lhs, Iterator const &rhs);
friend class Recursive;
Handle d_handle;
public:
Iterator &operator++();
size_t operator*() const;
private:
Iterator(Handle handle);
};
Iterator
的构造函数接收 Recursive::d_handle
,以便它可以使用自己的 d_handle
来控制 recursiveCoro
的行为:
Recursive::Iterator::Iterator(Handle handle)
:
d_handle(handle)
{}
成员函数 Recursive::begin
确保 Iterator::operator*
可以通过恢复协程立即提供下一个可用值。如果成功,它将 d_handle
传递给 Iterator
的构造函数。如果没有值,它返回 0,这是 Recursive::end
返回的 Iterator
也为 0:
Recursive::Iterator Recursive::begin()
{
if (d_handle.promise().level() == 0)
g_fibo.reset();
d_handle.resume();
return Iterator{ d_handle.done() ? 0 : d_handle };
}
Recursive::Iterator Recursive::end()
{
return Iterator{ 0 };
}
解引用运算符简单地调用并返回由 State::value()
返回的值,而前缀递增运算符恢复协程。如果没有生成值,它将 d_handle
赋值为 0,从而在与 Recursive::end
返回的迭代器进行比较时结果为 true
:
size_t Recursive::Iterator::operator*() const
{
return d_handle.promise().value();
}
Recursive::Iterator &Recursive::Iterator::operator++()
{
d_handle.resume();
if (d_handle.done())
d_handle = 0;
return *this;
}
使用协程访问目录
由于协程通常在生成一些中间但有用的结果后会被挂起,因此它们提供了一种替代基于栈的递归方法的方式。这一节介绍了一个协程,它访问所有元素的(嵌套)目录,列出所有相对于原始起始目录的路径名称。首先介绍一种更传统的方法,使用一个具有递归访问目录元素的成员的类。然后描述一个执行相同任务的协程。最后,讨论两种方法的执行时间统计信息。
Dir
类显示目录条目
在这里,开发了一个 Dir
类(递归地)显示指定目录及其子目录中的所有条目。程序定义了一个 Dir
类,并在 main
函数中使用:
int main(int argc, char **argv)
{
Dir dir{ argc == 1 ? "." : argv[1] };
while (char const *entryPath = dir.entry())
cout << entryPath << '\n';
}
Dir
类与下一节中的基于协程的实现类似,使用 dirent
C 结构。由于我们更喜欢以大写字母开头的类型名,Dir
使用 using DirEntry = dirent
,以便不用使用 C 的类型名。
Dir
只定义了几个数据成员:d_dirPtr
存储 C 函数 opendir
返回的指针;d_recursive
指向一个 Dir
条目,用于处理当前目录的子目录;d_entry
是 Dir::entry
成员返回的目录名称,每次调用时会刷新;d_path
存储 Dir
对象访问的目录名称;d_entryPath
是从初始目录名称开始的 d_entry
的路径名。以下是 Dir
的类接口:
class Dir
{
using DirEntry = dirent;
DIR *d_dirPtr = 0;
Dir *d_recursive = 0;
char const *d_entry; // 返回由 entry() 提供
std::string d_path; // Dir 的目录名,以 '/' 结尾
std::string d_entryPath;
public:
Dir(char const *dir); // dir: 要访问的目录名
~Dir();
char const *entry();
};
Dir
的构造函数为检查其参数中目录的条目准备了对象:它调用 opendir
以获取该目录,并准备其 d_path
数据成员:
Dir::Dir(char const *dir)
:
d_dirPtr(opendir(dir)), // 准备条目
d_path( dir )
{
if (d_path.back() != '/') // 确保目录以 '/' 结尾
d_path += '/';
}
一旦 Dir
对象的生命周期结束,其析构函数简单地调用 closedir
以释放由 opendir
分配的内存:
inline Dir::~Dir()
{
closedir(d_dirPtr);
}
成员函数 entry
执行两个任务:首先,如果递归是激活的,则如果存在递归条目,则返回该条目。否则,如果没有递归条目,则删除 d_recursive
的内存,并将 d_recursive
设置为 0:
// 第一部分
if (d_recursive) // 递归激活
{
if (char const *entry = d_recursive->entry()) // 下一个条目
return entry; // 返回它
delete d_recursive; // 删除对象
d_recursive = 0; // 结束递归
}
第二部分在没有递归或所有递归条目都已获取的情况下执行。在这种情况下,检索当前目录的所有条目,跳过两个简单的点条目。如果获得的条目名称是一个目录,则 d_recursive
存储一个新分配的 Dir
对象的地址(然后在 Dir::entry
的下一次调用中处理),并返回刚收到的条目名称:
// 第二部分
while (DirEntry const *dirEntry = readdir(d_dirPtr))// 访问所有条目
{
char const *name = dirEntry->d_name; // 获取名称
if (name == "."s or name == ".."s) // 忽略点名
continue;
name = (d_entryPath = d_path + name).c_str(); // 条目名称
// (作为路径)
if (dirEntry->d_type == DT_DIR) // 是子目录?
d_recursive = new Dir{ name }; // 下次处理它
return name; // 返回条目
}
成员函数 Dir::entry
本身由这两部分组成,当第二部分的 while
循环结束时返回零(没有更多条目):
char const *Dir::entry()
{
// 第一部分
// 第二部分
return 0;
}
因此,Dir
类本质上只需要一个成员函数,使用递归访问所有存在于指定起始目录中或其下的目录条目。该程序的所有源代码可以在分发的 yo/coroutines/demo/dir
目录中找到。
使用协程访问目录
在本节中,我们讨论了一个基于协程的实现程序,该程序递归地显示所有目录条目。该程序基于 Lewis Baker 的 cppcoro 库提供的功能。该程序的源文件可以在分发包的 yo/coroutines/demo/corodir
目录中找到。它使用与前一节相同的 DirEntry
类型定义,并指定了 using Pair = std::pair<DirEntry, char const *>
来访问 DirEntry
及其路径名。
程序的 main
函数与使用 Dir
类的 main
函数非常相似,但这次 main
使用了 visitAllEntries
协程:
int main(int argc, char **argv)
{
char const *path = argc == 1 ? "." : argv[1];
for (auto [entry, entryPath ]: visitAllEntries(path))
cout << entryPath << '\n';
}
main
函数使用范围-based for 循环来显示 visitAllEntries
协程产生的条目,这些条目是指定起始目录中(递归)找到的文件和目录。
程序使用了三个协程来处理目录。visitAllEntries
协程返回一个 RecursiveGenerator<Pair>
作为其处理程序。与 main
类似,visitAllEntries
协程也使用范围-based for 循环(第 3 行)来检索目录条目。该协程产生 Pair
对象(第 5 行)或来自嵌套目录的结果(第 9 行)。其处理程序(一个 RecursiveGenerator
)是一个类模板,在 Lewis Baker 的 cppcoro 库中定义:
1: RecursiveGenerator<Pair> visitAllEntries(char const *path)
2: {
3: for (auto &entry_pair: dirPathEntries(path))
4: {
5: co_yield entry_pair;
6:
7: auto [entry, entry_path] = entry_pair;
8: if (entry.d_type == DT_DIR)
9: co_yield visitAllEntries(entry_path);
10: }
11: }
目录条目由第二个协程 dirPathEntries
提供。每次条目时 visitAllEntries
会挂起(第 5 行),允许 main
显示其完整路径。在第 7 行和第 8 行检查条目的类型。如果接收到的条目指的是子目录,则 visitAllEntries
递归地调用自己,从而生成子目录的条目。一旦所有条目处理完毕,范围-based for 循环结束,协程通过自动调用 co_return
结束。
生成目录条目的协程是 dirPathEntries
,其处理程序是另一个 cppcoro 类 Generator<Pair>
的对象:
1: Generator<Pair> dirPathEntries(char const *path)
2: {
3: for (auto const &entry: dirEntries(path))
4: co_yield make_pair(entry,
5: (string{path} + '/' + entry.d_name).c_str());
6: }
dirPathEntries
协程执行一个表面上的任务:它接收一个目录的路径名,并调用第三个协程 dirEntries
来检索该目录的后续元素(第 3 行)。只要还有条目,协程会挂起,生成由 dirEntry
返回的值和这些条目的完整路径名的 Pair
对象(第 4 行和第 5 行)。最终,与 visitAllEntries
一样,co_return
结束协程。
第三个协程是 dirEntries
,返回一个 Generator<DirEntry>
处理程序:
1: Generator<DirEntry> dirEntries(char const *path)
2: {
3: DIR *dirPtr = opendir(path);
4:
5: while (auto entry = readdir(dirPtr))
6: {
7: if (accept(*entry))
8: co_yield *entry;
9: }
10: closedir(dirPtr);
11: }
这个协程,如同上一节中的 Dir
类一样,使用 C 的 opendir
、readdir
和 closedir
函数组。当 dirEntries
启动时,它调用 opendir
(第 3 行)。然后,只要有条目(第 5 行)并且这些条目既不是当前目录也不是父目录(第 7 行,由 accept
检查,未在此列出),协程会挂起,生成获取到的条目(第 8 行)。其 while
循环在所有条目都被检索完毕后结束。这时调用 closedir
(第 10 行),协程结束。
函数与协程
协程可能被视为普通函数的强有力竞争者。毕竟,与连续激活的函数相比,协程不需要反复处理栈:co_yield
只是挂起协程,将所有数据保留在堆上,随时可以(重新)使用。
在许多情况下,协程在直观上被认为更具吸引力:在本章的引言部分,它们被描述为协作例程,协程与调用者合作,提供调用者请求的信息,仿佛协程是调用者代码的一部分。尽管它正式上不是,但可以在概念上认为它是调用者代码的一部分,因为它不需要在调用者的生命周期内反复调用:一旦激活,协程会保留在内存中,不需要再次调用。协程在这个意义上是合作的:它们实际上可以被视为调用者代码的一部分。这与独立函数不同,后者每次调用都从头开始,这意味着频繁的栈操作。
另一方面,使用协程比使用传统函数要复杂得多。讨论的 Dir
类的源文件大约需要 100 行代码,而基于协程的实现则需要约 700 行代码。但这可能不是一个公平的比较。也许 cppcoro 库的源代码不应该被考虑在内,就像我们使用字符串、流和向量时,也忽略了它们的源代码大小一样。如果忽略 cppcoro 源码的大小,那么基于协程的实现实际上比 Dir
类需要更少的代码行数,因为 Generator
和 RecursiveGenerator
处理程序类由 cppcoro 库提供。
最终,使用协程实现算法的部分,而不是使用基于函数的结构化编程方法,可能仅仅是个人喜好的问题。但也许,由于协程允许我们将算法拆分成不使用栈激活的独立部分,协程实现的效率可能超过使用独立辅助函数的实现。为了获取有关协程与使用独立辅助函数的程序效率的信息,将 Dir
类程序和 coroDir
程序各运行了五次,处理了一个包含超过 40 万条条目的大型多目录、多文件结构。每次运行的执行时间高度相似。下表显示了 Dir
类程序和 coroDir
程序的平均时钟时间、平均用户时间和平均系统时间:
时间 | 实际时间 | 用户时间 | 系统时间 |
---|---|---|---|
class Dir | 82 | 22 | 59 |
coroDir | 88 | 25 | 62 |
尽管 Dir
类实现所用时间略少于 coroDir
实现,但差异很小,不应被解释为协程实现天生比函数实现慢的迹象。此外,协程本身通常会调用普通函数(例如 coroDir
协程 dirEntries
调用的 readdir
),这些函数仍然需要栈处理。
因此,本章的结论是,虽然 C++ 中确实提供了协程,但在使用之前需要付出大量的努力。一些库(如 cppcoro)是可用的,但它们还不是 C++ 编译器标准软件的一部分。然而,能够使用协作例程的基本理念确实很有吸引力,尽管这并不一定会导致比使用传统结构化编程方法开发的程序更高效。因此,是否使用协程最终可能仅仅是个人喜好的问题。
具体示例
在本章中,展示了一些 C++ 程序、类和模板的具体示例。本章涵盖了 C++ 注释中的主题,如虚函数、静态成员等。这些示例大致遵循了早期章节的组织结构。
作为额外的主题,本章不仅提供了 C++ 的示例,还涉及了扫描器和解析器生成器的主题。我们展示了这些工具如何在 C++ 程序中使用。这些额外的示例假设读者对这些工具的基本概念有所了解,如文法、语法树和语法树装饰。当程序的输入复杂度达到一定程度时,使用扫描器和解析器生成器来生成处理实际输入的代码是很有吸引力的。本章中的一个示例描述了在 C++ 环境中使用这些工具的情况。
使用 streambuf
类处理文件描述符
25.1.1 输出操作的类
读取和写入文件描述符的操作并不是 C++ 标准的一部分。然而,在大多数操作系统中,文件描述符是可用的,并且可以被视为一种设备。因此,使用 std::streambuf
类作为构建与文件描述符设备接口的类的起点似乎是很自然的选择。
下面我们将构建几个类,用于将数据写入给定的文件描述符。设备可以是文件,也可以是管道或套接字。第 25.1.2 节将涵盖从这些设备读取数据;第 25.2.3 节重新考虑了之前讨论过的重定向问题(第 6.6.2 节)。
使用 streambuf
类作为基类,我们可以相对容易地设计用于输出操作的类。唯一需要重写的成员函数是虚函数 int streambuf::overflow(int c)
。这个成员的责任是将字符写入设备。
如果 fd
是一个输出文件描述符,并且不需要缓冲输出,则可以简单地将 overflow()
实现如下:
class UnbufferedFD: public std::streambuf
{
public:
int overflow(int c) override;
// 其他成员函数和数据成员
};
int UnbufferedFD::overflow(int c)
{
if (c != EOF)
{
if (write(d_fd, &c, 1) != 1)
return EOF;
}
return c;
}
overflow
函数接收到的参数要么写入文件描述符并从 overflow
返回,要么返回 EOF。
这个简单的函数不使用输出缓冲。由于各种原因,使用缓冲通常是一个好主意(见下一节)。
当使用输出缓冲时,overflow
成员会复杂一些,因为它仅在缓冲区满时被调用。缓冲区满时,我们首先需要刷新缓冲区。刷新缓冲区是虚函数 streambuf::sync
的责任。由于 sync
是虚函数,派生自 streambuf
的类可以重写 sync
以刷新 streambuf
自身不知道的缓冲区。
重写 sync
并在 overflow
中使用它并不是全部工作。当定义缓冲区的类的对象生命周期结束时,缓冲区可能只是部分满。在这种情况下,缓冲区也必须被刷新。这可以通过简单地在类的析构函数中调用 sync
来完成。
考虑到使用输出缓冲的后果后,我们几乎准备好设计我们的派生类了。不过,还需添加几个额外的特性:
- 首先,我们应该允许用户指定输出缓冲区的大小。
- 其次,在实际文件描述符尚未确定之前,应该能够构造类的对象。稍后在第 25.2 节中我们将遇到这种情况。
为了节省 C++ 注释中的空间,示例代码中未检查函数的成功完成。在实际实现中,当然不应该省略这些检查。我们的类 OFdnStreambuf
具有以下特征:
- 它的成员函数使用对文件描述符操作的低级函数。因此,除了
streambuf
外,编译器还必须读取<unistd.h>
头文件才能编译其成员函数。 - 该类派生自
std::streambuf
。 - 它定义了三个数据成员。这些数据成员分别跟踪缓冲区的大小、文件描述符和缓冲区本身。以下是完整的类接口:
class OFdnStreambuf: public std::streambuf
{
int d_fd = -1;
size_t d_bufsize = 0;
char *d_buffer = 0;
public:
OFdnStreambuf() = default;
OFdnStreambuf(int fd, size_t bufsize = 1);
~OFdnStreambuf() override;
void open(int fd, size_t bufsize = 1);
private:
int sync() override;
int overflow(int c) override;
};
- 默认构造函数仅将缓冲区初始化为 0。稍微有趣的是它的构造函数,该构造函数期望一个文件描述符和一个缓冲区大小。该构造函数将参数传递给类的
open
成员(见下文)。以下是构造函数的实现:
inline OFdnStreambuf::OFdnStreambuf(int fd, size_t bufsize)
{
open(fd, bufsize);
}
- 析构函数调用
sync
,将存储在输出缓冲区中的任何字符刷新到设备。在不使用缓冲区的实现中,析构函数可以有一个默认实现:
inline OFdnStreambuf::~OFdnStreambuf()
{
sync();
delete[] d_buffer;
}
该实现不会关闭设备。留给读者的练习是将该类更改为使设备可以选择性地关闭(或选择性地保持打开)。这种方法被例如 Bobcat 库采用。参见第 25.1.2.2 节。
open
成员初始化缓冲区。使用streambuf::setp
定义缓冲区的起始和结束点。这用于初始化streambuf::pbase
、streambuf::pptr
和streambuf::epptr
:
inline void OFdnStreambuf::open(int fd, size_t bufsize)
{
d_fd = fd;
d_bufsize = bufsize == 0 ? 1 : bufsize;
delete[] d_buffer;
d_buffer = new char[d_bufsize];
setp(d_buffer, d_buffer + d_bufsize);
}
sync
成员将尚未刷新的缓冲区内容刷新到设备。刷新后,使用setp
重新初始化缓冲区。成功刷新缓冲区后,sync
返回 0:
inline int OFdnStreambuf::sync()
{
if (pptr() > pbase())
{
write(d_fd, d_buffer, pptr() - pbase());
setp(d_buffer, d_buffer + d_bufsize);
}
return 0;
}
streambuf::overflow
成员也被重写。由于这个成员在缓冲区满时由streambuf
基类调用,因此它应该首先调用sync
将缓冲区刷新到设备。接下来,它应将字符c
写入(现在为空的)缓冲区。字符c
应使用pptr
和streambuf::pbump
写入。进入缓冲区的字符应该使用现有的streambuf
成员函数,而不是手动实现,因为这样做可能会使streambuf
的内部记录失效。以下是overflow
的实现:
inline int OFdnStreambuf::overflow(int c)
{
sync();
if (c != EOF)
{
*pptr() = c;
pbump(1);
}
return c;
}
接下来的程序使用 OFdnStreambuf
类将标准输入复制到文件描述符 STDOUT_FILENO
,这是用于标准输出的文件描述符的符号名称:
#include <string>
#include <iostream>
#include <istream>
#include "fdout.h"
using namespace std;
int main(int argc, char **argv)
{
OFdnStreambuf fds(STDOUT_FILENO, 500);
ostream os(&fds);
switch (argc)
{
case 1:
for (string s; getline(cin, s); )
os << s << '\n';
os << "COPIED cin LINE BY LINE\n";
break;
case 2:
cin >> os.rdbuf();
// 另一种选择是:cin >> &fds;
os << "COPIED cin BY EXTRACTING TO os.rdbuf()\n";
break;
case 3:
os << cin.rdbuf();
os << "COPIED cin BY INSERTING cin.rdbuf() into os\n";
break;
}
}