C++进阶篇——C++11新特性
1. C++11简介
C++11在C++98的基础上增加了约140个新特性,对C++03标准中的约600个缺陷的修正,使得C++11能更好地用于系统开发和库开发、语法更加泛化和简单化、更加稳定和安全,不仅功能强大,而且能够提升程序员的开发效率,公司实际上项目开发中也用的比较多,下面对重要特性进行一一讲解。
2. 统一的列表初始化
2.1 {}初始化
在C++98中,标准允许用花括号对数组或者结构体元素进行统一的列表初始化,比如:
struct Point
{
int _x;
int _y;
};
int main()
{
int array[] = {1,2,3,4,5};
Point p = {1,2};
return 0;
}
C++11扩大了花括号对类型初始化的使用范围,内置类型和用户自定义类型可以使用初始化列表进行初始化,并且可以省略=
。
#include <vector>
#include <map>
using namespace std;
int a[] = {1,3,5};
int b[] {2,4,6}; // 省略 = 号
vector<int> c{1,3,5};
map<int,float> d = {{1,1.0f},{2,2.0f},{5,3.2f}};
不仅如此,使用初始化列表还可以调用类中构造函数。
class Date
{
public:
Date(int year, int month, int day)
: _year{year} // 这里也可以使用初始化列表
, _month{month}
, _day(day)
{}
private:
int _year;
int _month;
int _day;
}
int main()
{
Date d1(2022,1,1);
Date d2{2023,1,1};
Date d3 = {2023,1,3};
}
2.2 std::initializer_list
为什么"{}"
这种方式可以进行初始化呢,它内部是如何实现的呢?在C++11中,标准总是倾向于使用更为通用的方式来支持新特性。标准模板库中的容器对初始化列表的支持源自于<initializer_list>
头文件中initializer_list
类模板的支持。并且声明一个以initialize_list<T>
模板类为参数的构造函数,同样可以使得自定义的类使用列表初始化。
#include <vector>
#include <string>
using namespace std;
enum Gender {boy, girl};
class People {
public:
// 使用初始化列表进行构造
People(initializer_list<pair<string,Gender>> l){
auto i = l.begin();
for(;i != l.end();++i)
{
data.push_back(*i);
}
}
private:
vector<pair<string,Gender>> data;
};
People ship = {{"Garfield",boy},{"HelloKitty",girl}};
内部所有的STL容器都支持了initializer_list<>
进行构造初始化,下面模拟实现vector版本的列表初始化。
template<class T>
class vector {
public:
typedef T* iterator;
vector(initializer_list<T> l)
{
_start = new T[l.size()];
_finish = _start + l.size();
_endofstorage = _start + l.size();
iterator vit = _start;
typename initializer_list<T>::iterator lit = l.begin();
while (lit != l.end())
{
*vit++ = *lit++;
}
}
vector<T>& operator=(initializer_list<T> l)
{
vector<T> tmp(l);
std::swap(_start, tmp._start);
std::swap(_finish, tmp._finish);
std::swap(_endofstorage, tmp._endofstorage);
return *this;
}
private:
iterator _start;
iterator _finish;
iterator _endofstorage;
};
3. 声明
C++11提供了多种简化声明的方式,尤其是在使用模板时。
3.1 auto
C++98的auto是一个存储类型的说明符,表示遍历是局部自动存储类型,但是在局部与中定义的类型默认就是自动存储类型,所以auto就没什么价值了。C++11废除了auto原本的用法,将其用于编译时期的自动类型推断。这样要求必须进行显式初始化,让编译器自动推导类型。
int main()
{
int i = 10;
auto x = 1;
auto p = &i;
auto pf = strcpy;
cout << typeid(p).name() << endl; // int *
cout << typeid(pf).name() << endl; // char* (*ptr)(char*,const char*)
map<string, string> dict = { {"sort","排序"}, {"insert","插入"} };
auto it = dict.begin();
return 0;
}
分析上面三段使用auto
的代码:
- auto关键字自动推导x类型为
int
,这里的const
被去掉,这是因为auto推导类型的时候会默认丢弃掉volatile
和const
属性,如果需要保持它的常量性,需要在auto前面加上const
,也就是const auto x = 1;
。 - p被自动推导为
int*
类型,当然也可以写成auto* p = &i
,也是一样的效果。 - pf被推断为函数指针
char* (char*,const char*)
。 - 当我们想定义一个迭代器的时候,需要将写
std::map<std::string,std::string>::iterator
这样长的类型声明。即使是一位C++老手,面对这么长的代码很难视而不见。而使用auto的话,代码的可读性会大大提升。
3.2 decltype
关键字decltype
是C++11新增的关键字,会将变量的类型声明为表达式指定的类型。
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
// 我们并不知道t1 * t2的结果会是什么类型
// 使用decltype(表达式)自动推导类型作为变量的类型
decltype(t1 * t2) ret;
cout << typeid(ret).name() << endl;
}
int main()
{
const int x = 1;
double y = 2.2;
decltype(x * y) ret; // ret的类型是double
decltype(&x) p; // p的类型是int*
cout << typeid(ret).name() << endl;
cout << typeid(p).name() << endl;
F(1, 'a');
return 0;
}
3.3 nullptr
在C++中NULL被定义为字面量0,可能会带来一些问题,如果遇到重载的函数,其中一个是整形,另一个是指针,NULL会被当做是一个整形传入,那么就会导致结果与预想不一致。出于安全考虑,C++11新增了nullptr
,表示空指针。
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void*)0)
#endif
#endif
4. 范围for循环
这是C++11新增的一个有用的特性,C++11之前的for循环是for(表达式1;表达式2;表达式3)
,但是这种for循环,在使用STL容器的时候就显得有点麻烦,假设我们用一个迭代器遍历vector
容器,vector<int>::iterator it = v.begin(); it != v.end(); ++it)
,这种代码不利于阅读,可读性较差,如果在不了解迭代器的前提下,可能并不难理解这个代码是什么意思。所以C++11新增了范围for循环。
#include <vector>
#include <iostream>
int main()
{
vector<int> v{1,2,3,4,5,6};
for(int num : v)
{
cout << num << endl;
}
return 0;
}
这个代码的意思就是,从容器中依次取出一个元素,每次取出的元素用num保存,记住这里的num是一个副本,修改num并不会影响原本容器的值。如果需要修改容器的值,要使用&
引用。其实范围for本质上就是一个语法糖,他会在编译之后替换成迭代器的形式,所以只有满足迭代器遍历的要求,也就是支持迭代器iterator
,支持!=
和++
操作。
5. 右值引用和移动语义
5.1 左值引用和右值引用
传统C++就存在引用的概念,但其实这个引用叫做左值引用,C++11新增了右值引用。
-
什么是左值?什么是左值引用?
- 左值:一个表示数据的表达式,我们可以对它进行赋值,也可以对获取它的地址的值,我们叫做左值。
- 左值引用:对左值进行引用操作,给左值取别名。使用
&
表示左值引用。使用左值引用,两个人相当于共享这个变量所在的区域。
int main() { // 以下都是左值 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; }
-
什么是右值?什么是右值引用?
- 右值:右值也是一个表示数据的表达式,可以使字面常量、表达式返回值、函数返回值等等。右值不可以取地址。
- 右值引用:就是对右值的引用,给右值取别名。使用
&&
表示右值引用。使用右值引用,右值的表达式的结果会给予一个临时变量,右值引用会获取这个临时变量,使其的作用域与当前右值引用变量的作用域一致。
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); // 这里编译会报错:error C2106: “=”: 左操作数必须为左值 10 = 1; x + y = 1; fmin(x, y) = 1; return 0; }
5.2 左值引用和右值引用的比较
左值引用总结:
- 左值引用只能引用左值,不能引用右值。
- 左值的const引用能引用左值,也能引用右值。
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
右值引用总结:
- 右值引用只能引用右值,不能引用左值。
- 右值引用可以引用
move
以后的左值。
int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
int a = 10;
int&& r2 = a;
// 右值引用可以引用move以后的左值
// a这个变量就失效了
int&& r3 = std::move(a);
return 0;
}
5.3 右值引用使用场景和意义
在没有右值引用之前,我们思考一个问题,我们知道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)
{
//cout << "string(char* str)" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// 赋值重载
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
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;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str() const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity; // 不包含最后做标识的\0
};
C++中有一个函数使将整形转换为字符串型std::string to_srting(int value)
,这个函数只能使用传值返回,那么至少会导致一次拷贝构造,如果没有进行优化的编译器可能会导致两次拷贝构造。
右值引用和移动语义解决上述问题:
移动构造的本质就是将参数右值的资源窃取过来,就不会发生拷贝构造,而因为右值特殊的生命周期,即使窃取过来也不会影响原本的数据。
那么,当我们实现了移动构造,to_string
函数在返回的时候,返回值是一个右值,右值去构造对象就会调用对应的移动构造函数,原本返回的右值在离开作用域之后就会消失,但是右值引用的变量就会获取到这个右值并拉长了它的生命周期。
STL容器都实现了移动构造和移动赋值。
5.4 右值引用引用左值及其一些更深入的使用场景分析
按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C++11中,std::move()
函数位于头文件中<algorithm>
,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
int main()
{
std::string s1("hello world");
std::string s2(s1);
// s1的资源已经给了s3
std::string s3(std::move(s1));
// 之后就不要在使用s1了
}
STL容器插入接口函数也增加了右值引用版本:
void push_back (value_type&& val);
int main()
{
list<std::string> lt;
std::string s1("1111");
// 这里调用的是拷贝构造
lt.push_back(s1); // 深拷贝
// 下面调用都是移动构造
lt.push_back("2222"); // 移动语义
lt.push_back(std::move(s1));
return 0;
}
5.5 完美转发
void Fun(int &x){ cout << "左值引用" << endl; }
void Fun(const int &x){ cout << "const 左值引用" << endl; }
void Fun(int &&x){ cout << "右值引用" << endl; }
void Fun(const int &&x){ cout << "const 右值引用" << endl; }
// 模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
// 模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
// 但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,
// 我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发
template<typename T>
void PerfectForward(T&& t)
{
// t为左值
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
万能引用表示的是,可以接受左值和右值,但是接受之后的变量会退化成左值。但如果想要继续保持右值的属性,就需要使用完美转发。
// std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
template<typename T>
void PerfectForward(T&& t)
{
Fun(std::forward<T>(t));
}
完美转发实际的引用场景:
template<class T>
struct ListNode
{
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
T _data;
};
template<class T>
class List
{
typedef ListNode<T> Node;
public:
List()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
void PushBack(T&& x)
{
// 调用Insert函数,如果不使用完美转发,传入的参数是一个左值
Insert(_head, std::forward<T>(x));
}
void PushFront(T&& x)
{
//Insert(_head->_next, x);
Insert(_head->_next, std::forward<T>(x));
}
void Insert(Node* pos, T&& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
// 在使用一次完美转发,就会保持x仍然是右值属性,那么就不会发生拷贝构造
newnode->_data = std::forward<T>(x); // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
void Insert(Node* pos, const T& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = x; // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
private:
Node* _head;
};
int main()
{
List<std::string> lt;
lt.PushBack("1111");
lt.PushFront("2222");
return 0;
}
6. 新的类功能
C++11新增了两个默认生成的成员函数,分别是移动构造函数和移动赋值运算符重载。
- 如果你没有自己实现移动构造函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型,会调用对应的移动构造函数,若没有实现移动构造,则调用拷贝构造。
- 如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似) 。
- 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。
6.1 default
我们知道,编译器会根据情况默认生成六个成员函数,但有些时候,会因为一些情况,导致指定的成员函数不会被生成,这个时候,我们可以强制编译器生成。例如:我们实现了带参数的构造函数,编译器就不会生成默认的构造函数,但是在子类的构造函数,我们又需要父类的无参构造函数的情况下,这个时候就可以强制父类生成默认的构造函数。
class Person
{
public:
Person(const char* name, int age)
:_name(name)
, _age(age)
{}
Person() = default; // 必须显示生成
private:
string _name;
int _age;
};
class Student : public Person
{
public:
Student(int no)
:_no(no)
{}
private:
int _no;
};
6.2 delete
如果想要限制某些默认函数的生成,在C++98中,该函数设置为私有private
,C++11中,只需要为该函数加上=delete
即可,编译器就不会生成默认的版本。假如我们要实现一个不可被拷贝的类。
// C++98
class nocopyable
{
private:
nocopyable(const nocopyable&);
nocopyable& operator=(const noncopyable&);
};
// C++11
class nocopyable
{
nocopyable(const nocopyable&) = delete;
nocopyable& operator=(const noncopyable&) = delete;
};
7. 可变参数模板
在使用C语言的printf
函数中,我们是可以传入不定量的参数的,其实本质上使用的就是可变参数,其实printf
的实现是能够接受任何长度的参数列表。
#include <stdarg.h> // C99的可变参数头文件
double double SumOfFloat(int count, ...)
{
va_list ap;
double sum = 0;
va_start(ap, count); // ap是一个包含了所有传入的参数的列表,count为数量
for (int i = 0; i < count; i++)
sum += va_arg(ap, double);
va_end(ap);
return sum;
}
int main()
{
printf("%f\n", SumOfFloat(3, 1.2f, 3.4, 5.6)); // 10.200000
}
上面是C语言进行变长函数的使用,但是这种使用会有局限性,我们看到va_args
函数要传入指定的类型,也就是没有办法使用多个类型进行传递,并且在传入变长参数的同时,还要传入一个参数的个数count。面对这种局限性,C++需要引入一种更为**“现代化”**的变长参数的实现方式。
7.1 模板参数包
在模板中,参数的形式如果是class/typename ... 参数名
的形式,我们称为模板参数包。可变模板参数必须放在参数的最后一位。而使用模板参数包 ... 函数形参参数名
的形式,我们称这个参数为函数形参参数包。
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}
其实参数包和C99中的参数列表并没有太大的区别,只不过参数包是可以接受任意参数,并且无需传入参数的长度,本质上是可以包含0到任意个模板参数。
7.2 获取参数包的值
- 递归函数方式展开参数包
// 递归终止函数
template <class T>
void ShowList(const T& t)
{
cout << t << endl;
}
// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)
{
cout << value <<" ";
ShowList(args...);
}
int main()
{
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', std::string("sort"));
return 0;
}
- 逗号表达式方式展开参数包
这种展开参数包的方式,不需要通过递归终止函数,是直接在expand函数体中展开的, printarg不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式。
expand函数中的逗号表达式:(printarg(args), 0)
,也是按照这个执行顺序,先执行printarg(args)
,再得到逗号表达式的结果0。同时还用到了C++11的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)...}
将会展开成((printarg(arg1),0),(printarg(arg2),0), (printarg(arg3),0), etc... )
,最终会创建一个元素值都为0的数组int arr[sizeof...(Args)]
。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。
template <class T>
void PrintArg(T t)
{
cout << t << " ";
}
//展开函数
template <class ...Args>
void ShowList(Args... args)
{
int arr[] = { (PrintArg(args), 0)... };
cout << endl;
}
int main()
{
ShowList(1);
ShowList(1, 'A');
ShowList(1, 'A', std::string("sort"));
return 0;
}
7.3 tuple
tuple
是C++11一个可以接受任意不同类型的参数的类型,本质上也是使用变长模板来实现的。实现方式也很简单,使用递归+继承的方式。
template<typename... Elements>
class tuple;
template<typename Head,typename... Tail>
class tuple<Head, Tail...> :private tuple<Tail...> {
Head head;
};
template<> class tuple<> {}; // 边界条件
举个例子来看,当我们实例化一个tuple<double,int,char,float>
的类型,则会引起基类的递归构造,这样的递归,需要有一个边界条件,就是当参数包的个数为0时结束。当参数为0时,什么也不做即可。template<>
偏特化的意思是一个没有成员的空类型,这样一来,编译器相当于从tuple<>
建造出tuple<float>
,然后依次到tuple<char,float>
,tuple<int,char,float>
,最后是tuple<double,int,char,float>
类型。
7.4 模拟实现printf函数
// 包里面已经没有元素了,边界条件
void Printf(const char* s)
{
while (*s) {
// 当没有参数仍然遇到'%'表示传入的参数有误
if (*s == '%' && *++s != '%') {
throw runtime_error("invalid format string: missing arguments");
}
cout << *s++;
}
}
template<typename T,typename... Args>
void Printf(const char* s, T value, Args... args)
{
while (*s)
{
if (*s == '%' && *++s != '%') {
cout << value;
return Printf(++s, args...);
}
cout << *s++;
}
throw runtime_error("extra arguments provided to Printf");
}
int main()
{
Printf("hello %s%d\n", string{ "world" },1,2);
}
8. lambda表达式
C++98中,我们想要对一个数据进行排序,可以使用标准库中的sort
函数。
#include <algorithm>
#include <functional>
int main()
{
int array[] = {4,1,8,5,3,7,0,9,2,6};
// 默认按照小于比较,排出来结果是升序
// sort默认是按升序进行排序的
std::sort(array, array+sizeof(array)/sizeof(array[0]));
// 如果需要降序,需要改变元素的比较规则
std::sort(array, array + sizeof(array) / sizeof(array[0]), greater<int>());
return 0;
}
如果是对自定义类型排序,就必须构造相关的排序规则:
struct Goods
{
string _name; // 名字
double _price; // 价格
int _evaluate; // 评价
Goods(const char* str, double price, int evaluate)
:_name(str)
, _price(price)
, _evaluate(evaluate)
{}
};
// 按照价格降序排序
struct ComparePriceLess
{
bool operator()(const Goods& gl, const Goods& gr)
{
return gl._price < gr._price;
}
};
// 按照价格升序排序
struct ComparePriceGreater
{
bool operator()(const Goods& gl, const Goods& gr)
{
return gl._price > gr._price;
}
};
int main()
{
vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };
sort(v.begin(), v.end(), ComparePriceLess());
sort(v.begin(), v.end(), ComparePriceGreater());
}
当我们要实现一个自定义类型的排序规则,我们必须构造相应的类,而且每次比较的逻辑不一致,就需要构造更多的类,这无疑给程序员带来极大的不变。C++11中出现的lambda表达式就简化这种用法。
int main()
{
vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };
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._price > g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)
{
return g1._evaluate < g2._evaluate;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)
{
return g1._evaluate > g2._evaluate;
});
}
8.1 lambda表达式的定义
[capture](parameters) mutable -> return-type
{
statement
};
其中,
- capture:捕捉列表。捕捉列表捕捉上下文中的变量供lambda函数使用,如果不需要参数,则[]为空。
- [var]表示值的传递方式是捕捉变量var
- [=]表示值的传递方式捕捉所有父作用域的变量(包括this)
- [&var]表示引用传递var变量
- [&]表示引用传递方式捕捉所有父作用域的变量(包括this)
- [this]表示值床底方式捕捉当前的this指针
- [=,&a,&b]表示引用传递方式捕捉变量a和b,其他变量采用值传递的方式
- [&,a,this]表示以值传递的方式捕捉变量a和this,其他变量以引用的方式传递
- [=,a]这里的等于号已经表示值传递的方式捕捉所有变量,a是多余的
- [&,&this]这里已经以引用的方式捕捉所有的变量,&this是多余的
- parameters:参数列表。与普通函数的参数列表一直·。如果不需要参数传递,可以连括号一起省略,这是与普通函数的区别。
- mutable:修饰符。默认情况下,lambda表达式是一个const函数,mutable可以取消其常量性。使用该参数,不可以省略参数列表的括号
- ->return-type:返回类型。如果返回类型为void,则可以省略这部分。
- { statement }:函数体,与普通函数有区别的就是,只能使用捕捉列表和形参的变量。
8.2 lambda与仿函数
好的编程语言一般都有好的库支持,C++也不例外。C++11之前,我们在使用STL算法时,通常会用到一种特别的对象,一般我们称为仿函数。仿函数其实就是重写了成员函数operator()的一种自定义对象。使用起来与函数并无太大的区别,比如下面这个例子:
class functor {
public:
int operator() (int x, int y)
{
return x + y;
}
};
int main()
{
int girls = 3, boys = 4;
functor totalChild;
cout << totalChild(girls, boys);
}
其实,lambda表达式本质上就是一种仿函数,而且是一种简单写法仿函数,可以理解为是“语法甜点”。我们可以使用lambda表达式来取代仿函数。不过在比较复杂的仿函数中,仍然还是使用仿函数比较恰当。
9. 包装器
9.1 可调用对象
在C++中,存在**“可调用对象”**这么一个概念。可调用对象有如下几种定义:
- 是一个函数指针。
- 是一个仿函数。
- 是一个可被转换为函数指针的类对象。
- 是一个类成员函数指针。
void func(void)
{
// ...
}
struct Foo
{
void operator()(void)
{
//...
}
};
struct Bar
{
using fr_t = void(*)(void);
static void func(void)
{
//...
}
operator fr_t(void)
{
return func;
}
};
struct A
{
int _a;
void mem_func(void)
{
//...
}
};
int main()
{
void(*func_ptr)(void) = func; // 1.函数指针
func_ptr();
Foo foo; // 2.仿函数
foo();
Bar bar; // 3.可被转换为函数指针的类对象
bar();
void (A:: * mem_func_ptr)(void) = A::mem_func; // 4.类成员函数指针
int A::* mem_obj_ptr = &A::_a; // 或者是类成员指针
A aa;
(aa.*mem_func_ptr)();
aa.*mem_obj_ptr = 123;
return 0;
}
从上述操作中,可以看出,除了类成员指针之外,上面定义涉及的对象都像一个函数调用那样操作。在C++11中,这些都被称为可调用对象。相对应的,这些对象的类型被称为可调用类型。
9.2 可调用对象包装器——std::function
std::function
是一个类模板,可以容纳除了类成员指针之外的所有可调用对象。通过指定它的模板参数,可以用统一的方式处理函数、函数对象、函数指针,并允许保存和延迟执行它们。
#include <functional>
void func(void)
{
cout << __FUNCTION__ << endl; // 打印函数名字
}
class Foo
{
public:
static int foo_func(int a)
{
cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};
class Bar
{
public:
int operator()(int a)
{
cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};
int main()
{
// 第一个void是返回值,括号内的void是参数
function<void(void)> fr1 = func; // 绑定一个普通函数
fr1(); // func
function<int(int)> fr2 = Foo::foo_func; // 绑定一个类的静态成员函数
cout << fr2(123) << endl; // foo_func(123)->: 123
Bar bar;
fr2 = bar; // 绑定仿函数
cout << bar(123) << endl; // operator()(123)->: 123
return 0;
}
其实函数包装器本质上和函数指针很像,但是使用方便,我们在创建的时候,传入返回值和参数,参数列表需要加一个括号。然后这个包装器就可以容纳相同返回值和参数的可调用对象。
我们知道C语言中有一个回调函数,并且是使用函数指针的方式完成,我们可以使用包装器来代替,如下:
class A
{
function<void()> _callback;
public:
A(const function<void()>& f) : _callback(f) {}
void notify()
{
_callback();
}
};
class Foo
{
public:
void operator()()
{
cout << __FUNCTION__ << endl;
}
};
int main()
{
Foo foo;
A aa(foo);
aa.notify();
return 0;
}
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<long long> st;
map<string,function<long long(long long,long long)>> opFuncMap =
{
{ "+",[](long long x,long long y) -> long long { return x + y; }},
{ "-",[](long long x,long long y) -> long long { return x - y; }},
{ "*",[](long long x,long long y) -> long long { return x * y; }},
{ "/",[](long long x,long long y) -> long long { return x / y; }}
};
for(auto& str : tokens)
{
if(opFuncMap.count(str)) // 操作符
{
int right = st.top();
st.pop();
int left = st.top();
st.pop();
st.push(opFuncMap[str](left,right));
}
else
{
st.push(stoll(str));
}
}
return st.top();
}
};
9.3 std::bind绑定器
std::bind
绑定器可以用来将可调用对象与其参数一起进行绑定。绑定后的结果可以使用std::function
进行保存,并延迟调用到任何我们所需要的时候。
通俗来说,它有两大作用:
- 将可调用对象与其参数一起绑定成一个仿函数。
- 将多元(n元)可调用对象转成一元或者(n-1)元可调用对象,也就是说绑定部分参数。
void call_when_even(int x, const function<void(int)>& f)
{
if (!(x & 1)) // x % 2 == 0
{
f(x);
}
}
void output(int x)
{
cout << x << " ";
}
void output_add_2(int x)
{
cout << x + 2 << " ";
}
int main()
{
{
auto fr = bind(output, placeholders::_1);
for (int i = 0; i < 10; ++i)
{
// if(i % 2 == 0)
// output(i)
call_when_even(i, fr);
}
cout << endl;
}
{
auto fr = bind(output_add_2, placeholders::_1);
for (int i = 0; i < 10; ++i)
{
// if(i % 2 == 0)
// output_add_2(i)
call_when_even(i, fr);
}
cout << endl;
}
return 0;
}
我们使用了bind保存了bind的绑定结果,使用了auto关键字,这是因为,我们不需要关心bind真正的类型,只知道它是一个仿函数,placeholders::_1
是一个占位符,代表这个函数将在函数调用时,被传入的第一个参数所替代。也就是你把bind当成一个函数,而placeholders::_1
就是一个占位符,1就是传入的第一个参数会把参数替换成对应数字的占位符。这里其实就是用一个function包装了一个bind的仿函数,然后里面的f(x)相当于把x替换了占位符,然后call_when_even(i, fr)
本质上就是调用了output(x)
。
下面这个例子是bind绑定类成员函数的特殊情况:
class A
{
public:
int i = 0;
void output(int x, int y)
{
cout << x << " " << y << endl;
}
};
int main()
{
A a;
// 需要传递对象进去
function<void(int, int)> fr = bind(A::output, &a, placeholders::_1, placeholders::_2);
fr(1, 2); // 1 2
function<int& (void)> fr_i = bind(&A::i, &a);
fr_i() = 123;
cout << a.i << endl; // 123
return 0;
}
10. 线程库
在C++11之前,涉及到多线程的问题,都是采用平台相关的接口,这使得代码的可移植性较差。C++11最重要的特性是对线程进行支持,使得C++在并行编程的时候不需要依赖第三方库,并且在原子操作中引入了原子类的概念。包含在<thread>
头文件中。
10.1 thread线程类
函数名 | 功能 |
---|---|
thread() | 构造一个线程对象,没有关联任何线程函数,没有启动任何线程。 |
thread(fn,args1,args2,…) | 构造一个线程对象,关联线程函数fn,args1,args2,…都是线程函数的参数。 |
get_id() | 获取线程的id。 |
joinable() | 线程是否还在执行。 |
join() | 该函数调用后会阻塞线程,当该线程结束后,主线程继续执行。 |
detach() | 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离开的线程变为后台线程。与主线程无关,主线程一旦执行完毕,无论该线程有没有执行完,程序都会结束。 |
使用:
#include <iostream>
#include <thread>
using namespace std;
void ThreadFunc(int a)
{
cout << "Thread1" << a << endl;
}
class TF
{
public:
void operator()()
{
cout << "Thread3" << endl;
}
};
int main()
{
// 线程函数为函数指针
thread t1(ThreadFunc, 10);
// 线程函数为lambda表达式
thread t2([]{cout << "Thread2" << endl; });
// 线程函数为函数对象
TF tf;
thread t3(tf);
t1.join();
t2.join();
t3.join();
cout << "Main thread!" << endl;
return 0;
}
10.2 线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
#include <thread>
void ThreadFunc1(int& x)
{
x += 10;
}
void ThreadFunc2(int* x)
{
*x += 10;
}
int main()
{
int a = 10;
// 下面这段代码会显示找不到可重载的函数,原因在于我们没有采用引用传值,会导致传入的其实是一个const引用,这与ThreadFunc1的类型不一致
thread t1(ThreadFunc1, a);
t1.join();
cout << a << endl;
// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
thread t2(ThreadFunc1, std::ref(a)); // a = 20
t2.join();
cout << a << endl;
// 地址的拷贝
thread t3(ThreadFunc2, &a); // a = 30
t3.join();
cout << a << endl;
return 0;
}
10.3 原子性操作库
所谓原子操作,就是多线程程序中"最小的且不可并行化"
的操作。如果对一个共享资源的操作是原子性的话,意味着多个线程访问该资源时,有且仅有一个线程对这个资源进行操作。在C++11之前,Linux平台借助pthread
库中的互斥锁来完成原子操作,但是锁带来的开销是很大的,并且需要考虑进入临界区的加锁和解锁的操作。
因此C++11中引入了原子操作,使得线程间数据的同步变得非常高效。
以上是基于内置类型的原子性操作,同样也可以使用atomic
类模板,对自定义类型进行原子操作。
std::atomic<T> t;
看个例子:
#include <iostream>
#include <thread>
#include <atomic>
using namespace std;
atomic_long sum{ 0 };
void fun(size_t num)
{
for (size_t i = 0; i < num; ++i)
sum ++; // 原子操作
}
int main()
{
cout << "Before joining, sum = " << sum << std::endl;
thread t1(fun, 1000000);
thread t2(fun, 1000000);
t1.join();
t2.join();
cout << "After joining, sum = " << sum << std::endl;
return 0;
}
注意:原子类型通常属于"资源型"
数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
10.4 lock_guard与unique_lock
在多线程环境下,如果想要保证某个变量的安全性,只要将其设置成对应的原子类型即可,即高效又不容易出现死锁问题。但是有些情况下,我们可能需要保证一段代码的安全性,那么就只能通过锁的方式来进行控制。
比如:一个线程对变量number进行加一100次,另外一个减一100次,每次操作加一或者减一之后,输出number的结果,要求:number最后的值为1。
#include <thread>
#include <mutex>
int number = 0;
mutex g_lock; // 互斥锁
int ThreadProc1()
{
for (int i = 0; i < 100; i++)
{
g_lock.lock(); // 加锁
++number;
cout << "thread 1 :" << number << endl;
g_lock.unlock(); // 解锁
}
return 0;
}
int ThreadProc2()
{
for (int i = 0; i < 100; i++)
{
g_lock.lock();
--number;
cout << "thread 2 :" << number << endl;
g_lock.unlock();
}
return 0;
}
述代码的缺陷:锁控制不好时,可能会造成死锁,最常见的比如在锁中间代码返回,或者在锁的范围内抛异常。因此:C++11采用RAII的方式对锁进行了封装,即lock_guard
和unique_lock
。
10.4.1 mutex的种类
-
std::mutex
函数名 函数功能 lock() 上锁:锁住互斥量。 unlock() 解锁:释放互斥量。 try_lock() 尝试锁住互斥量,如果互斥量被其他线程占用,当前线程不会进行阻塞。 注意,线程函数调用
lock()
时,可能会发生以下三种情况:- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一直拥有该锁。
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
线程函数调用
try_lock()
时,可能会发生以下三种情况:- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用unlock释放互斥量。
- 如果当前互斥量被其他线程锁住,则当前调用线程返回false,而并不会被阻塞掉。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
-
std::recursive_mutex
其允许同一个线程对互斥量多次上锁,来获取对互斥量对象的多层所有权,释放互斥量是需要调用与该锁层次深度相同次数的
unlock()
,除此之外,与mutex
大致相同。 -
std::timed_mutex
比
mutex
多出了两个成员函数。函数名 函数功能 try_lock_for() 在一个时间范围内,尝试进行锁住该线程,如果没有锁住,不会立即返回false,在这个时间段内会被阻塞住,一旦其他线程释放了锁,在时间范围内可以进行上锁,一旦超出范围,则立刻返回false。 try_lock_until() 与上述函数不同的地方在于接收的是一个时间点作为参数,而上面这个函数是一个时间范围。 -
std::recursive_timed_mutex
这个锁相当于recursive_mutex
的基础上多了两个成员函数,函数的功能与timed_mutex
的一致。
10.4.2 lock_guard
std::lock_guard
是C++11中定义的模板类,实现如下:
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
通过上述代码可以看到,在构造lock_guard
的时候会为互斥锁进行上锁操作,在析构函数自动进行解锁,可以有效避免死锁的问题。
10.4.3 unique_lock
与lock_gard
类似,unique_lock
类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
y_lock_until() | 与上述函数不同的地方在于接收的是一个时间点作为参数,而上面这个函数是一个时间范围。 |
std::recursive_timed_mutex
这个锁相当于recursive_mutex
的基础上多了两个成员函数,函数的功能与timed_mutex
的一致。
10.4.2 lock_guard
std::lock_guard
是C++11中定义的模板类,实现如下:
template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
_MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
通过上述代码可以看到,在构造lock_guard
的时候会为互斥锁进行上锁操作,在析构函数自动进行解锁,可以有效避免死锁的问题。
10.4.3 unique_lock
与lock_gard
类似,unique_lock
类模板也是采用RAII的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 mutex 对象作为它的参数,新创建的unique_lock 对象负责传入的 mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。