目录
1. {} 初始化
在C++98中,标准允许使用花括号 {} 对数组或者结构体元素进行统一的列表初始值设定;
而在C++11中,C++11扩大了用大括号括起的列表的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用 {} 初始化时,可添加等号(=),也可不添加。例如下面的代码
1.1. 内置类型
void Test1(void)
{
int x = 1;
// 建议: 对于内置类型来讲,我们需要看懂下面两种的初始化方式,但不建议使用
// 注意: 这个 ‘=’ 可以不添加
int y = {2};
int z{3};
}
1.2. 自定义类型
列表初始化对于内置类型来说,用的相对较少;但是对于自定义类型,特别是 STL 的容器,还是很有意义的。例如:
void Test2(void)
{
std::vector<int> v1;
std::vector<int> v2 = {1,2,3};
std::vector<int> v3{10,20,30};
}
1.3. std::initializer_list
那么问题来了,那上面的代码是如何被支持的呢?
我们需要引出一个新类型:initializer_list<T>,如下:
编译器会把上面代码的 {1,2,3} 当成一个 initializer_list<int> 的匿名对象,而在C++11中,标准库的容器都会支持一个这样的构造函数,我们在这里看一下vector的initializer list构造函数,用于说明:
也就是说,上面的代码之所以可以编译成功,是因为 STL 中的 vector 实现了 initializer_list 这个构造函数,通过 initializer_list 的匿名对象构造 vector对象。
std::initializer_list 一般是作为构造函数的参数,C++11对 STL 中的不少容器都增加了std::initializer_list 作为参数的构造函数,这样初始化容器对象就更方便了,比如 list、set、map等等,如下:
list:
set:
map:
同时,std::initializer_list 也可以作为 operator= 的参数,这样就可以用大括号赋值,例如:
void Test3(void)
{
std::map<std::string, std::string> my_map{ { "effective", "有效的" }, { "career", "事业" }, { "success", "成功" } };
for (auto& e : my_map)
{
std::cout << e.first << " - " << e.second << std::endl;
}
std::initializer_list<std::pair<const std::string, std::string>> tmp{ {"exist","存在"} };
//标准库里的operator=也提供了 initializer_list版本
my_map = tmp; // map& operator= (initializer_list<value_type> il);
for (auto& e : my_map)
{
std::cout << e.first << " - " << e.second << std::endl;
}
}
总结:C++11以后一切对象都可以用列表初始化,但我们建议内置类型还是以前的方式初始化,自定义类型 (比如STL的容器) 如果有需求可以用列表初始化。
2. 变量类型推导
2.1. auto
在 C++11 中,auto 是一个关键字,用于自动推导变量的类型。通过使用 auto 关键字,可以让编译器根据变量的初始化表达式自动推导出变量的类型,从而简化代码编写,尤其是在使用模板和复杂类型时特别有用。使用 auto 可以减少代码中显式指定类型的繁琐和重复,提高代码的可读性和维护性。需要注意的是,auto 推导的类型一般都是“值类型”,即去掉引用、const 和 volatile 等修饰符后的类型。
一般使用 auto 的两个场景:
- 范围for;
- 类型特别长,比如 STL 容器的迭代器。
如下:
void Test4(void)
{
std::unordered_map<std::string, std::string> my_map{ { "Cookie", "网络饼干" }, { "Route", "路由" }, { "protocol", "协议" } };
// scene one: 范围for
for (const auto& it : my_map)
{
std::cout << it.first << ":" << it.second << std::endl;
}
// sence two: 类型特别长,比如 STL 容器的迭代器
// 太繁琐了
//std::unordered_map<std::string, std::string>::iterator pos = my_map.begin();
// 这样就舒服多了
auto pos = my_map.begin();
}
需要注意的是:
auto 关键字在 C++11 中的作用是在编译阶段进行类型推导,而不是在运行时。
编译器根据变量的初始化表达式来推导变量的类型,并在编译阶段将其替换为相应的类型,然后生成目标代码。因此,auto 关键字的使用不会增加运行时的开销,而是在编译时确定变量的类型。
2.2. decltype
在C++11中:
- decltype:用于获取表达式的类型,包括变量、函数返回值、表达式等,是在编译阶段确定类型的,通常用于类型声明、模板中的类型推导等场景;
- typeid(变量).name:用于获取变量的类型信息的字符串表示,是在运行时获取的,通常用于调试和类型比较等场景。
void Test5(void)
{
int x = 10;
decltype(x) y = 5; // 将y的类型指明为 decltype(x)表达式的类型,即x的类型
std::cout << typeid(x).name() << std::endl; // int
std::cout << typeid(y).name() << std::endl; // int
}
可能会遇到的场景如下:
template<class T1, class T2>
void F(T1 x, T2 y)
{
// 此时x和y的类型不确定,我们可以(x*y)的结果去推出ret的类型
decltype(x * y) ret;
cout << typeid(ret).name() << endl;
}
void Test6(void)
{
int x = 10;
double y = 1.1;
F(x, y);
}
2.3. nullptr
由于C++中 NULL 被定义成0,这样就可能回带来一些问题,因为0既能表示指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
源码如下:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
3. C++11新增加的容器
C++11增加的新容器,亮点就是 unordered_set 和 unordered_map ,但是array 和 forward_list 很鸡肋。
3.1. std::array
可以看到,std::array 是一个大小为N的静态数组。那么C++11增加array的初衷是什么呢?
其中最主要的点就是,c数组和 std::array 对越界行为的判定。
void Test7(void)
{
int arr[5] = { 1, 2, 3, 4, 5 };
// C数组对越界读是很难检查出来的
std::cout << arr[-1] << " - " << arr[6] << std::endl; // 非法读操作,检查不出来
// C数组对越界写的检查是有限的,或者说是一种抽查检查
//++arr[6]; // 在数组的边界可以检查出来
++arr[20]; // 非法写操作,无法检查出来
// 但是对于 std::array来说,不论你是越界读亦或者是越界写都可以检查出来
std::array<int, 5> std_arr;
std::cout << std_arr[6] << std::endl;
++std_arr[20];
// 因为std::array的读写操作(operator[])都会强制检查 pos < size();
}
因此C++11 增加array的初衷就是:希望杜绝或者减少使用C数组。
- C数组对越界的判定:是一种抽查检查,越界读检查不出来,越界写可能会查出来(一种边界/抽查检查);
- std::array 对越界的判定:operator[] 会强制检查,pos < size(),对越界读和越界写都会检查出来。
从这个角度来讲,std::array 还是有一定的价值的,但为什么 std::array 用的不多呢?
- 一方面,大家用C数组用习惯了;
- 另一方面,如果使用者担心越界,那么用 std::array 还不如用 std::vector;
- 其次,std::array 是一个静态数组,具有一定的局限性。
3.2. std::forward_list
forward_list是一个单链表,和 list 没有太大差别。
在这里说一个差别:
forward_list的insert和erase都是在当前位置的下一个位置进行操作:
- insert 会在当前迭代器的后面插入 ;
- erase 它不是删除当前位置的迭代器,而是删除下一个位置的迭代器。因为如果要删除当前位置的迭代器,那么时间复杂度就是O(N) (需要找前一个位置的迭代器)。
3.3. 容器内部的变化
- 都支持 initializer_list 构造,用来支持列表初始化;
- 比较鸡肋的接口。比如cbegin、cend系列;
- 移动构造和移动赋值,可以在某些地方提高效率(对于需要深拷贝的类意义很大);
- 右值引用参数的插入,其作用也是提高效率。例如,insert系列,emplace系列。
4. 左值引用和右值引用
传统的C++语法中就有引用的语法,而 C++11 中新增了的右值引用语法特性,之前学习的引用就叫做左值引用,但无论左值引用还是右值引用,都是给对象取别名。
在 C++ 中,左值表达式表示一个标识符或者一个具有持久性的对象,而右值表达式表示一个临时对象或者一个表达式的结果,它没有持久性。
- 左值引用是指向左值类型的引用,通过使用 & 来声明。它允许我们修改引用所绑定的对象的值,并且可以将这个引用用作函数的参数和返回值。
- 右值引用是指向右值类型的引用,通过使用 && 来声明。它主要用于实现移动语义和完美转发,可以在不发生额外内存分配的情况下将资源从一个对象转移到另一个对象。
4.1. 左值和右值
4.1.1. 什么是左值,什么是右值
简单的理解:
- 可以被取地址的对象,都被称之为左值;
- 反之,不能被取地址的对象,称之为右值。
在C++中,左值(L-value)是可以被标识符引用的表达式或对象,它具有持久性并且可以被修改。通常,变量、对象、数组元素和函数返回的左值引用都被认为是左值。
左值可以取地址并且可以对它赋值。
左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。
特例:定义时const修饰符后的左值,不能给它赋值,但是可以取它的地址。
void Test1(void)
{
int x = 10; // x是一个左值
int arr[5]; // arr是一个左值
int& ref = x; // ref是一个左值引用
}
右值(R-value)是临时的表达式或对象,它在表达式求值后就会消失。通常,字面常量、临时对象和表达式的结果都被认为是右值。
右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边(右值不可修改)
右值不能取地址。
右值引用通常用于处理右值。右值引用具有特殊的语法形式(使用 &&),可以扩展右值的生命周期,并且可以支持移动语义和完美转发等高级功能。
void Test2(void)
{
10; // 字面常量是一个右值
int&& ref = 10; // ref是一个右值引用
int x = 10, y = 20;
(x + y); // (x+y)表达式的结果会生成一个临时对象,这个临时对象就是一个右值
}
4.1.2. 左值可以引用右值吗?
普通情况下不可以,但是 const 左值引用可以引用右值。
const 左值引用既可以引用左值,也可以引用右值。
template<class T>
void func1(T x) {}
// 对于func1()来说,由于此时是值传递,不管你的形参是左值亦或者是右值
// 我都可以接收,因为此时是拷贝,不受影响
template<class T>
void func2(T& x) {}
// 而对于func2()来说,此时是左值引用传参,那么此时我只能引用左值,无法引用右值(权限被放大)
template<class T>
void func3(const T& x) {}
// 因此,我们以前就提过,对于左值引用传参,最好加上const,即可以引用左值,亦可以引用右值
void Test3(void)
{
int& x = 10; // 默认情况下,左值是不可以引用右值的,我们理解为权限被放大了
const int&x = 10; // const的左值引用是可以引用右值的
}
4.1.3. 右值引用可以引用左值吗?
默认情况下,右值引用不可以引用左值,但是可以引用std::move(左值)。
void Test4(void)
{
int x = 10;
int&& R_ref1 = x; //编译报错,默认情况下,右值引用不可以引用左值
int&& R_ref2 = std::move(x); // 但是右值引用可以引用std::move(左值)
}
4.1.4. 总结
- 对于左值引用来说:
- 左值引用可以引用左值,默认情况下不可以引用右值,但是const左值引用既可以引用左值,也可以引用右值。
- 对于右值引用来说:
- 右值引用可以引用右值,默认情况下不可以引用左值,但是右值引用可以引用std::move(左值)。
需要注意的是:
默认情况下,右值是不可以取地址的,但是给右值取别名后,会导致别名被存储到特定位置,且可以取到这个别名的地址。例如:
void Test5(void)
{
//&(10); // 编译报错,默认情况下,不可以对右值取地址
int&& R_ref = 10; // 对右值取别名
std::cout << &R_ref << std::endl; // 对右值取别名后,我们发现即可以取R_ref的地址
std::cout << ++R_ref << std::endl; // 同时可以修改R_ref的值
// 当然,如果不想R_ref被修改,可以用const int&& R_ref去引用
// const int&& R_ref = 10;
// 可以这样理解,右值的别名是一个左值
}
4.2. 左值引用
上面的问题了解了之后,我们回到开始,在C++98中,标准是不区分左值引用和右值引用的,那为什么在C++11中还要右值引用呢?
首先,左值引用有什么用途呢?或者说左值引用解决了哪些问题呢?
左值引用的用途:
- 其一:做函数参数。a. 减少拷贝,提高效率;b. 可以做输出型参数;
- 其二:做返回值。a. 减少拷贝,提高效率;b. 引用返回,可以修改返回对象。
这样看来,左值引用已经解决了很多问题,但实际上,对于第二种用途,左值引用在某些情况下,不能起到很好的作用。例如:std::to_string,如下:
string to_string(int num)
{
// 具体实现省略
// 可以看到tmp是一个局部对象,出了函数作用域
// 就会被销毁,因此不可以用传引用返回,故在此传值返回
// 而传值返回(拷贝)代价是很大的
string tmp;
return tmp;
}
而C++11的右值引用就是为了解决类似上面的场景,而为了更好的理解,我们在这里写了一个简陋版本的string,为了更好地说明问题;
namespace Xq
{
class string
{
public:
string(const char* str = "")
{
_size = strlen(str);
_str = new char[_size + 1];
_capacity = _size;
strcpy(_str, str);
std::cout << "string(const char* str = "")" << std::endl;
}
string(const string& copy)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(copy._str);
swap(tmp);
std::cout << "string(const string& str)" << std::endl;
}
void swap(string& tmp)
{
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
string& operator=(const string& str)
{
string tmp(str._str);
swap(tmp);
std::cout << "string operator=(const string& str)" << std::endl;
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
_capacity = _size = 0;
std::cout << "~string()" << std::endl;
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
string to_string(int num)
{
// 具体实现省略
string tmp;
return tmp;
}
}
第一个问题:请看如下代码
这段代码,会发生什么呢?
是一次构造 + 两次拷贝构造
还是 一次构造 + 一次拷贝构造呢?
void Test6(void)
{
Xq::string ret = Xq::to_string(1234);
}
现象如下:
结果很明确,一次构造 + 一次拷贝构造,为什么呢?
解释一下,由于拷贝构造中显式调用了一次构造,故现象是两次构造 + 一次拷贝构造,但事实上,还是调用了一次构造 + 一次拷贝构造。
我们之前说过,传值返回会拷贝构造一个临时对象,这个临时对象具有常性 (也就是说,是一个右值),再由临时对象拷贝构造 ret 这个对象,但是编译器认为中间的这个临时对象意义不大,于是编译器进行了优化,将这个对象进行了省略,而是将 tmp 直接拷贝构造 ret 这个对象 ,一般情况下:
- 如果 tmp 比较小 (4 or 8字节),那么 tmp 会存于寄存器中;
- 如果 tmp 比较大,那么tmp会在上一级函数栈帧中。
而我们也可以去掉这种编译器的优化,在这里用 g++ 演示:
-fno-elide-constructors 编译的时候带上该选项,可以去掉编译器的这种优化,如下:
结果符合预期,当去掉编译器的优化时,此时就是两次拷贝构造。
有可能我们会认为这个临时对象是没有价值的,那能不能省略呢?
答案是,不可以。因为会有下面的场景:
void Test6(void)
{
Xq::string ret;
ret = Xq::to_string(1234);
}
因为编译器的优化只会发生在连续的一个式子中,而上面的式子是不会发生优化的,因此必须要这个临时变量返回给to_string,在调用operator=给ret。
而 C++11 的右值引用是如何解决上面的问题的呢?
首先,C++11的右值引用并不是直接起作用,而是借助移动构造和移动赋值来达到目的的。
而C++11中,又将右值分为两种:
- 内置类型的右值 --- 称之为纯右值;
- 自定义类型右值 --- 称之为将亡值,例如上面to_string的临时对象。
那么,移动构造和移动赋值如何实现呢?
4.3. 移动构造
// str是一个右值
string(string&& str)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
// 将str这个将亡值的资源转移给*this
// 移动构造体现的是资源的转移
swap(str);
std::cout << "string(string&& str)" << std::endl;
}
void Test6(void)
{
// xq::to_string(1234)返回的临时对象是一个右值
// 此时会调用移动构造,而不是拷贝构造
Xq::string ret1 = Xq::to_string(1234);
}
现象如下:
可以看到,移动构造减少了拷贝构造,间接提高了效率。
为了更好地理解移动构造和拷贝构造的区别:
- 移动构造是转移将亡值的资源;
- 拷贝构造是重新开辟一块空间,构造一个新的对象。
void Test7(void)
{
Xq::string str1 = "haha";
// 调拷贝构造
Xq::string str2(str1);
// move(str1) 是一个右值,调用移动构造
Xq::string str3(std::move(str1));
}
4.4. 移动赋值
string& operator=(string&& copy)
{
// 在这里不仅将copy这个将亡值的资源转移给了*this
// 同时copy还会去调用析构释放*this原有的资源
swap(copy);
std::cout << "string& operator=(string&& copy)" << std::endl;
return *this;
}
void Test8(void)
{
// 在没有实现移动构造和移动赋值的情况下:
// 一次构造
Xq::string str1;
// 三次构造(拷贝构造和赋值以及to_string各自调了一次构造) + 一次拷贝构造 + 一次赋值
str1 = Xq::to_string(1234);
std::cout << "----------------------------------------" << std::endl;
// 当实现移动构造和移动赋值的情况下:
// 一次构造
Xq::string str2;
// 移动构造 + 移动赋值 + 一次构造(to_string里面的构造)
str2 = Xq::to_string(1234);
}
现象如下:
分析如下:
移动赋值也是对资源的转移,从而达到减少拷贝的目的。
在C++11中,标准库的 vector 和 list 的成员函数 push_back 提供了新的版本,不仅如此,标准库的容器只要是插入都会增加右值引用的版本。
插入过程中,如果传递对象是一个右值,那么就会调移动语句,进行资源转移,减少拷贝。
vector:
list:
测试 demo 如下:
void Test9(void)
{
std::vector<Xq::string> v;
Xq::string str1("hehe");
// 左值,调用void push_back(const value_type& val);
// 调用拷贝构造
v.push_back(str1);
std::cout << "-----------------------------" << std::endl;
// 匿名对象 右值,调用void push_back(value_type&& val);
// 调用移动构造
v.push_back(Xq::string("haha"));
}
现象如下:
void Test10(void)
{
std::list<Xq::string> lt;
Xq::string str1("hehe");
// 左值,调用void push_back(const value_type& val);
// 调用拷贝构造
lt.push_back(str1);
std::cout << "-----------------------------" << std::endl;
// 匿名对象 右值,调用void push_back(value_type&& val);
// 调用移动构造
lt.push_back(Xq::string("haha"));
}
现象如下:
4.5. 完美转发
对于右值引用来说,如果你是确定的类型,那么你就是右值引用,但如果你是模板,那么我们称之为 "万能引用",也称之为 "引用折叠 "。
万能引用:既能引用左值,也能引用右值。
例如:
void test_func(int&& ref)
{
// 首先,这里是一个右值引用,默认情况下只能引用右值,或者引用std::move(左值)
// 但在这里,ref是一个左值,为什么?
// 我们之前说过,右值的引用是一个左值,因为它可以取地址,即是一个左值
std::cout << "我是右值引用" << std::endl;
}
template<class T>
void test_func(T&& ref)
{
// 如果增加了模板,这里不是一个右值引用,称之为"万能引用"或者"引用折叠"
// 什么意思呢? 就是说,此时的ref 既可以引用左值,也可以引用右值
// 也就是说,万能引用提供了能够同时接收左值引用和右值引用的能力
// 但是,此时ref就是一个左值了
std::cout << "我是万能引用" << std::endl;
}
void func(int& ref){ std::cout << "左值引用" << std::endl; }
void func(const int& ref){ std::cout << "const 左值引用" << std::endl; }
void func(int&& ref){ std::cout << "右值引用" << std::endl; }
void func(const int&& ref){ std::cout << "const 右值引用" << std::endl; }
template<class T>
void perfect_forwarding(T&& t)
{
func(t);
}
void Test11(void)
{
int i = 10;
const int j = 20;
perfect_forwarding(i); // 左值引用
perfect_forwarding(j); // const 左值引用
perfect_forwarding(std::move(i)); // 右值引用
perfect_forwarding(std::move(j)); // const 右值引用
}
现象如下:
可以发现,不管你是左值还是右值,最后调用的接口都是左值的引用,只不过,最后会根据是否具有 const 属性去调用对应的函数。
那么我们可以这样认为,万能引用虽然提供了能够同时接收左值引用和右值引用的能力,但是此时的 ref 却无条件的成为了左值,即后续的使用都会成为左值, 但是如果我们希望能够在传递过程中保持它的左值或者右值的属性,该如何做呢?
std::forward 完美转发在传参的过程中保留对象原生类型属性
如果要保持对象的原有属性,需要完美转发,完美转发会保持原对象的属性,例如:
template<class T>
void perfect_forwarding(T&& t)
{
// 此时我想保持t的原有属性,因此需要完美转发
// 语法: std::forward<T>(要保持的对象)
func(std::forward<T>(t));
}
结果如下:
总结:
模板的万能引用提供了能够接收同时接收左值引用和右值引用的能力,但是这种能力也会带来一些 "副作用",它会让该函数接收后的类型都成为左值。
完美转发(perfect forwarding)是使用万能引用来传递参数的过程,主要目的是提供一种机制,使得函数模板能够将参数传递给其他函数,同时保持参数的原始特征。