C++11

发布于:2024-12-07 ⋅ 阅读:(127) ⋅ 点赞:(0)

C++11简介

C++11的更新一言难尽。有的更新,诸如右值引用,移动语义,unordered系列容器,lambda表达式等极有价值;有的诸如array(检查严格但没有vector好用),forward_list(虽然省空间但list更常用),cbegin接口等就很鸡肋。

一.列表初始化{ }

C++11将c语言中的用来初始化结构体和数组的{ }扩大成初始化一切类型,并且可以省略赋值符号=。

int x = { 1 };
int y{ 1 };//=可省略
//int x(1);//调用int的默认构造,注意区别开来
int a[]{ 1,2 };
string s{ "hello" };//自定义类型,本质上是构造+拷贝构造,可能优化为直接构造
int* p = new int[2] {3, 4};//支持初始化new出来的数组
vector<int>v{ 1,2,3 };//initializer_list初始化
vector<string>vs{ {"a"},{"b"} };//嵌套使用

initializer_list

0

initializer_list是C++11提出的一种类型,{ }括起来的编译器会自动识别为该类型。而vector增添了参数为initializer_list的构造函数,所以才支持{ }初始化。实际上 ,所有容器(当然不包括适配器)都增添了这种构造。另外,赋值方法也都支持{ }赋值。列表初始化在初始化时,如果出现类型截断,是会报警告或者错误的。

auto il1 = { 1,2 };
auto il2 = { 1,2 };
cout << typeid(il1).name() << endl;
cout << typeid(il2).name() << endl;
cout << il1.begin() << endl;//begin返回的是il1的首元素地址
cout << il2.begin() << endl;
const int c = 1;
const int d = 1;
cout << &c << endl;//这3个地址接近,可见initializer_list存在常量区,不能修改

res
所以在vector中添加如下代码即可支持{ }初始化:

vector(initializer_list<T>il)
{
	/*typename initializer_list<T>::iterator it = il.begin();
	while (it != il.end())
	{
		push_back(*it++);
	}*/
	for (auto& x : il)
	{
		push_back(x);
	}
}

评价:使用方便

二.一些关键字

  1. auto
    自动推演类型,一直都在用来简化代码,比如代替写迭代器类型,不再多言。
  2. decltype
    取出表达式的类型。
    虽然typeid可以打出类型,但你没法写在代码里。有些场景auto也无能为力,比如:两个变量相乘之后的类型要作为vector的模板参数,就只能这么写vector<decltype(x*y)>v;
  3. nullptr
    因为c语言的NULL在C++中既可以作整形的0,又可以作空指针,所以官方推荐使用nullptr当空指针使用。
  4. default
    强制生成默认函数。比如A(A&&)=default;
  5. delete
    禁掉函数,不允许生成和使用。C++98则是通过private里声明该函数来禁掉。
  6. final&override
    注意,final修饰基类虚函数时,表示该虚函数不能被其子类重写。

三.右值引用和移动语义(重要)

左值:可以取地址的变量/表达式,可以出现在赋值符号=两端。
右值:不能取地址的,不能出现在=左边,即不能赋值。右值分为纯右值(内置类型的右值)和将亡值(自定义类型的右值)。包括字面常量,表达式返回值,函数传值返回值等。

int a = 0, b = 1;
int& lref1 = a;//左值引用引用左值
const int& lref2 = a + b;//左值引用引用右值
int&& rref1 = a + b;//右值引用引用右值
int&& rref2 = move(a);//右值引用引用move后的左值,但不能直接引用左值

其实,左值引用功能上足够强大了,左值右值都能引用,那为什么需要右值引用呢?
首先,右值引用底层是将被引用的右值的数据资源转移给引用者。对于纯右值,右值引用和左值引用差不多,都是同样的拷贝。但是对于深拷贝的将亡值可就意义重大了!

string(const string& s) {};//拷贝构造
string(string&& s)//移动构造,针对右值,直接交换资源即可
	:_str(nullptr)
{
	std::swap(_str, s._str);
	std::swap(_size, s._size);
	std::swap(_capacity, s._capacity);
}

string func()
{
	string str("abc");
	return str;
}
int main()
{
	string s = func();
}

见上图,s在接收时,应是2个拷贝构造,可能会被编译器优化为1个拷贝构造;在右值引用出来之后,编译器会在return str时,会在str析构之前拷贝构造一个临时对象,再用s去右值引用这个临时对象(将亡值),总计1个拷贝构造+1个移动构造,可能被优化为1个移动构造(编译器会想办法将返回的str识别成右值,类似move)。所以传值返回的深拷贝问题基本解决了。
注意,函数肯定不能传引用返回的,无论左值右值,你返回的都是局部对象的别名,出作用域就销毁了,非法访问该空间是未定义行为。比如传右值引用返回,和左值引用返回类似,不会在上一层栈帧创建临时变量来保存,所以你接收的就是个被看作右值的局部对象罢了。

