c++高级编程学习笔记7

深入了解模板参数

实际上有 3 种模板参数: 类型参数、非类型参数和 template template 参数(这里没有重复,确实就是这个名称)。第 12 章曾列举类型参数和非类型参数的例子,但没有见过 template template 参数。这一章也有一些第 12章没有涉及的有关类型参数和非类型参数的为手问题。下面深入探讨这三类模板参数。

深入了解模板类型参数

模板的类型参数是模板的精髓。可声明任意数目的类型参数。例如,可给第 12 章的网格模板添加第二个类型参数,以表示这个网格构建于另一个模板化的类容器之上。标准库定义了几个模板化的容器类,包括 vector 和 deque。原始的网格类使用 vector 的 vector 存储网格的元素,Grid 类的用户可能想使用 deque 的 vector。通过另一个模板的类型参数,可以让用户指定底层容器是 vector 还是 deque。下面是带有额外模板参数的类定义:

template <typename T,typename Container>
class Grid
{
 	public:
    	explicit Grid(size_t width = kDefaultWidth,
                      size_t height = kDefaultHeight);
    	virtual ~Grid() = default;
    	
    	//Explicity default a copy constructor and assignment operator
    	Grid(const Grid& src) = default;
    	Grid<T,Container>& operator=(const Grid& rhs) = default;
    	
    	//Explicitly default a move constructor and assignment operator
    	Grid(Grid&& src) = default;
    	Grid<T,Container>& operator=(Grid&& rhs) = default;
    	
    	typename Container::value_type& at(size_t x,size_t y);
    	const typename Container::value_type& at(size_t x,size_t y) const;
    	size_t getHeight() const{return mHeight;}
    	size_t getWidth() const{return mWidth;}
    
    	static const size_t kDefaultWidth = 10;
    	static const size_t kDefaultHeight = 10;
    private:
    	void verifyCoordinate(size_t x,size_t y) const;
    	std::vector<Container> mCells;
    	size_t mWidth = 0,mHeight = 0;
};

现在这个模板有两个参数: T 和 Container。因此,所有引用了 Grid的地方现在都必须指定 Grid<T,Container>以表示两个模板参数。其他仅有的变化是, mCells 现在是 Container 的 vector, 而不是 vector 的 vector。下面是构造函数的定义:

template<typename T,typename Container>
Grid<T,Container>::Grid(size_t width,size_t height)
    :mWidth(width),mHeight(height)
    {
        mCells.resize(mWidth);
        for(auto& column : mCells)
        {
            column.resize(mHeight);
        }
    }

这个构造函数假设 Container 类型具有 resize()方法。如果尝试通过指定没有 resize()方法的类型来实例化这个模板,编译器将生成错误。at()方法的返回类型是存储在给定类型容器中的元素类型。可以使用 typename Container:value_type 访问该下面是其余方法的实现:

template<typename T,typename Container>
void Grid<T,Container>::verifyCoordinate(size_t x,size_t y) const
{
	if(x >= mWidth || y >= mHeight)
	{
		throw std::out_of_range("");
	}
}
template<typename T,typename Container>
const typename Container::value_type& 
	Grid<T,Container>::at(size_t x,size_t y) const
{
	verifyCoordinate(x,y);
	return mCells[x][y];
}

template<typename T,typename Container>
typename Container::value_type&
Grid<T,Container>::at(size_t x,size_t y)
{
    return const_cast<typename Container::value_type&>(std::as_const(*this).at(x,y));
}

现在,可按以下方式实例化和使用 Grid 对象

Grid<int,vector<optional<int>>> myIntVectorGrid;
Grid<int,queue<optional<int>>> myIntDequeGrid;

myIntVectorGrid.at(3,4) = 5;
cout<<myIntVectorGrid.at(3,4).value_or(0)<<endl;

myIntDequeGrid.at(1,2) = 3;
cout<<myIntVectorGrid.at(1,2).value_or(0)<<endl;

Grid<int,vector<optional<int>>> grid2(myIntVectorGrid);
grid2 = myIntVectorGrid;

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp -std=c++17
test.cpp: In instantiation of ‘Grid<T, Container>::Grid(size_t, size_t) [with T = int; Container = std::queue<std::optional<int> >; size_t = long unsigned int]’:
test.cpp:77:36:   required from here
test.cpp:47:20: error: ‘class std::queue<std::optional<int> >’ has no member named ‘resize’; did you mean ‘size’?
   47 |             column.resize(mHeight);
      |             ~~~~~~~^~~~~~
      |             size
test.cpp: In instantiation of ‘const typename Container::value_type& Grid<T, Container>::at(size_t, size_t) const [with T = int; Container = std::queue<std::optional<int> >; typename Container::value_type = std::optional<int>; size_t = long unsigned int]’:
test.cpp:72:12:   required from ‘typename Container::value_type& Grid<T, Container>::at(size_t, size_t) [with T = int; Container = std::queue<std::optional<int> >; typename Container::value_type = std::optional<int>; size_t = long unsigned int]’
test.cpp:82:26:   required from here
test.cpp:65:21: error: no match for ‘operator[]’ (operand types are ‘const value_type’ {aka ‘const std::queue<std::optional<int> >’} and ‘size_t’ {aka ‘long unsigned int’})
   65 |     return mCells[x][y];
      |            ~~~~~~~~~^
xz@xiaqiu:~/study/test/test$ 

代码

int main()
{
    Grid<int, vector<optional<int>>> myIntVectorGrid;
    //Grid<int, queue<optional<int>>> myIntDequeGrid;

    myIntVectorGrid.at(3, 4) = 5;
    cout << myIntVectorGrid.at(3, 4).value_or(0) << endl;

    // myIntDequeGrid.at(1, 2) = 3;
    // cout << myIntVectorGrid.at(1, 2).value_or(0) << endl;

    Grid<int, vector<optional<int>>> grid2(myIntVectorGrid);
    grid2 = myIntVectorGrid;
    
    //Grid<int,int> test; // WILL NOT COMPILE
    return 0;
}

给参数名称使用 Container 并不意味着类型必须是容器。可尝试用 int 实例化 Grid 类:

Grid<intint> test; // WILL NOT COMPILE

此行代码无法成功编译,但编译器可能不会给出期望的错误。编译器不会报错说第二个类型参数不是容器而是 int,而是给出古怪的错误。例如,Microsoft Visual C++报告“Container": must be a class or namespace when followed by '“::”。这是因为编译器尝试生成将 int 当成 Container 的 Grid 类。在尝试处理类模板定义的这一行之前,一切都正常;

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp -std=c++17
test.cpp: In instantiation of ‘class Grid<int, int>:
test.cpp:90:19:   required from here
test.cpp:70:1: error:int’ is not a class, struct, or union type
   70 | Grid<T, Container>::at(size_t x, size_t y)
      | ^~~~~~~~~~~~~~~~~~
test.cpp:62:1: error:int’ is not a class, struct, or union type
   62 | Grid<T, Container>::at(size_t x, size_t y) const
      | ^~~~~~~~~~~~~~~~~~
test.cpp: In instantiation of ‘Grid<T, Container>::Grid(size_t, size_t) [with T = int; Container = int; size_t = long unsigned int]:
test.cpp:90:19:   required from here
test.cpp:48:16: error: request for member ‘resize’ in ‘column’, which is of non-class typeint48 |         column.resize(mHeight);
      |         ~~~~~~~^~~~~~
xz@xiaqiu:~/study/test/test$ 
typename Container::value_type& at(size_t x,size_t y);

在这一行,编译器意识到 column 是 int 类型,没有嵌入的 value_type 类型别名。与函数参数一样,可给模板参数指定默认值。例如,可能想表示 Grid 的默认容器是 vector。这个模板类定义如下所示:

template <typename T,typename Container = std::vector<std::optional<T>>>
class Grid
{
    //Everything else is the same as before
};

可以使用第一个模板参数中的类型 T 作为第二个模板参数的默认值中 optional 模板的参数。C++语法要求不能在方法定义的模板标题行中重复默认值。现在有了这个默认参数后,实例化网格时,客户可指定或不指定底层容器:

Grid<int, deque<optional<int>>> myDequeGrid;
Grid<int, vector<optional<int>>> myVectorGrid;
Grid<int> myVectorGrid2(myVectorGrid);

template template 参数介绍

节讨论的 Container 参数还存在一个问题。当实例化类模板时,这样编写代码:

Grid<int,vector<optional<int>>> myIntGrid;

请注意 int 类型的重复。必须在 vector 中同时为 Grid 和 vector 指定元素类型。如果编写了下面这样的代码,会怎样?

Grid<int,vector<optional<SpreadsheetCell>>>myIntGrid;

这不能很好地工作。如果能编写以下代码就好了,这样就不会出现此类错误:

Grid<int,vector>myIntGrid;

Grid 类应该能够判断出需要一个元素类型为int 的 optional vector。不过编译器不会允许传递这样的参数给普通的类型参数,因为 vector 本身并不是类型,而是模板。如果想要接收模板作为模板参数,那么必须使用一种特殊参数,称为 template template 参数。指定 template template 参数,有点像在普通函数中指定函数指针参数。函数指针的类型包括函数的返回类型和参数类型。同样,指定 template template 参数时,template template 参数的完整规范包括该模板的参数。

例如,vector 和 deque 等容器有一个模板参数列表,如下所示。E 参数是元素类型,Allocator 参数参见第17 章。

template<typename E,typename Allocator = std::allocator<E>>
class vector
{
	//vector definition
};

要把这样的容器传递为 template template 参数,只能复制并粘贴类模板的声明(在本例中是 template<typename E, typename Allocator=allocator> class vector,用参数名(Container)替代类名(vector),并把它用作另一个模板声明的 template template 参数(本例中的 Grid),而不是简单的类型名。有了前面的模板规范,下面是接收一个容器模板作为第二个模板参数的 Grid 类的类模板定义:

template<typename T,
	template<typename E,
			 typename Allocate = std::allocator<E>> class Container = std::vector>
    class Grid
    {
		public:
        	// Omitted code that is the same as before
			std::optional<T>& at(size_t x,size_t y);
        	const std::optional<T>& at(size_t x,size_t y) const;
        	// Omitted code that is the same as before
		private:
        	void verifyCoordinate(size_t x,size_t y) const;
        	
        	std::vector<Container<std::optional<T>>> mCells;
        	size_t mWidth = 0,mHeight = 0;
    };

这里是怎么回事? 第一个模板参数与以前一样: 元素类型 T。第二个模板参数现在本身就是容器的模板,如 vector 或 deque。如前所述,这种“模板类型”必须接收两个参数: 元素类型 E 和分配器类型。注意嵌套模板参数列表后面重复的单词 class。这个参数在 Grid 模板中的名称是 Container。默认值现为 vector 而不是 vector,因为 Container 是模板而不是实际类型。

template template 参数更通用的语法规则是:

template <..., template <TemplateTypeParams> class ParameterName, ...>

从 C++17 开始,也可以用 typename 关键字替代 class,如下所示:

template <..., template <TemplateTypeParams> typename ParameterName, ...>

在代码中不要使用 Container 本身, 而必须把 Container<std::optional>指定为容器类型.例如,现在 mCells的声明如下:

std::vector<Container<std::optional<T>>> mCells;

不需要更改方法定义,但必须更改模板行,例如:

template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= mWidth || y >= mHeight) 
    {
	    throw std::out_of_range("");
    }
}

可以这样使用 Grid 模板:

Grid<int,vector>myGrid;
myGrid.at(1,2) = 3;
cout<<myGrid.at(1,2).value_or(0)<<endl;
Grid<int,vector>myGrid2(myGrid);

代码

#include <cstddef>
#include <stdexcept>
#include <vector>
#include <optional>
#include <utility>
#include <deque>
#include <iostream>
using namespace std;

template <typename T,
          template <typename E, typename Allocator = std::allocator<E>> class Container = std::vector>
class Grid
{
public:
    explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
    virtual ~Grid() = default;

    // Explicitly default a copy constructor and assignment operator.
    Grid(const Grid &src) = default;
    Grid<T, Container> &operator=(const Grid &rhs) = default;

    // Explicitly default a move constructor and assignment operator.
    Grid(Grid &&src) = default;
    Grid<T, Container> &operator=(Grid &&rhs) = default;

    std::optional<T> &at(size_t x, size_t y);
    const std::optional<T> &at(size_t x, size_t y) const;

    size_t getHeight() const { return mHeight; }
    size_t getWidth() const { return mWidth; }

    static const size_t kDefaultWidth = 10;
    static const size_t kDefaultHeight = 10;

private:
    void verifyCoordinate(size_t x, size_t y) const;

    std::vector<Container<std::optional<T>>> mCells;
    size_t mWidth = 0, mHeight = 0;
};

template <typename T, template <typename E, typename Allocator = std::allocator<E>> class Container>
Grid<T, Container>::Grid(size_t width, size_t height)
    : mWidth(width)
    , mHeight(height)
{
    mCells.resize(mWidth);
    for (auto &column : mCells)
    {
        column.resize(mHeight);
    }
}

template <typename T, template <typename E, typename Allocator = std::allocator<E>> class Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= mWidth || y >= mHeight)
    {
        throw std::out_of_range("");
    }
}

template <typename T, template <typename E, typename Allocator = std::allocator<E>> class Container>
const std::optional<T> &Grid<T, Container>::at(size_t x, size_t y) const
{
    verifyCoordinate(x, y);
    return mCells[x][y];
}

template <typename T, template <typename E, typename Allocator = std::allocator<E>> class Container>
std::optional<T> &Grid<T, Container>::at(size_t x, size_t y)
{
    return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}

int main()
{
    Grid<int, vector>myGrid;
    myGrid.at(1, 2) = 3;
    cout << myGrid.at(1, 2).value_or(0) << endl;
    Grid<int, vector>myGrid2(myGrid);
    return 0;
}

输出

xz@xiaqiu:~/study/test/test$ ./test3xz@xiaqiu:~/study/test/test$ 

注意

上述 C++语法有点令人费解,因为它试图获得最大的灵活性。尽量不要在这里陷入语法困境,记住主要概念: 可向其他模板传入模板作为参数。

深入了解非类型模板参数

有时可能想让用户指定一个默认元素,用来初始化网格中的每个单元格。下面是实现这个目标的一种完全合理的方法,它使用T()作为第二个模板参数的默认值;

template<typename T,const T DEFAULT = T()>
class Grid
{
public:
    explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
    virtual ~Grid() = default;

    // Explicitly default a copy constructor and assignment operator.
    Grid(const Grid& src) = default;
    Grid<T, DEFAULT>& operator=(const Grid& rhs) = default;

    // Explicitly default a move constructor and assignment operator.
    Grid(Grid&& src) = default;
    Grid<T, DEFAULT>& operator=(Grid&& rhs) = default;

    std::optional<T>& at(size_t x, size_t y);
    const std::optional<T>& at(size_t x, size_t y) const;

    size_t getHeight() const { return mHeight; }
    size_t getWidth() const { return mWidth; }

    static const size_t kDefaultWidth = 10;
    static const size_t kDefaultHeight = 10;

private:
    void verifyCoordinate(size_t x, size_t y) const;

    std::vector<std::vector<std::optional<T>>> mCells;
    size_t mWidth = 0, mHeight = 0;
};

这个定义是合法的。可使用第一个参数中的类型 T 作为第二个参数的类型,非类型参数可为 const,就像函数参数一样。可使用T的初始值来初始化网格中的每个单元格:

template<typename T,const T DEFAULT>
Grid<T,DEFAULT>::Grid(size_t width,size_t height)
:mWidth(width),mHeight(height)
{
	mCells.resize(mWidth);
    for(auto& column : mCells)
    {
        column.resize(mHeight);
        for(auto& element : column)
        {
            element = DEFAULT;
        }
    }
}

其他的方法定义保持不变,只是必须向模板行添加第二个模板参数,所有 Grid实例要变为 Grid<T,DEFAULT>。完成这些修改后,可实例化一个 int 网格,并为所有元素设置初始值;

Grid<int> myIntGrid; //Initial value is 0
Grid<int,10>myIntGrid2; //Initial value is 10

初始值可以是任何整数。但是,假设尝试创建一个 SpreasheetCell 网格:

SpreadsheetCell defaultCell;Grid<SpreadsheetCell,defaultCell> mySpreadsheet; //WILL NOT COMPILE

这会导致编译错误,因为不能向非类型参数传递对象作为参数。

