C++11
文章目录
统一的列表初始化
{ }的初始化
- C++98中,标准允许使用大括号{}对数组或者结构体元素进行统一的列表初始值设定。
#include <iostream>
struct Test
{
int _x;
int _y;
};
int main()
{
//使用大括号对数组元素进行初始化
int array1[] = { 1, 2, 3, 4, 5 };
int array2[10] = { 0 };
//使用大括号对结构体元素进行初始化
Test t = { 1, 2 };
return 0;
}
- C++11扩大了用大括号括起来的列表
{初始化列表}
的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号,也可不添加。
struct Test
{
int _x;
int _y;
};
int main()
{
//使用大括号对内置类型进行初始化
int x1 = { 1 }; //可添加等号
int x2{ 2 }; //可不添加等号
//使用大括号对数组元素进行初始化
int array1[]{1, 2, 3, 4, 5}; //可不添加等号
int array2[10]{0}; //可不添加等号
//使用大括号对结构体元素进行初始化
Test t{ 1, 2 }; //可不添加等号
//C++11中列表初始化也可以用于new表达式中(C++98无法初始化)
int* p1 = new int[5]{0}; //不可添加等号
int* p2 = new int[5]{0,1,2,3,4}; //不可添加等号
return 0;
}
注意:用大括号对new表达式初始化时不能加等号。
- 创建对象时也可以使用列表初始化方式调用构造函数初始化。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//一般调用构造函数创建对象的方式
Date d1(2024, 4, 11);
//C++11支持的列表初始化,这里也会调用构造函数初始化
Date d2 = { 2024, 4, 11 }; //可添加等号
Date d3{ 2024, 4, 11 }; //可不添加等号
return 0;
}
initializer_list容器
C++11中新增了initializer_list容器,该容器没有提供过多的成员函数。
- 提供了begin和end函数,用于支持迭代器遍历。
- 以及size函数支持获取容器中的元素个数。
initializer_list本质就是一个大括号括起来的列表,如果用auto关键字定义一个变量来接收一个大括号括起来的列表,然后以typeid(变量名).name()
的方式查看该变量的类型,此时会发现该变量的类型就是initializer_list。
#include <iostream>
using namespace std;
int main()
{
auto list = { 1, 2, 3, 4, 5 };
cout << typeid(list).name() << endl; //class std::initializer_list<int>
return 0;
}
initializer_list容器的使用场景
initializer_list容器没有提供对应的增删查改等接口,因为initializer_list并不是专门用于存储数据的,而是为了让其他容器支持列表初始化的。
#include <iostream>
#include <vector>
#include <list>
#include <map>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//用大括号括起来的列表对容器进行初始化
vector<int> v = { 1, 2, 3, 4, 5 };
list<int> l = { 10, 20, 30, 40, 50 };
vector<Date> vd = { Date(2022, 8, 29), Date{ 2022, 8, 30 }, { 2022, 8, 31 } };
map<string, string> m = { make_pair("delete", "删除"), { "insert", "插入" } };
//用大括号括起来的列表对容器赋值
v = { 5, 4, 3, 2, 1 };
return 0;
}
C++98并不支持直接用列表对容器进行初始化,这种初始化方式是在C++11引入initializer_list后才支持的。
这些容器之所以支持使用列表进行初始化,根本原因是因为C++11给这些容器都增加了一个构造函数,这个构造函数就是以initializer_list作为参数的。
例如,vector:
当用列表对容器进行初始化时,这个列表被识别成initializer_list类型,于是就会调用这个新增的构造函数对该容器进行初始化。
这个新增的构造函数要做的就是遍历initializer_list中的元素,然后将这些元素依次插入到要初始化的容器当中即可。
如果要让我们实现的Vector支持列表初始化,就需要增加一个以initializer_list作为参数的构造函数。
template<class T>
class Vector
{
public:
typedef T* iterator;
Vector(initializer_list<T> list)
{
_start = new T[list.size()];
_finish = _start;
_endofstorage = _start + list.size();
//范围for遍历
for (auto e : list)
{
push_back(e);
}
}
Vector<T>& operator=(initializer_list<T> list)
{
Vector<T> tmp(list);
std::swap(_start, tmp._start);
std::swap(_finish, tmp._finish);
std::swap(_endofstorage, tmp._endofstorage);
return *this;
}
private:
iterator _start;
iterator _finish;
iterator _endofstorage;
};
注意:
- 在构造函数中遍历initializer_list时可以使用迭代器遍历,也可以使用范围for遍历,因为范围for底层实际采用的就是迭代器方式遍历。
- 使用迭代器方式遍历时,需要在迭代器类型前面加上typename关键字,指明这是一个类型名字。因为这个迭代器类型定义在一个类模板中,在该类模板未被实例化之前编译器是无法识别这个类型的。
- 最好也增加一个以initializer_list作为参数的赋值运算符重载函数,以支持直接用列表对容器对象进行赋值,但实际也可以不增加。
如果没有增加以initializer_list作为参数的赋值运算符重载函数,下面的代码也可以正常执行:
Vector<int> v = { 1, 2, 3, 4, 5 };
v = { 5, 4, 3, 2, 1 };
- 调用以initializer_list作为参数的构造函数完成对象的初始化。
- 调用initializer_list作为参数的构造函数构造出一个vector对象,然后再调用Vector原有的赋值运算符重载函数完成两个Vector对象之间的赋值。
声明
C++11提供了多种简化声明的方式,尤其是在使用模板的时候。
auto
-
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。
-
C++11中废弃auto原来的用法,将其用于实现自动类型推断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。
#include <iostream>
#include <cstring>
#include <map>
using namespace std;
int main() {
int i = 10;
auto p = &i;
auto pf = strcpy;
cout << typeid(p).name() << endl; //int *
cout << typeid(pf).name() << endl; //char * (__cdecl*)(char *,char const *)
map<string, string> dict = { { "sort", "排序" }, { "insert", "插入" } };
//map<string, string>::iterator it = dict.begin();
auto it = dict.begin(); //简化代码
return 0;
}
自动类型推断在某些场景下还是非常必要的,因为编译器要求在定义变量时必须先给出变量的实际类型,而如果我们自己设定类型在某些情况下可能会出问题。
int main()
{
short a = 32670;
short b = 32670;
//c如果给成short,会造成数据丢失,如果能够让编译器根据a+b的结果推导c的实际类型,就不会存在问题
short c = a + b;
return 0;
}
decltype
- 关键字decltype可以将变量的类型声明为表达式指定的类型。
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
decltype(t1*t2) ret;
cout << typeid(ret).name() << endl;
}
int main()
{
const int x = 1;
double y = 2.2;
decltype(x*y) ret;
decltype(&x) p;
cout << typeid(ret).name() << endl; //double
cout << typeid(p).name() << endl; //int const *
F(1, 'a'); //int
F(1, 2.2); //double
return 0;
}
注意:通过typeid(变量名).name()
的方式可以获取一个变量的类型,但无法用获取到的这个类型去定义变量。
- decltype除了能够推演表达式的类型,还能推演函数返回值的类型。
void* GetMemory(size_t size)
{
return malloc(size);
}
int main()
{
//如果没有带参数,推导函数的类型
cout << typeid(decltype(GetMemory)).name() << endl;
//如果带参数列表,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
cout << typeid(decltype(GetMemory(0))).name() << endl;
return 0;
}
- decltype不仅可以指定定义出的变量类型,还可以指定函数的返回类型。
template<class T1, class T2>
auto Add(T1 t1, T2 t2)->decltype(t1+t2)
{
decltype(t1+t2) ret;
ret = t1 + t2;
cout << typeid(ret).name() << endl;
return ret;
}
int main()
{
cout << Add(1, 2) << endl;; //int
cout << Add(1, 2.2) << endl;; //double
return 0;
}
nullptr
由于C++中NULL被定义成字面量0,这样就可能会带来一些问题,因为0既能表示指针常量,又能表示整型常量。
所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
在大部分情况下使用NULL不会存在什么问题,但是在某些极端场景下就可能会导致匹配错误。
NULL和nullptr的含义都是空指针,所以这里调用函数时肯定希望匹配到的都是参数类型为int*
的重载函数,但最终却因为NULL本质是字面量0,而导致NULL匹配到了参数为int
类型的重载函数,因此在C++中一般推荐使用nullptr。
范围for循环
在C++98中我们要遍历一个数组,可以按照以下方式:
int main()
{
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//将数组元素值全部乘以2
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
arr[i] *= 2;
}
//打印数组中的所有元素
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
C++11中引入了基于范围的for循环,for循环后的括号由冒号分为两部分,第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
int main()
{
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//将数组元素值全部乘以2
for (auto& e : arr)
{
e *= 2;
}
//打印数组中的所有元素
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}
注意:与普通循环类似,可用continue来结束本次循环,也可以用break来跳出整个循环。
范围for循环的使用条件
- for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
- 迭代的对象要支持++和==操作
范围for本质上是由迭代器支持的,在代码编译的时候,编译器会自动将范围for替换为迭代器的形式。
而由于在使用迭代器遍历时需要对对象进行++
和==
操作,因此使用范围for的对象也需要支持++
和 ==
操作。
STL的一些变化
新容器
- array
array容器本质就是一个静态数组,即固定大小的数组。
array容器有两个模板参数,第一个模板参数代表的是存储的类型,第二个模板参数是一个非类型模板参数,代表的是数组中可存储元素的个数。
#include <iostream>
#include <array>
using namespace std;
int main()
{
array<int, 10> a1; //定义一个可存储10个int类型元素的array容器
array<double, 5> a2; //定义一个可存储5个double类型元素的array容器
return 0;
}
array容器与普通数组对比:
- array容器与普通数组一样,支持通过[]访问指定下标的元素,也支持使用范围for遍历数组元素,并且创建后数组的大小也不可改变。
- array容器与普通数组不同之处就是,array容器用一个类对数组进行了封装,并且在访问array容器中的元素时会进行越界检查。用[]访问元素时采用断言检查,调用at成员函数访问元素时采用抛异常检查。
- 而对于普通数组来说,一般只有对数组进行写操作时才会检查越界,如果只是越界进行读操作可能并不会报错。
但array容器与其他容器不同的是,array容器的对象是创建在栈上的,因此array容器不适合定义太大的数组。
- forward_list
forward_list容器本质就是一个单链表。
forward_list很少使用,原因如下:
- forward_list只支持头插头删,不支持尾插尾删,因为单链表在进行尾插尾删时需要先找尾,时间复杂度为O(N)。
- forward_list提供的插入函数叫做insert_after,也就是在指定元素的后面插入一个元素,而不像其他容器是在指定元素的前面插入一个元素,因为单链表如果要在指定元素的前面插入元素,还要遍历链表找到该元素的前一个元素,时间复杂度为O(N)。
- forward_list提供的删除函数叫做erase_after,也就是删除指定元素后面的一个元素,因为单链表如果要删除指定元素,还需要还要遍历链表找到指定元素的前一个元素,时间复杂度为O(N)。
- 因此一般情况下要用链表我们还是选择使用list容器。
- unordered_map和unordered_set
- unordered_map的使用
构造
指定key和value的类型构造一个空容器
unordered_map<int, int> um1; //构造一个key为int类型,value为double类型的空容器
拷贝构造某同类型容器的复制品。
unordered_map<int, double> um2(um1); //拷贝构造同类型容器um1的复制品
使用迭代器拷贝构造某一段内容。
unordered_map<int, double> um3(um2.begin(), um2.end()); //使用迭代器拷贝构造um2容器某段区间的复制品
插入键值对
int main() {
unordered_map<string , string> dict;
//插入键值对方式一:构造匿名对象插入
dict.insert(pair<string, string>("苹果", "apple"));
//插入键值对方式二:调用make_pair函数模板插入
dict.insert(make_pair("香蕉", "banana"));
//插入键值对方式三:利用[]运算符重载函数进行插入(最常用)
dict["梨"] = "pear";
}
遍历
int main() {
//...
//遍历方式一:范围for
for(auto e : dict) {
cout << e.first << "->" << e.second << " ";
}
cout << endl;
//遍历方式二:迭代器遍历
//unordered_map<string, string>::iterator it = dict.begin();
auto it = dict.begin();
while (it != dict.end())
{
cout << it->first << "->" << it->second << " ";
it++;
}
cout << endl;
}
删除键值对
int main() {
//...
//删除键值对方式一:根据key值删除
dict.erase("香蕉");
for(auto e : dict) {
cout << e.first << "->" << e.second << " ";
}
cout << endl;
//删除键值对方式二:根据迭代器删除
auto pos = dict.find("梨");
if(pos != dict.end()) {
dict.erase(pos);
}
}
修改键值对
int main() {
//...
//修改键值对方式一:通过find获得迭代器,通过迭代器修改
pos = dict.find("苹果");
if (pos != dict.end())
{
pos->second = "banana";
}
for(auto e : dict) {
cout << e.first << "->" << e.second << " ";
}
cout << endl;
//修改键值对方式二:利用[]运算符重载函数进行修改(常用)
dict["苹果"] = "pear";
for (auto e : dict)
{
cout << e.first << "->" << e.second << " ";
}
cout << endl;
}
其他操作
int main() {
//容器中key值为苹果的键值对的个数
cout << dict.count("苹果") << endl;
//容器的大小
cout << dict.size() << endl;
//清空容器
dict.clear();
//容器判空
cout << dict.empty() << endl;
//交换两个容器的数据
unordered_map<int, string> tmp{ { 2021, "before" }, { 2022, "now" } };
um.swap(tmp);
for (auto e : um)
{
cout << e.first << "->" << e.second << " ";
}
cout << endl;
return 0;
}
-
unordered_set
-
unordered_set是不按特定顺序存储键值的关联式容器,其允许通过键值快速的索引到对应的元素。
-
在unordered_set中,元素的值同时也是唯一地标识它的key。
-
在内部,unordered_set中的元素没有按照任何特定的顺序排序,为了能在常数范围内找到指定的key,unordered_set将相同哈希值的键值放在相同的桶中。
-
unordered_set容器通过key访问单个元素要比set快,但它通常在遍历元素子集的范围迭代方面效率较低。
-
它的迭代器至少是前向迭代器。
构造
构造一个某类型的空容器。
unordered_set<int> us1; //构造int类型的空容器
拷贝构造某同类型容器的复制品。
unordered_set<int> us2(us1); //拷贝构造同类型容器us1的复制品
使用迭代器拷贝构造某一段内容。
string str("abcedf");
unordered_set<char> us3(str.begin(), str.end()); //构造string对象某段区间的复制品
插入
int main() {
unordered_set<int> us;
//插入元素(可以完成去重)
us.insert(1);
us.insert(1);
us.insert(2);
us.insert(2);
us.insert(3);
us.insert(3);
//遍历容器方式一(范围for)
for(auto& e : us) {
cout << e << " ";
}
cout << endl;
}
遍历
int main() {
//遍历容器方式一(范围for)
for(auto& e : us) {
cout << e << " ";
}
cout << endl;
//遍历容器方式二(迭代器遍历)
auto it = us.begin();
while (it != us.end())
{
cout << *it << " ";
it++;
}
cout << endl;
}
删除
int main() {
//删除元素方式一
us.erase(1);
for(auto& e : us) {
cout << e << " ";
}
cout << endl;
//删除元素方式二
auto pos = us.find(2); //查找值为2的元素
if (pos != us.end())
{
us.erase(pos);
}
for(auto& e : us) {
cout << e << " ";
}
cout << endl;
}
字符串转换函数
C++11提供了各种内置类型与string之间相互转换的函数,比如to_string、stoi、stol、stod等函数。
- 内置类型转换为
string
string
转换内置类型
容器中的一些新方法
C++11为每个容器都增加了一些新方法,比如:
- 提供了一个以initializer_list作为参数的构造函数,用于支持列表初始化。
- 提供了cbegin和cend方法,用于返回const迭代器。
- 提供了emplace系列方法,并在容器原有插入方法的基础上重载了一个右值引用版本的插入函数,用于提高向容器中插入元素的效率。
右值引用
左值 和 右值
什么是左值?
左值是一个表示数据的表达式,如变量名或解引用的指针。
- 左值可以被取地址,也可以被修改(const修饰的左值除外)。
- 左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边。
int main()
{
//以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
return 0;
}
什么是右值?
右值也是一个表示数据的表达式,如字母常量、表达式的返回值、函数的返回值(不能是左值引用返回)等等。
- 右值不能被取地址,也不能被修改。
- 右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边。
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//错误示例(右值不能出现在赋值符号的左边)
//10 = 1;
//x + y = 1;
//fmin(x, y) = 1;
return 0;
}
- 右值本质就是一个临时变量或常量值,比如代码中的10就是常量值,表达式x+y和函数fmin的返回值就是临时变量,这些都叫做右值。
- 这些临时变量和常量值并没有被实际存储起来,这也就是为什么右值不能被取地址的原因,因为只有被存储起来后才有地址。
- 但需要注意的是,这里说函数的返回值是右值,指的是传值返回的函数,因为传值返回的函数在返回对象时返回的是对象的拷贝,这个拷贝出来的对象就是一个临时变量。
对于左值引用返回的函数来说,这些函数返回的是左值。比如string类实现的[]
运算符重载函数:
这里的[]
运算符重载函数返回的是一个字符的引用,因为它需要支持外部对该位置的字符进行修改,所以必须采用左值引用返回。之所以说这里返回的是一个左值,是因为这个返回的字符是被存储起来了的,是存储在string对象的_str对象当中的,因此这个字符是可以被取到地址的。
左值引用 和 右值引用
传统的C++语法中就有引用的语法,而C++11中新增了右值引用的语法特性,为了进行区分,于是将C++11之前的引用就叫做左值引用。但是无论左值引用还是右值引用,本质都是给对象取别名。
左值引用
左值引用就是对左值的引用,给左值取别名,通过“&”来声明。
int main()
{
//以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
//以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
右值引用
右值引用就是对右值的引用,给右值取别名,通过“&&”来声明。
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double rr3 = fmin(x, y);
return 0;
}
需要注意的是,右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,如果不想让被引用的右值被修改,可以用const修饰右值引用。
int main()
{
double x = 1.1, y = 2.2;
int&& rr1 = 10;
const double&& rr2 = x + y;
rr1 = 20;
rr2 = 5.5; //报错
return 0;
}
左值引用可以引用右值吗?
- 左值引用不能引用右值,因为这涉及权限放大的问题,右值是不能被修改的,而左值引用是可以修改。
- 但是const左值引用可以引用右值,因为const左值引用能够保证被引用的数据不会被修改。
因此const左值引用既可以引用左值,也可以引用右值。
template<class T>
void func(const T & val)
{
cout << val << endl;
}
int main()
{
string s("hello");
func(s); //s为左值
func("world"); //"world"为右值
return 0;
}
右值引用可以引用左值吗?
- 右值引用只能引用右值,不能引用左值。
- 但是右值引用可以引用move以后的左值。
move函数是C++11标准提供的一个函数,被move后的左值能够赋值给右值引用。
int main()
{
int a = 10;
//int&& r1 = a; //右值引用不能引用左值
int&& r2 = move(a); //右值引用可以引用move以后的左值
return 0;
}
右值引用使用场景和意义
虽然const左值引用既能接收左值,又能接收右值,但左值引用终究存在短板,而C++11提出的右值引用就是用来解决左值引用的短板的。
右值引用主要用于实现移动语义,即将临时对象的资源(如动态分配的内存)转移到另一个对象,而不是复制它们。这可以提高性能,特别是在处理大型数据结构时。例如,在实现 std::vector
或 std::string
等容器类时,可以使用右值引用来实现移动构造函数和移动赋值运算符。
为了更好的说明问题,这里需要借助一个深拷贝的类,下面模拟实现了一个简化版的string类。类当中实现了一些基本的成员函数,并在string的拷贝构造函数和赋值运算符重载函数当中打印了一条提示语句,这样当调用这两个函数时我们就能够知道。
class String {
public:
typedef char* iterator;
iterator Begin() {
return _str;
}
iterator End() {
return _str + _size;
}
//构造函数
String(const char* str = "")
{
_size = strlen(str);//初始时,字符串大小设置为字符串长度
_capacity = _size;//初始时,字符串容量设置为字符串长度
_str = new char[_capacity + 1]; //为存储字符串开辟空间(多开一个用于存放'\0')
strcpy(_str, str);
}
//交换两个对象的数据
void Swap(String& s) {
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//拷贝构造函数
String(const String& s):_str(nullptr), _size(0), _capacity(0)
{
cout << "string(const string& s) -- 深拷贝" << endl;
String tmp(s._str);
Swap(tmp);
}
//赋值运算符重载
String& operator=(const String& s)
{
cout << "string& operator=(const string& s) -- 深拷贝" << endl;
String tmp(s._str);
Swap(tmp);
return *this;
}
//析构函数
~String()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
//改变容量,大小不变
void reserve(size_t n) {
//当n大于对象当前容量时才需执行操作
if(n > _capacity) {
char* tmp = new char[n + 1];
strncpy(tmp, _str, n);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
//尾插字符
void push_back(char ch) {
//判断是否需要增容
if(_size == _capacity) {
//将容量扩大为原来的两倍
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;//将字符尾插到字符串
_str[_size+1] = '\0';
_size++;
}
//[]运算符重载
char & operator[](size_t i) {
assert(i < _size);
return _str[i];
}
//+=运算符重载
String& operator+=(char ch) {
push_back(ch);
return *this;
}
//返回C类型的字符串
const char* c_str()const {
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
左值引用的使用场景
在说明左值引用的短板之前,我们先来看看左值引用的使用场景:
- 左值引用做参数,防止传参时进行拷贝操作。
- 左值引用做返回值,防止返回时对返回对象进行拷贝操作。
因为我们模拟实现是string类的拷贝构造函数当中打印了提示语句,因此运行代码后通过程序运行结果就知道,值传参时调用了string的拷贝构造函数。
此外,因为string的+=
运算符重载函数是左值引用返回的,因此在返回+=
后的对象时不会调用拷贝构造函数,但如果将+=
运算符重载函数改为传值返回,那么重新运行代码后你就会发现多了一次拷贝构造函数的调用。
我们都知道string的拷贝是深拷贝,深拷贝的代价是比较高的,我们应该尽量避免不必要的深拷贝操作,因此这里左值引用起到的作用还是很明显的。
左值引用的短板
左值引用虽然能避免不必要的拷贝操作,但左值引用并不能完全避免。
- 左值引用做参数,能够完全避免传参时不必要的拷贝操作。
- 左值引用做返回值,并不能完全避免函数返回对象时不必要的拷贝操作。
如果函数返回的对象是一个局部变量,该变量出了函数作用域就被销毁了,这种情况下不能用左值引用作为返回值,只能以传值的方式返回,这就是左值引用的短板。
比如下面我们模拟实现一个int版本的int_to_String函数,这个int_to_String函数就不能使用左值引用返回,因为int_to_String函数返回的是一个局部变量。
String& int_to_String(int num) {
//标志位判断是否该加负号
bool flag = true;
if(num < 0) {
flag = false;
num = -num;
}
String str;
while (num) {
int tmp = num % 10;
// str.push_back(tmp + '0');
str += (tmp + '0');
num /= 10;
}
if(flag == false) {
str += '-';
}
std::reverse(str.Begin(), str.End());
return str;
}
此时调用int_to_String函数返回时,就一定会调用string的拷贝构造函数。
如果不用左值引用作为返回值,就不会出现深拷贝的情况。
C++11提出右值引用就是为了解决左值引用的这个短板的,但解决方式并不是简单的将右值引用作为函数的返回值。
右值引用和移动语义
右值引用和移动语句解决上述问题的方式就是,给当前模拟实现的string类增加移动构造和移动赋值方法。
移动构造
移动构造是一个构造函数,该构造函数的参数是右值引用类型的,移动构造本质就是将传入右值的资源窃取过来,占为己有,这样就避免了进行深拷贝,所以它叫做移动构造,就是窃取别人的资源来构造自己的意思。
在当前的string类中增加一个移动构造函数,该函数要做的就是调用swap函数将传入右值的资源窃取过来,为了能够更好的得知移动构造函数是否被调用,可以在该函数当中打印一条提示语句。
class String {
//移动构造
String(String&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
Swap(s);
}
};
移动构造和拷贝构造的区别:
- 在没有增加移动构造之前,由于拷贝构造采用的是const左值引用接收参数,因此无论拷贝构造对象时传入的是左值还是右值,都会调用拷贝构造函数。
- 增加移动构造之后,由于移动构造采用的是右值引用接收参数,因此如果拷贝构造对象时传入的是右值,那么就会调用移动构造函数(最匹配原则)。
- string的拷贝构造函数做的是深拷贝,而移动构造函数中只需要调用swap函数进行资源的转移,因此调用移动构造的代价比调用拷贝构造的代价小。
给string类增加移动构造后,对于返回局部string对象的这类函数,在返回string对象时就会调用移动构造进行资源的移动,而不会再调用拷贝构造函数进行深拷贝了。
int main()
{
String s = int_to_String(100);
return 0;
}
说明:
- 虽然int_to_String当中返回的局部string对象是一个左值,但由于该string对象在当前函数调用结束后就会立即被销毁,我可以把这种即将被消耗的值叫做“将亡值”,比如匿名对象也可以叫做“将亡值”。
- 既然“将亡值”马上就要被销毁了,那还不如把它的资源转移给别人用,因此编译器在识别这种“将亡值”时会将其识别为右值,这样就可以匹配到参数类型为右值引用的移动构造函数。
编译器做的优化
实际当一个函数在返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再用这个临时对象来拷贝构造我们接收返回值的对象。
因此在C++11标准出来之前,对于深拷贝的类来说这里就会进行两次深拷贝,所以大部分编译器为了提高效率都对这种情况进行了优化,这种连续调用构造函数的场景通常会被优化成一次。
因此按道理来说,在C++11标准出来之前这里应该调用两次string的拷贝构造函数,但最终被编译器优化成了一次,减少了一次无意义的深拷贝。(并不是所有的编译器都做了这个优化)
在C++11出来之后,编译器的这个优化仍然起到了作用。
- 如果编译器不优化这里应该调用两次移动构造,第一次调用移动构造用返回的局部string对象构造出一个临时对象,第二次调用移动构造用这个临时对象构造接收返回值的对象。
- 而经过编译器优化后,最终这两次移动构造就被优化成了一次,也就是直接将返回的局部string对象的资源移动给了接收返回值的对象。
- 此外,C++11之后就算编译器没有进行这个优化问题也不大,因为不优化也就是调用两次移动构造进行两次资源的转移而已。
但如果我们不是用函数的返回值来构造一个对象,而是用一个之前已经定义出来的对象来接收函数的返回值,这时编译器就无法进行优化了。
这时当函数返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再调用赋值运算符重载函数将这个临时对象赋值给接收函数返回值的对象。
- 编译器并没有对这种情况进行优化,因此在C++11标准出来之前,对于深拷贝的类来说这里就会存在两次深拷贝,因为深拷贝的类的赋值运算符重载函数也需要以深拷贝的方式实现。
- 但在深拷贝的类中引入C++11的移动构造后,这里仍然需要再调用一次赋值运算符重载函数进行深拷贝,因此深拷贝的类不仅需要实现移动构造,还需要实现移动赋值。
这里需要说明的是,对于返回局部对象的函数,就算只是调用函数而不接收该函数的返回值,也会存在一次拷贝构造或移动构造,因为函数的返回值不管你接不接收都必须要有,而当函数结束后该函数内的局部对象都会被销毁,所以就算不接收函数的返回值也会调用一次拷贝构造或移动构造生成临时对象。、
移动赋值
移动赋值是一个赋值运算符重载函数,该函数的参数是右值引用类型的,移动赋值也是将传入右值的资源窃取过来,占为己有,这样就避免了深拷贝,所以它叫移动赋值,就是窃取别人的资源来赋值给自己的意思。
在当前的string类中增加一个移动赋值函数,该函数要做的就是调用swap函数将传入右值的资源窃取过来,为了能够更好的得知移动赋值函数是否被调用,可以在该函数中打印一条提示语句。
class string
{
public:
//移动赋值
String& operator=(String&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
Swap(s);
return *this;
}
};
移动赋值和原有operator=
函数的区别:
- 在没有增加移动赋值之前,由于原有operator=函数采用的是const左值引用接收参数,因此无论赋值时传入的是左值还是右值,都会调用原有的operator=函数。
- 增加移动赋值之后,由于移动赋值采用的是右值引用接收参数,因此如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)。
- string原有的operator=函数做的是深拷贝,而移动赋值函数中只需要调用swap函数进行资源的转移,因此调用移动赋值的代价比调用原有operator=的代价小。
现在给string增加移动构造和移动赋值以后,就算是用一个已经定义过的string对象去接收to_string函数的返回值,此时也不会存在深拷贝。
现在给string增加移动构造和移动赋值以后,就算是用一个已经定义过的string对象去接收to_string函数的返回值,此时也不会存在深拷贝。
int main()
{
String s;
s = int_to_String(100);
return 0;
}
此时当int_to_String函数返回局部的string对象时,会先调用移动构造生成一个临时对象,然后再调用移动赋值将临时对象的资源转移给我们接收返回值的对象,这个过程虽然调用了两个函数,但这两个函数要做的只是资源的移动,而不需要进行深拷贝,大大提高了效率。
注意:
在实现移动赋值函数之前,该代码的运行结果理论上应该是调用一次拷贝构造,再调用一次原有的operator=
函数,但由于原有operator=
函数实现时复用了拷贝构造函数,因此代码运行后的输出结果会多打印一次拷贝构造函数的调用,这是原有operator=
函数内部调用的。
STL中的移动构造和移动赋值
string类增加的移动构造:
string类增加的移动赋值:
右值引用引用左值
右值引用虽然不能引用左值,但也不是完全不可以,当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。
move函数的名字具有迷惑性,move函数实际并不能搬移任何东西,该函数唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
move函数的定义如下:
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
//forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
注意:
- move函数中_Arg参数的类型不是右值引用,而是万能引用。万能引用跟右值引用的形式一样,但是右值引用需要是确定的类型。
- 一个左值被move以后,它的资源可能就被转移给别人了,因此要慎用一个被move后的左值。
右值引用的其他使用场景
右值引用版本的插入函数
C++11标准出来之后,STL中的容器除了增加移动构造和移动赋值之外,STL容器插入接口函数也增加了右值引用版本。
以list容器的push_back接口为例:
如果list容器当中存储的是string对象,那么在调用push_back向list容器中插入元素时,可能会有如下几种插入方式:
int main()
{
String s("123");
list<String> ls;
ls.push_back(s);
ls.push_back("456");
ls.push_back(String("789"));
ls.push_back(std::move(s));
return 0;
}
list容器的push_back函数需要先构造一个结点,然后将该结点插入到底层的双链表当中。
- 在C++11之前list容器的push_back接口只有一个左值引用版本,因此在push_back函数中构造结点时,这个左值只能匹配到string的拷贝构造函数进行深拷贝。
- 而在C++11出来之后,string类提供了移动构造函数,并且list容器的push_back接口提供了右值引用版本,此时如果传入push_back函数的string对象是一个右值,那么在push_back函数中构造结点时,这个右值就可以匹配到string的移动构造函数进行资源的转移,这样就避免了深拷贝,提高了效率。
- 上述代码中的插入第一个元素时就会匹配到push_back的左值引用版本,在push_back函数内部就会调用string的拷贝构造函数进行深拷贝,而插入后面三个元素时由于传入的是右值,因此会匹配到push_back的右值引用版本,此时在push_back函数内部就会调用string的移动构造函数进行资源的转移。
完美转发
万能引用
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
template<class T>
void PerfectForward(T&& t)
{
//...
}
右值引用和万能引用的区别就是,右值引用需要是确定的类型,而万能引用是根据传入实参的类型进行推导,如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用。
下面重载了四个Func函数,这四个Func函数的参数类型分别是左值引用、const左值引用、右值引用和const右值引用。在主函数中调用PerfectForward
函数时分别传入左值、右值、const左值和const右值,在PerfectForward
函数中再调用Func函数。
void Func(int& n) {
cout << "Func(int& n) 左值引用" << endl;
}
void Func(const int& n) {
cout << "Func(const int& n) 左值引用" << endl;
}
void Func(int&& n) {
cout << "Func(int&& n) 右值引用" << endl;
}
void Func(const int&& n) {
cout << "Func(const int&& n) 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
Func(t);
}
由于PerfectForward函数的参数类型是万能引用,因此既可以接收左值也可以接收右值,而我们在PerfectForward函数中调用Func函数,就是希望调用PerfectForward函数时传入左值、右值、const左值、const右值,能够匹配到对应版本的Func函数。
- 但实际调用PerfectForward函数时传入左值和右值,最终都匹配到了左值引用版本的Func函数,调用PerfectForward函数时传入const左值和const右值,最终都匹配到了const左值引用版本的Func函数。
- 根本原因就是,右值被引用后会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。
也就是说,右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发。
完美转发保持值的属性
要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。
template<class T>
void PerfectForward(T&& t)
{
Func(std::forward<T>(t));
}
经过完美转发后,调用PerfectForward函数时传入的是右值就会匹配到右值引用版本的Func函数,传入的是const右值就会匹配到const右值引用版本的Func函数,这就是完美转发的价值。
完美转发的使用场景
下面模拟实现了一个简化版的list类,类当中分别提供了左值引用版本和右值引用版本的push_back和insert函数。
template<class T>
struct ListNode {
T _data;
ListNode* _pre = nullptr;
ListNode* _nxt = nullptr;
// ListNode(const T& data):_data(data), _pre(nullptr), _nxt(nullptr)
// {}
};
template<class T>
class List {
public:
typedef ListNode<T> Node;
void empty_initialize()
{
_head = new Node;
_head->_nxt = _head;
_head->_pre = _head;
_size = 0;
}
//构造函数
List()
{
empty_initialize();
}
//拷贝构造
List(const list<T>& lt)
{
empty_initialize();
list<T> tmp(lt.begin(), lt.end());
swap(tmp);
}
//析构函数
~List()
{
// Node* cur = _head;
// while (cur != nullptr) {
// Node* nxt = cur;
// delete cur;
// cur = nxt;
// }
// delete _head;
// _head = nullptr;
}
//左值引用版本的insert
void insert(Node* pos, const T& x) {
Node* pre = pos->_pre;
Node* newNode = new Node;
newNode->_data = x;
//pre pos
pre->_nxt = newNode;
newNode->_pre = pre;
newNode->_nxt = pos;
pos->_pre = newNode;
}
//左值引用版本的push_back
void push_back(const T& x) {
//双链表的尾就是头部节点的前一个位置
insert(_head, x);
}
//右值引用版本的insert
void insert(Node* pos, T&& x) {
Node* pre = pos->_pre;
Node* newNode = new Node;
//完美转发
newNode->_data = std::forward<T>(x);
//pre pos
pre->_nxt = newNode;
newNode->_pre = pre;
newNode->_nxt = pos;
pos->_pre = newNode;
}
//右值引用版本的push_back
void push_back(T&& x) {
//完美转发
insert(_head, std::forward<T>(x));
}
private:
Node* _head;//指向链表头结点的指针
size_t _size;
};
面定义一个list对象,list容器中存储的就是之前模拟实现的string类,这里分别传入左值和右值调用不同版本的push_back。
调用左值引用版本的push_back函数插入元素时,会调用string原有的operator=
函数进行深拷贝,而调用右值引用版本的push_back函数插入元素时,只会调用string的移动赋值进行资源的移动。
- 因为实现push_back函数时复用了insert函数的代码,对于左值引用版本的push_back函数,在调用insert函数时只能调用左值引用版本的insert函数,而在insert函数中插入元素时会先new一个结点,然后将对应的左值赋值给该结点,因此会调用string原有的operator=函数进行深拷贝。
- 而对于右值引用版本的push_back函数,在调用insert函数时就可以调用右值引用版本的insert函数,在右值引用版本的insert函数中也会先new一个结点,然后将对应的右值赋值给该结点,因此这里就和调用string的移动赋值函数进行资源的移动。
- 这个场景中就需要用到完美转发,否则右值引用版本的push_back接收到右值后,该右值的右值属性就退化了,此时在右值引用版本的push_back函数中调用insert函数,也会匹配到左值引用版本的insert函数,最终调用的还是原有的operator=函数进行深拷贝。
- 此外,除了在右值引用版本的push_back函数中调用insert函数时,需要用完美转发保持右值原有的属性之外,在右值引用版本的insert函数中用右值给新结点赋值时也需要用到完美转发,否则在赋值时也会将其识别为左值,导致最终调用的还是原有的operator=函数。
也就是说,只要想保持右值的属性,在每次右值传参时都需要进行完美转发,实际STL库中也是通过完美转发来保持右值属性的。
注意:
代码中push_back和insert函数的参数T&&是右值引用,而不是万能引用,因为在list对象创建时这个类就被实例化了,后续调用push_back和insert函数时,参数T&&中的T已经是一个确定的类型了,而不是在调用push_back和insert函数时才进行类型推导的。
与STL中的list的区别
如果将刚才测试代码中的list换成STL当中的list。
- 调用左值引用版本的push_back插入结点,在构造结点时会调用string的拷贝构造函数。
- 调用右值引用版本的push_back插入结点,在构造结点时会调用string的移动构造函数。
而用我们模拟实现的list时,调用的却不是string的拷贝构造和移动构造,而对应是string原有的operator=
和移动赋值。
原因是因为我们模拟实现的list容器,是通过new操作符为新结点申请内存空间的,在申请内存后会自动调用构造函数对进行其进行初始化,因此在后续用左值或右值对其进行赋值时,就会调用对应的operator=
或移动赋值进行深拷贝或资源的转移。
而STL库中的容器都是通过空间配置器获取内存的,因此在申请到内存后不会调用构造函数对其进行初始化,而是后续用左值或右值对其进行拷贝构造,因此最终调用的就是拷贝构造或移动构造。
如果想要得到与STL相同的实验结果,可以使用malloc函数申请内存,这时就不会自动调用构造函数进行初始化,然后在用定位new的方式用左值或右值对申请到的内存空间进行构造,这时调用的对应就是拷贝构造或移动构造。
类的新功能
默认成员函数
在C++11之前,一个类中有如下六个默认成员函数:
- 构造函数。
- 析构函数。
- 拷贝构造函数。
- 拷贝赋值函数。
- 取地址重载函数。
- const取地址重载函数。
其中前四个成员函数最重要,后面两个成员函数一般不会用到,这里“默认”的意思就是你不写编译器会自动生成。在C++11标准中又增加了两个默认成员函数,分别是移动构造函数和移动赋值重载函数。
默认移动构造和移动赋值的生成条件
- 移动构造函数的生成条件:没有自己实现移动构造函数,并且没有自己实现析构函数、拷贝构造函数和拷贝赋值函数。
- 移动赋值重载函数的生成条件:没有自己实现移动赋值重载函数,并且没有自己实现析构函数、拷贝构造函数和拷贝赋值函数。
也就是说,移动构造和移动赋值的生成条件与之前六个默认成员函数不同,并不是单纯的没有实现移动构造和移动赋值编译器就会默认生成。
注意:
如果我们自己实现了移动构造或者移动赋值,就算没有实现拷贝构造和拷贝赋值,编译器也不会生成默认的拷贝构造和拷贝赋值。
默认生成的移动构造和移动赋值会做什么?
- 默认生成的移动构造函数:对于内置类型的成员会完成值拷贝(浅拷贝),对于自定义类型的成员,如果该成员实现了移动构造就调用它的移动构造,否则就调用它的拷贝构造。
- 默认生成的移动赋值重载函数:对于内置类型的成员会完成值拷贝(浅拷贝),对于自定义类型的成员,如果该成员实现了移动赋值就调用它的移动赋值,否则就调用它的拷贝赋值。
验证默认生成的移动构造和移动赋值所做的工作
要验证默认生成的移动构造和移动赋值确实做了上述工作,这里需要模拟实现一个简化版的string类,类当中只编写了几个我们需要用到的成员函数。
class String {
public:
typedef char* iterator;
iterator Begin() {
return _str;
}
iterator End() {
return _str + _size;
}
//构造函数
String(const char* str = "")
{
_size = strlen(str);//初始时,字符串大小设置为字符串长度
_capacity = _size;//初始时,字符串容量设置为字符串长度
_str = new char[_capacity + 1]; //为存储字符串开辟空间(多开一个用于存放'\0')
strcpy(_str, str);
}
//交换两个对象的数据
void Swap(String& s) {
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//拷贝构造函数
String(const String& s):_str(nullptr), _size(0), _capacity(0)
{
cout << "string(const string& s) -- 深拷贝" << endl;
String tmp(s._str);
Swap(tmp);
}
//赋值运算符重载
String& operator=(const String& s)
{
cout << "string& operator=(const string& s) -- 深拷贝" << endl;
String tmp(s._str);
Swap(tmp);
return *this;
}
//移动构造
String(String&& s):_str(nullptr), _size(0), _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
Swap(s);
}
//移动赋值
String& operator=(String&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
Swap(s);
return *this;
}
//析构函数
~String()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
//改变容量,大小不变
void reserve(size_t n) {
//当n大于对象当前容量时才需执行操作
if(n > _capacity) {
char* tmp = new char[n + 1];
strncpy(tmp, _str, n);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
//尾插字符
void push_back(char ch) {
//判断是否需要增容
if(_size == _capacity) {
//将容量扩大为原来的两倍
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;//将字符尾插到字符串
_str[_size+1] = '\0';
_size++;
}
//[]运算符重载
char& operator[](size_t i) {
assert(i < _size);
return _str[i];
}
//+=运算符重载
String& operator+=(char ch) {
push_back(ch);
return *this;
}
//返回C类型的字符串
const char* c_str()const {
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
然后再编写一个简单的Person类,Person类中的成员name的类型就是我们模拟实现的string类
class Person {
public:
//构造函数
Person(const char* name = "", int age = 0):_name(name), _age(age)
{}
//拷贝构造函数
Person(const Person& p):_name(p._name), _age(p._age)
{}
//拷贝赋值函数
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
//析构函数
~Person()
{}
private:
String _name; //姓名
int _age; //年龄
};
class Person {
public:
//构造函数
Person(const char* name = "", int age = 0):_name(name), _age(age)
{}
//拷贝构造函数
Person(const Person& p):_name(p._name), _age(p._age)
{}
//拷贝赋值函数
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
//析构函数
~Person()
{}
private:
String _name; //姓名
int _age; //年龄
};
虽然Person类当中没有实现移动构造和移动赋值,但拷贝构造、拷贝赋值和析构函数Person类都实现了,因此Person类中不会生成默认的移动构造和移动赋值,可以通过下面的代码来验证:
int main()
{
Person s1("张三", 18);
Person s2 = std::move(s1); //想要调用Person默认生成的移动构造
return 0;
}
上述代码中用一个右值去构造s2对象,但由于Person类没有生成默认的移动构造函数,因此这里会调用Person的拷贝构造函数(拷贝构造既能接收左值也能接收右值),这时在Person的拷贝构造函数中就会调用string的拷贝构造函数对name成员进行深拷贝。
如果要让Person类生成默认的移动构造函数,就必须将Person类中的拷贝构造、拷贝赋值和析构函数全部注释掉,这时用右值去构造s2对象时就会调用Person默认生成的移动构造函数。
- Person默认生成的移动构造,对于内置类型成员age会进行值拷贝,而对于自定义类型成员name,因为我们的string类实现了移动构造函数,因此它会调用string的移动构造函数进行资源的转移。
- 而如果我们将string类当中的移动构造函数注释掉,那么Person默认生成的移动构造函数,就会调用string类中的拷贝构造函数对name成员进行深拷贝。
注意:
- 我们在模拟实现的string类的拷贝构造、拷贝赋值、移动构造和移动赋值函数中都打印了一条提示语句,因此可以通过控制台输出判断是否调用了对应的函数。
- 由于VS2013没有完全支持C++11,因此上述代码无法在VS2013当中验证,需要使用更新一点的编译器进行验证,比如VS2019。
类成员变量初始化
默认生成的构造函数,对于自定义类型的成员会调用其构造函数进行初始化,但并不会对内置类型的成员进行处理。于是C++11支持非静态成员变量在声明时进行初始化赋值,默认生成的构造函数会使用这些缺省值对成员进行初始化。
class Person
{
public:
//...
private:
//非静态成员变量,可以在成员声明时给缺省值
String _name = "张三"; //姓名
int _age = 18; //年龄
static int _n; //静态成员变量不能给缺省值
};
注意:这里不是初始化,而是给声明的成员变量一个缺省值。
强制生成默认函数的关键字default
C++11可以让我们更好的控制要使用的默认成员函数,假设在某些情况下我们需要使用某个默认成员函数,但是因为某些原因导致无法生成这个默认成员函数,这时可以使用default关键字强制生成某个默认成员函数。
就像刚才的例子,如果要让Person类生成默认的移动构造函数,就必须将Person类中的拷贝构造、拷贝赋值和析构函数全部注释掉,此时我们使用default就可以强制类生成对应的默认成员函数。
此时,Person类调用的是默认移动构造。
注意:默认成员函数都可以用default关键字强制生成,包括移动构造和移动赋值。
禁止生成默认函数的关键字delete
当我们想要限制某些默认函数生成时,可以通过如下两种方式:
- 在C++98中,可以将该函数设置成私有,并且只用声明不用定义,这样当外部调用该函数时就会报错。
- 在C++11中,可以在该函数声明后面加上=delete,表示让编译器不生成该函数的默认版本,我们将=delete修饰的函数称为删除函数。
例如,要让Person类不能被拷贝,可以用=delete修饰将该类的拷贝构造和拷贝赋值。
class Person
{
public:
Person()
{}
private:
Person(const Person&) = delete;
Person& operator=(const Person&) = delete;
};
说明:被=delete修饰的函数可以设置为公有,也可以设置为私有,没有说明区别。
继承和多态中final与override关键字
final修饰类
被final修饰的类叫做最终类,最终类无法被继承。
class Person final {
private:
String _name; //姓名
int _age; //年龄
};
class worker:public Person {
private:
String _name; //姓名
int _age; //年龄
};
final修饰虚函数
final修饰虚函数,表示该虚函数不能再被重写,如果子类继承后重写了该虚函数则编译报错。
class Person {
public:
virtual void Print() final{
cout << "Person" << endl;
}
private:
String _name; //姓名
int _age; //年龄
};
class worker:public Person {
public:
virtual void Print() {
cout << "worker" << endl;
}
private:
String _name; //姓名
int _age; //年龄
};
override修饰虚函数
override修饰子类的虚函数,检查子类是否重写了父类的某个虚函数,如果没有没有重写则编译报错。
class Person {
public:
virtual void Print() {
cout << "Person" << endl;
}
};
class worker:public Person {
public:
virtual void Print(int a) override{
cout << "worker" << endl;
}
};
实际上去掉参数int a 就编译通过了。
Lambda表达式
lambda表达式是一个匿名函数,恰当使用lambda表达式可以让代码变得简洁,并且可以提高代码的可读性。
假设现在要对若干商品分别按照价格和数量进行升序、降序排序。
struct Goods {
string _name;
double _price;
int _count;
}
- 要对一个数据集合中的元素进行排序,可以使用sort函数,但由于这里待排序的元素为自定义类型,因此需要用户自行定义排序时的比较规则。
- 要控制sort函数的比较方式常见的有两种方法,一种是对商品类的的()运算符进行重载,另一种是通过仿函数来指定比较的方式。
- 显然通过重载商品类的()运算符是不可行的,因为这里要求分别按照价格和数量进行升序、降序排序,每次排序就去修改一下比较方式是很笨的做法。
- 使用仿函数
struct ComparePriceLess {
bool operator()(const Goods& g1, const Goods& g2) {
return g1._price < g2._price;
}
};
struct ComparePriceGreater {
bool operator()(const Goods& g1, const Goods& g2) {
return g1._price > g2._price;
}
};
struct CompareCountLess {
bool operator()(const Goods& g1, const Goods& g2) {
return g1._count < g2._count;
}
};
struct CompareCountGreater {
bool operator()(const Goods& g1, const Goods& g2) {
return g1._count > g2._count;
}
};
int main()
{
vector<Goods> v = {{"苹果", 12.2, 100}, {"香蕉", 8.8, 80}, {"梨", 9.7, 70}};
sort(v.begin(), v.end(), ComparePriceLess());
sort(v.begin(), v.end(), ComparePriceGreater());
sort(v.begin(), v.end(), CompareCountLess());
sort(v.begin(), v.end(), ComparePriceLess());
sort(v.begin(), v.end(), CompareCountGreater());
return 0;
}
仿函数确实能够解决这里的问题,但可能仿函数的定义位置可能和使用仿函数的地方隔得比较远,这就要求仿函数的命名必须要通俗易懂,否则会降低代码的可读性。
对于这种场景就比较适合使用lambda表达式。
int main()
{
vector<Goods> v = {{"苹果", 12.2, 100}, {"香蕉", 8.8, 80}, {"梨", 9.7, 70}};
// sort(v.begin(), v.end(), ComparePriceLess());
// sort(v.begin(), v.end(), ComparePriceGreater());
// sort(v.begin(), v.end(), CompareCountLess());
// sort(v.begin(), v.end(), ComparePriceLess());
// sort(v.begin(), v.end(), CompareCountGreater());
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._price < g2._price;
});
for(auto& e : v) {
cout << e._name << " " << e._price << " " << e._count << endl;
}
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._price > g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._count < g2._count;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._count > g2._count;
});
return 0;
}
这样一来,每次调用sort函数时只需要传入一个lambda表达式指明比较方式即可,阅读代码的人一看到lambda表达式就知道本次排序的比较方式是怎样的,提高了代码的可读性。
lambda表达式语法
lambda表达式书写格式:[capture-list](parameters)mutable->return-type{statement}
- [capture-list]:捕捉列表。该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。
- (parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略。
- mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。
- ->return-type:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可以省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
- {statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
lambda函数的参数列表和返回值类型都是可选部分,但捕捉列表和函数体是不可省略的,因此最简单的lambda函数如下:
int main()
{
[]{}; //最简单的lambda表达式
return 0;
}
但是该lambda函数不能做任何事情。
捕获列表
捕获列表描述了上下文中哪些数据可以被lambda函数使用,以及使用的方式是传值还是传引用。
- [var]:表示值传递方式捕捉变量var。
- [=]:表示值传递方式捕获所有父作用域中的变量(成员函数包括this指针)。
- [&var]:表示引用传递捕捉变量var。
- [&]:表示引用传递捕捉所有父作用域中的变量(成员函数包括this指针)。
- [this]:表示值传递方式捕捉当前的this指针。
注意:
- 父作用域指的是包含lambda函数的语句块。
- 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。比如
[=, &a, &b]
。 - 捕捉列表不允许变量重复传递,否则会导致编译错误。比如
[=, a]
重复传递了变量a。 - 在块作用域以外的lambda函数捕捉列表必须为空,即全局lambda函数的捕捉列表必须为空。
- 在块作用域中的lambda函数仅能捕捉父作用域中的局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错。
- lambda表达式之间不能相互赋值,即使看起来类型相同。
Lambda表达式交换两个数
标准语法写法
参数列表中包含两个形参,表示需要交换的两个数,注意需要以引用的方式传递。比如:
int main()
{
int x = 10, y = 20;
auto swap = [](int& x, int& y)->void {
int tmp = x;
x = y;
y = tmp;
};
swap(x, y);
cout << x << " " << y << endl;
return 0;
}
注意:
- lambda表达式是一个匿名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量,此时这个变量就可以像普通函数一样使用。
- lambda表达式的函数体在格式上并不是必须写成一行,如果函数体太长可以进行换行,但换行后不要忘了函数体最后还有一个分号。
利用捕捉列表进行捕捉
int main()
{
int x = 10, y = 20;
// auto swap = [](int& x, int& y)->void {
// int tmp = x;
// x = y;
// y = tmp;
// };
// swap(x, y);
auto swap = [&] {
int tmp = x;
x = y;
y =tmp;
};
swap();
cout << x << " " << y << endl;
return 0;
}
这样一来,调用lambda表达式时就不用传入参数了,但实际我们只需要用到变量a和变量b,没有必要把父作用域中的所有变量都进行捕捉,因此也可以只对父作用域中的a、b变量进行捕捉。比如:
int main()
{
int x = 10, y = 20;
// auto swap = [](int& x, int& y)->void {
// int tmp = x;
// x = y;
// y = tmp;
// };
// swap(x, y);
// auto swap = [&] {
// int tmp = x;
// x = y;
// y =tmp;
// };
auto swap = [&x, &y] {
int tmp = x;
x = y;
y =tmp;
};
swap();
cout << x << " " << y << endl;
return 0;
}
实际s上,当我们以[&]
或[=]
的方式捕获变量时,编译器也不一定会把父作用域中所有的变量捕获进来,编译器可能只会对lambda表达式中用到的变量进行捕获,没有必要把用不到的变量也捕获进来,这个主要看编译器的具体实现。
传值捕获
如果以传值方式进行捕捉,那么首先编译不会通过,因为传值捕获到的变量默认是不可修改的,如果要取消其常量性,就需要在lambda表达式中加上mutable,并且此时参数列表不可省略。比如:
int main()
{
int x = 10, y = 20;
auto swap = [x, y]()mutable {
int tmp = x;
x = y;
y =tmp;
};
swap();
cout << x << " " << y << endl;
return 0;
}
但由于这里是传值捕捉,lambda函数中对a和b的修改不会影响外面的a、b变量,与函数的传值传参是一个道理,因此这种方法无法完成两个数的交换。
lambda底层原理
实际编译器在底层对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的。
函数对象就是我们平常所说的仿函数,就是在类中对()
运算符进行了重载的类对象。
下面编写了一个Add类,该类对()
运算符进行了重载,因此Add类实例化出的add1对象就叫做函数对象,add1可以像函数一样使用。然后我们编写了一个lambda表达式,并借助auto将其赋值给add2对象,这时add1和add2都可以像普通函数一样使用。
class Add {
public:
Add(int base):_base(base)
{}
int operator()(int num) {
return _base + num;
}
private:
int _base;
};
int main()
{
int base = 0;
Add add1(base);
add1(100);
cout << base << endl;
auto add2 = [base](int num) {
return base + num;
};
add2(200);
return 0;
}
clion查看汇编:1、打断点 2、GDB中输入disas
调试代码并转到反汇编,可以看到:
- 在创建函数对象add1时,会调用Add类的构造函数。
- 在使用函数对象add1时,会调用Add类的
()
运算符重载函数。
观察lambda表达式时,也能看到类似的代码:
- 借助auto将lambda表达式赋值给add2对象时,会调用
<lambda_uuid>
类的构造函数。 - 在使用add2对象时,会调用
<lambda_uuid>
类的()
运算符重载函数。
本质就是因为lambda表达式在底层被转换成了仿函数。
- 当我们定义一个lambda表达式后,编译器会自动生成一个类,在该类中对
()
运算符进行重载,实际lambda函数体的实现就是这个仿函数的operator()
的实现。 - 在调用lambda表达式时,参数列表和捕获列表的参数,最终都传递给了仿函数的
operator()
。
lambda表达式和范围for是类似的,它们在语法层面上看起来都很神奇,但实际范围for底层就是通过迭代器实现的,lambda表达式底层的处理方式和函数对象是一样的。
lambda表达式之间不能相互赋值
lambda表达式之间不能相互赋值,就算是两个一模一样的lambda表达式。
- 因为lambda表达式底层的处理方式和仿函数是一样的,在VS下,lambda表达式在底层会被处理为函数对象,该函数对象对应的类名叫做<lambda_uuid>。
- 类名中的uuid叫做通用唯一识别码(Universally Unique Identifier),简单来说,uuid就是通过算法生成一串字符串,保证在当前程序当中每次生成的uuid都不会重复。
- lambda表达式底层的类名包含uuid,这样就能保证每个lambda表达式底层类名都是唯一的。
因此每个lambda表达式的类型都是不同的,这也就是lambda表达式之间不能相互赋值的原因,我们可以通过typeid(变量名).name()
的方式来获取lambda表达式的类型。
可以看到,就算是两个一模一样的lambda表达式,它们的类型都是不同的。(图中为Clion,注释中为VS)
注意: 编译器只需要保证每个lambda表达式底层对应类的类名不同即可,并不是每个编译器都会将lambda表达式底层对应类的类名处理成<lambda_uuid>
,这里只是以VS为例。
包装器
function包装器
function是一种函数包装器,也叫做适配器。它可以对可调用对象进行包装,C++中的function本质就是一个类模板。
function类模板的原型如下:
template <class T> function; // undefined
template <class Ret, class... Args>
class function<Ret(Args...)>;
模板参数说明:
Ret
:被包装的可调用对象的返回值类型。Args...
:被包装的可调用对象的形参类型。
示例:
function包装器可以对可调用对象进行包装,包括函数指针(函数名)、仿函数(函数对象)、lambda表达式、类的成员函数。
#include <iostream>
#include <functional>
using namespace std;
int f(int a, int b)
{
return a + b;
}
struct Functor
{
public:
int operator()(int a, int b)
{
return a + b;
}
};
class Plus
{
public:
static int plusi(int a, int b)
{
return a + b;
}
double plusd(double a, double b)
{
return a + b;
}
};
int main()
{
//1、包装函数指针(函数名)
function<int (int, int)> func1 = f;
cout << func1(1, 2) << endl;
//2、包装仿函数(函数对象)
function<int (int, int)> func2 = Functor();
cout << func2(1, 3) << endl;
//3、包装lambda表达式
function<int (int, int)> func3 = [](int a, int b) {
return a + b;
};
cout << func3(1, 4) << endl;
//4、类的静态成员函数
function<int (int, int)> func4 = &Plus::plusi;//&可省略
cout << func4(1, 5) << endl;
//5、类的非静态成员函数
function<double (Plus, double, double)> func5 = &Plus::plusd;
cout << func5(Plus(), 1, 6) << endl;
return 0;
}
注意:
- 包装时指明返回值类型和各形参类型,然后将可调用对象赋值给function包装器即可,包装后function对象就可以像普通函数一样使用了。
- 取静态成员函数的地址可以不用取地址运算符“&”,但取非静态成员函数的地址必须使用取地址运算符“&”。
- 包装非静态的成员函数时需要注意,非静态成员函数的第一个参数是隐藏this指针,因此在包装时需要指明第一个形参的类型为类的类型。
function包装器统一类型
对于以下函数模板useF:
传入该函数模板的第一个参数可以是任意的可调用对象,比如函数指针、仿函数、lambda表达式等。
useF中定义了静态变量count,并在每次调用时将count的值和地址进行了打印,可判断多次调用时调用的是否是同一个useF函数。
代码如下:
template<class F, class T>
T useF(F f, T x)
{
static int count = 0;
cout << "count: " << ++count << endl;
cout << "count: " << &count << endl;
return f(x);
}
在传入第二个参数类型相同的情况下,如果传入的可调用对象的类型是不同的,那么在编译阶段该函数模板就会被实例化多次。
double f(double i)
{
return i / 2;
}
struct Functor
{
double operator()(double d)
{
return d / 3;
}
};
int main()
{
//函数指针
cout << useF(f, 11.11) << endl;
//仿函数
cout << useF(Functor(), 11.11) << endl;
//lambda表达式
cout << useF([](double d)->double{return d / 4; }, 11.11) << endl;
return 0;
}
由于函数指针、仿函数、lambda表达式是不同的类型,因此useF函数会被实例化出三份,三次调用useF函数所打印count的地址也是不同的。
- 但实际这里根本没有必要实例化出三份useF函数,因为三次调用useF函数时传入的可调用对象虽然是不同类型的,但这三个可调用对象的返回值和形参类型都是相同的。
- 这时就可以用包装器分别对着三个可调用对象进行包装,然后再用这三个包装后的可调用对象来调用useF函数,这时就只会实例化出一份useF函数。
- 根本原因就是因为包装后,这三个可调用对象都是相同的function类型,因此最终只会实例化出一份useF函数,该函数的第一个模板参数的类型就是function类型的。
int main()
{
//函数名
function<double(double)> func1 = func;
cout << useF(func1, 11.11) << endl;
//函数对象
function<double(double)> func2 = Functor();
cout << useF(func2, 11.11) << endl;
//lambda表达式
function<double(double)> func3 = [](double d)->double{return d / 4; };
cout << useF(func3, 11.11) << endl;
return 0;
}
这时三次调用useF函数所打印count的地址就是相同的,并且count在三次调用后会被累加到3,表示这一个useF函数被调用了三次。
function包装器简化代码
求解逆波兰表达式的步骤如下:
- 定义一个栈,依次遍历所给字符串。
- 如果遍历到的字符串是数字则直接入栈。
- 如果遍历到的字符串是加减乘除运算符,则从栈定抛出两个数字进行对应的运算,并将运算后得到的结果压入栈中。
- 所给字符串遍历完毕后,栈顶的数字就是逆波兰表达式的计算结果。
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> st;
for(auto& e : tokens)
{
if(e == "+" || e == "-" || e == "*" || e == "/")
{
int x = st.top();
st.pop();
int y = st.top();
st.pop();
switch(e[0])
{
case '+':
st.push(y + x);
break;
case '-':
st.push(y - x);
break;
case '*':
st.push(y * x);
break;
case '/':
st.push(y / x);
break;
}
}
else
{
st.push(stoi(e));
}
}
return st.top();
}
};
在上述代码中,我们通过switch语句来判断本次需要进行哪种运算,如果运算类型增加了,比如增加了求余、幂、对数等运算,那么就需要在switch语句的后面中继续增加case语句。
这种情况可以用包装器来简化代码。
- 建立各个运算符与其对应需要执行的函数之间的映射关系,当需要执行某一运算时就可以直接通过运算符找到对应的函数进行执行。
- 当运算类型增加时,就只需要建立新增运算符与其对应函数之间的映射关系即可。
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> st;
unordered_map<string, function<int (int, int)>> hash = {
{"+", [](int a, int b) { return a + b; }},
{"-", [](int a, int b) { return a - b; }},
{"*", [](int a, int b) { return a * b; }},
{"/", [](int a, int b) { return a / b; }},
};
for(auto& e : tokens) {
if(e == "+" || e == "-" || e == "*" || e == "/") {
int right = st.top(); st.pop();
int left = st.top(); st.pop();
int res = hash[e](left, right);
st.push(res);
}
else {
st.push(stoi(e));
}
}
return st.top();
}
};
需要注意的是,这里建立的是运算符与function类型之间的映射关系,因此无论是函数指针、仿函数还是lambda表达式都可以在包装后与对应的运算符进行绑定。
function包装器的意义
- 将可调用对象的类型进行统一,便于我们对其进行统一化管理。
- 包装后明确了可调用对象的返回值和形参类型,更加方便使用者使用。
bind包装器
bind也是一种函数包装器,也叫做适配器。它可以接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表,C++中的bind本质是一个函数模板。
bind函数模板的原型如下:
template <class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);
template <class Ret, class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);
模板参数说明:
fn
:可调用对象。args...
:要绑定的参数列表:值或占位符。
调用bind的一般形式
auto newCallable = bind(callable, arg_list);
解释说明:
- callable:需要包装的可调用对象。
- newCallable:生成的新的可调用对象。
- arg_list:逗号分隔的参数列表,对应给定的callable的参数。当调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。
arg_list中的参数可能包含形如_n
的名字,其中n是一个整数,这些参数是占位符,表示newCallable的参数,它们占据了传递给newCallable的参数的位置。数值n表示生成的可调用对象中参数的位置,比如_1
为newCallable的第一个参数,_2
为第二个参数,以此类推。
此外,除了用auto接收包装后的可调用对象,也可以用function类型指明返回值和形参类型后接收包装后的可调用对象。
bind包装器绑定固定参数
无意义的绑定
int Plus(int a, int b)
{
return a + b;
}
int main()
{
//无意义的绑定
function<int(int, int)> func = bind(Plus, placeholders::_1, placeholders::_2);
cout << func(1, 2) << endl; //3
return 0;
}
绑定时第一个参数传入函数指针这个可调用对象,但后续传入的要绑定的参数列表依次是placeholders::_1
和placeholders::_2
,表示后续调用新生成的可调用对象时,传入的第一个参数传给placeholders::_1
,传入的第二个参数传给placeholders::_2
。此时绑定后生成的新的可调用对象的传参方式,和原来没有绑定的可调用对象是一样的,所以说这是一个无意义的绑定。
绑定固定参数
如果想把Plus函数的第二个参数固定绑定为10,可以在绑定时将参数列表的placeholders::_2设置为10。
int Plus(int a, int b)
{
return a + b;
}
int main()
{
function<int(int)> func = bind(Plus, placeholders::_1, 10);
cout << func(1) << endl; //3
return 0;
}
此时调用绑定后新生成的可调用对象时就只需要传入一个参数,它会将该值与10相加后的结果进行返回。
bind包装器调整传参顺序
对于下面Sub类中的sub成员函数,sub成员函数的第一个参数是隐藏的this指针,如果想要在调用sub成员函数时不用对象进行调用,那么可以将sub成员函数的第一个参数固定绑定为一个Sub对象。
class Sub
{
public:
int sub(int a, int b)
{
return a - b;
}
};
int main()
{
//绑定固定参数
function<int(int, int)> func = bind(&Sub::sub, Sub(), placeholders::_1, placeholders::_2);
cout << func(1, 2) << endl; //-1
return 0;
}
此时调用绑定后生成的可调用对象时,就只需要传入用于相减的两个参数了,因为在调用时会固定帮我们传入一个匿名对象给this指针。
如果想要将sub成员函数用于相减的两个参数的顺序交换,那么直接在绑定时将placeholders::_1和placeholders::_2的位置交换一下就行了。
class Sub
{
public:
int sub(int a, int b)
{
return a - b;
}
};
int main()
{
//调整传参顺序
function<int(int, int)> func = bind(&Sub::sub, Sub(), placeholders::_2, placeholders::_1);
cout << func(1, 2) << endl; //1
return 0;
}
根本原因就是因为,后续调用新生成的可调用对象时,传入的第一个参数会传给placeholders::_1
,传入的第二个参数会传给placeholders::_2
,因此可以在绑定时通过控制placeholders::_n的位置,来控制第n个参数的传递位置。
bind包装器的意义
- 将一个函数的某些参数绑定为固定的值,让我们在调用时可以不用传递某些参数。
- 可以对函数参数的顺序进行灵活调整。
线程库
在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。
线程对象的构造方式
无参构造
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。
int main() {
thread t;
t.join();
return 0;
}
由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。
#include <iostream>
#include <thread>
using namespace std;
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t;
//...
t = thread(func, 10);
t.join();
return 0;
}
使用场景: 实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。
调用带参的构造函数
thread的带参的构造函数的定义如下:
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
参数说明:
fn
:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...
:调用可调用对象fn时所需要的若干参数。
调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 10);
t.join();
return 0;
}
调用移动构造函数
thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t = thread(func, 10);
t.join();
return 0;
}
注意:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
- 如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
- thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
thread提供的成员函数
成员函数 | 功能 |
---|---|
join | 对该线程进行等待,在等待的线程返回之前,调用join函数的线程将会被阻塞 |
joinable | 判断该线程是否已经执行完毕,如果是则返回true,否则返回false |
detach | 将该线程与创建线程进行分离,被分离后的线程不再需要创建线程调用join函数对其进行等待 |
get_id | 获取该线程的id |
swap | 将两个线程对象关联线程的状态进行交换 |
此外,joinable函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
- 线程已经调用join或detach结束。(线程已经结束)
获取线程的id的方式
调用thread的成员函数get_id
可以获取线程的id,但该方法必须通过线程对象来调用get_id
函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread
命名空间下的get_id
函数。
void func()
{
cout << this_thread::get_id() << endl; //获取线程id
}
int main()
{
thread t(func);
t.join();
return 0;
}
this_thread
函数名 | 作用 |
---|---|
yield | 当前线程“放弃”执行,让操作系统调度另一线程继续执行 |
sleep_until | 让当前线程休眠到一个具体时间点 |
sleep_for | 让当前线程休眠一个时间段 |
线程函数的参数问题
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
如果要通过线程函数的形参改变外部的实参,可以参考以下三种方式:
- 借助std::ref函数
当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。
- 地址的拷贝
将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。
- 借助lambda表达式
将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。
join和detach
启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了如下两种回收线程资源的方式:
1.join
主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join
函数就会自动清理线程相关的资源。
join
函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join
,否则程序会崩溃。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t.join(); //程序崩溃
return 0;
}
但如果一个线程对象join
后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join
。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
int main()
{
thread t(func, 20);
t.join();
t = thread(func, 30);
t.join();
return 0;
}
join的问题
但采用join
的方式结束线程,在某些场景下也可能会出现问题。比如在该线程被join
之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join
。
void func(int n)
{
for (int i = 0; i <= n; i++)
{
cout << i << endl;
}
}
bool Return()
{
return false;
}
int main()
{
thread t(func, 20);
//...
if (!Return())
return -1;
//...
t.join(); //不会被执行
return 0;
}
因此采用join
方式结束线程时,join
的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。
class myThread
{
public:
myThread(thread& t)
:_t(t)
{}
~myThread()
{
if (_t.joinable())
_t.join();
}
//防拷贝
myThread(myThread const&) = delete;
myThread& operator=(const myThread&) = delete;
private:
thread& _t;
};
- 每当创建一个线程对象后,就用myThread类对其进行封装产生一个myThread对象。
- 当myThread对象生命周期结束时就会调用析构函数,在析构中会通过
joinable
判断这个线程是否需要被join
,如果需要那么就会调用join
对其该线程进行等待。
使用我们自己封装的myThread类就能保证线程一定会被join
。
int main()
{
thread t(func, 20);
myThread mt(t); //使用myThread对线程对象进行封装
//...
if (!DoSomething())
return -1;
//...
t.join();
return 0;
}
2.detach
主线程创建新线程后,也可以调用detach
函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。
注意:
- 使用detach的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach函数。
- 否则线程对象可能会因为某些原因,在后续调用detach函数分离线程之前被销毁掉,这时就会导致程序崩溃。
- 因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过joinable判断这个线程是否需要被join,如果需要那么就会调用terminate终止当前程序(程序崩溃)。
int main()
{
thread t(func, 20);
t.detach();
std::this_thread::sleep_for(std::chrono::seconds(1)); // 让主线程睡眠1秒
return 0;
}
主线程在创建并分离子线程后立即结束,由于主线程结束,程序退出,子线程可能无法完成其任务。因此,我们可能看不到任何输出。
我们使用std::this_thread::sleep_for
方法让主线程睡眠1秒。这样,子线程有足够的时间完成其任务,并将输出打印到控制台,这种方法并不是最佳实践,因为它依赖于固定的延迟时间,可能导致不可预测的行为。
在实际应用中,最好使用std::thread::join
方法等待子线程完成任务。
互斥量库
mutex
在C++11中,mutex中总共包了四种互斥量:
- std::mute
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。
mutex中常用的成员函数如下:
成员函数 | 功能 |
---|---|
lock | 对互斥量进行加锁 |
try_lock | 尝试对互斥量进行加锁 |
unlock | 对互斥量进行解锁,释放互斥量的所有权 |
线程函数调用lock
时,可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一致拥有该锁。
- 如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
- 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
线程调用try_lock
时,类似也可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用
unlock
之前,该线程一致拥有该锁。 - 如果该互斥量已经被其他线程锁住,则
try_lock
调用返回false,当前的调用线程不会被阻塞。 - 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
- std::recursive_mutex
recursive_mutex
叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
- 如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
- 而
recursive_mutex
允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的unlock
。
除此之外,recursive_mutex
也提供了lock
、try_lock
和unlock
成员函数,其的特性与mutex大致相同。
- std::timed_mutex
timed_mutex中提供了以下两个成员函数:
- try_lock_for:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。
- try_lock_untill:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。
除此之外,timed_mutex也提供了lock、try_lock和unlock成员函数,其的特性与mutex相同。
- std::recursive_timed_mutex
recursive_timed_mutex
就是recursive_mutex
和timed_mutex
的结合,recursive_timed_mutex
既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
加锁示例
在没有使用互斥锁保证线程安全的情况下,让两个线程各自打印1-100的数字,就会导致控制台输出错乱。
如果要让两个线程的输出不会相互影响,即不会让某一次输出中途被另一个线程打断,那么就需要用互斥锁对打印过程进行保护。
这里加锁的方式有两种,一种是在for循环体内进行加锁,一种是在for循环体外进行加锁。
void func(int n, mutex& mtx)
{
mtx.lock(); //for循环体外加锁
for (int i = 1; i <= n; i++)
{
//mtx.lock(); //for循环体内加锁
cout << i << endl;
//mtx.unlock();
}
mtx.unlock();
}
int main()
{
mutex mtx;
thread t1(func, 100, ref(mtx));
thread t2(func, 100, ref(mtx));
t1.join();
t2.join();
return 0;
}
注意:
- 此处在for循环体外加锁比在for循环体内加锁更高效,因为在for循环体内加锁会导致线程打印数字时频繁进行加锁解锁操作,而如果在for循环体外加锁,那么这两个线程只需要在开始打印1之前进行一次加锁,在打印完100后进行一次解锁就行了。
- 在for循环体外加锁也就意味着两个线程的打印过程变成了串行的,即一个线程打印完1-100后另一个线程再打印,但这时打印效率提高了,因为避免了这两个线程间的频繁切换。
- 为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用。
- 此外,也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁。
实际上:
- 在项目中实际不太建议定义全局变量,因为全局变量如果定义在头文件中,当这个头文件被多个源文件包含时,在这多个源文件中都会对这个全局变量进行定义,这时就会导致变量重定义,但如果将全局变量定义为静态,那这个全局变量就只在当前文件可见。
- 如果确实有一些变量需要在多个文件中使用,那么一般建议将这些变量封装到一个类当中,然后将这个类设计成单例模式,当需要使用这些变量时就通过这个单例对象去访问即可。
lock_guard和unique_lock
使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。
因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。
因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
lock_guard
lock_guard是C++11中的一个模板类,其定义如下:
template <class Mutex>
class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用lock进行加锁。
- 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用unlock自动解锁。
通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。
mutex mtx;
void func()
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
//...
FILE* fout = fopen("data.txt", "r");
if (fout == nullptr)
{
//莫名的return
//...
return; //调用析构函数解锁
}
//...
} //调用析构函数解锁
int main()
{
func();
return 0;
}
从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。
如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期。
mutex mtx;
void func()
{
//匿名局部定义域
{
lock_guard<mutex> lg(mtx); //调用构造函数加锁
//...
FILE *fout = fopen("data.txt", "r");
if (fout == nullptr) {
//莫名的return
//...
return; //调用析构函数解锁
}
}
//...
} //调用析构函数解锁
int main()
{
func();
return 0;
}
模拟实现lock_guard
模拟实现lock_guard类的步骤如下:
- lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
- 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的lock函数进行加锁。
- lock_guard的析构函数中调用互斥锁的unlock进行解锁。
- 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。
template<class Mutex>
class Lock_Guard {
public:
Lock_Guard(Mutex& mtx):_mtx(mtx)
{
_mtx.lock();
}
~Lock_Guard()
{
_mtx.unlock();
}
Lock_Guard(const Lock_Guard&) = delete;
Lock_Guard operator=(const Lock_Guard&) = delete;
private:
Mutex& _mtx;
};
测试两个打印1-100:
template<class Mutex>
class Lock_Guard {
public:
Lock_Guard(Mutex& mtx):_mtx(mtx)
{
_mtx.lock();
}
~Lock_Guard()
{
_mtx.unlock();
}
Lock_Guard(const Lock_Guard&) = delete;
Lock_Guard operator=(const Lock_Guard&) = delete;
private:
Mutex& _mtx;
};
void func(int n, mutex& mtx)
{
Lock_Guard<mutex> lg(mtx);
for (int i = 1; i <= n; i++)
{
//mtx.lock(); //for循环体内加锁
cout << this_thread::get_id() << " : " << i << endl;
//mtx.unlock();
}
}
int main()
{
{
mutex mtx;
thread t1(func, 100, ref(mtx));
thread t2(func, 100, ref(mtx));
t1.join();
t2.join();
}
return 0;
}
unique_lock
但由于lock_guard太单一,用户没有办法对锁进行控制,因此C++11又提供了unique_lock。
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
如下场景就适合使用unique_lock:
- 要用互斥锁保护函数1的大部分代码,但是中间有一小块代码调用了函数2,而调用函数2时不需要用函数1中的互斥锁进行保护,函数2内部的代码由其他互斥锁进行保护。
- 因此在调用函数2之前需要对当前互斥锁进行解锁,当函数2调用返回后再进行加锁,这样当调用函数2时其他线程调用函数1就能够获取到这个锁。
mutex mtx;
void func1() {
unique_lock<mutex> ul(mtx);//构造函数加锁
//...
ul.unlock();
func2();
ul.lock();
//...
}//析构函数解锁
原子性操作库(atomic)
线程安全问题
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。
但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
void func(int& n, int times) {
for (int i = 0; i < times; ++i) {
n++;
}
}
int main() {
int n = 0, times = 1000000;
thread t1(func, ref(n), times);
thread t2(func, ref(n), times);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
上述代码中分别让两个线程对同一个变量n进行了100000次++
操作,理论上最终n的值应该是200000,但最终打印出n的值却是小于200000的。
根本原因就是++
操作并不是一个原子操作,该操作分为三步:
load
:将共享变量n从内存加载到寄存器中。update
:更新寄存器里面的值,执行+1操作。store
:将新值从寄存器写回共享变量n的内存地址。
++
操作对应的汇编代码如下:
- 将
n
的地址加载到%rax
寄存器:mov 0x10(%rbp),%rax
。 - 将
n
的值加载到%eax
寄存器:mov (%rax),%eax
。 - 计算
n+1
并将结果存储在%edx
寄存器:lea 0x1(%rax),%edx
。 - 再次将
n
的地址加载到%rax
寄存器:mov 0x10(%rbp),%rax
。 - 将
%edx
寄存器中的值(即n+1
)存储到n
的地址:mov %edx,(%rax)
。
因此可能当线程1刚将n的值加载到寄存器中就被切走了,也就是只完成了++
操作的第一步,而线程2可能顺利完成了一次完整的++
操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量n进行了一次++
操作,但最终n的值却只被++
了一次。
解决方法:
- 加锁解决线程安全问题
C++98中对于这里出现的线程安全的问题,会选择对共享修改的数据进行加锁保护。
这里可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。
但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行,而且如果锁控制得不好,还容易造成死锁。
- 原子类解决线程安全问题
C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。
原子类型名称 | 对应的内置类型名称 |
---|---|
atomic_bool | bool |
atomic_char | char |
atomic_schar | signed char |
atomic_uchar | unsigned char |
atomic_int | int |
atomic_uint | unsigned int |
atomic_short | short |
atomic_ushort | unsigned short |
atomic_long | long |
atomic_ulong | unsigned long |
atomic_llong | long long |
atomic_ullong | unsigned long long |
atomic_char16_t | char16_t |
atomic_char32_t | char32_t |
atomic_wchar_t | wchar_t |
注意: 需要用大括号对原子类型的变量进行初始化。
程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量互斥访问。
除此之外,也可以使用atomic类模板定义出任意原子类型。
注意:
-
原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等。
-
为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
-
原子类型不仅仅支持原子的++操作,还支持原子的–、加一个值、减一个值、与、或、异或操作。
条件变量库(condition_variable)
condition_variable
中提供的成员函数,可分为wait
系列和notify
系列两类。
wait系列
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括wait
、wait_for
和wait_until
。
wait函数提供了两个不同版本的接口:
//版本一
void wait(unique_lock<mutex>& lck);
//版本二
template<class Predicate>
void wait(unique_lock<mutex>& lck, Predicate pred);
参数说明:
- 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
- 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。
为什么调用wait系列函数时需要传入一个互斥锁?
- 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动获得这个互斥锁。
- 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。
wait_for
和wait_until
函数的使用方式与wait函数类似:
- wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
- wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
- 线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。
注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
notify系列
notify系列成员函数的作用就是唤醒等待的线程,包括notify_one
和notify_all
。
notify_one
:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。notify_all
:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。
注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
实现两个线程交替打印1-100
尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。
该题目主要考察的就是线程的同步和互斥。
- 互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护。
- 同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印。
但如果只有同步和互斥是无法满足题目要求的。
-
首先,我们无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的。
-
此外,有可能会出现某个线程连续多次打印的情况,比如线程1先创建并打印了一个数字,当线程1准备打印第二个数字的时候线程2可能还没有创建出来,或是线程2还没有在互斥锁上进行等待,这时线程1就会再次获取到锁进行打印。
鉴于此,这里还需要定义一个flag变量,该变量的初始值设置为true。
- 假设让线程1打印奇数,线程2打印偶数。那么就让线程1调用wait函数阻塞等待时,传入的可调用对象返回flag的值,而让线程2调用wait函数阻塞等待时,传入的可调用对象返回!flag的值。
- 由于flag的初始值是true,就算线程2先获取到互斥锁也不能进行打印,因为最开始线程2调用wait函数时,会因为可调用对象的返回值为false而被阻塞,这就保证了线程1一定先进行打印。
- 为了让两个线程交替进行打印,因此两个线程每次打印后都需要更改flag的值,线程1打印完后将flag的值改为false并唤醒线程2,这时线程2被唤醒时其可调用对象的返回值就变成了true,这时线程2就可以进行打印了。
- 当线程2打印完后再将flag的值改为true并唤醒线程1,这时线程1就又可以打印了,就算线程2想要连续打印也不行,因为如果线程1不打印,那么线程2的可调用对象的返回值就一直为false,对于线程1也是一样的道理。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
using namespace std;
int main() {
mutex mtx;
condition_variable cv;
bool flag = true;
thread t1([&]() {
int i = 1;
unique_lock<mutex> ul(mtx);
while (i <= 100000) {
cv.wait(ul, [&flag]() { return flag; });
cout << this_thread::get_id() << ":" << i << endl;
i += 2;
flag = !flag;
cv.notify_one();
}
});
thread t2([&]() {
int j = 2;
unique_lock<mutex> ul(mtx);
while (j <= 100000) {
cv.wait(ul, [&flag]() { return !flag; });
cout << this_thread::get_id() << ":" << j << endl;
j += 2;
flag = !flag;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
可变参数模板的概念
可变参数模板是C++11新增的最强大的特性之一,它对参数高度泛化,能够让我们创建可以接受可变参数的函数模板和类模板。
- 在C++11之前,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
- 在C++11之前其实也有可变参数的概念,比如printf函数就能够接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。
可变参数模板的定义方式
template<class …Args>
返回类型 函数名(Args… args)
{
//函数体
}
//例如
template<class ...Args>
void ShowList(Args... args)
{}
注意:
- 模板参数
Args
前面有省略号,代表它是一个可变模板参数,我们把带省略号的参数称为参数包,参数包里面可以包含0到N ( N ≥ 0 ) 个模板参数,而args
则是一个函数形参参数包。 - 模板参数包
Args
和函数形参参数包args
的名字可以任意指定,并不是说必须叫做Args
和args
。
现在调用ShowList函数时就可以传入任意多个参数了,并且这些参数可以是不同类型的。
template<class ...Args>
void ShowList(Args... args)
{}
int main() {
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 3);
ShowList(1, 2, 3, "hello");
ShowList(1, 2, 3, "hello", 12.99);
return 0;
}
我们可以在函数模板中通过sizeof计算参数包中参数的个数。
template<class ...Args>
void ShowList(Args... args)
{
cout << sizeof...(args) << endl; //获取参数包中参数的个数
}
但是我们无法直接获取参数包中的每个参数,只能通过展开参数包的方式来获取,这是使用可变参数模板的一个主要特点,也是最大的难点。
-
特别注意,语法并不支持使用
args[i]
的方式来获取参数包中的参数。 -
只能通过展开参数包的方式来获取,一般我们会通过递归或逗号表达式来展开参数包。
参数包的展开方式
1.递归展开参数包
- 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数出来。
- 在函数模板中递归调用该函数模板,调用时传入剩下的参数包。
- 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来。
比如我们要打印调用函数时传入的各个参数,那么函数模板可以这样编写:
//展开函数
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印分离出的第一个参数
ShowList(args...); //递归调用,将参数包继续向下传
}
但是我们需要注意如何终止函数的递归调用:
无参终止
我们可以在刚才的基础上,再编写一个无参的递归终止函数,该函数的函数名与展开函数的函数名相同。
//递归终止函数
void ShowList()
{
cout << endl;
}
//展开函数
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印分离出的第一个参数
ShowList(args...); //递归调用,将参数包继续向下传
}
这样一来,当递归调用ShowList函数模板时,如果传入的参数包中参数的个数为0,那么就会匹配到这个无参的递归终止函数,这样就结束了递归。
- 但如果外部调用ShowList函数时就没有传入参数,那么就会直接匹配到无参的递归终止函数。
- 而我们本意是想让外部调用ShowList函数时匹配的都是函数模板,并不是让外部调用时直接匹配到这个递归终止函数。
我们可以将展开函数和递归调用函数的函数名改为ShowListArg,然后重新编写一个ShowList函数模板,该函数模板的函数体中要做的就是调用ShowListArg函数展开参数包。
就是让函数自己去调用进行递归的函数:
//递归终止函数
void ShowListArg()
{
cout << endl;
}
//展开函数
template<class T, class ...Args>
void ShowListArg(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
ShowListArg(args...); //将剩下参数继续向下传
}
//供外部调用的函数
template<class ...Args>
void ShowList(Args... args)
{
ShowListArg(args...);
}
这时无论外部调用时传入多少个参数,最终匹配到的都是同一个函数了。
带参终止
//递归终止函数
template<class T>
void ShowListArg(const T& t)
{
cout << t << endl;
}
//展开函数
template<class T, class ...Args>
void ShowListArg(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
ShowList(args...); //将剩下参数继续向下传
}
//供外部调用的函数
template<class ...Args>
void ShowList(Args... args)
{
ShowListArg(args...);
}
这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归调用函数需要写成函数模板,因为我们并不知道最后一个参数是什么类型的。
但该方法有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。
sizeof终止?(不可行)
既然我们可以通过sizeof计算出参数包中参数的个数,那我们能不能在ShowList函数中设置一个判断,当参数包中参数个数为0时就终止递归呢?
//错误示例
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
if (sizeof...(args) == 0)
{
return;
}
ShowList(args...); //将剩下参数继续向下传
}
这种方式是不可行的,原因如下:
- 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
- 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的ShowList函数,此时就会产生报错,因为ShowList函数要求至少传入一个参数。
- 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。
2.逗号表达式展开参数包
通过列表获取参数包中的参数
数组可以通过列表进行初始化,比如:
int a[] = {1,2,3,4};
除此之外,如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... }; //列表初始化
//打印参数包中的各个参数
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
}
这时调用ShowList函数时就可以传入多个整型参数了。
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 3);
return 0;
}
但C++并不像Python这样的语言,C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,并且还不能传入0个参数,因为数组的大小不能为0,因此我们还需要在此基础上借助逗号表达式来展开参数包。
通过逗号表达式展开参数包
虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。
- 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
- 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
- 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。
这样一来,在执行逗号表达式时就会先调用处理函数处理对应的参数,然后再将逗号表达式中的最后一个整型值作为返回值来初始化整型数组。
//处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
这时调用ShowList函数时就可以传入多个不同类型的参数了,但调用时仍然不能传入0个参数,因为数组的大小不能为0,如果想要支持传入0个参数,也可以写一个无参的ShowList函数。
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
注意:
- 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可。
- 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入
PrintArg
函数,代码中的{(PrintArg(args), 0)...}
将会展开成{(PrintArg(arg1), 0)
,(PrintArg(arg2), 0)
,(PrintArg(arg3), 0), etc...}
。
实际上我们也可以不用逗号表达式,因为这里的问题就是初始化整型数组时必须用整数,那我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的。
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
int PrintArg(const T& t)
{
cout << t << " ";
return 0;
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { PrintArg(args)... }; //列表初始化
cout << endl;
}
<< value << " "; //打印传入的若干参数中的第一个参数
ShowListArg(args…); //将剩下参数继续向下传
}
//供外部调用的函数
template<class …Args>
void ShowList(Args… args)
{
ShowListArg(args…);
}
这时无论外部调用时传入多少个参数,最终匹配到的都是同一个函数了。
**带参终止**
```cpp
//递归终止函数
template<class T>
void ShowListArg(const T& t)
{
cout << t << endl;
}
//展开函数
template<class T, class ...Args>
void ShowListArg(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
ShowList(args...); //将剩下参数继续向下传
}
//供外部调用的函数
template<class ...Args>
void ShowList(Args... args)
{
ShowListArg(args...);
}
这样一来,在递归调用过程中,如果传入的参数包中参数的个数为1,那么就会匹配到这个递归终止函数,这样也就结束了递归。但是需要注意,这里的递归调用函数需要写成函数模板,因为我们并不知道最后一个参数是什么类型的。
但该方法有一个弊端就是,我们在调用ShowList函数时必须至少传入一个参数,否则就会报错。因为此时无论是调用递归终止函数还是展开函数,都需要至少传入一个参数。
sizeof终止?(不可行)
既然我们可以通过sizeof计算出参数包中参数的个数,那我们能不能在ShowList函数中设置一个判断,当参数包中参数个数为0时就终止递归呢?
//错误示例
template<class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value << " "; //打印传入的若干参数中的第一个参数
if (sizeof...(args) == 0)
{
return;
}
ShowList(args...); //将剩下参数继续向下传
}
这种方式是不可行的,原因如下:
- 函数模板并不能调用,函数模板需要在编译时根据传入的实参类型进行推演,生成对应的函数,这个生成的函数才能够被调用。
- 而这个推演过程是在编译时进行的,当推演到参数包args中参数个数为0时,还需要将当前函数推演完毕,这时就会继续推演传入0个参数时的ShowList函数,此时就会产生报错,因为ShowList函数要求至少传入一个参数。
- 这里编写的if判断是在代码编译结束后,运行代码时才会所走的逻辑,也就是运行时逻辑,而函数模板的推演是一个编译时逻辑。
2.逗号表达式展开参数包
通过列表获取参数包中的参数
数组可以通过列表进行初始化,比如:
int a[] = {1,2,3,4};
除此之外,如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { args... }; //列表初始化
//打印参数包中的各个参数
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
}
这时调用ShowList函数时就可以传入多个整型参数了。
int main()
{
ShowList(1);
ShowList(1, 2);
ShowList(1, 2, 3);
return 0;
}
但C++并不像Python这样的语言,C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,并且还不能传入0个参数,因为数组的大小不能为0,因此我们还需要在此基础上借助逗号表达式来展开参数包。
通过逗号表达式展开参数包
虽然我们不能用不同类型的参数去初始化一个整型数组,但我们可以借助逗号表达式。
- 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
- 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
- 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。
这样一来,在执行逗号表达式时就会先调用处理函数处理对应的参数,然后再将逗号表达式中的最后一个整型值作为返回值来初始化整型数组。
[外链图片转存中…(img-S3WSCEHa-1713278516386)]
//处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
这时调用ShowList函数时就可以传入多个不同类型的参数了,但调用时仍然不能传入0个参数,因为数组的大小不能为0,如果想要支持传入0个参数,也可以写一个无参的ShowList函数。
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
void PrintArg(const T& t)
{
cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
cout << endl;
}
注意:
- 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可。
- 可变参数的省略号需要加在逗号表达式外面,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入
PrintArg
函数,代码中的{(PrintArg(args), 0)...}
将会展开成{(PrintArg(arg1), 0)
,(PrintArg(arg2), 0)
,(PrintArg(arg3), 0), etc...}
。
实际上我们也可以不用逗号表达式,因为这里的问题就是初始化整型数组时必须用整数,那我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的。
//支持无参调用
void ShowList()
{
cout << endl;
}
//处理函数
template<class T>
int PrintArg(const T& t)
{
cout << t << " ";
return 0;
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
int arr[] = { PrintArg(args)... }; //列表初始化
cout << endl;
}