总结一下:左值引用直接减少拷贝:传引用传参和传引用返回(不包括函数局部对象返回)。右值引用间接减少拷贝:针对不能传引用返回的将亡值转移资源来避免深拷贝,延长了资源的生命周期,提高效率。

和initializer_list一样,STL所有容器的构造函数和赋值函数和插入接口诸如push_back、insert等都分别添加了移动构造,移动赋值和右值引用版本

完美转发

先看代码猜结果:

void Func(int& x) { cout << "左值引用" << endl; }
void Func(const int& x) { cout << "const 左值引用" << endl; }
void Func(int&& x) { cout << "右值引用" << endl; }
void Func(const int&& x) { cout << "const 右值引用" << endl; }
//万能引用(引用折叠):函数模板参数后接&&,即可接收左值,也可接收右值
template<typename T>
void PerfectForward(T&& t)
{
	Func(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右值
}

在这里插入图片描述
但是结果全是左值?这是因为右值引用的引用者在接收了右值后,肯定得开空间存储这个右值,从而变得可以取地址,引用者就成了左值 (这是合理的,如果右值引用后的引用者还是右值,那string移动构造中还要怎么对s进行swap修改?)。这里同理,函数形参在接收右值后,实际上变成了左值,再传参给Func时就和左值参数相匹配。但是这是个问题啊,我们的代码为了复用,必定会有需要右值一直传递下去且要保持它的右值属性不变的场景,为此有了完美转发只要把接收右值后的引用者套上forward< T >()即可保持右值属性传递下去
下面可以认为是C++98下list的push_back实现版本:

template<class T>
struct ListNode
{
	ListNode(const T& val = T())
		:_val(val)
	{}
	ListNode* _next = nullptr;
	ListNode* _prev = nullptr;
	T _val;
};
void push_back(T val)
{
	insert(end(), val);
}
void insert(iterator pos, T val)
{
	node* newnode = new node(val);
	newnode->_next = pos._node;
	newnode->_prev = pos._node->_prev;
	pos._node->_prev->_next = newnode;
	pos._node->_prev = newnode;
}

下面是C++11右值引用版本:通过完美转发forward< T>实现

ListNode(T&& val = T())
	:_val(std::forward<T>(val))
{}
void push_back(T&& val)
{
	insert(end(), std::forward<T>(val));
}
void insert(iterator pos, T&& val)
{
	node* newnode = new node(std::forward<T>(val));
	newnode->_next = pos._node;
	newnode->_prev = pos._node->_prev;
	pos._node->_prev->_next = newnode;
	pos._node->_prev = newnode;
}

移动构造和移动赋值重载

C++11新增了上面两个默认成员函数。但是默认生成函数的生成条件苛刻:

  • 当你没有实现移动构造,且没有实现析构、拷贝构造、拷贝赋值重载。编译器会生成默认移动构造,后者会对内置类型浅拷贝,对自定义类型调用它的移动构造(如果它没就调用拷贝构造)。
  • 当你没有实现移动赋值重载,且没有实现析构、拷贝构造、拷贝赋值重载。编译器会生成默认移动赋值,后者会对内置类型浅拷贝,对自定义类型调用它的移动赋值(如果它没就调用拷贝赋值)。
  • 如果你实现了移动构造/移动赋值,编译器不会生成默认拷贝构造和拷贝赋值。

总结,移动构造、移动赋值、析构、拷贝构造、拷贝赋值只要有一个实现了,编译器就不会生成默认移动构造/默认移动赋值。
条件也好理解,深拷贝的类肯定要实现析构、拷贝构造、拷贝赋值中至少一个。都不实现,编译器认为这个类不需要深拷贝。

vs2022关于右值对象构造与赋值的优化

已知,operator+函数(以下简称+函数)想传值返回tmp,就要在tmp析构前拷贝/移动构造一个位于main栈帧上的临时变量,tmp销毁,再用这个临时变量构造/赋值s2。如果有连续拷贝/移动构造,会被优化为一个,tmp最后再销毁。
注意,图片里的s3就当成文中的s2即可。
operator+的实现:

string operator+(char ch)
{
	string tmp(*this);
	tmp.push_back(ch);
	cout << "tmp:" << &tmp << endl;//打印tmp地址以便观察
	return tmp;
}

右值对象用来构造的场景:

lky::string s2 = (s1 + '!');//自己实现的operator+里面传值返回,传的是局部对象tmp
  1. 只写了拷贝构造的:拷贝构造+拷贝构造被优化成直接构造。底层原理上:要返回的、用来给s2拷贝用的tmp直接就是s2的引用,也就是说函数内直接对s2操作。可以打印tmp和s2的地址比较,或观察tmp的析构。
    在这里插入图片描述

  2. 写了拷贝构造+移动构造:拷贝构造+拷贝构造被优化成直接构造,同上
    在这里插入图片描述

右值对象用来赋值的场景:

lky::string s2;
s2 = (s1 + '!');//自己实现的operator+里面传值返回,传的是局部对象tmp
cout << "s3:" << &s3 << endl;
  1. 只写了拷贝构造+拷贝赋值的:拷贝构造+拷贝赋值优化成直接构造main栈帧的临时对象,函数里return的tmp就是临时对象的引用,也就是说函数内直接对临时对象操作,再用临时对象拷贝赋值给s2,赋值后临时对象作为将亡值销毁。
    在这里插入图片描述

  2. 写了拷贝构造+拷贝赋值+移动构造+移动赋值:拷贝构造+移动赋值被优化成直接构造main栈帧的临时对象,函数里return的tmp就是临时对象的引用,再用临时对象移动赋值给s2。(此处移动构造有没有不影响)
    在这里插入图片描述

特殊场景:如果把+函数的结果move一下,就成功干扰了编译器的高级优化,它不会再去搞这些引用,而是老老实实的创建局部对象tmp再返回。原因很简单,它不敢,因为第一个场景如果tmp是s2的引用,那返回的tmp被move一下再和自己移动构造一下,风险太大了。至于第二个场景,不太清楚。🥲测试发现,move和不move调用函数是没区别的,就是不清楚底层是高级优化还是低级优化。
下面是场景一2的高级优化与基本优化对比:
在这里插入图片描述
上图基本优化是指把两个拷贝构造优化为一个;高级优化是指引进引用的优化。

四.小知识

类成员变量初始化

可以在成员变量的声明处给缺省值,用于初始化列表。

委托构造(认识)
可以在构造函数里(初始化列表里和函数体里都可)调用其他构造函数。

五.可变参数模板

模板的可变参数,即模板参数包,像printf里的参数包一样,包含0到任意个模板参数。用法如下:

template<class ...Args>
void ShowSize(Args... args)
{
	cout << sizeof...(args) << endl;
}
void _Show() { cout << endl; }
template<class T, class ...Args>
void _Show(T&& val, Args&&... args)
{
	cout << val << " ";
	_Show(args...);//递归解析参数包
}
template<class ...Args>
void Show(Args&&...args)
{
	_Show(args...);
}
template<class T>
void PrintArg(T t) { cout << t << " "; }
template<class ...Args>
void show(Args&&... args)
{
	int arr[] = { (PrintArg(args),0)... };
	cout << endl;
}
int main()
{
	Show();
	Show(1, 'x', "adv");
	show(1, 'x', "adv");
}

其实,模板参数包在编译时就在参数包arg…处展开成里面的一个个数据。
可变模板参数一个重要应用就是emplace系列接口(C++11后所有容器的每一个插入接口都增添了一个emplace系列接口),下面以list的两个接口为例说明emplace和原本插入接口的效率差别
在这里插入图片描述
大多数场景()两者效率相差不大,但是当push_back发生隐式类型转换时,就有差别了。比如list存string类,同样插入一个"abc",emplace_back只会调用1构造,而push_back要调用1构造匿名对象+1移动构造。因为,前者用模板参数包接收,那么参数包一路往下层层传,直到最后插入时才直接构造string;而后者是单参数类型函数,参数会发生隐式类型转换,所以"abc"在第一层就被构造成匿名string,作为右值一路下传,最后插入时调移动构造。相当于后者只多了一个移动构造😂。
总结:emplace系列大多场景和原本插入接口效率没区别,少数场景优于后者(但移动构造的效率极高,所以差别依然不大),推荐使用emplace系列。
emplace还有一个重要应用,在C++线程创建时用可变模板参数接收函数参数,这样线程传参更加灵活方便。

六.lambda表达式(又称匿名函数)(重要)

格式形如:[capture-list] (parameters)mutable -> return-type { statement }

  • [capture-list] :捕捉列表,不可省略。用来捕捉父作用域,即包含它的作用域的变量。
  • (parameters):参数列表,可省略,但不建议。
  • mutable:翻译为可变的,用于取消参数默认的const属性,可省略。使用时不可省略参数列表。
  • -> return-type:返回值类型,可省略。编译器会自动推导。
  • { statement }:函数体,不可省略。

关于捕捉列表,用法如下:

int a = 0, b = 1, c = 2;
auto f = [&]() {return a + b + c; };//全部传引用捕捉
auto f = [=]() {return a + b + c; };//全部传值捕捉,注意修改时要用mutable
auto f = [&, a]() {return a; };//a传值捕捉,其他传引用捕捉
auto f = [=, &a]() {return a; };//a传引用捕捉,其他传值捕捉

lambda的底层是用仿函数实现的:
lambda底层
可见调用lambda对象时是在调用一个lambda_id的对象的仿函数,其中id是标识lambda对象唯一性的数字,也可以了解一下uuid,全称universally unique identifier,是一种唯一标识符。所以lambda对象之间不能互相赋值,因为对象名字都不一样。
lambda的捕捉列表本质在生成lambda对象的成员变量,其他则是为对象内的仿函数所使用。

七.多线程相关

thread库

linux和windows下的线程相关接口不同,C++11为了支持跨平台编译,整出了线程库。
thread
这里thread的构造使用了可变模板参数,所以线程传参自由灵活。拷贝构造被禁掉了,线程拷贝不如创建一个。
但是,为什么会有无参构造?下面展示一种用法

#include<thread>
//m个线程打印0~n,展示无参构造的一种用法
int main()
{
	int m; cin >> m;
	vector<thread>vtd(m);
	for (int i = 0; i < m; i++)
	{
		int n; cin >> n;
		vtd[i] = thread([&]() {//移动赋值
			for (int j = 0; j < n; j++)
			{
				cout << i << ":" << j << endl;
			}
			});
	}
	for (auto&td: vtd)//必须带引用,因为不支持拷贝构造
	{
		td.join();
	}
}

其他接口与posix的那一套接口类似。想要获取线程id时,既可以用thread的get_id接口,也可以用命名空间this_thread里的get_id接口(谁在执行代码,谁打印它的id)。

mutex库

  • 库中mutex类的拷贝构造自然被封禁,常用接口lock与unlock与posix的一致。(try_lock的until和for都是与尝试时间相关,用法简单,自行了解即可,以下不再提until/for类接口)
  • 如果函数内涉及递归,应当使用recursive_mutex,不会有死锁,且接口类似。
  • lock_guard采用RAII(在下一篇博客,智能指针处详细介绍)设计风格,在代码块内自动加锁解锁,可以防止诸如抛异常直接跳转至catch部分导致忘记解锁的问题。模拟实现如下
template<class Lock>
class LockGuard
{
public:
	LockGuard(Lock& mtx) :_mtx(mtx) { _mtx.lock(); cout << "Locked" << endl; }
	~LockGuard() { _mtx.unlock(); cout << "UnLocked" << endl; }
private:		//指针接收也可
	Lock& _mtx;	//必须引用接收,库里mutex禁掉拷贝构造
};
int main()
{
	mutex mtx;
	LockGuard<mutex>lg(mtx);
}

类似还有unique_guard,它提供lock/unlock接口可以手动加锁解锁,如果代码块内有相关需求可以使用。

atomic库

如果你嫌加锁解锁太过繁琐,可以了解一下无锁编程,它的其中一种实现方式就是CAS(compare and swap),即对于一个变量,要写入它之前,先比较寄存器里存的旧值和读取的新值是否相等,相等就直接写入,否则说明有别的线程改动了它,那就更新寄存器里的值再操作即可。而且,无论是读取还是写入它,CAS都能保证操作的原子性(atomic),即每个操作都是一句汇编/cpu指令。所以,CAS是一种原子操作。

关于原子操作的个人理解:原子操作就是底层只有一个cpu指令的操作,这样就算线程时间片到了被切走,它要么是操作完了,要么是根本没操作,不存在中间状态,这样避免了最复杂的情况,也就是操作一半被切走的情况。而搭配其他机制诸如CAS等即可基本解决线程安全问题。
关于原子操作的linux接口直接搜难找,可以在源码中<linux/atomic.h>找到。

C++11的atomic类底层实现机制主要是CAS操作,所以使用atomic类无需加锁。基本用法如下:

atomic<int>i{1};//带参构造,还有无参构造(调对应默认构造)
i++;//对被封装的变量原子性++
i.fetch_add(100);//对被封装的变量原子性+
cout << i.load() << endl;//load取出被封装的变量

其余的接口使用见官方文档示例。

condition_variable库

  • condition_variable也是默认无参构造,拷贝构造禁掉。
  • wait和notify_one接口与posix含义一致。wait需要传unique_lock,因为进入wait后,线程要unlock,被notify后,wait会自动帮线程抢锁并lock。需要注意,可能伪唤醒或条件不再满足,所以应使用while套wait,或在wait的第二个参数传可调用对象来判断条件,为真才唤醒,和前者等价。而notify_one也是随即唤醒一个wait的线程,如果没有,就啥也不做,继续向下执行。
    wait
    notify-one
    下面演示一个条件变量控制2线程交替打印奇偶数:
#include<thread>
#include<mutex>
#include<condition_variable>
//双线程交替打印1~100
int main()
{
	int x = 1;
	mutex mtx;
	condition_variable cv;
	thread t1([&]() {
		while (x < 100)
		{
			unique_lock<mutex> ul(mtx);
			while (x % 2 == 0)
				cv.wait(ul);
			cout << "1:" << x << endl;
			++x;
			cv.notify_one();
		}
		});
	thread t2([&]() {
		while (x <= 100)
		{
			unique_lock<mutex> ul(mtx);
			while (x % 2 != 0)
				cv.wait(ul);//就算t2先抢到锁,也会因条件不满足而wait放手
			cout << "2:" << x << endl;
			++x;
			cv.notify_one();
		}
		});
	t1.join();
	t2.join();
}

八.包装器

function包装器

也叫适配器,就是一个类,可以像函数一样被调用。包装器的初始化就是实例化出一份类来封装可调用对象,包装器的调用也是仿函数,仿函数内再去调用包装的可调用对象。相当于封装了一层,抹除了类型差异,在调用时看起来是一样的。
C++中可调用对象(能像函数一样调用的对象)包括函数指针、仿函数和lambda表达式,但是它们类型不同,想统一使用或者传递时指明类型就很不方便,比如传参时想不用模板来接受一个lambda对象该怎么接收,都写不出来它的类型?为此有了function包装器来统一表示可调用对象的类型,方便统一管理与使用。包装器定义形如function<int(float,double)>func=funcAint是返回值,float和double是参数类型,func就是这个包装器的名字,funcA是可调用对象。
用法如下:

#include<functional>
struct A
{
	void operator()(int, long) { cout << "()" << endl; }
	void func(int, long) { cout << "func" << endl; }
};
void f(int, long) { cout << "f" << endl; }
int main()
{
	A a;
	function<void(int, long)>f1 = f;
	function<void(int, long)>f2 = A();
	function<void(int, long)>f3 = [](int, long) {};
	function<void(A, int, long)>f4 = &A::func;//指明通过对象调用func
	function<void(A*, int, long)>f5 = &A::func;//指明通过指针调用func
	function<void(A&&, int, long)>f6 = &A::func;//指明通过右值对象调用func
	f4(a, 1, 1);
	f4(A(), 1, 1);//指明对象调用,匿名对象也可调用func
	f5(&a, 1, 1);
	//f5(A(), 1, 1);//编译不通过,因为右值没法&,就没有指针
	f6(A(), 1, 1);
	f6(move(a), 1, 1);
}

bind

一个函数模板,通过接收可调用对象来改变它的传参模式。用途有改变参数顺序,改变参数个数等,如下:

#include<functional>
void Print(int a, int b) { cout << a << " " << b << endl; }
struct A
{
	void func(int a, int b) { cout << a << " " << b << endl; }
};
int main()
{
	Print(1, 5);
	auto RPrint = bind(Print, placeholders::_2, placeholders::_1);//后两个是占位符,placeholders是一个命名空间
	//function<void(int,int)> fPrint = bind(Print, placeholders::_2, placeholders::_1);//后两个是占位符,placeholders是一个命名空间
	//bind(Print, placeholders::_2, placeholders::_1)(1, 5);
	RPrint(1, 5);
	//fPrint(1, 5);
	bind(&A::func, A(), placeholders::_2, placeholders::_1)(1, 5);//第一个参数绑定成匿名对象,后两个参数顺序互换,注意剩下参数依然从_1开始
}

其实,bind返回的也是一个类对象,后跟()就是调用仿函数,仿函数里调用原来的函数。改变参数顺序就是把你传给它的第参数按占位符传给原来函数的对应位置参数。记住用法即可。

C++11完结,天呐,手打了快1万字,代码块算是stl篇里最少的一次了,终于结束了,眼都麻了现在。就差一个智能指针和异常C++就彻底over了。同志仍需努力啊🥲


网站公告

今日签到

点亮在社区的每一天
去签到