int main(){    Grid<int> myIntGrid; //Initial value is 0    Grid<int,10>myIntGrid2; //Initial value is 10    Grid<int,10.0>myIntGrid2; //Initial value is 10    // SpreadsheetCell defaultCell;    // Grid<SpreadsheetCell,defaultCell> mySpreadsheet; //WILL NOT COMPILE    return 0;}

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp SpreadsheetCell.cpp -std=c++17test.cpp: In function ‘int main():test.cpp:60:18: error: conversion from ‘double’ to ‘int’ in a converted constant expression   60 |     Grid<int,10.0>myIntGrid2; //Initial value is 10      |                  ^test.cpp:60:18: error: could not convert ‘1.0e+1’ from ‘double’ to ‘int’test.cpp:60:19: error: conflicting declaration ‘int myIntGrid2’   60 |     Grid<int,10.0>myIntGrid2; //Initial value is 10      |                   ^~~~~~~~~~test.cpp:59:17: note: previous declaration as ‘Grid<int, 10> myIntGrid2’   59 |     Grid<int,10>myIntGrid2; //Initial value is 10      |                 ^~~~~~~~~~xz@xiaqiu:~/study/test/test$ 

警告

非类型参数不能是对象,甚至不能是 double 和 float 值。非类型参数被限定为整型、枚举、指针和引用。这个例子展示了模板类的一种奇怪行为 可正常用于一种类型,但另一种类型却会编译失败。允许用户指定网格初始元素值的一种更详尽方式是使用引用作为非类型模板参数。下面是新的类定义:

template<typename T,const T& DEFAULT>class Grid{ public:    explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);    virtual ~Grid() = default;    // Explicitly default a copy constructor and assignment operator.    Grid(const Grid &src) = default;    Grid<T, DEFAULT> &operator=(const Grid &rhs) = default;    // Explicitly default a move constructor and assignment operator.    Grid(Grid &&src) = default;    Grid<T, DEFAULT> &operator=(Grid &&rhs) = default;    std::optional<T> &at(size_t x, size_t y);    const std::optional<T> &at(size_t x, size_t y) const;    size_t getHeight() const { return mHeight; }    size_t getWidth() const { return mWidth; }    static const size_t kDefaultWidth = 10;    static const size_t kDefaultHeight = 10;private:    void verifyCoordinate(size_t x, size_t y) const;    std::vector<std::vector<std::optional<T>>> mCells;    size_t mWidth = 0, mHeight = 0;};template <typename T, const T &DEFAULT>Grid<T, DEFAULT>::Grid(size_t width, size_t height)    : mWidth(width)    , mHeight(height){    mCells.resize(mWidth);    for (auto &column : mCells)    {        column.resize(mHeight);        for (auto &element : column)        {            element = DEFAULT;        }    }}template <typename T, const T &DEFAULT>void Grid<T, DEFAULT>::verifyCoordinate(size_t x, size_t y) const{    if (x >= mWidth || y >= mHeight)    {        throw std::out_of_range("");    }}template <typename T, const T &DEFAULT>const std::optional<T> &Grid<T, DEFAULT>::at(size_t x, size_t y) const{    verifyCoordinate(x, y);    return mCells[x][y];}template <typename T, const T &DEFAULT>std::optional<T> &Grid<T, DEFAULT>::at(size_t x, size_t y){    return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}

现在可为任何类型实例化这个模板类。C++17 标准指定,作为第二个模板参数传入的引用必须是转换的常量表达式(模板参数类型),不允许引用子对象、临时对象、字符串字面量、typeid 表达式的结果或预定义的_func_ 变量。下例声明了带有初始值的 int 网格和 SpreadsheetCell 网格。

int main(){	int defaultInt = 1;	Grid<int,defaultInt>myIntGrid;		SpreadsheetCell defaultCell(1.2);	Grid<SpreadsheetCell,defaultCell> mySpreadsheet;	return 0;}

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp SpreadsheetCell.cpp -std=c++17test.cpp: In function ‘int main():test.cpp:81:24: error:& defaultInt’ is not a valid template argument of type ‘const int&’ because ‘defaultInt’ is not a variable   81 |     Grid<int,defaultInt>myIntGrid;      |                        ^test.cpp:84:37: error:& defaultCell’ is not a valid template argument of type ‘const SpreadsheetCell&’ because ‘defaultCell’ is not a variable   84 |     Grid<SpreadsheetCell,defaultCell> mySpreadsheet;      |                                     ^xz@xiaqiu:~/study/test/test$ 

声明为static就可以编译通过

int main(){    static int defaultInt = 1;    Grid<int,defaultInt>myIntGrid;        static SpreadsheetCell defaultCell(1.2);    Grid<SpreadsheetCell,defaultCell> mySpreadsheet;    return 0;}

但这些是 C++17 的规则,大多数编译器尚未实施这些规则。在 C++17 之前,传给引用非类型模板参数的实参不能是临时的,不能是无链接(外部或内部)的命名左值。因此,对于上面的示例,下面使用 C++17 之前的规则。使用内部链接定义初始值:

namespace {    int defaultInt = 11;    SpreadsheetCell defaultCell(1.2);}int main(){    Grid<int, defaultInt> myIntGrid;    Grid<SpreadsheetCell, defaultCell> mySpreadsheet;    return 0;}

模板类部分特例化

第 12 章中 const char*类的特例化被称为完整模板类特例化,因为它对 Grid 模板中的每个模板参数进行了特例化。在这个特例化中没有剩下任何模板参数。这并不是特例化类的唯一方式, 还可编写部分特例化的类,这个类允许特例化部分模板参数,而不处理其他参数。例如,基本版本的 Grid 模板带有宽度和高度的非类型参数:

Grid.h

template <typename T, size_t WIDTH, size_t HEIGHT>class Grid{public:    Grid() = default;    virtual ~Grid() = default;    // Explicitly default a copy constructor and assignment operator.    Grid(const Grid& src) = default;    Grid& operator=(const Grid& rhs) = default;    std::optional<T>& at(size_t x, size_t y);    const std::optional<T>& at(size_t x, size_t y) const;    size_t getHeight() const { return HEIGHT; }    size_t getWidth() const { return WIDTH; }private:    void verifyCoordinate(size_t x, size_t y) const;    std::optional<T> mCells[WIDTH][HEIGHT];};template <typename T, size_t WIDTH, size_t HEIGHT>void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(size_t x, size_t y) const{	if (x >= WIDTH || y >= HEIGHT) {		throw std::out_of_range("");	}}template <typename T, size_t WIDTH, size_t HEIGHT>const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const{	verifyCoordinate(x, y);	return mCells[x][y];}template <typename T, size_t WIDTH, size_t HEIGHT>std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y){	return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}

可采用这种方式为 char* C 风格字符串特例化这个模板类;

#include "Grid.h" // The file containing the Grid template definitiontemplate <size_t WIDTH, size_t HEIGHT>class Grid<const char*, WIDTH, HEIGHT>{public:    Grid() = default;    virtual ~Grid() = default;    // Explicitly default a copy constructor and assignment operator.    Grid(const Grid& src) = default;    Grid& operator=(const Grid& rhs) = default;    std::optional<std::string>& at(size_t x, size_t y);    const std::optional<std::string>& at(size_t x, size_t y) const;    size_t getHeight() const { return HEIGHT; }    size_t getWidth() const { return WIDTH; }private:    void verifyCoordinate(size_t x, size_t y) const;    std::optional<std::string> mCells[WIDTH][HEIGHT];};template <size_t WIDTH, size_t HEIGHT>void Grid<const char*, WIDTH, HEIGHT>::verifyCoordinate(size_t x,                                                         size_t y) const{	if (x >= WIDTH || y >= HEIGHT)     {		throw std::out_of_range("");	}}template <size_t WIDTH, size_t HEIGHT>const std::optional<std::string>& 	Grid<const char*, WIDTH, HEIGHT>::at(size_t x, size_t y) const{	verifyCoordinate(x, y);	return mCells[x][y];}template <size_t WIDTH, size_t HEIGHT>std::optional<std::string>& 	Grid<const char*, WIDTH, HEIGHT>::at(size_t x, size_t y){	return const_cast<std::optional<std::string>&>(std::as_const(*this).at(x, y));}

在这个例子中,没有特例化所有模板参数。因此,模板代码行如下所示:

template <size_t WIDTH, size_t HEIGHT>class Grid<const char*, WIDTH, HEIGHT>

注意,这个模板只有两个参数: WIDTH 和 HEIGHT。然而,这个 Grid 类带有 3 个参数: T、WIDTH 和HEIGHT。因此,模板参数列表包含两个参数,而显式的 Grid<const char*,WIDTH, HEIGHT>包含 3 个参数。实例化模板时仍然必须指定 3 个参数。不能只通过高度和宽度实例化模板:

Grid<int, 2, 2> myIntGrid; // Uses the original GridGrid<const char*, 2, 2> myStringGrid; // Uses the partial specializationGrid<2, 3> test; // DOES NOT COMPILE! No type specified.

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp SpreadsheetCell.cpp -std=c++17test.cpp: In function ‘int main():test.cpp:56:14: error: wrong number of template arguments (2, should be 3)   56 |     Grid<2, 3> test; // DOES NOT COMPILE! No type specified.      |              ^In file included from test.cpp:1:Grid.h:10:7: note: provided for ‘template<class T, long unsigned int WIDTH, long unsigned int HEIGHT> class Grid’   10 | class Grid      |       ^~~~xz@xiaqiu:~/study/test/test$ 

上述语法的确很乱。更糟糕的是,在部分特例化中,与完整特例化不同,在每个方法定义的前面要包含模板代码行,如下所示;

template <size_t WIDTH, size_t HEIGHT>const std::optional<std::string>&Grid<const char*, WIDTH, HEIGHT>::at(size_t x, size_t y) const{    verifyCoordinate(x, y);    return mCells[x][y];}

需要这一带有两个参数的模板行,以表示这个方法针对这两个参数做了参数化处理。注意,需要表示完整类名时,都要使用 Grid<const char*, WIDTH, HEIGHT> 。

前面的例子并没有表现出部分特例化的真正威力。可为可能的类型子集编写特例化的实现,而不需要为每种类型特例化。例如,可为所有的指针类型编写特例化的 Grid 类。这种特例化的复制构造函数和赋值运算符可对指针指向的对象执行深层复制,而不是保存网格中指针的浅层复制。下面是类的定义,假设只用一个参数特例化最早版本的 Grid。在这个实现中,Grid 成为所提供指针的拥有者,所以它在需要时自动释放内存;

GridPtr.h

#pragma once

#include "Grid.h"
#include <memory>

template <typename T>
class Grid<T *>
{
public:
    explicit Grid(size_t width = kDefaultWidth, 
                  size_t height = kDefaultHeight);
    virtual ~Grid() = default;

    // Copy constructor and copy assignment operator.
    Grid(const Grid &src);
    Grid<T *> &operator=(const Grid &rhs);

    // Explicitly default a move constructor and assignment operator.
    Grid(Grid &&src) = default;
    Grid<T *> &operator=(Grid &&rhs) = default;

    void swap(Grid &other) noexcept;

    std::unique_ptr<T> &at(size_t x, size_t y);
    const std::unique_ptr<T> &at(size_t x, size_t y) const;

    size_t getHeight() const { return mHeight; }
    size_t getWidth() const { return mWidth; }

    static const size_t kDefaultWidth = 10;
    static const size_t kDefaultHeight = 10;

private:
    void verifyCoordinate(size_t x, size_t y) const;

    std::vector<std::vector<std::unique_ptr<T>>> mCells;
    size_t mWidth = 0, mHeight = 0;
};

template <typename T>
Grid<T *>::Grid(size_t width, size_t height)
    : mWidth(width)
    , mHeight(height)
{
    mCells.resize(mWidth);
    for (auto &column : mCells)
    {
        column.resize(mHeight);
    }
}

template <typename T>
void Grid<T *>::swap(Grid &other) noexcept
{
    using std::swap;

    swap(mWidth, other.mWidth);
    swap(mHeight, other.mHeight);
    swap(mCells, other.mCells);
}

template <typename T>
Grid<T *>::Grid(const Grid &src)
    : Grid(src.mWidth, src.mHeight)
{
    // The ctor-initializer of this constructor delegates first to the
    // non-copy constructor to allocate the proper amount of memory.

    // The next step is to copy the data.
    for (size_t i = 0; i < mWidth; i++)
    {
        for (size_t j = 0; j < mHeight; j++)
        {
            // Make a deep copy of the element by using its copy constructor.
            if (src.mCells[i][j])
            {
                mCells[i][j].reset(new T(*(src.mCells[i][j])));
            }
        }
    }
}

template <typename T>
Grid<T *> &Grid<T *>::operator=(const Grid &rhs)
{
    // Check for self-assignment.
    if (this == &rhs)
    {
        return *this;
    }

    // Use copy-and-swap idiom.
    auto copy = rhs;    // Do all the work in a temporary instance
    swap( copy);        // Commit the work with only non-throwing operations
    return *this;
}

template <typename T>
void Grid<T *>::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= mWidth || y >= mHeight)
    {
        throw std::out_of_range("");
    }
}

template <typename T>
const std::unique_ptr<T> &Grid<T *>::at(size_t x, size_t y) const
{
    verifyCoordinate(x, y);
    return mCells[x][y];
}

template <typename T>
std::unique_ptr<T> &Grid<T *>::at(size_t x, size_t y)
{
    return const_cast<std::unique_ptr<T>&>(std::as_const(*this).at(x, y));
}

Grid.h

#pragma once#include <cstddef>#include <stdexcept>#include <vector>#include <optional>#include <utility>template <typename T>class Grid{public:    explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);    virtual ~Grid() = default;    // Explicitly default a copy constructor and assignment operator.    Grid(const Grid &src) = default;    Grid<T> &operator=(const Grid &rhs) = default;    // Explicitly default a move constructor and assignment operator.    Grid(Grid &&src) = default;    Grid<T> &operator=(Grid &&rhs) = default;    std::optional<T> &at(size_t x, size_t y);    const std::optional<T> &at(size_t x, size_t y) const;    size_t getHeight() const { return mHeight; }    size_t getWidth() const { return mWidth; }    static const size_t kDefaultWidth = 10;    static const size_t kDefaultHeight = 10;private:    void verifyCoordinate(size_t x, size_t y) const;    std::vector<std::vector<std::optional<T>>> mCells;    size_t mWidth = 0, mHeight = 0;};template <typename T>Grid<T>::Grid(size_t width, size_t height)    : mWidth(width)    , mHeight(height){    mCells.resize(mWidth);    for (auto &column : mCells)    {        column.resize(mHeight);    }}template <typename T>void Grid<T>::verifyCoordinate(size_t x, size_t y) const{    if (x >= mWidth || y >= mHeight)    {        throw std::out_of_range("");    }}template <typename T>const std::optional<T> &Grid<T>::at(size_t x, size_t y) const{    verifyCoordinate(x, y);    return mCells[x][y];}template <typename T>std::optional<T> &Grid<T>::at(size_t x, size_t y){    return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}

像往常一样,下面这两行代码是关键所在:

template <typename T>class Grid<T*>

上述语法表明这个类是 Grid 模板对所有指针类型的特例化。只有是指针类型的情况下才提供实现。请注意,如果像下面这样实例化网格: Grid<int*> myIntGrid,那么了实际上是 int 而非 int*。这不够直观,但遗憾的是,这种语法就是这样使用的。下面是一个示例:

Grid<int> myIntGrid; // Uses the non-specialized gridGrid<int*> psGrid(2, 2); // Uses the partial specialization for pointer typespsGrid.at(0, 0) = make_unique<int>(1);psGrid.at(0, 1) = make_unique<int>(2);psGrid.at(1, 0) = make_unique<int>(3);Grid<int*> psGrid2(psGrid);Grid<int*> psGrid3;psGrid3 = psGrid2;auto& element = psGrid2.at(1, 0);if (element) {    cout << *element << endl;    *element = 6;}cout << *psGrid.at(1, 0) << endl; // psGrid is not modifiedcout << *psGrid2.at(1, 0) << endl; // psGrid2 is modified

代码输出

xz@xiaqiu:~/study/test/test$ ./test336xz@xiaqiu:~/study/test/test$

方法的实现相当简单,但复制构造函数除外,复制构造函数使用各个元素的复制构造函数进行深层复制:

template <typename T>Grid<T *>::Grid(const Grid &src)    : Grid(src.mWidth, src.mHeight){    // The ctor-initializer of this constructor delegates first to the    // non-copy constructor to allocate the proper amount of memory.    // The next step is to copy the data.    for (size_t i = 0; i < mWidth; i++)    {        for (size_t j = 0; j < mHeight; j++)        {            // Make a deep copy of the element by using its copy constructor.            if (src.mCells[i][j])            {                mCells[i][j].reset(new T(*(src.mCells[i][j])));            }        }    }}

通过重载模拟函数部分特例化

C++标准不允许函数的模板部分特例化。相反,可用另一个模板重载函数。区别十分微妙。假设要编写一个特例化的 Find()函数模板(参见第 12 章),这个特例化对指针解除引用,对指向的对象直接调用 operator==。根据类模板部分特例化的语法,可能会编写下面的代码:

template <typename T>
size_t Find<T*>(T* const& value, T* const* arr, size_t size)
{
    for (size_t i = 0; i < size; i++) 
    {
        if (*arr[i] == *value) 
        {
        	return i; // Found it; return the index
    	}
    }
    return NOT_FOUND; // failed to find it; return NOT_FOUND
}

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp  -std=c++17
test.cpp:10:12: error: expected initializer before ‘<’ token
   10 | size_t Find<T*>(T* const& value, T* const* arr, size_t size)
      |            ^
xz@xiaqiu:~/study/test/test$ 

然而,这种声明函数模板部分特例化的语法是 C++标准所不允许的。实现所需行为的正确方法是为 Find()编写一个新模板,区别看似微不足道且不切合实际,但不这样就无法编译

template <typename T>
size_t Find(T* const& value, T* const* arr, size_t size)
{
    for (size_t i = 0; i < size; i++) 
    {
        if (*arr[i] == *value) 
        {
        	return i; // Found it; return the index
        }
    }
    return NOT_FOUND; // failed to find it; return NOT_FOUND
}

这个 Find()版本的第一个参数是 T* const&, 这是为了与原来的 Find()函数模板(它把 const T&作为第一个参数)保持一致,但这里将 T*(而不是 T* const&)用作 Find()部分特例化的第一个参数,这也是可行的。可在一个程序中定义原始的 Find(模板、针对指针类型的部分特例化版本、针对 const char*的完整特例化版本以及仅对 const char*重载的版本。编译器会根据推导规则选择合适的版本来调用。

注意:

在所有重载的版本、函数模板特例化和特定的函数模板实例化中,编译器总是选择“最具体的”函数版本。如果非模板化的版本与函数模板实例化等价,编译器更偏向非模板化的版本。

几个特化版本

static const size_t NOT_FOUND = static_cast<size_t>(-1);template <typename T>size_t Find(const T &value, const T *arr, size_t size){    cout << "original" << endl;    for (size_t i = 0; i < size; i++)    {        if (arr[i] == value)        {            return i; // found it; return the index        }    }    return NOT_FOUND; // failed to find it; return NOT_FOUND}template <typename T>size_t Find(T *const &value, T *const *arr, size_t size){    cout << "ptr special" << endl;    for (size_t i = 0; i < size; i++)    {        if (*arr[i] == *value)        {            return i; // found it; return the index        }    }    return NOT_FOUND; // failed to find it; return NOT_FOUND}/* // This does not work.template <typename T>size_t Find<T*>(T* const& value, T* const* arr, size_t size){    cout << "ptr special" << endl;    for (size_t i = 0; i < size; i++) {        if (*arr[i] == *value) {            return i; // found it; return the index        }    }    return NOT_FOUND; // failed to find it; return NOT_FOUND}*/template<>size_t Find<const char *>(const char *const &value, const char *const *arr, size_t size){    cout << "Specialization" << endl;    for (size_t i = 0; i < size; i++)    {        if (strcmp(arr[i], value) == 0)        {            return i; // found it; return the index        }    }    return NOT_FOUND; // failed to find it; return NOT_FOUND}size_t Find(const char *const &value, const char *const *arr, size_t size){    cout << "overload" << endl;    for (size_t i = 0; i < size; i++)    {        if (strcmp(arr[i], value) == 0)        {            return i; // found it; return the index        }    }    return NOT_FOUND; // failed to find it; return NOT_FOUND}

下面的代码调用了几次 Find(),里面的注释说明了调用的是哪个版本的 Find():

int main()
{
    size_t res = NOT_FOUND;

    int myInt = 3, intArray[] = { 1, 2, 3, 4 };
    size_t sizeArray = std::size(intArray);
    res = Find(myInt, intArray, sizeArray);      // calls Find<int> by deduction
    res = Find<int>(myInt, intArray, sizeArray); // calls Find<int> explicitly

    double myDouble = 5.6, doubleArray[] = { 1.2, 3.4, 5.7, 7.5 };
    sizeArray = std::size(doubleArray);
    res = Find(myDouble, doubleArray, sizeArray);         // calls Find<double> by deduction
    res = Find<double>(myDouble, doubleArray, sizeArray); // calls Find<double> explicitly

    const char *word = "two";
    const char *words[] = { "one", "two", "three", "four" };
    sizeArray = std::size(words);
    res = Find<const char *>(word, words, sizeArray); // calls template specialization for const char*s
    res = Find(word, words, sizeArray);               // calls overloaded Find for const char*s

    int *intPointer = &myInt, *pointerArray[] = { &myInt, &myInt };
    sizeArray = std::size(pointerArray);
    res = Find(intPointer, pointerArray, sizeArray);    // calls the overloaded Find for pointers

    SpreadsheetCell cell1(10), cellArray[] = { SpreadsheetCell(4), SpreadsheetCell(10) };
    sizeArray = std::size(cellArray);
    res = Find(cell1, cellArray, sizeArray);                  // calls Find<SpreadsheetCell> by deduction
    res = Find<SpreadsheetCell>(cell1, cellArray, sizeArray); // calls Find<SpreadsheetCell> explicitly

    SpreadsheetCell *cellPointer = &cell1;
    SpreadsheetCell *cellPointerArray[] = { &cell1, &cell1 };
    sizeArray = std::size(cellPointerArray);
    res = Find(cellPointer, cellPointerArray, sizeArray); // Calls the overloaded Find for pointers

    return 0;
}

输出

xz@xiaqiu:~/study/test/test$ ./testoriginaloriginaloriginaloriginalSpecializationoverloadptr specialoriginaloriginalptr specialxz@xiaqiu:~/study/test/test$ 

模板递归

C++模板提供的功能比本章前面和第 12 章介绍的简单类和函数强大得多。其中一项功能称为模板递归。这一节首先讲解模板递归的动机,然后讲述如何实现模板递归。本节采用第 15 章讨论的运算符重载功能。如果跳过了那一章或者对 operator[]重载的语法不熟悉,在继续阅读之前请参阅第 15 章。

N 维网格: 初次尝试

前面的 Grid 模板示例到现在为止只支持两个维度,这限制了它的实用性。如果想编写三维井字游戏(Tic-Tac-Toe)或四维矩阵的数学程序,该怎么办? 当然,可为每个维度写一个模板类或非模板类。然而,这会重复很多代码。另一种方法是只编写一个一维网格。然后,利用另一个网格作为元素类型实例化 Grid,可创建任意维度的网格。这种 Grid 元素类型本身可以用网格作为元素类型进行实例化,依此类推。下面是 OneDGrid类模板的实现。这只是前面例子中 Grid 模板的一维版本,添加了 resize()方法,并用 operator[]替换了 at()。与诸如vector 的标准库容器类似,operator[]实现不执行边界检查。另外,在这个示例中,mElements 存储 工 的实例而非 std::optional的实例。

template <typename T>class OneDGrid{public:    explicit OneDGrid(size_t size = kDefaultSize);    virtual ~OneDGrid() = default;    T &operator[](size_t x);    const T &operator[](size_t x) const;    void resize(size_t newSize);    size_t getSize() const { return mElements.size(); }    static const size_t kDefaultSize = 10;private:    std::vector<T> mElements;};template <typename T>OneDGrid<T>::OneDGrid(size_t size){    resize(size);}template <typename T>void OneDGrid<T>::resize(size_t newSize){    mElements.resize(newSize);}template <typename T>T &OneDGrid<T>::operator[](size_t x){    return mElements[x];}template <typename T>const T &OneDGrid<T>::operator[](size_t x) const{    return mElements[x];}

有了 OneDGrid 的这个实现,就可通过如下方式创建多维网格;

int main(){    OneDGrid<int> singleDGrid;    OneDGrid<OneDGrid<int>> twoDGrid;    OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;    singleDGrid[3] = 5;    twoDGrid[3][3] = 5;    threeDGrid[3][3][3] = 5;    return 0;}

此代码可正常工作,但声明代码看上去有点乱。下面对其加以改进。

真正的 N 维网格

可使用模板递归编写“真正的”N 维网格,因为网格的维度在本质上是递归的。从如下声明中可以看出,OneDGrid<OneDGrid<OneDGrid>> threeDGridy

可将嵌套的每层 OneDGrid 想象为一个递归步骤,int 的 OneDGrid 是递归的基本情形。换名话说,三维网格是 int 一维网格的一维网格的一维网格。用户不需要自己进行递归,可以编写一个类模板来自动进行递归。然后,可创建如下 N 维网格:

NDGrid<int, 1> singleDGrid;NDGrid<int, 2> twoDGrid;NDGrid<int, 3> threeDGrid;

NDGrid 类模板需要元素类型和表示维度的整数作为参数。这里的关键问题在于,NDGrid 的元素类型不是模板参数列表中指定的元素类型,而是上一层递归的维度中指定的另一个 NDGrid。换名话说,三维网格是二维网格的矢量,二维网格是一维网格的各个矢量。使用递归时,需要处理基本情形(base case)。可编写维度为 1 的部分特例化的 NDGrid,其中元素类型不是另一个NDGrid,而是模板参数指定的元素类型。下面是 NDGrid 模板定义的一般形式,突出显示了与前面 OneDGrid 的不同之处

template <typename T, size_t N>
class NDGrid
{
public:
    explicit NDGrid(size_t size = kDefaultSize);
    virtual ~NDGrid() = default;

    NDGrid < T, N - 1 > & operator[](size_t x);
    const NDGrid < T, N - 1 > & operator[](size_t x) const;

    void resize(size_t newSize);
    size_t getSize() const { return mElements.size(); }

    static const size_t kDefaultSize = 10;

private:
    std::vector < NDGrid < T, N - 1 >> mElements;
};

注意,mElements 是 NDGrid<T, N - 1>的矢量: 这是递归步骤。此外,operator[]返回一个指向元素类型的引用,依然是NDGrid <T, N - 1>而非 T。基本情形的模板定义是维度为 1 的部分特例化:

template <typename T>
class NDGrid<T, 1>
{
public:
    explicit NDGrid(size_t size = kDefaultSize);
    virtual ~NDGrid() = default;

    T &operator[](size_t x);
    const T &operator[](size_t x) const;

    void resize(size_t newSize);
    size_t getSize() const { return mElements.size(); }

    static const size_t kDefaultSize = 10;

private:
    std::vector<T> mElements;
};

递归到这里就结束了: 元素类型是 T,而非另一个模板实例。模板递归实现最棘手的部分不是模板递归本身,而是网格中每个维度的正确大小。这个实现创建了N 维网格,每个维度都是一样大的。为每个维度指定不同的大小要困难得多。然而,即使做了这样的简化,也仍然存在一个问题:用户应该有能力创建指定大小的数组,例如 20 或 50。因此,构造函数接收一个整数作为大小参数。然而,当动态重设子网格的 vector 时,不能将这个大小参数传递给子网格元素,因为 vector 使用默认的构造函数创建对象。因此,必须对 vector 的每个网格元素显式调用 resize()。基本情形不需要调整元素大小,因为基本情形的元素是 T,而不是网格。

下面是 NDGrid 主模板的实现,这里突出显示了与 OneDGrid 之间的差异:

template <typename T, size_t N>
NDGrid<T, N>::NDGrid(size_t size)
{
    resize(size);
}

template <typename T, size_t N>
void NDGrid<T, N>::resize(size_t newSize)
{
    mElements.resize(newSize);

    // Resizing the vector calls the 0-argument constructor for
    // the NDGrid<T, N-1> elements, which constructs
    // them with the default size. Thus, we must explicitly call
    // resize() on each of the elements to recursively resize all
    // nested Grid elements.
    for (auto &element : mElements)
    {
        element.resize(newSize);
    }
}

template <typename T, size_t N>
NDGrid < T, N - 1 > & NDGrid<T, N>::operator[](size_t x)
{
    return mElements[x];
}

template <typename T, size_t N>
const NDGrid < T, N - 1 > & NDGrid<T, N>::operator[](size_t x) const
{
    return mElements[x];
}

下面是部分特例化的实现(基本情形)。请注意,必须重写很多代码,因为不能在特例化中继承任何实现。这里突出显示了与非特例化 NDGrid 之间的差异:

template <typename T>NDGrid<T, 1>::NDGrid(size_t size){    resize(size);}template <typename T>void NDGrid<T, 1>::resize(size_t newSize){    mElements.resize(newSize);}template <typename T>T &NDGrid<T, 1>::operator[](size_t x){    return mElements[x];}template <typename T>const T &NDGrid<T, 1>::operator[](size_t x) const{    return mElements[x];}

现在,可编写下面这样的代码;

int main(){    NDGrid<int, 3> my3DGrid;    my3DGrid[2][1][2] = 5;    my3DGrid[1][1][1] = 5;    cout << my3DGrid[2][1][2] << endl;    return 0;}

输出

xz@xiaqiu:~/study/test/test$ ./test5xz@xiaqiu:~/study/test/test$ 

可变参数模板

普通模板只可采取固定数量的模板参数。可变参数模板(variadic template)可接收可变数目的模板参数。例如,下面的代码定义了一个模板,它可以接收任何数目的模板参数,使用称为 Types 的参数包(parameter pack):

template<typename... Types>class MyVariadicTemplate { };

注意:

typename 之后的三个点并非错误。这是为可变参数模板定义参数包的语法。参数包可以接收可变数目的参数。在三个点的前后允许添加空格。

可用任何数量的类型实例化 MyVariadicTemplate,例如:

MyVariadicTemplate<int> instance1;MyVariadicTemplate<string, double, list<int>> instance2;

甚至可用零个模板参数实例化 MyVariadicTemplate:

MyVariadicTemplate<> instance3;

为避免用零个模板参数实例化可变参数模板,可以像下面这样编写模板:

template<typename T1, typename... Types>class MyVariadicTemplate { };

有了这个定义后,试图通过零个模板参数实例化MyVariadicTemplate会导致编译错误。例如, Microsoft Visual C++会给出如下错误:

error C2976: 'MyVariadicTemplate' : too few template arguments

不能直接遍历传给可变参数模板的不同参数。唯一方法是借助模板递归的帮助。下面通过两个例子来说明如何使用可变参数模板。

类型安全的变长参数列表

可变参数模板允许创建类型安全的变长参数列表。下面的例子定义了一个可变参数模板 processValues(),它允许以类型安全的方式接收不同类型的可变数目的参数。函数 processValues()会处理变长参数列表中的每个值,对每个参数执行 handleValue()函数。这意味着必须对每种要处理的类型编写 handleValue()函数,例如下例中的 int、double 和 string:

void handleValue(int value) { cout << "Integer: " << value << endl; }void handleValue(double value) { cout << "Double: " << value << endl; }void handleValue(string_view value) { cout << "String: " << value << endl; }void processValues() { /* Nothing to do in this base case.*/ }template<typename T1, typename... Tn>void processValues(T1 arg1, Tn... args){    handleValue(arg1);    processValues(args...);}

在前面的例子中,三点运算符“.…”用了两次。这个运算符出现在 3 个地方,有两个不同的含义。首先,用在模板参数列表中 typename 的后面以及函数参数列表中类型 Tn 的后面。在这两种情况下, 它都表示参数包。参数包可接收可变数目的参数。“…”运算符的第二种用法是在函数体中参数名 args 的后面。这种情况下,它表示参数包扩展。这个运算符会解包/展开参数包, 得到各个参数。它基本上提取出运算符左边的内容, 为包中的每个模板参数重复该内容,并用逗号隔开。从前面的例子中取出以下行:

processValues(args...);

这一行将 args 参数包解包(或扩展)为不同的参数,通过逗号分隔参数,然后用这些展开的参数调用processValues()函数。模板总是需要至少一个模板参数: T1。通过 args.…递归调用 processValues()的结果是: 每次调用都会少一个模板参数。由于 processValues()函数的实现是递归的, 因此需要采用一种方法来停止递归。 为此, 实现一个processValues()函数,要求它接收零个参数。可通过下面的代码来测试 processValues()可变参数模板:

processValues(1, 2, 3.56, "test", 1.1f);

这个例子生成的递归调用是:

processValues(1, 2, 3.56, "test", 1.1f); handleValue(1);  processValues(2, 3.56, "test", 1.1f);   handleValue(2);    processValues(3.56, "test", 1.1f);     handleValue(3.56);        processValues("test", 1.1f);       handleValue("test");        processValues(1.1f);         handleValue(1.1f);          processValues();

重要的是要记住,这种变长参数列表是完全类型安全的。processValues()函数会根据实际类型自动调用正确的 handlevalue()重载版本。C++中也会像通常那样自动执行类型转换。例如,前面例子中 1.1f 的类型为 float。processValues()函数会调用 handleValue(double value),因为从 float 到 double 的转换没有任何损失。然而,如果调用 processValues()时带有某种类型的参数,而这种类型没有对应的 handlevalue()函数,编译器会产生错误。前面的实现存在一个小问题。由于这是一个递归的实现,因此每次递归调用 processValues()时都会复制参数。根据参数的类型,这种做法的代价可能会很高。你可能会认为,向 processValues(函数传递引用而不使用按值传递方法,就可以避免这种复制问题。遗憾的是,这样也无法通过字面量调用 processValues()了,因为不允许使用字面量引用,除非使用 const 引用。为了在使用非 const 引用的同时也能使用字面量值,可使用转发引用(forwarding references)。以下实现使用了转发引用 T&&,还使用 std::forward(完美转发所有参数“完美转发”意味着,如果把 rvalue 传递给processValues(),就将它作为 rvalue 引用转发,如果把 lvalue 或 lvalue 引用传递给 processValues(),就将它作为lvalue 引用转发。

void processValues() { /* Nothing to do in this base case.*/ }template<typename T1, typename... Tn>void processValues(T1&& arg1, Tn&&... args){    handleValue(std::forward<T1>(arg1));    processValues(std::forward<Tn>(args)...);}

有一行代码需要做进一步解释:

processValues(std::forward<Tn>(args)...);

“.…”运算符用于解开参数包,它在参数包中的每个参数上使用 std::forward(),用逗号把它们隔开。例如,假设 args 是一个参数包,有三个参数(a1、a2 和 a3),分别对应三种类型(A1、A2 和 A3)。扩展后的调用如下:

processValues(std::forward<A1>(a1),			  std::forward<A2>(a2),              std::forward<A3>(a3));

在使用了参数包的函数体中,可通过以下方法获得参数包中参数的个数:

int numOfArgs = sizeof...(args);

一个使用变长参数模板的实际例子是编写一个类似于 printf()版本的安全且类型安全的函数模板。这是实践变长参数模板的一次不错练习。

#include <iostream>#include <string>#include <string_view>using namespace std;void handleValue(int value){    cout << "Integer: " << value << endl;}void handleValue(double value){    cout << "Double: " << value << endl;}void handleValue(string_view value){    cout << "String: " << value << endl;}// First version using pass-by-valuevoid processValues()    // Base case{    // Nothing to do in this base case.}template<typename T1, typename... Tn>void processValues(T1 arg1, Tn... args){    handleValue(arg1);    processValues(args...);}// Second version using pass-by-rvalue-referencevoid processValuesRValueRefs()  // Base case{    // Nothing to do in this base case.}template<typename T1, typename... Tn>void processValuesRValueRefs(T1 &&arg1, Tn &&... args){    handleValue(std::forward<T1>(arg1));    processValuesRValueRefs(std::forward<Tn>(args)...);}int main(){    processValues(1, 2, 3.56, "test", 1.1f);    cout << endl;    processValuesRValueRefs(1, 2, 3.56, "test", 1.1f);    return 0;}

输出

xz@xiaqiu:~/study/test/test$ ./testInteger: 1Integer: 2Double: 3.56String: testDouble: 1.1Integer: 1Integer: 2Double: 3.56String: testDouble: 1.1xz@xiaqiu:~/study/test/test$ 

可变数目的混入类

参数包几乎可用在任何地方。例如,下面的代码使用一个参数包为 MyClass 类定义了可变数目的混入类。第 5 章讨论了混入类的概念。

class Mixin1
{
public:
    Mixin1(int i) : mValue(i) {}
    virtual void Mixin1Func() { cout << "Mixin1: " << mValue << endl; }
private:
	int mValue;
};
class Mixin2
{
public:
    Mixin2(int i) : mValue(i) {}
    virtual void Mixin2Func() { cout << "Mixin2: " << mValue << endl; }
private:
	int mValue;
};
template<typename... Mixins>
class MyClass : public Mixins...
{
public:
    MyClass(const Mixins&... mixins) : Mixins(mixins)... {}
    virtual ~MyClass() = default;
};

上述代码首先定义了两个混入类 Mixin1 和 Mixin2。它们在这个例子中的定义非常简单。它们的构造函数接收一个整数,然后保存这个整数,这两个类有一个函数用于打印特定实例的信息。MyClass 可变参数模板使用参数包 typename… Mixins 接收可变数目的混入类。MyClass 继承所有的混入类,其构造函数接收同样数目的参数来初始化每一个继承的混入类。注意,.….扩展运算符基本上接收运算符左边的内容,为参数包中的每个模板参数重复这些内容,并用逗号隔开。MyClass 可以这样使用:

MyClass<Mixin1, Mixin2> a(Mixin1(11), Mixin2(22));
a.Mixin1Func();
a.Mixin2Func();

MyClass<Mixin1> b(Mixin1(33));
b.Mixin1Func();
//b.Mixin2Func(); // Error: does not compile.

MyClass<> c;
//c.Mixin1Func(); // Error: does not compile.
//c.Mixin2Func(); // Error: does not compile.

试图对b 调用 Mixin2Func()会产生编译错误,因为b 并非继承自 Mixin2 类。这个程序的输出如下:

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp -std=c++17test.cpp: In function ‘int main():test.cpp:35:7: error:class MyClass<Mixin1>’ has no member named ‘Mixin2Func’; did you mean ‘Mixin1Func’?   35 |     b.Mixin2Func(); // Error: does not compile.      |       ^~~~~~~~~~      |       Mixin1Funcxz@xiaqiu:~/study/test/test$ 

输出

xz@xiaqiu:~/study/test/test$ ./testMixin1: 11Mixin2: 22Mixin1: 33xz@xiaqiu:~/study/test/test$ 

折叠表达式

C++17 增加了对折私表达式(folding expression)的支持。这样一来,将可更容易地在可变参数模板中处理参数包。表 22-1 列出了支持的4 种折状闫型。在该表中,6 可以是以下任意运算符: +、 - 、*、/、 %、 ^、& 、 |、<<、>>、+=、-=、*=、/=、%=、^=、&=、|=、<<=、>>=、=、==、!=、<、>、<=、>=、&&、||、, 、.* 、->*。

在这里插入图片描述

下面分析一些示例。以递归方式定义前面的 processValues()函数模板,如下所示:

void processValues() //Nothing to do in this base case
{
	template<typename T1,typename... Tn>
	void processValues(T1 arg1,Tn... args)
	{
		handleValue(arg1);
		processValue(args...);
	}
}

由于以递归方式定义,因此需要基本情形来停止递归。使用折叠表达式,利用一元右折叠,通过单个函数模板来实现。此时,不需要基本情形。

template<typename... Tn>void processValues(const Tn&... args){	(handleValue(args),...);}

基本上,函数体中的三个点触发折登。扩展这一行,针对参数包中的每个参数调用 handlevalue0,对handlevalue()的每个调用用逗号分隔。例如,假设 args 是包含三个参数(a1、a2 和 a3)的参数包。一元右折叠如下

(handleValue(a1), (handleValue(a2), handleValue(a3)));

下面是另一个示例。printValues()函数模板将所有实参写入控制台,实参之间用换行符分开。

template<typename... Values>
void printValues(const Values&... values)
{
	((cout << values << endl), ...);
}

假设 values 是包含三个参数(v1、v2 和 v3)的参数包。一元右折私扩展后的形式如下:

((cout << v1 << endl), ((cout << v2 << endl), (cout << v3 << endl)));

调用 printValues()时可使用任意数量的实参,如下所示:

printValues(1, "test", 2.34);

在这些示例中,将折叠与逗号运算符结合使用,但实际上,折受可与任何类型的运算符结合使用。例如,以下代码定义了可变参数函数模板, 使用二元左折重计算传给它的所有值之和。二元左折登始终需要一个Init 值(参见表 22-1D)。因此,sumValues()有两个模板类型参数: 一个是普通参数,用于指定 Init 的类型,另一个是参数包,可接收 0 个或多个实参。

template<typename T, typename... Values>double sumValues(const T& init, const Values&... values){	return (init + ... + values);}

假设 values 是包含三个参数(v1、v2 和 v3)的参数包。二元左折县扩展后的形式如下;

return (((init + v1) + v2) + v3);

sumValues()函数模板的使用方式如下:

cout << sumValues(1, 2, 3.3) << endl;cout << sumValues(1) << endl;

该函数模板至少需要一个参数,因此以下代码无法编译:

cout << sumvalues() << endl;

模板元编程

本节讲解模板元编程。这是一个非常复杂的主题,有一些关于模板元编程的书讲解了所有细节。本书没有足够的篇幅来讲解模板元编程的所有细节。本节通过几个例子解释最重要的概念。模板元编程的目标是在编译时而不是运行时执行一些计算。模板元编程基本上是基于 C++的一种小型编程语言。下面首先讨论一个简单示例,这个例子在编译时计算一个数的阶乘,并在运行时能将计算结果用作简单的常数。

编译时阶乘

下面的代码演示了在编译时如何计算一个数的阶乘。代码使用了本章前面介绍的模板递归,我们需要一个递归模板和用于停止递归的基本模板。根据数学定义,0 的阶乘是 1,所以用作基本情形:

template<unsigned char f>class Factorial{    public:    static const unsigned long long val = (f * Factorial<f - 1>::val);};template<>class Factorial<0>{    public:    static const unsigned long long val = 1;};int main(){    cout << Factorial<6>::val << endl;    return 0;}

这将计算 6 的阶乘,数学表达为 6!,值为 1x2x3x4x5x6 或 720。

注意

要记住,在编译时计算阶乘。在运行时,可通过::val 访问编译时计算出来的值,这不过是一个静态常量值。上面这个具体示例在编译时计算一个数的阶乘,但未必需要使用模板元编程。由于引入了 constexpr,可不使用模板,写成如下形式。不过,模板实现仍然是实现递归模板的优秀示例。

constexpr unsigned long long factorial(unsigned char f){    if (f == 0)     {	    return 1;    }     else     {    	return f * factorial(f - 1);    }}

如果调用如下版本,则在编译时计算值:

constexpr auto f1 = factorial(6);

不过,在这条语句中,切勿忘掉 constexpr。如果编写如下代码,将在运行时完成计算!

auto f1 = factorial(6);

在模板元编程版本中,不能犯此类错误。始终使计算在编译时完成。

循环展开

模板元编程的第二个例子是在编译时展开循环,而不是在运行时执行循环。注意循环展开(loop unrolling)应仅在需要时使用,因为编译器通常足够智能,会自动展开可以展开的循环。这个例子再次使用了模板递归,因为需要在编译时在循环中完成一些事情。在每次递归中,Loop 模板都会通过i- 1 实例化自身。当到达 0 时,停止递归。

template<int i>
class Loop
{
public:
    template <typename FuncType>
    static inline void Do(FuncType func) 
    {
        Loop<i - 1>::Do(func);
        func(i);
    }
};
template<>
class Loop<0>
{
public:
    template <typename FuncType>
    static inline void Do(FuncType /* func */) { }
};

可以像下面这样使用 Loop 模板:

void DoWork(int i) { cout << "DoWork(" << i << ")" << endl; }int main(){	Loop<3>::Do(DoWork);}

这段代码将导致编译器展开循环,并连续 3 次调用 DoWork()函数。这个程序的输出如下所示:

DoWork(1)DoWork(2)DoWork(3)

使用lambda 表达式,可使用接收多个参数的 DoWork()版本:

void DoWork2(string str, int i){	cout << "DoWork2(" << str << ", " << i << ")" << endl;}int main(){	Loop<2>::Do([](int i) { DoWork2("TestStr", i); });}

上述代码首先实现了一个函数,这个函数接收一个字符串和一个 int 值。main()函数使用 lambda 表达式,在每个迭代上将一个固定的字符串 Test() 作为第一个参数调用 DoWork()。编译并运行上述代码,输出应该如下所示:

DoWork2(TestStr,1)
DoWork2(TestStr,2)

打印元组

这个例子通过模板元编程来打印 std::tuple 中的各个元素。第 20 章讲解了元组。元组允许存储任何数量的值,每个值都有各自的特定类型。元组有固定的大小和值类型,这些都是在编译时确定的。然而,元组没有提供任何内置的机制来遍历其元素。下面的示例演示如何通过模板元编程在编译时饥历元组中的元素。与模板元编程中的大部分情况一样, 这个例子也使用了模板递归。tuple_print类模板接收两个模板参数:tuple类型和初始化为元组大小的整数。然后在构造函数中递归地实例化自身,每一次调用都将大小减小。当大小变成0时,tuple_print 的一个部分特例化停止递归。main()函数演示了如何使用这个 tuple_print 类模板。

template<typename TupleType, int n>
class tuple_print
{
public:
    tuple_print(const TupleType& t) 
    {
        tuple_print<TupleType, n - 1> tp(t);
        cout << get<n - 1>(t) << endl;
    }
};
template<typename TupleType>
class tuple_print<TupleType, 0>
{
public:
	tuple_print(const TupleType&) { }
};
int main()
{
    using MyTuple = tuple<int, string, bool>;
    MyTuple t1(16, "Test", true);
    tuple_print<MyTuple, tuple_size<MyTuple>::value> tp(t1);
}

分析一下 main()函数,你会发现使用 tuple_print 类模板的那一行看起来有点复杂,因为需要元组的大小和确切类型作为模板参数。引入自动推导模板参数的辅助函数模板可以简化这段代码。简化的实现如下所示;

template<typename TupleType, int n>
class tuple_print_helper
{
    public:
    tuple_print_helper(const TupleType& t) 
    {
        tuple_print_helper<TupleType, n - 1> tp(t);
        cout << get<n - 1>(t) << endl;
	}
};
template<typename TupleType>
class tuple_print_helper<TupleType, 0>
{
    public:
    tuple_print_helper(const TupleType&) { }
};
template<typename T>
void tuple_print(const T& t)
{
	tuple_print_helper<T, tuple_size<T>::value> tph(t);
}
int main()
{
    auto t1 = make_tuple(167, "Testing", false, 2.3);
    tuple_print(t1);
}

这里的第一个变化是将原来的 tuple_print 类模板重命名为 tnple_print_helper。然后,上述代码实现了一个名为 tuple_print()的小函数模板,这个函数模板接收 tuple 类型作为模板类型参数,并接收对元组本身的引用作为函数参数。在该函数的主体中实例化 tuple_print_helper 类模板。main()函数展示了如何使用这个简化的版本。既然再也不必了解元组的确切类型,那么可以结合 auto 关键字使用 make_tuple()。tuple_print()函数模板的调用非常简单,如下所示:

tuple_print (t1)

不需要指定函数模板的参数,因为编译器可以根据提供的参数自动推断。

  1. constexpr if

C++17 引入了 constexpr if。这些是在编译时(而非运行时)执行的让语句。如果 constexpr if语句的分支从未到达,就不会进行编译。这可用于简化大量的模板元编程技术,也可用于本章后面讨论的 SFINAE。例如,可按如下方式使用 constexp ri 简化前面的打印元组元素的代码。 注意,不再需要模板递归基本情形,原因在于可通过 constexpr if证语句停止递归。

template<typename TupleType, int n>class tuple_print_helper{public:    tuple_print_helper(const TupleType& t)     {        if constexpr(n > 1)         {        	tuple_print_helper<TupleType, n - 1> tp(t);    	}    	cout << get<n - 1>(t) << endl;    }};template<typename T>void tuple_print(const T& t){	tuple_print_helper<T, tuple_size<T>::value> tph(t);}

现在,甚至可丢弃类模板本身,蔡换为简单的函数模板 tuple_print_helper:

template<typename TupleType, int n>void tuple_print_helper(const TupleType& t) {    if constexpr(n > 1)     {	    tuple_print_helper<TupleType, n - 1>(t);    }    cout << get<n - 1>(t) << endl;}template<typename T>void tuple_print(const T& t){	tuple_print_helper<T, tuple_size<T>::value>(t);}

可对其进一步简化。将两个方法合为一个,如下所示:

template<typename TupleType, int n = tuple_size<TupleType>::value>void tuple_print(const TupleType& t) {    if constexpr(n > 1)     {    	tuple_print<TupleType, n - 1>(t);    }    cout << get<n - 1>(t) << endl;}

仍然像前面那样进行调用:

auto t1 = make_tuple(167, "Testing", false, 2.3);tuple_print(t1);
  1. 使用编译时整数序列和折叠

C++使用 std::integer_sequence(在中定义)支持编译时整数序列。模板元编程的一个常见用例是生成编译时索引序列,即 size_t 类型的整数序列。此处,可使用辅助用的 std::index_sequence。可使用 std::index_sequence_for,生成与给定的参数包等长的索引序列。下面使用可变参数模板、编译时索引序列和 C++17 折叠表达式,实现元组打印程序:

template<typename Tuple, size_t... Indices>
void tuple_print_helper(const Tuple& t, index_sequence<Indices...>)
{
	((cout << get<Indices>(t) << endl), ...);
}
template<typename... Args>
void tuple_print(const tuple<Args...>& t)
{
	tuple_print_helper(t, index_sequence_for<Args...>());
}

调用时,tuple_print_helper()函数模板中的一元右折县表达式扩展为如下形式

类型 trait

通过类型 trait 可在编译时根据类型做出决策。例如,可编写一个模板,这个模板要求从某种特定类型派生的类型,或者要求可转换为某种特定类型的类型,或者要求整数类型,等等。C++标准为此定义了一些辅助类。所有与类型 trait 相关的功能都定义在<type_traits>头文件中。 类型 trait 分为几个不同类别。下面列出了每个类别的可用类型 trait 的一些例子。完整清单请参阅标准库参考资源(见附录 B)。

➤ 原始类型类别
o is_void
o is_integral
o is_floating_point
o is_pointer
o …
➤ 类型属性
o is_const
o is_literal_type
o is_polymorphic
o is_unsigned
o is_constructible
o is_copy_constructible
o is_move_constructible
o is_assignable
o is_trivially_copyable
o is_swappable*
o is_nothrow_swappable*
o has_virtual_destructor
o has_unique_object_representations*
o …
➤ 引用修改
o remove_reference
o add_lvalue_reference
o add_rvalue_reference
➤ 指针修改
o remove_pointer
o add_pointer
➤ 复合类型类别
o is_reference
o is_object
o is_scalar
o …
➤ 类型关系
o is_same
o is_base_of
o is_convertible
o is_invocable*
o is_nothrow_invocable*
o …
➤ const-volatile修改
o remove_const
o add_const
o …
➤ 符号修改
o make_signed
o make_unsigned
➤ 数组修改
o remove_extent
o remove_all_extents
➤ 逻辑运算符trait
o conjuction*
o disjunction*
o negation*
➤ 其他转换
o enable_if
o conditional
o invoke_result*
o …

标有星号的类型 trait 在 C++17 及其之后版本中才可用。类型 trait 是一个非常高级的 C++功能。以上列表只显示了 C++标准中的部分类型 trait,光从这个列表中就可以看出,本书不可能解释类型 trait 的所有细节。下面只列举几个用例,展示如何使用类型 trait。

  1. 使用类型类别

在给出使用类型 trait 的模板示例前,首先要了解一下诸如 is_integral 的类的工作方式。C++标准对integral_constant 类的定义如下所示;

template <class T, T v>struct integral_constant {    static constexpr T value = v;    using value_type = T;    using type = integral_constant<T, v>;    constexpr operator value_type() const noexcept { return value; }    constexpr value_type operator()() const noexcept { return value; }};

这也定义了 bool_constant、true_type 和 false_type 类型别名:

template <bool B>using bool_constant = integral_constant<bool, B>;using true_type = bool_constant<true>;using false_type = bool_constant<false>;

这定义了两种类型:true_type 和 false_type。当调用 true_type::value时,得到的值是 true; 调用 false_type::value时,得到的值是 false。还可调用 true_type::type, 这将返回 true_type 类型.这同样适用于 false_type。诸如 is_integral和is_class 的类继承了 true_type或 false_type。例如,is_integral 为类型 bool 特例化,如下所示:

template<> struct is_integral<bool> : public true_type { };

这样就可编写 is_integral::value,并返回 true。注意,不需要自己编写这些特例化,这些是标准库的一部分。下面的代码演示了使用类型类别的最简单例子:

if (is_integral<int>::value) {	cout << "int is integral" << endl;} else {	cout << "int is not integral" << endl;}if (is_class<string>::value) {	cout << "string is a class" << endl;} else {	cout << "string is not a class" << endl;}

这个例子通过 is_integral 来检查 int 是否为整数类型,并通过 is_class 来检查 string 是否为类。输出如下:

int is integralstring is a class

​ 对于每一个具有 value 成员的 trait,C++17 添加了一个变量模板,它与 trait 同名,后跟_v。不是编写some trait::value,而是编写 some trait_v,例如 is_integral_v和 is_const_v等。下面用变量模板重写了前面的例子

if (is_integral_v<int>) {	cout << "int is integral" << endl;} else {	cout << "int is not integral" << endl;}if (is_class_v<string>) {	cout << "string is a class" << endl;} else {	cout << "string is not a class" << endl;}

当然,你可能永远都不会采用这种方式使用类型 trait。只有结合模板根据类型的某些属性生成代码时,类型 trait 才更有用。下面的模板示例演示了这一点。代码定义了函数模板 process_helper()两个重载版本,这个函数模板接收一种类型作为模板参数。第一个参数是一个值,第二个参数是true_type或false_type的实例.process()函数模板接收一个参数,并调用 process_helper()函数:

template<typename T>void process_helper(const T& t, true_type){	cout << t << " is an integral type." << endl;}template<typename T>void process_helper(const T& t, false_type){	cout << t << " is a non-integral type." << endl;}template<typename T>void process(const T& t){	process_helper(t, typename is_integral<T>::type());}

process_helper()函数调用的第二个参数定义如下:

typename is_integral<T>::type()

该参数使用 is_integral 判断了是否为整数类型。使用::type 访问结果 integral_constant 类型, 可以是 true_type或 包false_type。process_helper()函数需要 true_ type 或 false type 的一个实例作为第二个参数,这也是为什么:type后面有两个空括号的原因。 注意, process_helper()函数的两个重载版本使用了类型为 true_type 或 false_type 的无名参数。因为在函数体的内部没有使用这些参数,所以这些参数是无名的。这些参数仅用于函数重载解析。

这些代码的测试如下:

process(123);process(2.2);process("Test"s);

输出

123 is an integral type.2.2 is a non-integral type.Test is a non-integral type.

前面的例子只使用单个函数模板来编写,但没有说明如何使用类型 trait,以基于类型选择不同的重载。

template<typename T>void process(const T& t){    if constexpr (is_integral_v<T>)     {    	cout << t << " is an integral type." << endl;    }     else     {    	cout << t << " is a non-integral type." << endl;    }}
  1. 使用类型关系

有三种类型关系; is_same、is_base of 和 is_convertible。下面将给出一个例子来展示如何使用is_same。其余类型关系的工作原理类似。下面的 same()函数模板通过 is_same 类型 trait 特性判断两个给定参数是否类型相同,然后输出相应的信息。

template<typename T1, typename T2>void same(const T1& t1, const T2& t2){    bool areTypesTheSame = is_same_v<T1, T2>;    cout << "'" << t1 << "' and '" << t2 << "' are ";    cout << (areTypesTheSame ? "the same types." : "different types.") << endl;}int main(){    same(1, 32);    same(1, 3.01);	same(3.01, "Test"s);}

输出

'1' and '32' are the same types.'1' and '3.01' are different types'3.01' and 'Test' are different types
  1. 使用 enable if

使用 enable 让需要了解“替换失败不是错误”(Substitution Failure Is NotAn Eror,SFINAE)特性, 这是 C++中一个复杂临涩的特性。下面仅讲解 SFINAE 的基础知识。如果有一组重载函数,就可以使用 enable_if 根据某些类型特性有选择地禁用某些重载。enable_if通常用于重载函数组的返回类型。enable_if接收两个模板类型参数。第一个参数是布尔值,第二个参数是默认为 void 的类型。如果布尔值是 true,enable_if类就有一种可使用::type 访问的嵌套类型,这种嵌套类型由第二个模板类型参数给定。如果布尔值是 false,就没有败套类型 。C++标准为具有 type 成员的 trait(如 enable_if) 定义别名模板,这些与 trait 同名,但附加了_t。例如,不编写如下代码:

typename enable_if<..., bool>::type

而编写如下更简短的版本:

enable_if_t<..., bool>

通过 enable_if 可将前面使用 same()函数模板的例子重写为一个重载的 check_type()函数模板。在这个版本中,check type()函数根据给定值的类型是否相同,返回 true 或 false。如果不希望 check_ type返回任何内容,可删除 return 语句,可删除 enable_if的第二个模板类型参数,或用 void 替换。

template<typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool>
check_type(const T1& t1, const T2& t2)
{
    cout << "'" << t1 << "' and '" << t2 << "' ";
    cout << "are the same types." << endl;
    return true;
}
template<typename T1, typename T2>
enable_if_t<!is_same_v<T1, T2>, bool>
check_type(const T1& t1, const T2& t2)
{
    cout << "'" << t1 << "' and '" << t2 << "' ";
    cout << "are different types." << endl;
    return false;
}

int main()
{
    check_type(1, 32);
    check_type(1, 3.01);
    check_type(3.01, "Test"s);
}

输出

'1' and '32' are the same types.'1' and '3.01' are different types.'3.01' and 'Test' are different types.

上述代码定义了两个版本的 check_type(),它们的返回类型都是 enable_if 的嵌套类型 bool。首先,通过is_same_v 检查两种类型是否相同,然后通过 enable_if_t 获得结果。当 enable_if_t 的第一个参数为 true 时,enable_if_t 的类型就是 bool; 当第一个参数为 false 时,将不会有返回类型。这就是 SFINAE 发挥作用的地方。当编译器开始编译 main()函数的第一行时,它试图找到接收两个整型值的 check_type()函数。编译器会在源代码中找到第一个重载的 check_type()函数模板,并将 TI 和 T2 都设置为整数,以推断可使用这个模板的实例。然后, 编译器会尝试确定返回类型。由于这两个参数是整数, 因此是相同的类型,is_same_v<T1, T2>将返回 true,这导致enable_if_t<true,bool>返回类型 bool。这样实例化时一切都很好,编译器可使用该版本的 check type()。然而,当编译器尝试编译 main()函数的第二行时,编译器会再次尝试找到合适的 check_type()函数。编译器从第一个 check_type()开始,判断出可将 TI 设置为 int 类型,将 T2 设置为 double 类型。然后,编译器会尝试确定返回类型。这一次, TI 和 T2 是不同的类型,这意味着 is_same_v<T1, T2>将返回 false。 因此 enable if t<false,bool>不表示类型,check type()函数不会有返回类型。编译器会注意到这个错误,但由于 SFINAE,还不会产生真正的编译错误。编译器将正常回溯,并试图找到另一个 check_type()函数。这种情况下,第二个 check_type()可以正常工作,因为!is_same_v<T1, T2>为 true,此时 enable_if_t<true,bool>返回类型 bool。

如果希望在一组构造函数上使用 enable_if就不能将它用于返回类型,因为构造函数没有返回类型。此时可在带默认值的额外构造函数参数上使用 enable_if。建议慎用 enable_if仅在需要解析重载歧义时使用,即无法使用其他技术(例如特例化、部分特例化等)解析重载歧义时使用。例如, 如果只希望在对模板使用了错误类型时编译失败, 应使用第27 章介绍的static_assert(),而不是 SFINAE。当然,enable_if有合法的用例。一个例子是为类似于自定义矢量的类特例化复制函数,使用enable 这和 is_trivially_copyable 类型 trait 对普通的可复制类型执行按位复制(例如使用 C 函数 memcpy()。

警告:

依赖于 SFEINAE 是一件很业手和复杂的事情。如果有选择地使用 SFINAE 和 enable 计禁用重载集中的错误重载,就会得到奇怪的编译错误,这些错误很难跟踪。

​ 4. 使用 constexpr_if 简化 enable_if 结构

从前面的示例可以看到,使用 enable_if将十分复杂。某些情况下,C++17 引入的 constexpr if特性有助于极大地简化 enable_ if。例如,假设有以下两个类;

class IsDoable{public:	void doit() const { cout << "IsDoable::doit()" << endl; }};class Derived : public IsDoable { };

可创建一个函数模板 call doit()。如果方法可用,它调用 doit()方法,和否则在控制台上打印错误消息。为此,可使用 enable_if,检查给定类型是否从 IsDoable 派生:

template<typename T>enable_if_t<is_base_of_v<IsDoable, T>, void>call_doit(const T& t){	t.doit();}template<typename T>enable_if_t<!is_base_of_v<IsDoable, T>, void>call_doit(const T&){	cout << "Cannot call doit()!" << endl;}

下面的代码对该实现进行测试:

Derived d;
call_doit(d);
call_doit(123);

输出

IsDoable::doit()
Cannot call doit()!

使用 C++17 的 constexpr_if可极大地简化 enable_if实现:

template<typename T>
void call_doit(const T& [[maybe_unused]] t)
{
	if constexpr(is_base_of_v<IsDoable, T>) 
	{
		t.doit();
	} 
	else 
	{	
		cout << "Cannot call doit()!" << endl;
	}
}	

无法使用普通证语句做到这一点! 使用普通if语句,两个分支都需要编译,而如果指定并非从 IsDoable 派生的类型 T,这将失败。此时,t.doit()一行无法编译。但是,使用 constexpr计语句,如果提供了并非从 IsDoable派生的类型,t.doit()一行甚至不会编译! 注意, 这里使用了 C++17 引入的[[maybe_unused]]特性。如果给定类型 T不是从 ISDoable 派生而来, t.doit()行就不会编译。因此,在 call_doit()的实例化中,不会使用参数 t。如果有具有未使用的参数,大多数编译器会给出警告,甚至发生错误。该特性可阻止参数 t 的此类警告或错误。不使用is_base_of 类型 trait,也可使用 C++17 新引入的 is_invocable trait,这个 trait 可用于确定在调用给定函数时是否可以使用一组给定的参数。下面是使用 is_invocable trait 的 call_doit()实现:

template<typename T>void call_doit(const T& [[maybe_unused]] t){    if constexpr(is_invocable_v<decltype(&IsDoable::doit), T>)     {        t.doit();    }     else     {        cout << "Cannot call doit()!" << endl;    }}
  1. 逻辑运算符 trait

在三种逻辑运算符 trait,串联(conjunction)、分离(disjunction)与否定(negation)。以_v 结尾的可变模板也可供使用。这些 trait 接收可变数量的模板类型参数,可用于在类型 trait 上执行逻辑操作,如下所示:

cout << conjunction_v<is_integral<int>, is_integral<short>> << " ";cout << conjunction_v<is_integral<int>, is_integral<double>> << " ";cout << disjunction_v<is_integral<int>, is_integral<double>,is_integral<short>> << " ";cout << negation_v<is_integral<int>> << " ";

输出

1 0 1 0

C++多线程编程

在多处理器的计算机系统上,多线程编程非常重要,人允许编写并行利用所有处理器的程序。系统可通过多种方式获得多个处理器单元。系统可有多个处理器芯片,每个芯片都是一个独立的 CPU(中央处理单元),系统也可只有一个处理器芯片,但该芯片内部由多个独立的 CPU(也称为核心组成。这些处理器称为多核处理器。系统也可是上述两种方式的组合。尽管具有多个处理器单元的系统已经存在了很长一段时间,然而,它们很少在消费系统中使用。今天,所有主要的 CPU 供应商都在销售多核处理器。如今,从服务器到 PC,甚至智能手机都在使用多核处理器。由于这种多核处理器的流行,编写多线程的应用程序变得越来越重要。专业的 C++程序员需要知道如何编写正确的多线程代码,以充分利用所有可用的处理器单元。多线程应用程序的编写曾经依赖平台和操作系统相关的 API。这使得跨平台的多线程编程很困难。C++11 引入了一个标准的线程库,从而解决了这个问题。多线程编程是一个复杂主题。本章讲解利用标准的线程库进行多线程编程,但由于篇幅受限,不可能涉及所有细节。市场上有一些关于多线程编程的专业图书。如果对更深入的细节感兴趣,请参阅附录 B 的“多线程”部分列出的参考文献。还可使用其他第三方 C++库,尽量编写平台独立的多线程程序,例如 pthreads 库和 boost:thread 库。然而,由于这些库不属于 C++标准的一部分,因此本书不予讨论。

多线程编程概述

通过多线程编程可并行地执行多个计算,这样可以充分利用当今大部分系统中的多个处理器单元。几十年前,CPU 市场竞争的是最高频率,对于单线程的应用程序来说主频非常重要。到 2005 年前后,由于电源管理和散热管理的问题, 这种竞争已经停止了。如今 CPU 市场竞争的是单个处理器芯片中的最多核心数目。在撰写本书时,双核和四核 CPU 已经非常普遍了,也有消息说要发布 12 核、16 核、18 核甚至更多核的处理器。同样,看一下显卡中称为 GPU 的处理器,你会发现,它们是大规模并行处理器。今天,高端显卡已经拥有 4000 多个核心,这个数目还会高速增加。这些显卡不只用于游戏,还能执行计算密集型任务,例如图像和视频处理、蛋白质折叠(用于发现新的药物)和 SETI(Search for Extra-Terrestrial Intelligence,搜寻地外智慧生命)项目中的信号处理等。C++98/03 不支持多线程编程,所以必须借助第三方库或目标操作系统中的多线程API。自 C++11 开始,C++有了一个标准的多线程库,使编写跨平台的多线程应用程序变得更容易了。目前的 C++标准仅针对 CPU,不适用于 GPU,这种情形将来可能会改变。有两个原因促使我们应该开始编写多线程代码。首先,假设有一个计算问题,可将它分解为可互相独立运行的小块,那么在多处理器单元上运行可获得巨大的性能提升。其次,可在正交轴上对计算任务模块化;例如在线程中执行长时间的计算,而不会阻塞 UI 线程,这样在后台进行长时间计算时,用户界面仍然可以响应。

当然,并不总能将问题分解为可互相独立且并行执行的部分。但至少通常可将问题部分并行化,从而提升性能。多线程编程中的一个难点是将算法并行化,这个过程和算法的类型高度相关。其他困难之处是防止争用条件、死锁、撕裂和伪共享等。这些都可以使用原子或显式的同步机制来解决,参见本章后面的内容。

警告;

为避免这些多线程问题,应该设计程序,使多个线程不需要读写共享的内存位置。还可使用本章后面 23.3节“原子操作库”中描述的原子操作,或使用 23.4 节“互斥”中描述的同步方法。

争用条件

当多个线程要访问任何种类的共享资源时,可能发生争用条件。共享内存上下文的争用条件称为“数据争用”。当多个线程访问共享的内存,且至少有一个线程写入共享的内存时,就会发生数据争用。例如,假设有一个共享变量,一个线程递增该变量的值,而另一个线程递减其值。递增和递减这个值,意味着需要从内存中获取当前值,递增或递减后再将结果保存回内存。在较旧的架构中,例如 PDP-11 和 VAX,这是通过一条原子的INC 处理器指令完成的。在现代 x86 处理器中,INC 指令不再是原子的,这意味着在这个操作中,可执行其他指令,这可能导致代码获取错误值。表 23-1 展示了递增线程在递减线程开始之前结束的结果,假设初始值是 1。

线程 1(递增)线程 2(递减)
加载值(值=1)
递增值(值=2)
存储值(值=2)
加载值(值=2)
递减值(值=1)
存储值(值=1)

存储在内存中的最终值是 1。当递减线程在递增线程开始之前完成时,最终值也是 1,如表 23-2 所示。

线程 1(递增)线程 2(递减)
加载值(值=1)
递减值(值=0)
存储值(值=0)
加载值(值=0)
递增值(值=1)
存储值(值=1)

然而,当指令交错执行时,结果是不同的,如表 23-3 所示

线程 1(递增)线程 2(递减)
加载值(值=1)
递增值(值=2)
加载值(值=1)
递减值(值=0)
存储值(值=2)
存储值(值=0)

这种情况下,最终结果是 0。换名话说,递增操作的结果丢失了。这是一个争用条件。

斯裂

撕裂(tearing)是数据争用的特例或结果。有两种莫裂类型; 撕裂读和据裂写。如果线程已将数据的一部分写入内存,但还有部分数据没有写入,此时读取数据的其他任何线程将看到不一致的数据,发生撕裂读。如果两个线程同时写入数据,其中一个线程可能写入数据的一部分,而另一个线程可能写入数据的另一部分,最终结果将不一致,发生撕裂写。

死锁

如果选择使用互斥等同步方法解决争用条件的问题,那么可能遇到多线程编程的另一个常见问题: 死锁。死锁指的是两个线程因为等待访问另一个阻塞线程锁定的资源而造成无限阻塞,这也可扩展到超过两个线程的情形。例如,假设有两个线程想要访问某共享资源,它们必须拥有权限才能访问该资源。如果其中一个线程当前拥有访问该资源的权限,但由于其他一些原因而被无限期阻塞,那么此时,试图获取同一资源权限的另一个线程也将无限期阻塞。获得共享资源权限的一种机制是互斥对象,见稍后的讨论。例如,假设有两个线程和两种资源(由两个互斥对象A和 B 保护)。这两个线程获取这两种资源的权限,但它们以不同的顺序获得权限。表 23-4以伪代码形式展示了这种现象。

最好总是以相同的顺序获得权限,以避免这种死锁。也可在程序中包含打破这类死锁的机制。一种可行的方法是试图等待一定的时间,看看能否获得某个资源的权限。如果不能在某个时间间隔内获得这个权限,那么线程停止等待,并释放当前持有的其他锁。线程可能睡眠一小段时间,然后重新尝试获取需要的所有资源。这种方法也可能给其他线程获得必要的锁并继续执行的机会。这种方法是否可用在很大程度上取决于特定的死锁情形。

- 不要使用前一段中描述的那种变通方法,而是应该尝试避免任何可能的死锁情形。如果需要获得由多个互斥对象保护的多个资源的权限,而非单独获取每个资源的权限,推荐使用 23.4 节描述的标准的 std::lock()或std::try_lock()函数。这两个函数会通过一次调用获得或尝试获得多个资源的权限。

伪共享

大多数缓存都使用所谓的“缓存行(cache line)j”。对于现代 CPU 而言,缓存行通常是 64 个字节。如果需要将一些内容写入缓存行,则需要锁定整行。如果代码结构设计不当,对于多线程代码而言,这会带来严重的性能问题。例如,假设有两个线程正在使用数据的两个不同部分,而那些数据共享一个缓存行,如果其中一个线程写入一些内容, 那么将阻塞另一个线程, 因为整个缓存行都被锁定。可使用显式的内存对齐(nemory alignment)方式优化数据结构,确保由多个线程处理的数据不共享任何缓存行。为了以便携方式做到这一点,C++17 引入了 hardware_destructive interference size 常量,该常量在中定义,为避免共享缓存行,返回两个并发访问的对象之间的建议偏移量。可将这个值与 alignas 关键字结合使用,以合理地对齐数据。

线程

借助在头文件中定义的 C++线程库,启动新的线程将变得非常容易。可通过多种方式指定新线程中需要执行的内容。可让新线程执行全局函数、函数对象的 operator()、lambda 表达式甚至某个类实例的成员函数。

通过函数指针创建线程

像 Windows 上的 CreateThread()、_beginthread()等函数,以及 pthreads 库中的 pthread_create()函数,都要求线程函数只有一个参数。另一方面,标准 C++的 std::thread 类使用的函数可以有任意数量的参数。假设 counter()函数接收两个整数: 第一个表示 ID,第二个表示这个函数要循环的迭代次数。函数体是一个循环,这个循环执行给定次数的迭代。在每次迭代中,向标准输出打印一条消息:

void counter(int id, int numIterations){    for (int i = 0; i < numIterations; ++i)     {    	cout << "Counter " << id << " has value " << i << endl;    }}

可通过 std::thread 启动执行此函数的多个线程。可创建线程 t1,使用参数 1 和 6 执行 counter():

thread t1(counter, 1, 6);

thread 类的构造函数是一个可变参数模板,也就是说,可接收任意数目的参数。第 22 章详细讨论了可变参数模板。第一个参数是新线程要执行的函数的名称。当线程开始执行时,将随后可变数目的参数传递给这个函数。

如果一个线程对象表示系统当前或过去的某个活动线程,则认为它是可结合的(joinable)。即使这个线程执行完毕,该线程对象也依然处于可结合状态。默认构造的线程对象是不可结合的。在销毁一个可结合的线程对象前, 必须调用其 join()或 detach()方法.对 join()的调用是阻塞调用, 会一直等到线程完成工作为止。调用 detach()时,会将线程对象与底层 OS 线程分离。此时,OS 线程将继续独立运行。调用这两个方法时,都会导致线程变得不可结合。如果一个仍可结合的线程对象被销毁,析构函数会调用 std::terminate(),这会突然间终止所有线程以及应用程序本身。下面的代码启动两个线程来执行 counter()函数。启动线程后,main()调用这两个线程的 join()方法。

#include <iostream>#include <thread>using namespace std;void counter(int id, int numIterations){	for (int i = 0; i < numIterations; ++i)     {		cout << "Counter " << id << " has value " << i << endl;	}}int main(){	thread t1(counter, 1, 6);	thread t2(counter, 2, 4);	t1.join();	t2.join();	return 0;}

输出

xz@xiaqiu:~/study/test/test$ ./testCounter Counter 1 has value 20 has value 0Counter 1 has value Counter 2 has value 1Counter 2 has value 2Counter 2 has value 31Counter 1 has value 2Counter 1 has value 3Counter 1 has value 4Counter 1 has value 5xz@xiaqiu:~/study/test/test$ 

不同系统上的输出会有所不同,很可能每次运行的结果都不同。这是因为两个线程同时执行 counter()函数,所以输出取决于系统中处理核心的数量以及操作系统的线程调度。默认情况下,从不同线程访问 cout 是线程安全的,没有任何数据争用,除非在第一个输出或输入操作之前调用了 coutsync_with_stdio(false)。然而,即使没有数据争用,来自不同线程的输出仍然可以交错。这意味着,前面示例的输出可能会混合在一起

xz@xiaqiu:~/study/test/test$ ./test
Counter 1 has value 0
Counter Counter 1 has value 1
Counter 1 has value 2
Counter 1 has value 3
Counter 1 has value 4
Counter 1 has value 5
2 has value 0
Counter 2 has value 1
Counter 2 has value 2
Counter 2 has value 3

注意:

线程函数的参数总是被复制到线程的某个内部存储中。 通过头文件中的 std’:ref()或 cref()按引用传递参数。

通过函数对象创建线程

不使用函数指针,也可以使用函数对象在线程中执行。23.2.1 节使用函数指针技术,给线程传递信息的唯-方式是给函数传递参数。而使用函数对象,可向函数对象类添加成员变量,并可以采用任何方式初始化和使用这些变量。下例首先定义 Counter 类。这个类有两个成员变量: 一个表示 ID,另一个表示循环和代次数。这两个成员变量都通过类的构造函数进行初始化。为让 Counter 类成为函数对象,根据第 18 章的讨论,需要实现operator()。operator()的实现和 counter()函数一样

class Counter
{
public:
    Counter(int id, int numIterations)
    : mId(id), mNumIterations(numIterations)
    {
    }
    void operator()() const
    {
        for (int i = 0; i < mNumIterations; ++i) 
        {
        	cout << "Counter " << mId << " has value " << i << endl;
    	}
	}
private:
    int mId;
    int mNumIterations;
};

下面的代码片段演示了通过函数对象初始化线程的三种方法。第一种方法使用了统一初始化语法。通过构造函数参数创建 Counter 类的一个实例,然后把这个实例放在花括号中,传递给 thread 类的构造函数。第二种方法定义了 Counter 类的一个命名实例,并将它传递给 thread 类的构造函数。第三种方法类似于第一种方法: 创建 Counter 类的一个实例并传递给 thread 类的构造函数,但是使用了圆括号而不是花括号。

// Using uniform initialization syntaxthread t1{ Counter{ 1, 20 }};// Using named variableCounter c(2, 12);thread t2(c);// Using temporarythread t3(Counter(3, 10));// Wait for threads to finisht1.join();t2.join();t3.join();

比较 和世 的创建方法,看上去唯一的区别在于第一种方法使用了花括号,而第三种方法使用了圆括号。然而,如果函数对象构造函数不需要任何参数,上述第三种方法将不能正常工作。例如;

class Counter
{
public:
    Counter() {}
    void operator()() const { /* Omitted for brevity */ }
};
int main()
{
    thread t1(Counter());
    t1.join();
}

输出

xz@xiaqiu:~/study/test/test$ g++ -o test test.cpp -lpthread
test.cpp: In function ‘int main():
test.cpp:13:8: error: request for member ‘join’ in ‘t1’, which is of non-class type ‘std::thread(Counter (*)())13 |     t1.join();
      |        ^~~~
xz@xiaqiu:~/study/test/test$ 

这将导致编译错误,因为 C++会将 main()函数中的第一行解释为函数的声明,t1 函数返回一个 thread 对象,其参数是一个函数指针,指向返回一个 Counter 对象的无参函数。因此,建议使用统一初始化语法:

thread tl{ Counter{} }; //1 oK

注意:

阴数对象总是被复制到线程的某个内部存储中。如果要在函数对象的某个特定实例上执行 operator()而非进行复制,那么应该使用头文件中的 std::ref()或 cref(),通过引用传入该实例。

Counter c(2, 12);
thread t2(ref(c));

通过lambda 创建线程

lambda 表达式能很好地用于标准 C++线程库。下例启动一个线程来执行给定的 lambda 表达式:

#include <thread>#include <iostream>using namespace std;int main(){	int id = 1;	int numIterations = 5;	thread t1([id, numIterations] {		for (int i = 0; i < numIterations; ++i) {			cout << "Counter " << id << " has value " << i << endl;		}	});	t1.join();	return 0;}

通过成员函数创建线程

还可在线程中指定要执行的类的成员函数。下例定义了带有 process()方法的基类 Request。main()函数创建Request 类的一个实例,并启动一个新的线程,这个线程执行 Request 实例 req 的 process()成员函数:

class Request{	public:		Request(int id):mId(id){}		void process()		{			cout<<"Processing request "<<mId<<endl;		}	private:		int mId;};int main(){    Request req(100);    thread t{ &Request::process, &req };    t.join();}

通过这种技术,可在不同线程中执行某个对象中的方法。如果有其他线程访问同一个对象,那么需要确认这种访问是线程安全的,以避免争用条件。本章稍后讨论的互斥可用作实现线程安全的同步机制。

线程本地存储

C++标准支持线程本地存储的概念。通过关键字 thread_local,可将任何变量标记为线程本地数据,即每个线程都有这个变量的独立副本,而且这个变量能在线程的整个生命周期中持续存在。对于每个线程,该变量正好初始化一次。例如,在下面的代码中,定义了两个全局变量;每个线程都共享唯一的k 副本,而且每个线程都有自己的n 副本:

int k;thread_local int n;

注意,如果 thread_local 变量在函数作用域内声明,那么这个变量的行为和声明为静态变量是一致的,只不过每个线程都有自己独立的副本,而且不论这个函数在线程中调用多少次,每个线程仅初始化这个变量一次。

取消线程

C++标准没有包含在一个线程中取消另一个已运行线程的任何机制。实现这一目标的最好方法是提供两个线程都支持的某种通信机制。最简单的机制是提供一个共享变量,目标线程定期检查这个变量,判断是否应该终止。其他线程可设置这个共享变量,间接指示线程关闭。这里必须注意,因为是由多个线程访问这个共享变量,其中至少有一个线程向共享变量写入内容。建议使用本章后面讨论的原子变量或条件变量。

从线程获得结果

如前面的例子所示,启动新线程十分容易。然而,大多数情况下,你可能更感兴趣的是线程产生的结果。例如,如果一个线程执行了一些数学计算,你肯定想在执行结束时从这个线程获得计算结果。一种方法是向线程传入指向结果变量的指针或引用,线程将结果保存在其中。另一种方法是将结果存储在函数对象的类成员变量中,线程执行结束后可获得结果值。只有使用 std::ref(),将函数对象按引用传递给 thread 构造函数时,这才能生效。然而, 还有一种更简单的方法可从线程获得结果: furure。 通过 future 也能更方便地处理线程中发生的错误。

复制和重新抛出异常

整个异常机制在 C++中工作得很好,当然这仅限于单线程的情况。每个线程都可抛出自己的异常,但它们必须在自己的线程内捕获异常。如果一个线程抛出的异常不能在另一个线程中捕获,C++运行库将调用std::terminate(),从而终止整个应用程序。从一个线程抛出的异常不能在另一个线程中捕获。当希望将异常处理机制和多线程编程结合在一起时,这会引入不少问题。不使用标准线程库,就很难在线程间正常地处理异常,甚至根本办不到。标准线程库通过以下和异常相关的函数解决了这个问题。这些函数不仅可用于 std::exception,还可以用于所有类型的异常, int、string、自定义异常等。

➤➤exception_ptr current_exception() noexcept;

这个函数在 catch 块中调用,返回一个 exception_ptr 对象,这个对象引用目前正在处理的异常或其副本。如果没有处理异常,则返回空的 exception_ptr 对象。只要存在引用异常对象的 exception_ptr 类型的对象,引用的异常对象就是可用的。exception_ptr 对象的类型是 NullablePointer,这意味着这个变量很容易通过简单的 认语句来检查,详见后面的示例。

➤➤[[noreturn]] void rethrow_exception(exception_ptr p);

这个函数重新抛出由 exception_ptr 参数引用的异常。未必在最开始生成引用的异常的那个线程中重新抛出这个异常, 因此这个特性特别适合于跨不同线程的异常处理。[[noreturn]]特性表示这个函数绝不会正常地返回。第 11 章介绍了特性。

➤➤template exception_ptr make_exception_ptr(E e) noexcept;

这个函数创建一个引用给定异常对象副本的 exception_ptr 对象。这实际上是以下代码的简写形式:

try {throw e;} catch(...) {return current_exception();}

下面看一下如何通过这些函数实现不同线程间的异常处理。下面的代码定义了一个函数,这个函数完成一些事情并抛出异常。这个函数最终将运行在一个独立的线程中:

void doSomeWork()
{
    for (int i = 0; i < 5; ++i) 
    {
        cout << i << endl;
    }
    cout << "Thread throwing a runtime_error exception..." << endl;
    throw runtime_error("Exception from thread");
}

下面的 threadFunc()函数将上述函数包装在一个 try/catch 块中,捕获 doSomeWork()可能抛出的所有异常。为 threadFunc()传入一个参数,其类型为exception_ptr&。一旦捕获到异常,就通过 current_exception()函数获得正在处理的异常的引用,然后将引用赋给 exception_ptr 参数。之后,线程正常退出:

void doSomeWork()
{
    for (int i = 0; i < 5; ++i) 
    {
    	cout << i << endl;
    }
    cout << "Thread throwing a runtime_error exception..." << endl;
	throw runtime_error("Exception from thread");
}

下面的 threadFunc()函数将上述函数包装在一个 try/catch 块中,捕获 doSomeWork()可能抛出的所有异常。为 threadFunc()传入一个参数,其类型为 exception_ptr&。一旦捕获到异常,就通过 current_exception()函数获得正在处理的异常的引用,然后将引用赋给 exception_ptr 参数。之后,线程正常退出:

void threadFunc(exception_ptr& err){    try     {    	doSomeWork();    } catch (...)     {        cout << "Thread caught exception, returning exception..." << endl;        err = current_exception();    }}

以下 doWorkInThread()函数在主线程中调用,其职责是创建一个新的线程,并开始在这个线程中执行threadFunc()函数。对类型为 exception_ptr 的对象的引用被作为参数传入 threadFunc()。一旦创建了线程,doWorkInThread()函数就使用 join()方法等待线程执行完毕,之后检查 error 对象。由于 exception_ptr 的类型为NullablePointer,因此很容易通过 证语句进行检查。如果是一个非空值,则在当前线程中重新抛出异常,在这个例子中,当前线程即主线程。在主线程中重新抛出异常,异常就从一个线程转移到另一个线程。

void doWorkInThread()
{
    exception_ptr error;
    // Launch thread
    thread t{ threadFunc, ref(error) };
    // Wait for thread to finish
    t.join();
    // See if thread has thrown any exception
    if (error) 
    {
        cout << "Main thread received exception, rethrowing it..." << endl;
        rethrow_exception(error);
    } 
    else 
    {
    	cout << "Main thread did not receive any exception." << endl;
    }
}

main()函数相当简单.它调用doWorkInThread(), 将这个调用包装在一个trycatch块中, 捕获由doWorkInThread()创建的任何线程抛出的异常;

int main()
{
    try {
    doWorkInThread();
    } catch (const exception& e) {
    cout << "Main function caught: '" << e.what() << "'" << endl;
    }
}

代码

#include <thread>#include <iostream>#include <exception>#include <stdexcept>using namespace std;void doSomeWork(){    for (int i = 0; i < 5; ++i)    {        cout << i << endl;    }    cout << "Thread throwing a runtime_error exception..." << endl;    throw runtime_error("Exception from thread");}void threadFunc(exception_ptr &err){    try    {        doSomeWork();    }    catch (...)    {        cout << "Thread caught exception, returning exception..." << endl;        err = current_exception();    }}void doWorkInThread(){    exception_ptr error;    // Launch thread    thread t{ threadFunc, ref(error) };    // Wait for thread to finish    t.join();    // See if thread has thrown any exception    if (error)    {        cout << "Main thread received exception, rethrowing it..." << endl;        rethrow_exception(error);    }    else    {        cout << "Main thread did not receive any exception." << endl;    }}int main(){    try    {        doWorkInThread();    }    catch (const exception &e)    {        cout << "Main function caught: '" << e.what() << "'" << endl;    }    return 0;}

输出

xz@xiaqiu:~/study/test/test$ ./test01234Thread throwing a runtime_error exception...Thread caught exception, returning exception...Main thread received exception, rethrowing it...Main function caught: 'Exception from thread'xz@xiaqiu:~/study/test/test$ 

为让这个例子紧凑且更容易理解,main()函数通常使用 join()阻塞主线程,并等待线程完成。当然,在实际的应用程序中,你不想阻塞主线程。例如,在 GUI 应用程序中,阻塞主线程意味着 UI 失去响应。此时,可使用消息传递范型在线程之间通信。例如,可让前面的threadFunc()函数给 UI 线程发送一条消息,消息的参数为current_exception()结果的一份副本。但即使如此,如前所述,也需要确保在任何生成的线程上调用 join()或detach() 。

原子操作库

原子类型允许原子访问,这意味着不需要额外的同步机制就可执行并发的读写操作。没有原子操作,递增变量就不是线程安全的,因为编译器首先将值从内存加载到寄存器中,递增后再把结果保存回内存。另一个线程可能在这个递增操作的执行过程中接触到内存,导致数据争用。例如,下面的代码不是线程安全的,包含数据争用条件。这种争用条件在本章开头讨论过:

int counter = 0; // Global variable
++counter; // Executed in multiple threads

为使这个线程安全且不显式地使用任何同步机制(如本章后面讨论的互斥对象),可使用 std::atomic 类型。下面是使用原子整数的相同代码:

atomic<int> counter(0) ; // Global variable
++counter;// Executed in multiple threads

为使用这些原子类型,需要包含头文件。C++标准为所有基本类型定义了命名的整型原子类型,如表 23-5 所示。

可使用原子类型,而不显式使用任何同步机制。但在底层,某些类型的原子操作可能使用同步机制(如互斥对象)。 如果目标硬件缺少以原子方式执行操作的指令, 则可能发生这种情况。可在原子类型上使用is_lock_free()方法来查询它是否支持无锁操作,所谓无锁操作,是指在运行时,底层没有显式的同步机制。可将 std::atomic 类模板与所有类型一起使用,并非仅限于整数类型。例如,可创建 atomic或atomic,但这要求 MyType 具有 is_trivially_copy 特点。底层可能需要显式的同步机制,具体取决于指定类型的大小。在下例中,Foo 和 Bar 具有 is_trivially_copy 特点,即 std::is_trivially_copyable_v 都等于 true。 但atomic并非无锁操作,而 atomic是无锁操作。

class Foo { private: int mArray[123]; };class Bar { private: int mInt; };int main(){    atomic<Foo> f;    // Outputs: 1 0    cout << is_trivially_copyable_v<Foo> << " "          << f.is_lock_free() << endl;    atomic<Bar> b;    // Outputs: 1 1    cout << is_trivially_copyable_v<Bar> << " "          << b.is_lock_free() << endl;}

在多线程中访问一段数据时,原子也可解决内存排序、编译器优化等问题。基本上,不使用原子或显式的同步机制,就不可能安全地在多线程中读写同一段数据。

原子类型示例

本节解释为什么应该使用原子类型。假设有一个名为increment()的函数,它在一个循环中递增一个通过引用参数传入的整数值。这段代码使用 std::this_thread::sleep_for()在每个循环中引入一小段延迟。sleep_for()的参数是 std::chrono::duration,参见第 20 章。

void increment(int& counter){    for (int i = 0; i < 100; ++i)     {        ++counter;        this_thread::sleep_for(1ms);    }}

现在,想要并行运行多个线程,需要在共享变量 counter 上执行这个 increment()函数。如果不使用原子类型或任何线程同步机制,按原始方式实现这个程序,则会引入争用条件。下面的代码在加载了 10 个线程后,调用每个线程的 join(),等待所有线程执行完毕。

int main(){	int counter = 0;	vector<thread> threads;	for(int i = 0;i < 10; ++i)	{		threads.push_back(thread{increment,ref(counter)});	}	for(auto & t : threads)	{		t.join();	}	cout<<"Result = "<<counter<<endl;}

由于 increment()递增了这个整数 100 次,加载了 10 个线程,并且每个线程都在同一个共享变量 counter 上执行 increment(),因此期待的结果是 1000。如果执行这个程序几次,可能会得到以下输出,但值不同

xz@xiaqiu:~/study/test/test$ ./test
Result = 943
xz@xiaqiu:~/study/test/test$ ./test
Result = 875
xz@xiaqiu:~/study/test/test$ ./test
Result = 868
xz@xiaqiu:~/study/test/test$ ./test
Result = 826
xz@xiaqiu:~/study/test/test$ ./test
Result = 881
xz@xiaqiu:~/study/test/test$ ./test
Result = 842
xz@xiaqiu:~/study/test/test$ ./test
Result = 828
xz@xiaqiu:~/study/test/test$ ./test
Result = 850
xz@xiaqiu:~/study/test/test$ 

这段代码清楚地表现了数据争用行为。在这个例子中,可以使用原子类型解决该问题。下面的代码突出显示了所做的修改

#include <atomic>
void increment(atomic<int>& counter)
{
	for(int i = 0;i < 100; ++i)
	{
		++counter;
		this_thread::sleep_for(1ms);
	}
}
int main()
{
	atomic<int> counter(0);
    vector<thread> threads;
    for(int i = 0; i < 10; ++i)
    {
        threads.push_back(thread{increment,ref(counter)});
    }
    for(auto& t : threads)
    {
        t.join();
    }
    cout<<"Result = "<<counter<<endl;
}

为这段代码添加了头文件,将共享计数器的类型从 int 变为 std::atomic。运行这个改进后的版本,将永远得到结果 1000:

xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ ./test
Result = 1000
xz@xiaqiu:~/study/test/test$ 

不用在代码中显式地添加任何同步机制,就得到了线程安全上且没有争用条件的程序,因为对原子类型执行++counter 操作会在原子事务中加载值、递增值并保存值,这个过程不会被打断。但是,修改后的代码会引发一个新问题: 性能。应试着最小化同步次数,包括原子操作和显式同步,因为这会降低性能。对于这个简单示例,推荐的最佳解决方案是让 increment()在一个本地变量中计算结果,并且在循环把它添加到 counter 引用之后再计算。注意仍需要使用原子类型,因为仍要在多线程中写入 counter:

void increment(atomic<int>& counter)
{
	int result = 0;
	for(int i = 0;i<100;++i)
	{
		++result;
		this_thread::sleep_for(1ms);
	}
	counter += result;
}

原子操作

C++标准定义了一些原子操作。本节描述其中一些操作。完整的清单请参阅标准库参考资源(见附录 B)。

下面是一个原子操作示例:

bool atomic<T>::compare_exchange_strong(T& expected,T desired);

这个操作以原子方式实现了以下逻辑,伪代码如下:

if(*this == expected)
{
	*this = desired;
	return true;
}
else
{
    expected = *this;
    return false;
}

这个逻辑初看起来令人感到陌生,但这是编写无锁并发数据结构的关键组件。无锁并发数据结构允许不使用任何同步机制来操作数据。但实现此类数据结构是一个高级主题,超出了本书的讨论范围。

另一个例子是用于整型原子类型的atomic::fetch_add().这个操作获取该原子类型的当前值,将给定的递增值添加到这个原子值,然后返回未递增的原始值。例如:

atomic<int> value(10);cout<<"Value = "<<value<<endl;int fetched = value.fetch_add(4);cout<<"Fetched = "<<fetched<<endl;cout<<"value = "<<value<<endl;

如果没有其他线程操作 fetched 和 value 变量的内容,那么输出如下:

xz@xiaqiu:~/study/test/test$ ./testValue = 10Fetched = 10value = 14xz@xiaqiu:~/study/test/test$ 

整型原子类型支持以下原子操作: fetch_add()、fetch_sub()、fetch_and()、fetch_or()、fetch xor()、++、–、+=、-=、&=、^和|F。原子指针类型支持 fetch_add()、fetch_sub()、++、–、+=和-=。大部分原子操作可接收一个额外参数,用于指定想要的内存顺序。例如;

T atomic<T>::fetch_add(T value,memory_order = memory_order_seq_cst);

可改变默认的 memory order 。C++标准提供了 memory_order_relaxed 、memory_order_consume 、memory_order_ acquire、memory_order release、memory _order_acq_rel 和 memory_order seq_cst,这些都定义在std 名称空间中。然而,很少有必要使用默认之外的顺序。尽管其他内存顺序可能会比默认顺序性能好,但根据一些标准,使用稍有不当,就有可能会再次引入争用条件或其他和线程相关的很难跟踪的问题。如果需要了解有关内存顺序的更多信息,请参阅附录 B 中关于多线程的参考文献。

互斥

如果编写的是多线程应用程序,那么必须分外留意操作顺序。如果线程读写共享数据,就可能发生问题。可采用许多方法来避免这个问题,例如绝不在线程之间共享数据。然而,如果不能避免数据共享,那么必须提供同步机制,使一次只有一个线程能更改数据。布尔值和整数等标量经常使用上述原子操作来实现同步,但当数据更复杂且必须在多个线程中使用这些数据时,就必须提供显式的同步机制。标准库支持互斥的形式包括互斥体mutex()类和锁类。这些类都可以用来实现线程之间的同步,接下来讨论

互斥体类

互斥体(mutex,代表 mutual exclusion)的基本使用机制如下:

希望与其他线程共享内存读写的一个线程试图锁定互斥体对象。如果另一个线程正在持有这个锁,希望获得访问的线程将被阻塞,直到锁被释放,或直到超时。

一旦线程获得锁,这个线程就可随意使用共享的内存,因为这要假定希望使用共享数据的所有线程都正确获得了互斥体对象上的锁。

线程读写完共享的内存后,线程将锁释放,使其他线程有机会获得访问共享内存的锁。如果两个或多个线程正在等待锁,没有机制能保证哪个线程优先获得锁,并且继续访问数据。

C++标准提供了非定时的互斥体类和定时的互斥体类。

  1. 非定时的互斥体类

标准库有三个非定时的互斥体类: std::mutex、recursive_mutex 和 shared_mutex(自 C++17 开始引用)。前两个类在中定义,最后一个类在<shared_mutex>中定义。每个类都支持下列方法。lock(): 调用线程将尝试获取锁,并阻塞直到获得锁。这个方法会无限期阻塞。如果和希望设置线程阻塞的最长时间,应该使用定时的互斥体类。

try_lock(): 调用线程将尝试获取锁。如果当前锁被其他线程持有,这个调用会立即返回。如果成功获取锁,try_lock()返回 true,和否则返回 false。

unlock(): 释放由调用线程持有的锁,使另一个线程能获取这个锁。std::mutex 是一个标准的具有独占所有权语义的互斥体类。只能有一个线程拥有互斥体。如果另一个线程想获得互斥体的所有权,那么这个线程既可通过 lock()阻塞,也可通过 try_lock()尝试失败。已经拥有 std::mutex所有权的线程不能在这个互斥体上再次调用 lock()和 try_lock(),和否则可能导致死锁!std::recursive_ mutex 的行为几乎和 std::mutex 一致, 区别在于已经获得递归互斥体所有权的线程允许在同一个互斥体上再次调用 lock()和 try_lock()。调用线程调用 unlock()方法的次数应该等于获得这个递归互斥体上锁的次数。

shared_mutex 支持“共享锁拥有权”的概念,这也称为 readerswriters 锁。线程可获取锁的独占所有权或共享所有权。独占拥有权也称为写锁,仅当没有其他线程拥有独占或共享所有权时才能获得。共享所有权也称读锁, 如果其他线程都没有独占所有权, 则可获得, 但允许其他线程获取共享所有权。 shared_mutex 类支持 lock()、try_lock()和 unlock()。这些方法获取和释放独占锁。另外, 它们具有以下与共享所有权相关的方法: lock_shared()、try_lock_shared()和 unlock_shared()。这些方法与其他方法集合的工作方式相似,但尝试获取或释放共享所有权。不允许已经在 shared_mutex 上拥有锁的线程在互斥体上获取第二个锁,和否则会产生死锁!

  1. 定时的互斥体类

标准库提供了 3 个定时的互斥体类: std::timed_mutex、recursive_timed_mutex 和 shared_ timed_mutex。前两个类在中定义,最后一个类在<shared_mutex>中定义。它们都支持 lock()、try_ lock()和 unlock()方法,shared_timed_mutex 还支持 lock_shared()、try_lock shared()和 unlock_ shared()。所有这些方法的行为与前面描述的类似。此外,它们还支持以下方法。

try_lock_for(rel_time): 调用线程尝试在给定的相对时间内获得这个锁。如果不能获得这个锁,这个调用失败并返回 false。如果在超时之前获得了这个锁,这个调用成功并返回 true。将超时时间指定为std::chrono::duration,见第 20 章的讨论。

try_lock_until(abs_time): 调用线程将尝试获得这个锁, 直到系统时间等于或超过指定的绝对时间。如果能在超时之前获得这个锁,调用返回 true。如果系统时间超过给定的绝对时间,将不再尝试获得锁,并返回 false。将绝对时间指定为 std::chrono::time point,见第 20 章的讨论。

shared_timed_mutex 还支持 try_lock_shared_for()和 try_lock_shared_until()。已经拥有 timed_mutex 或 shared_timed_mutex 所有权的线程不允许再次获得这个互斥体上的锁,否则可能导致死锁!

recursive_timed_mutex 的行为和 recursive_mutex 类似,人允许一个线程多次获取锁。

警告

不要在任何互斥体类上手工调用上述锁定和解锁方法。互斥锁是资源,与所有资源一样,它们几乎总是应使用RAII(Resource Acquisition Is Initialization)范型获得,参见第 28 章。 C++标准定义了一些 RAII 锁定类,使用它们对避免死锁很重要。锁对象离开作用域时,它们会自动释放互斥体,所以不需要手工调用 unlock()。

锁类是 RAII 类,可用于更方便地正确获得和释放互斥体上的锁,锁类的析构函数会自动释放所关联的互斥体。C++标准定义了 4 种类型的锁: std::lock_guard、unique_lock、shared_lock 和 scoped_lock。最后一类是在C++17 中引入的。

1.lock_guard

lock_guard 在中定义,有两个构造函数。

 explicit lock_guard(mutex_type& m);

接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并阻塞直到获得锁。第 9 章讨论了构造函数的 explicit 关键字。

lock_guard(mutex_type& m, adopt_lock_t);

接收一个互斥体引用和一个 std::adopt_lock_t 实例的构造函数.C++提供了一个预定义的 adopt_lock_t实例,名为 std::adopt_lock。 该锁假定调用线程已经获得引用的互斥体上的锁, 管理该锁, 在销毁锁时自动释放互斥体。

unique_lock

std::unique_lock 定义在中,是一类更复杂的锁,人允许将获得锁的时间延迟到计算需要时,远在声明时之后。使用 owns_lock()方法可以确定是否获得了锁。unique_lock 也有 bool 转换运算符,可用于检查是否获得了锁。使用这个转换运算符的例子在本章后面给出。unique_ lock 有如下几个构造函数。

explicit unique_lock(mutex_type& m);

接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并且阻塞直到获得锁。

unique_lock(mutex_type& m, defer_lock_t) noexcept;

接收一个互斥体引用和一个 std::defer_lock_t 实例的构造函数。C++提供了一个预定义的 defer_lock_t 实例,名为 std::defer_lock。unique_lock 存储互斥体的引用,但不立即尝试获得锁,锁可以稍后获得。

unique_lock(mutex_type& m, try_to_lock_t);

接收一个互斥体引用和一个 std::defer lock_t 实例的构造函数。C++提供了一个预定义的 defer_lock_t 实例,名为 std::defer_lock。unique_lock 存储互斥体的引用,但不立即尝试获得锁,锁可以稍后获得。

unique_lock(mutex_type& m, adopt_lock_t);

接收一个互斥体引用和一个 std::try_to_lock_t 实例的构造函数。C++提供了一个预定义的 try_to_lock_t 实例,名为 std::try_to_lock。这个锁尝试获得引用的互斥体上的锁,但即便未能获得也不阻塞,此时,会在稍后获取锁。

unique_lock(mutex_type& m, const chrono::time_point<Clock, Duration>&abs_time);

接收一个互斥体引用和一个绝对时间的构造函数。这个构造函数试图获取一个锁,直到系统时间超过给定

unique_lock(mutex_type& m, const chrono::duration<Rep, Period>& rel_time);

接收一个互斥体引用和一个相对时间的构造函数。这个构造函数试图获得一个互斥体上的锁,直到到达给定的相对超时时间。

unique_lock 类也有以下方法: lock()、try_lock()、try_lock_for()、try_lock_until()和 unlock()。这些方法的行为和前面介绍的定时的互斥体类中的方法一致。

  1. shared_lock

shared_lock 类在<shared_mutex>中定义,它的构造函数和方法与 unique_lock 相同。区别是,shared_lock 类在底层的共享互斥体上调用与共享拥有权相关的方法。因此,shared_lock 的方法称为 lock()、try_lock()等,但在底层的共享互斥体上,它们称为 lock_shared()、try_lock_shared()等。所以,shared lock 与 unique_lock 有相同的接口,可用作 unique_ lock 的替代品,但获得的是共享锁,而不是独占锁。

  1. 一次性获得多个锁

C++有两个泛型锁函数,可用于同时获得多个互斥体对象上的锁,而不会出现死锁。这两个泛型锁函数都在 std 名称空间中定义,都是可变参数模板函数。第 22 章讨论了可变参数模板函数。第一个函数 lock()不按指定顺序锁定所有给定的互斥体对象,没有出现死锁的风险。如果其中一个互斥锁调用抛出异常,则在已经获得的所有锁上调用 unlock()。原型如下:

template <class L1, class L2, class... L3> void lock(L1&, L2&, L3&...);

try_lock()函数具有类似的原型,但它通过顺序调用每个给定互斥体对象的 try_lock(),试图获得所有互斥体对象上的锁。如果所有 try_lock()调用都成功,那么这个函数返回 - 1。如果任何 try_lock()调用失败,那么对所有已经获得的锁调用 unlock(),返回值是在其上调用 try_lock()失败的互斥体的参数位置索引(从 0 开始计算)。下例演示如何使用泛型函数 lock() 。process()函数首先创建两个锁,每个互斥体一个锁,然后将一个std::defer_lock_t 实例作为第二个参数传入,告诉 unique_lock 不要在构造期间获得锁。然后调用 std::lock()以获得这两个锁,而不会出现死锁:

mutex mut1;mutex mut2;void process(){    unique_lock lock1(mut1, defer_lock); // C++17    unique_lock lock2(mut2, defer_lock); // C++17    //unique_lock<mutex> lock1(mut1, defer_lock);    //unique_lock<mutex> lock2(mut2, defer_lock);    lock(lock1, lock2);    // Locks acquired} // Locks automatically released
  1. scoped_ lock

std::scoped_lock 在中定义,与 lock_guard 类似,只是接收数量可变的互斥体。这样,就可极方便地获取多个锁。例如,可以使用 scoped_lock,编写刚才包含 process()函数的那个示例,如下所示:

mutex mut1;mutex mut2;void process(){    scoped_lock locks(mut1, mut2);    // Locks acquired} // Locks automatically released

这使用了 C++17 的用于构造函数的模板参数推导方式。 如果编译器尚不支持此功能, 则必须编写如下代码:

scoped_lock<mutex, mutex> locks(mut1, mut2);

std::call_once

结合使用 std::call_once()和 std::once_flag 可确保某个函数或方法正好只调用一次,不论有多少个线程试图调用 call_once()(在同一 once_flag 上)都同样如此。只有一个 call_once()调用能真正调用给定的函数或方法。如果给定的函数不抛出任何异常,则这个调用称为有效的 call_once()调用。如果给定的函数抛出异常,异常将传回调用者,选择另一个调用者来执行此函数。某个特定的 once_flag 实例的有效调用在对同一个 once_flag 实例的其他所有 call_once()调用之前完成。在同一个 once_flag 实例上调用 call_once()的其他线程都会阻塞,直到有效调用结束。图 23-3 通过 3 个线程演示了这一点。线程 1 执行有效的 call_once()调用,线程 2 阻塞,直到这个有效调用完成,线程 3 不会阻塞,因为线程 1 的有效调用已经完成了。

在这里插入图片描述

下例演示了 call_once()的使用。这个例子运行使用某个共享资源的 processingFunction(),启动了 3 个线程。这些线程应调用 initializeSharedResources()一次,仅初始化一次。为此,每个线程用全局的 once_flag 调用call_once(),结果是只有一个线程执行 initializeSharedResources(),且只执行一次。在调用 call_once()的过程中,其他线程被阻塞,直到 initializeSharedResources()返回

once_flag gOnceFlag;
void initializeSharedResources()
{
    // ... Initialize shared resources to be used by multiple threads.
    cout << "Shared resources initialized." << endl;
}
void processingFunction()
{
    // Make sure the shared resources are initialized.
    call_once(gOnceFlag, initializeSharedResources);
    // ... Do some work, including using the shared resources
    cout << "Processing" << endl;
}
int main()
{
    // Launch 3 threads.
    vector<thread> threads(3);
    for (auto& t : threads) 
    {
    	t = thread{ processingFunction };
    }
    // Join on all threads
    for (auto& t : threads) 
    {
    	t.join();
    }
}

输出

xz@xiaqiu:~/study/test/test$ ./test
Shared resources initialized.
Processing
Processing
Processing
xz@xiaqiu:~/study/test/test$ ./test
Shared resources initialized.
Processing
Processing
Processing
xz@xiaqiu:~/study/test/test$ ./test
Shared resources initialized.
Processing
Processing
Processing
xz@xiaqiu:~/study/test/test$ ./test
Shared resources initialized.
Processing
Processing
Processing
xz@xiaqiu:~/study/test/test$ 

当然,在这个例子中,也可在启动线程之前,在 main()函数的开头调用 initializeSharedResources(),但那样就无法演示 call_once()的用法了。

互斥体对象的用法示例

下面列举几个例子,演示如何使用互斥体对象来同步多个线程。

  1. 以线程安全方式写入流

在本章前面有关线程的内容中,有一个例子使用 名为 Counter 的类。这个例子提到,C++中的流是不会出现争用条件的,但来自不同线程的输出仍会交错。为解决这个问题,可以使用一个互斥体对象,以确保一次只有一个线程读写流对象。下面的例子同步 Counter 类中所有对 count 的访问。为实现这种同步,向这个类中添加一个静态的 mutex 对象.这个对象应该是静态的, 因为类的所有实例都应该使用同一个 mutex 实例。在写入 cout 之前,使用 lock_guard获得这个 mutex 对象上的锁。下面高亮显示了和此前版本不同的代码:

class Counter
{
	public:
		Counter(int id,int numIterations)
			:mId(id),mNumIteration(numIterations)
			{
			
			}
		void operator()() const
		{
			for(int i = 0;i<mNumIterations;++i)
			{
				lock_guard lock(sMutex);
				cout<<"Counter "<<mId<<" has value "<<i<<endl;
			}
		}
	private:
		int mId;
		int mNumIterations;
		static mutex sMutex;
};
mutex Counter::sMutex;

这段代码在 for 循环的每次欠代中创建了一个 lock_guard 实例。建议尽可能限制拥有锁的时间,和否则阻塞其他线程的时间就会过长。例如,如果 lock_guard 实例在 for 循环之前创建一次,就基本上丢失了这段代码中的所有多线程特性,因为一个线程在其 for 循环的整个执行期间都拥有锁,所有其他线程都等待这个锁被释放。

  1. 使用定时锁

下面的示例演示如何使用定时的互斥体。这与此前是同一个 Counter 类,但这一次结合 unique_lock 使用了timed_mutex。将 200 毫秒的相对时间传给 unique lock 构造函数,试图在 200 毫秒内获得一个锁。如果不能在这个时间间隔内获得这个锁,构造函数返回。之后,可检查这个锁是否已经获得,对这个 lock 变量应用 让语句就可执行这种检查,因为 unique_lock 类定义了 bool 转换运算符。使用 chrono 库指定超时时间,第 20 章讨论了这个库。

class Counter
{
	public:
		Counter(int id,int numIterations)
			:mId(id),mNumIterations(numIterations)
			{
			
			}
		void operator()() const
		{
			for(int i = 0; i<mNumIterations;++i)
			{
				unique_lock lock(aTimedMutex,200ms);
				if(lock)
				{
					cout<<"Counter "<<mId<<" has value "<<i<<endl;
				}
				else
				{
					//Lock not acquired in 200ms,skip output
				}
			}
		}
    private:
    	int mId;
    	int mNumIterations;
    	static timed_mutex sTimedMutex;
};
timed_mutex Counter::sTimedMutex;
  1. 双重检查锁定

双重检查锁定(double-checked locking)实际上是一种反模式,应避免使用! 这里之所以介绍它,是因为你可能在现有代码库中遇到它。双重检查锁定模式旨在尝试避免使用互斥体对象。这是编写比使用互斥体对象更有效代码的一种半途而废的尝试。如果在后续示例中想要提高速度,真的可能出错,例如使用 relaxed atomic(本章不讨论),用普通的 Boolean 替代 atomic等。该模式容易出现争用条件,很难更正。具有讽刺意味的是,使用 call_once()实际上更快,使用 magic static(如果可用)速度更快。将函数的本地静态实例称为 magic static。C++确保以线程安全方式初始化此类本地静态实例,因此不需要手动执行任何线程同步。第 29 章讨论单例(Csingletom)模式时,将列举一个使用 magic static 的示例。

警告;

在新的代码中避免使用双重检查锁定模式,而使用其他机制,例如简单锁、原子变量、call once()和 magic static 等。

例如,双重检查锁定可用于确保资源正好初始化一次。下例演示了如何实现这个功能。之所以称为双重检查锁定算法,是因为它检查 gImnitialized 变量的值两次,一次在获得锁之前,另一次在获得锁之后。第一次检查gmitialized 变量是为了防止获得不需要的锁。第二次检查用于确保没有其他线程在第一次 gmitialized 检查和获得锁之间执行初始化。

void initializeSharedResources()
{
    // ... Initialize shared resources to be used by multiple threads.
    cout << "Shared resources initialized." << endl;
}
atomic<bool> gInitialized(false);
mutex gMutex;
void processingFunction()
{
    if (!gInitialized) 
    {
        unique_lock lock(gMutex);
        if (!gInitialized) 
        {
            initializeSharedResources();
            gInitialized = true;
        }
    }
	cout << "OK" << endl;
}	
int main()
{
    vector<thread> threads;
    for (int i = 0; i < 5; ++i) 
    {
    	threads.push_back(thread{ processingFunction });
    }
    for (auto& t : threads) 
    {
    	t.join();
    }
}

输出清楚地表明,只有一个线程初始化了共享资源:

xz@xiaqiu:~/study/test/test$ ./testShared resources initialized.OKOKOKOKOKxz@xiaqiu:~/study/test/test$ ./testShared resources initialized.OKOKOKOKOKxz@xiaqiu:~/study/test/test$ 

注意:

对于这个例子,建议使用 call once()而不是双重检查锁定。

条件变量

条件变量允许一个线程阻塞,直到另一个线程设置某个条件或系统时间到达某个指定的时间。条件变量允许显式的线程间通信。 如果熟悉 Win32API 的多线程编程, 就可将条件变量和 Windows 中的事件对象进行比较。需要包含<condition_variable>头文件来使用条件变量。有两类条件变量。std::condition_variable;, 只能等待 unique lock上的条件变量; 根据 C++标准的描述,这个条件变量可在特定平台上达到最高效率。std::condition_variable_any: 可等待任何对象的条件变量,包括自定义的锁类型 。

condition_variable 类支持以下方法。

➤➤ notify_one();
唤醒等待这个条件变量的线程之一。这类似于 Windows 上的 auto-reset 事件。
➤➤ notify_all();
唤醒等待这个条件变量的所有线程。
➤➤ wait(unique_lock& lk);
调用 wait()的线程应该已经获得 lk 上的锁。调用 wait()的效果是以原子方式调用 lk.unlock()并阻塞线程,等待通知。当线程被另一个线程中的 notify_one()或 notify_all()调用解除阻塞时,这个函数会再次调用 lk.lock(),可能会被这个锁阻塞,然后返回。
➤➤ wait_for(unique_lock& lk, const chrono::duration<Rep, Period>&
rel_time);
类似于此前的 wait()方法,区别在于这个线程会被 notify_one()或 notify_all()调用解除阻塞,也可能在给定超时时间到达后解除阻塞。
➤➤ wait_until(unique_lock& lk, const chrono::time_point<Clock,
Duration>& abs_time);
类似于此前的 wait()方法,区别在于这个线程会被 notify_one()或 notify_all()调用解除阻塞,也可能在系统时间超过给定的绝对时间时解除阻塞。

也有一些其他版本的 wait()、wait_for()和 wait_until()接收一个额外的谓词参数。例如,接收一个额外谓词的 wait()等同于:

while (!predicate())	wait(lk);

condition_variable_any 类支持的方法和 condition_variable 类相同,区别在于 condition_variable_any 可接收任何类型的锁类,而不只是 unique_lock。锁类应提供 lock()和 unlock()方法。

假唤醒

等待条件变量的线程可在另一个线程调用 notify_one()或 notify_all()时醒过来,或在系统时间超过给定时间时醒过来,也可能不合时宜地醒过来。这意味着,即使没有其他线程调用任何通知方法,线程也会醒过来。因此,当线程等待一个条件变量并醒过来时,就需要检查它是否因为获得通知而醒过来。一种检查方法是使用接收谓词参数的 wait()版本。

使用条件变量

例如,条件变量可用于处理队列项的后台线程。可定义队列,在队列中插入要处理的项。后台线程等待队列中出现项。把一项插入到队列中时,线程就醒过来,处理项,然后继续休眠,等待下一项。假设有以下队列:

queue<string> mQueue;

需要确保在任何时候只有一个线程修改这个队列。可通过互斥体实现这一点:

mutex mMutex;

为了能在添加一项时通知后台线程,需要一个条件变量:

condition_variable mCondVar;

需要向队列中添加项的线程首先要获得这个互斥体上的锁,然后向队列中添加项,最后通知后台线程。无论当前是否拥有锁,都可以调用 notify_one()或 notify_all(),它们都会正常工作:

// Lock mutex and add entry to the queue.unique_lock lock(mMutex);mQueue.push(entry);// Notify condition variable to wake up thread.mCondVar.notify_all();

后台线程在一个无限循环中等待通知。注意这里使用接收谓词参数的 wait()方法正确处理线程不合时宜地醒过来的情形。谓词检查队列中是否有队列项。对 wait()的调用返回时,就可以肯定队列中有队列项了。

unique_lock lock(mMutex);
while (true) 
{
    // Wait for a notification.
    mCondVar.wait(lock, [this]{ return !mQueue.empty(); });
    // Condition variable is notified, so something is in the queue.
    // Process queue item...
}

23.7 节给出了一个完整示例,讲解了如何通过条件变量向其他线程发送通知。C++标准还定义了辅助函数 std::notify_all_at_ thread_exit(cond,lk),其中 cond 是一个条件变量,ik 是一个unique_lock实例。调用这个函数的线程应该已经获得了锁 。当线程退出时,会自动执行以下代码:

lk.unlock();
cond.notify_all();

注意:

将锁k 保持锁定,直到该线程退出为止。所以,一定要确保这不会在代码中造成任何死锁,例如由于错误的锁顺序而产生的死锁。本章前面已经讨论了死锁。

future

根据本章前面的讨论,可通过 std::thread 启动一个线程,计算并得到一个结果,当线程结束执行时不容易取回计算的结果。与 std::thread 相关的另一个问题是处理像异常这样的错误。如果一个线程抛出一个异常,而这个异常没有被线程本身处理,C++运行时将调用 std::terminate(),这通常会终止整个应用程序。可使用 future 更方便地获得线程的结果,并将异常转移到另一个线程中,然后另一个线程可以任意处置这个异常。当然,应该总是尝试在线程本身中处理异常,不要让异常离开线程。fature 在 promise 中存储结果。可通过 future 来获取 promise 中存储的结果。也就是说,promise 是结果的输入端; future 是输出端。一旦在同一线程或另一线程中运行的函数计算出希望返回的值, 就把这个值放在 promise中。然后可以通过 future来获取这个值。可将 future/promise 对想象为线程间传递结果的通信信道。C++提供标准的 future,名为 std::future。可从 std::future 检索结果。T是计算结果的类型。

future<T> myFuture = ...; //Is discussed laterT result = myFuture.get();

调用 get()以取出结果,并保存在变量 result 中。如果另一个线程尚未计算完结果,对 get()的调用将阻塞,直到该结果值可用。只能在 future 上调用一次 get()。按照标准,第二次调用的行为是不确定的。可首先通过向 future 询问结果是否可用的方式来避免阻塞:

if (myFuture.wait_for(0)) { // Value is available	T result = myFuture.get();} else { // Value is not yet available...}

23.6.1 std::promise 和 std::future

C++提供了 std::promise 类, 作为实现 promise 概念的一种方式。可在 promise 上调用 set_value()来存储结果,也可调用 set_exception(), 在 promise 中存储异常.注意,只能在特定的 promise 上调用 set_value()或 set_exception()一次。如果多次调用它,将抛出 std::future_error 异常。如果线程 A 启动另一个线程 B 以执行计算,则线程 A 可创建一个 std::promise,将其传给已启动的线程。注意,无法复制 promise,但可将其移到线程中! 线程B 使用 promise 存储结果。将 promise 移入线程B 之前,线程 A 在创建的 promise 上调用 get future0,这样,线程 B 完成后就能访问结果。下面是一个简单示例:

void DoWork(promise<int> thePromise){    // ... Do some work ...    // And ultimately store the result in the promise.    thePromise.set_value(42);}int main(){    // Create a promise to pass to the thread.    promise<int> myPromise;    // Get the future of the promise.    auto theFuture = myPromise.get_future();    // Create a thread and move the promise into it.    thread theThread{ DoWork, std::move(myPromise) };    // Do some more work...    // Get the result.    int result = theFuture.get();    cout << "Result: " << result << endl;    // Make sure to join the thread.    theThread.join();}

注意

这段代码只用于演示。这段代码在一个新的线程中启动计算,然后在 fnture 上调用 get()。 这个线程会阻塞,直到结果计算完为止.这听起来像代价很高的函数调用.在实际应用程序中使用 future 模型时, 可定期检查 future中是否有可用的结果(通过此前描述的 wait for()),或者使用条件变量等同步机制。当结果还不可用时,可做其他事情,而不是阻塞。

23.6.2 std::packaged_task

有了 std::packaged_task,将可以更方便地使用 promise,而不是像 23.6.1 节那样显式地使用 std::promise。下面的代码演示了这一点。它创建一个packaged task来执行 CalculateSum()。通过调用 get_future(), 从packaged task检索 future。启动一个线程,并将 packaged_task 移入其中。无法复制 packaged_task’! 启动线程后,在检索到的名future 上调用 get()来获得结果。在结果可用前,将一直阻塞。CalculateSum()不需要在任何类型的 promise 中显式存储任何数据。packaged_task 自动创建 promise,自动在 promise 中存储被调用函数(这里是 CalculateSum()的结果,并自动在 promise 中存储函数抛出的任何异常。

int CalculateSum(int a, int b) { return a + b; }int main(){    // Create a packaged task to run CalculateSum.    packaged_task<int(int, int)> task(CalculateSum);    // Get the future for the result of the packaged task.    auto theFuture = task.get_future();    // Create a thread, move the packaged task into it, and    // execute the packaged task with the given arguments.    thread theThread{ std::move(task), 39, 3 };    // Do some more work...    // Get the result.    int result = theFuture.get();    cout << result << endl;    // Make sure to join the thread.    theThread.join();}

std::async

如果想让 C++运行时更多地控制是否创建一个线程以进行某种计算,可使用 std::async()。它接收一个将要执行的函数,并返回可用于检索结果的 future。async()可通过两种方法运行函数:

创建一个新的线程,异步运行提供的函数。

在返回的 future 上调用 get()方法时,在主调线程上同步地运行函数。如果没有通过额外参数来调用 async(),C++运行时会根据一些因素(例如系统中处理器的数目)从两种方法中自动选择一种方法。也可指定策略参数,从而调整 C++运行时的行为。

launch::async: 强制 C++运行时在一个不同的线程上异步地执行函数。

launch::deferred: 强制 C++运行时在调用 get()时,在主调线程上同步地执行函数。

launch::async | launch::deferred: 允许 C++运行时进行选择(=默认行为)。

下例演示了 async()的用法;

int calculate()
{
	return 123;
}
int main()
{
    auto myFuture = async(calculate);
    //auto myFuture = async(launch::async, calculate);
    //auto myFuture = async(launch::deferred, calculate);
    // Do some more work...
    // Get the result.
    int result = myFuture.get();
    cout << result << endl;
}

从这个例子可看出,std::async()是以异步方式(在不同线程中)或同步方式(在同一线程中)执行一些计算并在随后获取结果的最简单方法之一。

警告:

调用 async()锁返回的 future 会在其析构函数中阻塞,直到结果可用为止。这意味着如果调用 async()时未捕获返回的 future,async()调用将真正成为阻塞调用! 例如,以下代码行同步调用 calculate():async (calculate)在这条语句中,async()创建和返回 future。未捕获这个 future,因此是临时 future。由于是临时的,因此将在该语句完成前调用其析构函数,在结果可用前,该析构函数将一直阻塞.

异常处理

使用 future 的一大优点是它们会自动在线程之间传递异常。在 future 上调用 get()时,要么返回计算结果,要么重新抛出与 future 关联的 promise 中存储的任何异常。使用 packaged_task 或 async()时,从已启动的函数抛出的任何异常将自动存储在 promise 中。如果将 std::promise 用作 promise,可调用 set_exception()以在其中存储异常。下面是一个使用 async()的示例:

int calculate(){	throw runtime_error("Exception thrown from calculate().");}int main(){    // Use the launch::async policy to force asynchronous execution.    auto myFuture = async(launch::async, calculate);    // Do some more work...    // Get the result.    try     {        int result = myFuture.get();        cout << result << endl;    }     catch (const exception& ex)     {    	cout << "Caught exception: " << ex.what() << endl;    }}

std::shared_future

std::future只要求工可移动构建。在 future上调用 get()时,结果将移出 future,并返回给你。这意味着只能在 future上调用 get()一次。如果要多次调用 get(),甚至从多个线程多次调用,则需要使用 std::shared_future,此时,T 需要可复制构建。可使用 std::future::share(),或给 shared_future 构造函数传递 fnture,以创建 shared_future。注意,future不可复制,因此需要将其移入 shared_future 构造函数。shared_future 可用于同时唤醒多个线程。例如,下面的代码片段定义了两个 lambda 表达式,它们在不同的线程上异步地执行。每个 lambda 表达式首先将值设置为各自的 promise,以指示已经启动。接着在 signalFuture调用 get(),这一直阻塞,直到可通过 future 获得参数为止, 此后将继续执行。每个 lambda 表达式按引用捕获各自的 promise,按值捕获 signalFuture,因此这两个 lambda 表达式都有 signalFuture 的副本。主线程使用 async(),在不同线程上执行这两个 lambda 表达式,一直等到线程启动,然后设置 signalPromise 中的参数以唤醒这两个线程。

promise<void> thread1Started, thread2Started;
promise<int> signalPromise;
auto signalFuture = signalPromise.get_future().share();
//shared_future<int> signalFuture(signalPromise.get_future());
auto function1 = [&thread1Started, signalFuture] {
thread1Started.set_value();
// Wait until parameter is set.
int parameter = signalFuture.get();
// ...
};
auto function2 = [&thread2Started, signalFuture] {
thread2Started.set_value();
// Wait until parameter is set.
int parameter = signalFuture.get();
// ...
};
// Run both
 lambda expressions asynchronously.
// Remember
 to capture the future returned by async()!
auto result1 = async(launch::async, function1);
auto result2 = async(launch::async, function2);
// Wait until both threads have started.
thread1Started.get_future().wait();
thread2Started.get_future().wait();
// Both threads are now waiting for the parameter.
// Set the parameter to wake up both of them.
signalPromise.set_value(42);

示例: 多线程的Logger 类

本节演示如何使用线程、互斥体对象、锁和条件变量编写一个多线程的 Logger 类。这个类允许不同的线程向一个队列中添加日志消息。Logger 类本身会在另一个后台线程中处理这个队列,将日志信息串行地写入一个文件。这个类的设计经历了两次迭代,以说明编写多线程代码时可能遇到的问题。”C++标准没有线程安全的队列。很明显,必须通过一些同步机制保护对队列的访问,避免多个线程同时读写队列。这个示例使用互斥体对象和条件变量来提供同步。在此基础上,可以这样定义 Logger 类:

class Logger
{
public:
    // Starts a background thread writing log entries to a file.
    Logger();
    // Prevent copy construction and assignment.
    Logger(const Logger& src) = delete;
    Logger& operator=(const Logger& rhs) = delete;
    // Add log entry to the queue.
    void log(std::string_view entry);
private:
    // The function running in the background thread.
    void processEntries();
    // Mutex and condition variable to protect access to the queue.
    std::mutex mMutex;
    std::condition_variable mCondVar;
    std::queue<std::string> mQueue;
    // The background thread.
    std::thread mThread;
};

实现如下。注意这个最初的设计存在几个问题,尝试运行这个程序时,它可能会行为异常甚至崩溃,在Logger 类的下一次迭代中会讨论并解决这些问题。processEntries()方法中的内层 while 循环也值得关注。这个循环处理队列中的所有消息,一次处理一条,并在每次迭代中都要获得和释放锁。这样做是为了确保这个循环不会太长时间保持锁定,以免阻止其他线程运行。

Logger::Logger()
{
	//Start background thread
	mThread = thread(& Logger::processEntries,this);
}
void Logger::log(string_view entry)
{
    //Lock mutex and add entry to the queue
    unique_lock lock(mMutex);
    mQueue.push(string(entry));
    //Notify condition variable to wake up thread
}
void Logger::processEntries()
{
    //Open log file
    ofstream logFile("log.txt");
    if(logFile.fail())
    {
        cerr<<"Failed to open logfile "<<endl;
        return ;
    }
    //Start processing loop
    unique_lock lock(mMutex);
    while(true)
    {
        //Wait for a notification
        mCondVar.wait(lock);
        
        //Condition variable notified,something	might be in the qeue
        lock.unlock();
        while(true)
        {
            lock.lock();
            if(mQueue.empty())
            {
                break;
            }
            else
            {
                logFile<<mQueue.front()<<endl;
                mQueue.pop();
            }
            lock.unlock();
        }
    }
}

警告

从这个相当简单的任务中可看到,正确编写多线程代码是十分困难的。令人遗憾的是,目前,C++标准仅提供线程、atomic、互斥体对象、条件变量和 future,不提供任何并发数据结构,至少到 C++17 为止是这样的。当然,未来版本可能会有改观。Logger类是一个演示基本构建块的示例。对于生产环境中的代码而言,建议使用恰当的第三方并发数据结构,不要自行编写。例如, 开源的 Boost C++库(参见 http://www.boostorg1/)实现了一个无锁队列, 允许并发使用,而不需要任何显式的同步。

可通过下面的测试代码测试这个 Logger 类,这段代码启动一些线程,所有线程都向同一个 Logger 实例记录一些信息,

void logSomeMessage(int id,Logger& logger)
{
	for(int i = 0; i < 10; ++i)
	{
		stringstream ss;
		ss<<"Log entry "<<i<<" from thread "<<id;
		logger.log(ss.str());
	}
}
int main()
{
	Logger logger;
	vector<thread> threads;
	//Create a few threads all working with the same Logger instance
    for(int i = 0; i < 10;i++)
    {
        threads.emplace_back(logSomeMessages, i, ref(logger));
    }
    //Wait for all threads to finish
    for(auto& t : threads)
    {
        t.join();
    }
}

如果构建并运行这个原始的最初版本,你会发现应用程序突然终止。原因在于应用程序从未调用后台线程的 join()或 detach()。回顾本章前面的内容可知,thread 对象的析构函数仍是可结合的,即尚未调用 join()或detach(),而调用 std::terminate()来停止运行线程和应用程序本身。这意味着,仍在队列中的消息未写入磁盘文件。当应用程序像这样终止时,甚至一些运行时库会报错或生成月溃转储。需要添加一种机制来正常关闭后台线程,并在应用程序本身终止之前,等待后台线程完全关闭。这可通过向类中添加一个析构函数和一个布尔成员变量来解决。新的 Logger 类定义如下所示;

class Logger
{
	public:
		//Gracefully shut down background thread
		virtual ~Logger();
		//Other public members omitted for brevity
    	bool mExit = false;
    	//Other members omitted for brevity
};

析构函数将 mExit 设置为 true,唤醒后台线程,并等待直到后台线程被关闭。把 mExit 设置为 true, 在调用notify_all()之前,析构函数在 mMutex 上获得一个锁。这是在使用 processEntries()防止争用条件和死锁。processEntries()可以放在其 while 循环的开头, 即检查 mExit 之后、调用 wait()之前。如果主线程此时调用 Logger类的析构函数,而析构函数没有在 mMutex 上获得一个锁,则析构函数在 processEntries()检查 mExit 之后、等待条件变量之前,把 mExit 设置为 true,并调用 notify_all(),因此 processEntries()看不到新值,也收不到通知。此时,应用程序处于死锁状态,因为析构函数在等待 join()调用,而后台线程在等待条件变量。注意析构函数必须在 join()调用之前释放 mMutex 上的锁,这解释了使用花括号的额外代码块。

警告

一般而言,在设置等待条件时,应当始终拥有与条件变量相关的互斥体上的锁.

Logger::~Logger()
{
	{
		unique_lock lock(mMutex);
		//Gracefully shut down the thread by setting mExit
		//to true and notifying the thread
		mExit = true;
		//Notify condition variable to wake up thread
		mCondVar.notify_all();
	}
	//Wait until thread is shut down,This should be outside the above code
	//block because the lock must be released before calling join()
	mThread.join();
}

processEntries()方法需要检查此布尔变量,当这个布尔变量为 true 时终止处理循环:

void Logger::processEntries()
{
	//Open log file
	ofstream logFile("log.txt");
	if(logFile.fail)
	{
		cerr<<"Failed to open logfile "<<endl;
		return;
	}
	//Start processing log
	unique_lock lock(mMutex);
	while(true)
	{
		if(!mExit) //Only wait for notifications if we don't have to exit
		{
			//Wait for a notification
            mCondVar.wait(lock);
		}
        //Condition variable is notified,so something might be in the queue
        //and/or we need to shut down this thread
        lock.unlock();
        while(true)
        {
            if(mQueue.empty())
            {
                break;
            }
            else
            {
                logFile<<mQueue.front()<<endl;
                mQueue.pop();
            }
            lock.unlock();
        }
        if(mExit)
        {
            break;
        }
	}
}

注意不能只在外层 while 循环的条件中检查 mExit,因为即使 mExit 是 true,队列中也可能有需要写入的日志项。可在多线程代码的特殊位置添加人为的延迟,以触发某个行为。注意添加这种延迟应仅用于测试,并且应从最终代码中删除。 例如,要测试是否解决了析构函数带来的争用条件,可在主程序中删除对 log()的所有调用,使其几乎立即调用 Logger 类的析构函数,并添加如下延迟:

void Logger::processEntries()
{
	//Omitted for brevity
	//Start processing loop
	unique_lock lock(mMutex);
	while(true)
	{
		this_thread::sleep_for(1000ms); //Needs #include<chrono>
		if(!mExit) //Only wait for notification if we don't have exit
		{
			//Wait for a notification
			mConvar.wait(lock);
		}
	}
}

线程池

如果不在程序的整个生命周期中动态地创建和删除线程,还可以创建可根据需要使用的线程池。这种技术通常用于需要在线程中处理某类事件的程序。 在大多数环境中, 线程的理想数目应该和处理器核心的数目相等。如果线程的数目多于处理器核心的数目,那么线程只有被挂起,从而允许其他线程运行,这样最终会增加开销。注意,尽管理想的线程数目和核心数目相等,但这种情况只适用于计算密集型线程,这种情况下线程不能由于其他原因阻塞,例如 IO。当线程可以阻塞时,往往运行数目比核心数目更多的线程更合适。在此类情况下,确定最佳线程数难度较大,可能涉及测量系统正常负载条件下的吞吐量。由于不是所有的处理都是等同的,因此线程池中的线程经常接收一个表示要执行的计算的函数对象或lambda 表达式作为输入的一部分。由于线程池中的所有线程都是预先存在的,因此操作系统调度这些线程并运行的效率大大高于操作系统创建线程并响应输入的效率。此外,线程池的使用允许管理创建的线程数,因此根据平台的不同,可以少至1 个线程,也可以多达数千个线程。有几个库实现了线程池,例如 Intel Threading Building Blocks(TBB)、Microsoft Parallel Patterns Library(PPL)等。建议给线程池使用这样的库,而不是编写自己的实现。如果的确希望自己实现线程池,可以使用与对象池类似的方式实现。第 25 章将列举一个对象池的实现示例。

线程设计和最佳实践

本节简要介绍几个有关多线程编程的最佳实践。

e 使用并行标准库算法: 标准库中包含大量算法。从 C++17 开始,有 60 多个算法支持并行执行。尽量使用这些并行算法,而非编写自己的多线程代码。可参阅第 18 章,以详细了解如何为算法指定并行选项。

e 终止应用程序前,确保所有 thread 对象都不是可结合的: 确保对所有 thread 对象都调用了 join()或detach()。仍可结合的 thread 析构函数将调用 std::terminate(),从而突然间终止所有线程和应用程序。

e 最好的同步就是没有同步: 如果采用合理的方式设计不同的线程,让所有的线程在使用共享数据时只从共享数据读取,而不写入共享数据,或者只写入其他线程不会读取的部分,那么多线程编程就会变得简单很多。这种情况下不需要任何同步,也不会有争用条件或死锁的问题。

e 尝试使用单线程的所有权模式: 这意味着同一时间拥有 1 个数据块的线程数不多于 1。拥有数据意味着不允许其他任何线程读/写这些数据。当线程处理完数据时,数据可传递到另一个线程,那个线程目前拥有这些数据的唯一且完整的责任/拥有权。这种情况下,没必要进行同步。

e ”在可能时使用原子类型和操作: 通过原子类型和原子操作更容易编写没有争用条件和死锁的代码,因为它们能自动处理同步。如果在多线程设计中不可能使用原子类型和操作,而且需要共享数据,那么需要使用同步机制(如互斥)来确保同步的正确性。

e ”使用锁保护可变的共享数据: 如果需要多个线程可写入的可变共享数据,而且不能使用原子类型和操作,那么必须使用锁机制,以确保不同线程之间的读写是同步的。

e 尽快释放锁; 当需要通过锁保护共享数据时,务必尽快释放锁。当一个线程持有一个锁时,会使得其他线程阻塞等待这个锁,这可能会降低性能。

e ”不要手动获取多个锁,应当改用 std::lock()或 std::try_lock(): 如果多个线程需要获取多个锁,那么所有 线程都要以同样的顺序获得这些锁,以防止死锁。可通过泛型函数 std::lock()或 std::try_lock()获取多个锁。

e 使用 RAII 锁对象: 使用 lock_guard、unique_ lock、shared lock 或 scoped lock RAI 类,在正确的时间自动释放锁。

e 使用支持多线程的分析器: 通过支持多线程的分析器找到多线程应用程序中的性能瓶颈,分析多个线程是否确实利用了系统中所有可用的处理能力。支持多线程的分析器的一个例子是某些 Visual Studio版本中的 profiler。

e 了解调试器的多线程支持特性: 大部分调试器都提供对多线程应用程序调试的最基本支持。应该能得到应用程序中所有正在运行的线程列表,而且应该能切换到任意线程,查看线程的调用栈。例如,可通过这些特性检查死锁,因为可准确地看到每个线程正在做什么。

e, 使用线程池,而不是动态创建和销毁大量线程: 动态地创建和销毁大量的线程会导致性能下降。这种情况下,最好使用线程池来重用现有的线程。

e, 使用高级多线程库: 目前,C++标准仅提供用于编写多线程代码的基本构件。正确使用这些构件并非易事。尽可能使用高级多线程库,例如 Intel Threading Building Blocks(TBB)、Microsoft Parallel PatternsLibrary(PPL)等,而不是自己实现。多线程编程很难掌握,而且容易出错。另外,自己的实现不一定像预期那样正确工作。

编写高效的 C++程序

C++是不是低效的语言

C 程序员经常抵制 C++在高性能应用程序中的使用。他们声称 C++语言本质上比 C 语言或类似的过程式语言低效,因为 C++包含高层次的概念,如异常和虚函数。然而,这种说法是有问题的。首先,不能忽略编译器的作用。在讨论语言的效率时,必须将语言的性能和编译器优化这种语言的效果分离。计算机执行的并不是 C 或 C++代码。编译器首先将代码转换成机器语言,并在这个过程中进行优化。这意味着,不能简单地运行 C 和 C++程序的基准测试并比较结果。这实际上比较的是编译器优化语言的效果,而不是语言本身。C++编译器可优化掉语言中很多高层次的结构,生成类似于 C 语言生成的机器码。目前,研发投入更多集中于 C++编译器而非 C 编译器,因此与 C 代码相比,C++代码实际会得到更好的优化,运行速度可能更快。

然而,批评者仍然认为一些 C++特性不能被优化掉。例如,根据第 10 章的解释,虚函数需要一个 vtable,在运行时需要添加一个间接层次,因而比普通的非虚函数调用慢。然而,如果仔细思考,会发现这种说法仍然难以令人信服。虚函数调用不只是函数调用,还要在运行时选择调用哪个函数。对应的非虚函数调用可能需要一个条件语句来选择调用的函数。如果不需要这些额外的语义,可以使用一个非虚函数。C++语言的一般设计原则是:“如果不使用某项功能,则不需要付出代价。”如果不使用虚函数,那么不会因为能够使用虚函数而损失性能。因此在 C++中,非虚函数调用在性能上等同于 C 语言中的函数调用。然而,由于虚函数调用的开销如此之小,因此建议对于所有非 final 类,将所有的类方法,包括析构函数(但不包括构造函数)设计为虚方法。更重要的是,通过 C++高层次的结构可编写更干净的程序,这些程序的设计层次更高效,更易于读取,更便于维护,能避免积累不必要的代码和死代码。我们相信,如果选择 C++语言而不是过程式的语言(如 C 语言),在开发、性能和维护上会有更好的结果。还有其他更高级的面向对象语言,如 C#和 Java,二者都在虚拟机上运行。C++代码由 CPU 直接执行,不存在运行代码的虚拟机。C++离硬件更近,这意味着大多数情况下,它的速度快于 C#和 Java 等语言。

语言层次的效率

许多书籍、文章和程序员花费了大量时间,试图说服你对代码进行语言层次的优化。这些提示和技巧很重要,在某些情况下可加快程序的运行速度。然而,这些优化远不如整体设计和程序选择的算法重要。可以通过引用传递需要的所有数据, 但是如果写磁盘的次数比实际需要的次数多一倍, 那么按引用传递不会让程序更快。这很容易陷入引用和指针的优化而忘记大局。此外,一些语言层次的技巧可通过好的优化编译器自动进行。不应花费时间自己优化某个特定领域,除非分析器指明某个领域是瓶颈,如本章后面所述。

警告

谨慎使用语言级优化。建议先建立清晰、结构良好的设计和实现方案,再使用分析器,仅优化分析器标记为性能瓶颈的部分。

高效地操纵对象

C++在幕后做了很多工作,特别是和对象相关的工作。总是应该注意编写的代码对性能的影响。如果遵循-些简单的指导原则,代码将变得更有效率。注意这些原则仅与对象相关,与基本类型(例如 bool、int、float等)无关。

  1. 通过引用传递

本书已在其他地方讨论了这条规则,但这里有必要重申一次。

警告

应该尽可能不要通过值向函数或方法传递对象。

如果函数形参的类型是基类,而将派生类的对象作为实参按值传递,则会将派生对象切片,以符合基类类型。这导致信息丢失,详见第 10 章。

按值传递会产生复制的开销,而按引用传递能避免这种开销。这条规则很难记住的一个原因是: 从表面上看,按值传递不会有任何问题。考虑如下表示“人”的 Person 类;

class Person
{
	public:
		Person() = default;
		Person(std::string_view firstName,std::string_view lastName,int age);
		virtual ~Person() = default;
		std::string_view getFirstName() const{ return mFirstName;}
		std::string_view getLastName() const{ return mLastName; }
		int getAge() const { return mAge; }
	private:
    	std::string mFirstName,mLastName;
    	int mAge = 0;
};

可编写一个接收 Person 对象的函数,如下所示:

void processPerson(Person p)
{
	//Process the person
}

该函数可能会这样调用:

Person me("Marc","Gregoire",38);
processPersion(me);

像下面这样编写这个函数,看上去并未增加多少代码;

void processPerson(const Person& p)
{
	//Process the person
}

对函数的调用保持不变。然而,考虑一下在第一个版本的函数中按值传递会发生什么 。为初始化processPerson()的 p 参数, me 必须通过调用其复制构造函数进行复制。即使没有为 Person 类编写复制构造函数,编译器也会生成一个来复制每个数据成员。这看上去也没有那么糟: 只有 3 个数据成员。然而,其中的两个成员是字符串,都是带有复制构造函数的对象。因此,也会调用它们的复制构造函数。通过引用接收 p 的processPerson()版本没有这样的复制成本。因此,在这个例子中通过按引用传递,可以避免代码进入这个函数时进行的 3 次构造函数调用。

. 这个示例至此尚未完成。在第一个版本的 processPerson()中, p 是 processPerson()函数的一个局部变量, 因此在该函数退出时必须销毁。销毁时需要调用 Person 类的析构函数,而析构函数会调用所有数据成员的析构函数。string 类有析构函数,所以退出这个函数时(如果按值传递)会调用 3 次析构函数。如果通过引用传递 Person对象,则不需要执行任何这种调用。

注意

如果函数必须修改对象,可通过引用传递对象。如果邓数不应该修改对象,可通过 const 引用传递,如前面的例子所示。有关引用和 const 的详细信息,请参阅第 11 章。

注意:

应避免通过指针传递,因为相对按引用传递,按指针传递相对过时,相当于倒退到 C 语言了,很少适合于C++(除非在设计中传递 nullptr 有特殊意义)。

  1. 按引用返回

正如应该通过引用将对象传递给函数一样,也应该从函数返回引用,以避免对象发生不必要的复制。但有时不可能通过引用返回对象,例如编写重载的 operator+和其他类似运算符时。永远都不要返回指向局部对象的引用或指针,局部对象会在函数退出时被销毁。自 C++11 以后,C++语言支持移动语义,允许高效地按值返回对象,而不是使用引用语义。

  1. 通过引用捕捉异常

如第 14 章所述,应该通过引用捕捉异常,以避免分片和额外的复制。抛出异常的性能开销很大,因此任何提升效率的小事情都是有帮助的。

  1. 使用移动语义

应该为类实现移动构造函数和移动赋值运算符,以允许 C++编译器为类对象使用移动语义。根据“零规则”(见第 9 章),设计类时, 使编译器生成复制和移动构造函数以及复制和移动赋值运算符便足够了。 如果编译器不能隐式定义这些类,那么在允许的情况下,可显式将它们设置为 default。如果这行不通,应当自行实现。对象使用了移动语义时,从函数中通过值返回不会产生很大的复制开销,因而效率更高。有关移动语义的详细信

  1. 避免创建临时对象

有些情况下,编译器会创建临时的无名对象。第 9 章介绍过,为一个类编写全局 operator+之后,可对这个类的对象和其他类型的对象进行加法运算,只要其他类型的对象可转换为这个类的对象即可。例如,SpreadsheetCell 类的部分定义如下;

class SpreadsheetCell
{
	public:
		//Other constructors omitted for brevity
		SpreadsheetCell(double initialValue);
		//Reminder omitted	for brevity;
};
SpreadsheetCell operator+(const SpreadsheetCell& lhs,const SpreadsheetCell& rhs);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值