- Talk C++
作为一名长期 C With Class 的程序员,很惭愧没有深入学习过 C++ 的特性,几乎是会涉及到什么特性才会去看某个特性。
为了便于本人回顾翻阅,本文不定期更新,记录 C++ 中或常见,或生涩的特性。
C++ 11/14 关于编译时行为的一些强化在本章展示,本节源于 现代 C++ 教程 的第 2 章,但在此基础上做了很多补充。
C++ 的数组似乎是一个简单的问题,毕竟在 C 语言中就存在,一直沿用至今。这是一个数组定义的 Demo:
int main() {
int nums[10];
return 0;
}
上述 Demo 定义了一个数组,数组有 10 个元素。定义数组时,我们通常要求数组的长度在编译期已知,即是一个常量表达式。在 ISO C++ 中要求:
ISO C++ 不允许变长数组。
下面是一个不合法的定义:
int main() {
int size = 10;
// size 是变量,编译期不可知,所以是错误的
int nums[size];
return 0;
}
有意思的问题来了:用 g++ 编译上面的代码是没有问题的
。
这在 Stackoverflow 中也有相关讨论:C++ declare an array based on a non-constant variable?。
这其实是由编译器进行的非标准 C++ 扩展,但若我们在 g++ 编译时,强制使用 ISO C++ 标准,编译器仍然是会报错的:
g++ main.cc -pedantic-errors -std=c++1y
main.cc: 在函数‘int main()’中:
main.cc:4:16: 错误:ISO C++ 不允许变长数组‘nums’ [-Wvla]
int nums[size];
这个扩展其实来自于对 C99 的变长数组(Variable Length Array - VLA)支持。关于变长数组的更多信息可以参考:C 语言中的变长数组与零长数组 和 6.20 Arrays of Variable Length。
constexpr
是 C++ 11 引入的关键字,可以用于修饰变量和函数,指出这是一个常量表达式。
我们先看一下什么是常量表达式,这在 C++ Primer 中如此提到:
常量表达式是指值不会改变,并且编译过程就能得到计算结果的表达式。
字面量都属于常量表达式,对于非字面量,一个常量表达式由数据类型和初始值共同决定:
-
数据类型是只读类型,即
const
(也可以是经由constexpr
修饰的类型),例如:const int a = 0; constexpr int b = 1;
-
初始值必须是常量表达式,例如:
const int b = 5; // b 是常量表达式,字面量是常量表达式。 const int c = b; // c 是常量表达式,因为初始值 b 是个常量表达式 int n = 10; const int a = n; // a 不是常量表达式,因为初始值不是常量表达式
很明显,判断常量表达式是比较麻烦,从数据类型修饰上我们并不能判断是否为常量表达式。那我们如何保证一个变量是常量表达式呢?constexpr 就该出场了:
- constexpr 修饰的变量,编译器会在编译期判断是否为常量表达式,如果不是常量表达式,就不能编译通过。
通过 constexpr
修饰变量,可以由编译器保证这是常量表达式,这是一个错误的 Demo:
int main() {
int n = 10;
constexpr int a = n;
return 0;
}
编译后会有错误提示:
$ g++ test.cc
test.cc: In function ‘int main()’:
test.cc:3:21: error: the value of ‘n’ is not usable in a constant expression
constexpr int a = n;
^
test.cc:2:7: note: ‘int n’ is not const
int n = 10;
上面是 constexpr 对变量的修饰,那么当 constexpr 对函数修饰时,是什么含义呢?
C++ Primer 指出:
constexpr 函数是指能用于常量表达式的函数。
constexpr function 返回值也是常量表达式,因此 constexpr function 可以在编译期间就能计算出函数结果,而不用在运行时计算。
不过需要指出的是,C++ 11 和 C++ 14 对 constexpr function 的约束是非常不同的。
先看 C++ 11 对 constexpr function 的约束:
- 函数的返回类型,所有参数类型,都必须是常量表达式。
- 函数体必须只有一条 return 语句。
因此,在 C++ 11 中,这样定义 constexpr function 是有效的:
constexpr int fun(int n) {
return n;
}
int main() {
constexpr int a = fun(10);
return 0;
}
但是下面的搞法在 C++ 11 中都是无效的:
constexpr int fun(int n) {
if (n < 0) {
return 0;
}
return n;
}
int main() {
constexpr int a = fun(10);
return 0;
}
可以看到编译错误:
# 需要指定 C++ 11 编译
$ g++ test.cc -std=c++11
test.cc: In function ‘constexpr int fun(int)’:
test.cc:6:1: error: body of ‘constexpr’ function ‘constexpr int fun(int)’ not a return-statement
}
^
test.cc: In function ‘int main()’:
test.cc:9:24: error: ‘constexpr int fun(int)’ called in a constant expression
constexpr int a = fun(10);
但是 C++ 14 中 constexpr function 的条件更加宽松,并且是否真的作为编译期运行也将视其使用环境而定。
C++ 14 中 constexpr function 中可以编写复杂的逻辑语句,包括条件判断,函数调用,循环等等。并当 constexpr function 不满足编译期运行条件时,会在运行时执行。
例如这是一个 constexpr function 在编译期间运行的例子:
constexpr int fun(int n) {
for (int i = 0; i < 1000; ++i) {
for (int j = 0; j < 100; ++j) {
for (int k = 0; k < 50; ++k) {}
}
}
return n;
}
int main() {
const int n = 10;
int a = fun(n);
return 0;
}
当执行 g++ test.cc -std=c++14
时,可以明显感受到编译变慢,这是因为 constexpr function 满足编译期间执行的条件,而它在其中进行了延时。
但若如下编码:
constexpr int fun(int n) {
for (int i = 0; i < 1000; ++i) {
for (int j = 0; j < 100; ++j) {
for (int k = 0; k < 50; ++k) {}
}
}
return n;
}
int main() {
int n = 10;
int a = fun(n);
// constexpr int b = fun(n); // 错误,因为 fun(n) 返回的已经不再是常量表达式了。
return 0;
}
执行 g++ test.cc -std=c++14
并不会感受到编译变慢,而是在运行时感受到变慢,这是因为 constexpr function 不满足编译时运行的条件,因此在编译期间不运行。当这种情况时,constexpr function 返回的已不再是 constexpr 了,因此若使用 constexpr int b = fun(n)
会编译错误。
更多 C++ 11/14 在 constexpr 相关的限制,可以参考 constexpr。
在 C++ 17 开始,可以为 if 语句使用 constexpr,以提前计算出常量表达式会使用的分支,具体请看:if statement。
Constexpr If 要求 if 的 condition express 是一个常量表达式:
#include <iostream>
int main() {
constexpr int a = 10;
if constexpr (a) {
std::cout << "if" << std::endl;
} else {
std::cout << "else" << std::endl;
}
return 0;
}
要求使用 C++ 17 进行编译:g++ test.cc -std=c++17
。
我们在 for 循环语句中非常容易定义一个仅在 for 语句块内使用的变量:
for (int i = 0; i < 10; ++i) {}
但是在以前,并不能定义仅 if/switch 语句块内使用的变量。
在 C++ 17 对仅用作 if/switch 语句块的变量做了支持,可以参考 selection statements with initializer。
int main() {
if (int number = 10; number < 10) {
return 0;
} else {
return number;
}
}
switch 语句也是类似的。
在 C++ 11 中开始提供了 Initializer List,详情可以参考 std::initializer_list。
这个命名其实不是太好,容易和类成员函数的初始化列表搞混淆,因此一定要注意区分。在 cppreference.com 中提到:
not to be confused with member initializer list.
template < class T >
class initializer_list;
std::initializer_list
是一个容器,里面引用了多个 T 类型的对象,并提供了对 const T
的访问。
我们开发 C++ 代码通常不会主动去构造 initializer_list,而是由 C++ 自动进行 initializer_list 的构造,因此我们需要看 C++ 有些自动构造 initializer_list 的场景:
-
通过花括号初始化列表,进行列表初始化一个对象时,相应的构造函数会接收一个
std::initializer_list
对象:class MagicFoo { public: MagicFoo(std::initializer_list<int> list) {} }; int main() { // 列表初始化 MagicFoo a = {1, 2, 3, 4, 5}; MagicFoo b{1, 2, 3, 4, 5}; }
-
花括号初始化列表作为赋值运算符右值时,或者作为函数参数调用时,相应函数会接收一个
std::initializer_list
对象。class MagicFoo { public: test(std::initializer_list<int> list) {} MagicFoo& operator=(std::initializer_list<int> list) { returh *this; } }; int main() { // 列表初始化 MagicFoo a; a.test({1, 2, 3, 4, 5}); a = {1, 2} }
-
a braced-init-list is bound to auto, including in a ranged for loop.
auto al = {10, 11, 12}; // al 是 std::initializer_list for (int x : {-1, -2, -3}) { // {-1, -2, -3} 这个临时变量是 std::initializer_list std::cout << x << std::endl; }
聚合初始化,是在 Initializer List 基础上的延申,可以参考 Aggregate initialization。
虽然在 Initializer List 中,{}
中的所有元素类型必须相同,但是在 Aggregate initialization
场景下,{}
的每个元素类型不必相同。
Aggregate initialization 列出了满足聚合的条件,我们直接看聚合初始化的效果:
// demo 1
struct A { int x; int y; int z; };
A a{.y = 2, .x = 1}; // error; designator order does not match declaration order
A b{.x = 1, .z = 2}; // ok, b.y initialized to 0
// demo 2
union u { int a; const char* b; };
u f = { .b = "asdf" }; // OK, active member of the union is b
u g = { .a = 1, .b = "asdf" }; // Error, only one initializer may be provided
// demo 3
struct A {
string str;
int n = 42;
int m = -1;
};
A{.m=21} // Initializes str with {}, which calls the default constructor
// then initializes n with = 42
// then initializes m with = 21
Structured bindings 是类似于其他脚本语言将一个集合拆成多个变量的技术,例如在 python 中,可以把一个二维数组进行拆分:
a = [1, 2]
x, y = a
# x == 1
# y == 2
C++ 17 支持类似的语法,这在 C++ 中叫做结构化绑定,可以参考 Structured binding declaration。
结构化绑定支持三种类型:
-
绑定一个数组:
int a[2] = {1,2}; auto [x,y] = a; // creates e[2], copies a into e, then x refers to e[0], y refers to e[1] auto& [xr, yr] = a; // xr refers to a[0], yr refers to a[1]
-
绑定一个 tuple-like 类型:
std::tuple<int, double, bool> tup(10, 5.3, true); const auto& [a,b,c] = tup;
-
绑定一个 Class Member
class A { public: int a = 10; bool b = true; }; A a; auto [x, y] = a;
通过 auto
关键词作为变量的类型说明符,可以让编译器分析表达式推导出变量类型。
auto 让编译器通过初始值来推算变量类型,也因此,auto 的变量必须要由初始值:
auto i = 10;
std::vector<int> vect;
for(auto it = vect.begin(); it != vect.end(); ++it) {
std::cin >> *it;
}
auto ptr = [](double x){return x*x;};
template <class T, class U>
void Multiply(T t, U u) {
auto v = t * u;
std::cout << v;
}
// C++ 14 支持返回值是 auto 类型
auto add(int x, int y) {
return x + y;
}
auto 的推到规则并非表面那么简单,尤其是在考虑复合类型时,例如 const,指针,引用等场景,可以参考:auto(C++)。
简单来说,auto 作为类型推导时,会施加以下规则:
- 如果初始化表达式是引用,首先去除引用;
- 如果剩下的初始化表达式有顶层的 const 且/或 volatile 限定符,去除掉。
const int v1 = 101;
auto v2 = v1; // v2 类型是 int,脱去初始化表达式的顶层 const
int v3 = 10;
int& v4 = v3;
auto v5 = v4; // v5 类型是 int,去除掉引用
注意:
- 顶层 const,表示任意对象是个常量。
- 底层 const,表示指针或引用指向的对象是个常量。
- 对于指针可以简单的记作,靠右的 const 是顶层 const,靠左的 const 是底层 const。
如果要保留顶层 const,可以为 auto 添加 const 限定符:
const int v1 = 101;
const auto v2 = v1; // v2 是 const int 类型
如果要使用引用,也需要明确指定,并且如果使用了引用,顶层 const 不会被剔除:
int v1 = 101;
auto& v2 = v1;
const int v3 = 10;
auto& v4 = v3; // v4 是 const int&
auto 的使用也有一些限制:
- auto 不能用于函数传参,这种情况应该使用模板。
- auto 还不能用于推导数组类型。
C++ 11 还提供了 decltype
关键词,它可以根据表达式推导变量类型,又不需要像 auto 那样必须要提供初始值:
int fun() {
return 10;
}
int main(void) {
decltype(fun()) a; // a 是 int 类型
decltype(10 + 20) b; // b 是 int 类型
return 0;
}
decltype 进行推导时,并不会进行实际的表达式运算,只会根据表达式的类型去分析。
decltype 的推导规则和 auto 稍有不同,这里不详细列出,具体可参考 decltype specifier。
decltype 比较常见的场景是为模板函数的返回值进行类型推导,因为在 C++ 14 前,我们不能定义 auto 类型的返回值,因此为了让模板函数可以自行推导返回值,我们使用 decltype:
template<typename T, typename U>
auto add2(T x, U y) -> decltype(x+y) {
return x + y;
}
所幸的是,这在 C++ 14 中得到了解决,C++ 14 中可以直接使用 auto 进行返回了,这更方便:
template<typename T, typename U>
auto add2(T x, U y) {
return x + y;
}
语言运行期的强化,主要参考了 现代 C++ 教程 的第 3 章,并在此基础上做了很多补充。
Lambda 是 C++ 中的匿名函数,Lambda 的详细信息可以参考 Lambda expressions。
Lambda 表达式格式有多种,完整的 Lambda 表示是比较复杂的,我们一般常用的格式如下:
[ captures ] ( params ) <mutable> { body }
Lambda 表达式的返回值类型是由其中的 return 语句进行推导的,因此要求 return 都返回相同的类型。
为了在 Lambda 表达式的函数体中使用 Lambda 表达式外的变量,需要通过捕获列表将上下文中的变量捕获,以传入 Lambda 表达式。
有四种捕获方式:
-
值捕获:
int value = 1; auto fun = [value] { return value; }; auto r = fun();
-
引用捕获:
std::string value = "12345ABCDE"; auto fun = [&value] { return value; }; auto r = fun();
-
手动写捕获哪些变量可能是比较麻烦的,可以通过直接使用
[=]
或[&]
捕获 Lambda 外层上下文中所有的变量。std::string value = "12345ABCDE"; auto fun1 = [&] { return value; }; auto r1 = fun1(); auto fun2 = [=] { return value; }; auto r2 = fun2();
-
表达式捕获,即可以通过
value = express
进行捕获,这种方式对于捕获右值或右值引用更加方便:std::string value = "12345ABCDE"; auto fun = [value = std::move(value)] { return value; }; auto r = fun();
注意:
- 捕获的参数默认是不可更改的,除非使用了 mutable 修饰 Lambda 表达式。
除此外,还有一种范型 Lambda,顾名思义,就是 Lambda 对模版的支持。在 C++ 14 后,可以通过 auto 参数来支持:
auto add = [](auto x, auto y) {
return x+y;
};
add(1, 2);
add(1.1, 2.2);
注意:
- auto 参数,只允许在 Lambda 表达式中使用,其他函数不能使用,必须用模版。
有意思的事情来了,Lambda 表达式返回的类型到底是什么呢?我们通常使用 auto 变量去接住 Lambda 表达式的返回值,但是这在向函数传递该变量的时候是不方便的,因为声明函数的时候就需要指定接收的 Lambda 表达式类型。
很多同学都知道,Lambda 函数的变量,在 C++ 中通常使用 std::function<>
类型,例如:
#include <functional>
std::function<void(void)> fun = [](){};
那么 Lambda 表达式的类型就是 std::function<>
?
其实并不是的,std::function<>
是对真正类型进行的包装。参考 Type of a lambda function, using auto 和 What is the type of lambda when deduced with “auto” in C++11? 这两篇讨论,Lambda 表达式的类型属于特殊的未命名类型:
The type of a lambda expression is unspecified.
Lambda 表达式实际上是一种语法糖,对于这样的一个 Lambda 表达式:
int a = 10;
auto fun = [](int a) { return 10; }
会被 C++ 编译器进行如下转换:
int a = 10;
struct /* unnamed */ {
// function body
auto operator()(int a) const {
return a;
}
} fun; // <- instance name
综上,Lambda 表达式类型属于未命名类型,又因为实现了 operator()
,因此 Lambda 表达式可调用,std::function<>
只是可调用对象的包装器。
一个函数,甚至一个 Lambda 匿名函数,我们如何将其作为参数进行传递和使用呢?
主要由两种方法:
-
使用函数指针。
// Funpointer1 和 Funpointer2 都是对函数指针的定义 // Funpointer1 使用老式的 typedef 方式 // Funpointer2 使用 C++ 11 的 using 定义方式 typedef int (*Funpointer1)(void); using Funpointer2 = int(*)(void); int fun() { return 10; } Funpointer1 p1 = fun; Funpointer2 p2 = fun; // typedef ori new 是 typedef 的典型用法,但是 typdef 定义函数指针时却打破了这一规则,增加了理解的复杂度 // using 方式更加简单直观,现在一般推荐使用过 using 方式
-
使用
std::function
。#include <functional> int fun() { return 10; } std::function<int(void)> f = fun;
后者更加通用,直观,因此 C++ 11 后,通常使用 std::function<>
,并且要使用该对类,需要引入头文件:#include <functional>
。
在某些场景下,我们需要特化函数中的某些值,可以使用 std::bind()
,将函数的某些参数和具体的值绑定在一起,具体可以参考 std::bind。
用一个 Demo 快速阐述 bind 的作用:
int add(int a, int b, int c) {
return a + b + c;
}
// std::function<int(int, int)> newadd = std::bind(add, std::placeholders::_1, 2, std::placeholders::_2);
auto newadd = std::bind(add, std::placeholders::_1, 2, std::placeholders::_2);
std::cout << newadd(10, 20) << std::endl;
std::placeholders::_x
,代表该位置的参数不需要特例化,由函数调用时传入。
注意:
-
和 Lambda 函数一样,
std::bind()
返回的函数类型属于unspecified
,但是因为属于可调用对象,所以可以被std::function<>
包装。 -
如果我们要将对象的方法像函数一样传递,可以使用
std::bind
:class A { public: A(const std::string& name): name_(name) {} std::string Hello() { return "hello " + name_; } private: std::string name_; }; int main() { A a("arthur"); std::function<std::string(void)> fun = std::bind(&A::Hello, &a); std::cout << fun() << std::endl; return 0; }
C++ 中可以通过智能指针进行内存管理,这避免我们手动去使用 new/delete 创建对象和释放对象。
在 C++ 中,存在多种智能指针,不同的智能指针对于对象的所有权是不同的:
- shared_ptr,拥有对象的共享所有权,并且会使用引用计数判断对象是否被引用,若引用技术为 0 将会释放对象。
- unique_ptr,拥有对象的唯一所有权,其他智能指针不能拥有该对象的所有权。可以通过
std::move()
转移所有权。 - weak_ptr,和 shared_ptr 类似,但是该引用不会增加引用计数。可以通过
expired()
方法判断对象是否已经被释放。
std::shared_ptr
它能够记录多少个 shared_ptr 共同指向一个对象,当引用计数变为零的时候就会将对象自动删除,从而消除显式的调用 delete。
#include <memory>
#include <iostream>
#include <string>
int main() {
std::shared_ptr<std::string> p1(new std::string("hello world"));
std::cout << "p1.use_count(): " << p1.use_count() << std::endl;
auto p2 = p1;
std::cout << "p1.use_count(): " << p1.use_count() << std::endl;
std::cout << "p2.use_count(): " << p2.use_count() << std::endl;
}
为了避免显示的调用 new 来创建对象,可以使用 std::make_shared<T>(args)
来创建对象,这会隐式的触发 new T(args)
。
std::shared_ptr<std::string> p1 = std::make_shared<std::string>("hello world");
auto p2 = p1;
除此外,也可以通过 get()
获得原始对象:
std::shared_ptr<std::string> p1 = std::make_shared<std::string>("hello world");
std::string* p = p1.get();
std::unique_ptr
是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全,智能通过 std::move()
来转移所有权:
std::unique_ptr<std::string> p1(new std::string("hello world"));
std::unique_ptr<std::string> p2 = std::move(p1);
std::unique_ptr<std::string> p3 = p1; // 非法
通过 std::move()
转移所有权,p1 将不再引用对象,而是由 p2 引用对象,依然是一个智能指针独占对象,保证安全。
unique_ptr 统一可以使用 std::make_shared<T>()
隐式创建对象,以及使用 get()
获得原始对象:
std::unique_ptr<std::string> p1 = std::make_shared<std::string>("hello world");
std::string* p = p1.get();
在早期 C++ 中还不存在 std::unique_ptr
,类似的需求会使用另一种智能指针,即 std::auto_ptr
,它也具有对象的唯一所有权,并在拷贝时会进行所有权的转移:
std::auto_ptr<std::string> s1(new std::string("abcd"));
std::auto_ptr<std::string> s1 = s2; // s2 指向的资源转交给了 s1, s2 不可以再使用
std::cout << s1.get() << std::endl; // nullptr
auto_ptr 存在以下问题:
- auto_ptr 不能指向数组,因为 auto_ptr 在释放资源时使用 delete object,而不会使用 delete [] objects。
- 资源转移不够安全,在 unique_ptr 中通过显式的 std::move() 进行资源转移。
- 由于 auto_ptr 的赋值导致的资源转移,使其不适合在 STL 容器中进行使用。
因为 auto_ptr 的种种缺点,在 C++ 11 后,auto_ptr 被 unique_ptr 取代(但仍然可用),知道 C++ 17 中已经不再存在 auto_ptr 了。
参考 C++ Primer 中对默认构造函数的定义:
没有提供任何实参时使用的构造函数。
很明显,根据这个定义,无论是编译器自动生成的还是我们自己实现的,只要构造函数没有任何参数,就是默认构造函数,例如:
// 编译器自动生成默认构造函数
class A {
};
// 编译器自动生成默认构造函数
class B {
public:
B() = default;
}
// 自己实现的默认构造函数
class C {
public:
C() {}
};
我们在看代码和写代码时,往往涉及到 = defualt
,什么时候使用它呢?
默认构造函数会有编译器自动生成,但是当编译器中定义构造函数后,编译器就不会自动生成默认构造函数了:
// 生成默认构造函数
class A {
};
// 不生成默认构造函数
class B {
public:
B(int num) {}
};
int main() {
A a; // 编译正确
B b(10); // 编译正确
B b; // 编译错误,因为不具备 B 的默认构造函数
}
但是我们实际上可能需要编译器自动生成的默认构造函数,为了维持编译器自动生成这一行为,就可以使用 = default
:
class C {
public:
C() = default;
C(int num) {}
};
int main() {
C b(10); // 编译正确
C b; // 编译正确
}
那么编译器自动生成的默认构造函数具备哪些行为呢?其实编译器自动生成的默认构造函数,就是调用父类的默认构造函数,并初始化每个成员变量。为了更形象的说明,以下 A、B、C 类的默认构造函数行为其实是一样的:
// 编译器自动生成默认构造函数
class A {
};
// 编译器自动生成默认构造函数
class B {
public:
B() = default;
}
// A 和 B 的默认构造函数其实和这个一样
class C {
public:
C() {}
};
既然如此,为什么当我们需要定义默认构造函数时,会选择使用 ClassName() = default
不直接使用 ClassName() {}
呢?
其实是为了区别于 编译器实现
和 用户实现
,前者是编译器实现,后者是用户实现。使用 ClassName() = default
会更加规范和统一,并为阅读者强调,这是编译器实现的默认构造函数,而不是用户实现的。
对于这个问题,更多信息可以参考 The new syntax “= default” in C++11 和 How is “=default” different from “{}” for default constructor and destructor? 的讨论。
C++ Primer 中提到的最佳实践:
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
转换构造函数在 C++ Primer 中的定义:
可以用一个实参调用的非显示构造函数。这样的函数隐式地将参数类型转换为类类型。
其本质上就是有单个参数的构造函数,这类构造函数都属于一个 Converting Consructor。这在函数传值,以及赋值时往往可以看到:
class A {
public:
A(int num) : num_(num) {}
int num() { return num_; }
private:
int num_;
};
void fun(A a) {
}
int main() {
// 触发 Converting Constructor
A a1(30);
// 触发 Converting Constructor
A a2 = 30;
// 触发 Converting Constructor
fun(30);
}
多数情况,都不希望进行隐式的类型转换,此时可以定义 explicit
关键字:
class A {
public:
explicit A(int num) : num_(num) {}
int num() { return num_; }
private:
int num_;
};
void fun(A a) {
}
int main() {
A a1(30); // 正常
fun(A(30)); // 正常,触发单参数构造函数(等价于 A a = A(30))
A a2 = 30; // 失败,不能进行隐式转换
fun(30); // 失败,不能进行隐式转换
}
Google C++ Code Style 会建议我们:
不要定义隐式类型转换. 对于转换运算符和单参数构造函数, 请使用 explicit 关键字.
C++ 11 中通过引入右值引用的概念,支持了移动语义和完美转发。
首先我们需要弄懂,到底什么是左值,什么是右值。一个最简单,且在 C++ 11 前一直沿用的一种粗糙理解是:
可以在赋值符号左边的,就是左值。
只能在赋值符号右边的,就是右值。
当然, 这种理解是非常粗糙的,要理解这个概念,我们先说说表达式,参考 Value categories 中对表达式的阐述:
Each C++ expression (an operator with its operands, a literal, a variable name, etc.) is characterized by two independent properties: a type and a value category.
即每个表达式都由运算符(operator)和运算对象(operands)构成,其中字面值和变量,是最简单的表达式。
表达式一定会得到一个结果,即表达式是可求值的,表达式的结果右两种属性:
- 值类型(type)。
- 值类别(value categories)。
我们说的左值右值,其实就是指的值类别,完整的说法是 左值表达式
和 右值表达式
。
值类别有以下几种:
- 左值:lvalue。
- 纯右值:pralue。
- 将亡值:xvalue。
注意:
- lvalue 和 xvalue 合称:泛左值。
- prvalue 和 xvalue 合称:右值。
下图可以较为直观的看出其中的关系:
当给出一个表达式时,我们怎么快速判断其值类别呢?事实上,这并没有一个事实的标准,但是有常用的方式:
-
左值
-
描述:能够用 & 取地址的表达式是左值表达式。
-
示例:
int a = 42; // a 是左值 int b = a; // b 是左值 &("abc"); // 字面量 "abc" 是左值
-
-
纯右值
-
满足下列条件之一:
- 本身就是赤裸裸的、纯粹的字面值,如 3、false;
- 求值结果相当于字面值或是一个不具名的临时对象。
-
C++ 11 前的右值就是指的纯右值。
-
示例:
42; // 字面量 42 是右值 a + 32; // 表达式 a + 32 是右值 foobar(); // 函数调用返回的是临时对象,所以是右值
-
-
将亡值
-
“将亡值”概念的产生,是由右值引用的产生而引起的,将亡值与右值引用息息相关。所谓的将亡值表达式,就是下列表达式:
- 返回右值引用的函数的调用表达式
- 转换为右值引用的转换函数的调用表达式
-
示例:
int a = 10; std::move(a); // 将亡值表达式 std::move(a + 20); // 将亡值表达式
-
在理解了左值和右值后,我们可以看下左值引用和右值引用。
其实故名思意,左值引用就是对左值的引用,用 T&
表示,右值引用就是对右值的引用,用 T&&
表示,其中 T 代表被引用的数据类型。
int main()
{
std::string lv1 = "string,"; // lv1 是一个左值
std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
std::string&& rv1 = std::move(lv1); // 合法, std::move 可以将左值转移为右值
const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
lv2 += "Test"; // 非法, 常量引用无法被修改
std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
return 0;
}
左值引用在 C++ 11 前就已经被广泛使用,较容易理解,而右值引用,有两个作用:
-
引用临时变量,延长临时变量的生命周期,避免进行拷贝。
std::vector<int>& fun() { static std::vector<int> v {1, 2, 3, 4, 5}; return v; } int main() { std::vector<int> v1 = fun(); // 触发拷贝构造函数 std::vector<int>& v2 = fun(); // 非法 const std::vector<int>& v3 = fun(); // 不会触发构造函数,只是一个引用 std::vector<int>&& v4 = fun(); // 不会触发构造函数,只是一个引用 }
-
触发右值引用为参数的函数,例如移动构造函数,移动赋值构造函数,对于这一部分,在 Move semantics 中再详细阐述。
class A { public: A() = default; A(const A& a) {} A(A&& a) {} }; int main() { A a; // 默认构造函数 A b = a; // 拷贝构造函数 A c = std::move(a) // 移动拷贝构造函数 }
一个左值是不能直接(隐式)被右值引用的,若一个左值需要使用右值引用,需要通过 std::move()
强制将其转为右值,在上述的例子中已经看到了。
需要注意的是:
- 虽然右值临时变量会很快释放,但当被右值引用所指向时,临时变量生命周期会延长,直至右值引用生命周期结束。
- 右值引用的变量名是一个左值,它所代表的是个右值。
理解了左值和右值后,可以看下移动语义了。移动的意义是什么:
- 语义上,目的是为了将一个对象的资源转移到另外一个对象。
- 语法上,目的是为了触发移动构造函数或者移动赋值函数。
我们常见的对象拷贝,通常是触发拷贝构造函数
和拷贝赋值函数
:
std::vector<int> a {1, 2, 3, 4, 5};
std::vector<int> b = a; // 拷贝构造函数
b = a; // 拷贝赋值函数
这两个拷贝函数,在语义是执行深拷贝
,即将数据完全拷贝一份。很明显,这种方式在进行传递传递,和函数返回时,因为涉及到数据的反复拷贝,所以性能很差。为了解决这个问题,我们通常会使用引用或指针,但是这种方式降低代码的可读性,例如两个字符串的拼接:
void Join(const std::string& s1, const std::string& s2, std::string* out) {
*out = s1 + s2;
}
std::string out;
Join("123", "456", &out);
C++ 11 开始,新增了移动语义,这种方式并不会去进行深拷贝,而是将掌握的资源转给新的对象,自己不再拥有资源。
std::vector<int> a {1, 2, 3, 4, 5};
std::vector<int> b = std::move(a);
std::cout << a.size() << std::endl; // 0, 数据被转移走了
std::cout << b.size() << std::endl; // 5
转移的实现根本在于对象的 移动构造函数
和 移动赋值函数
的实现,例如这是一种可能的实现:
public:
A() {
m_ = new std::string("hello");
}
~A() {
if (m_ != nullptr) {
delete m_;
}
}
A(A&& rhs) {
m_ = rhs.m_;
rhs.m_ = nullptr;
}
A& operator=(A&& rhs) {
delete m_;
m_ = rhs.m_;
rhs.m_ = nullptr;
return *this;
}
public:
std::string* m_ = nullptr;
};
int main() {
A a;
std::cout << a.m_ << std::endl;
A b = std::move(a);
std::cout << a.m_ << std::endl;
std::cout << b.m_ << std::endl;
A c;
c = std::move(b);
std::cout << b.m_ << std::endl;
std::cout << c.m_ << std::endl;
}
C++ 11 标准中的对象,都实现了移动语义转移资源,对于 std::string, std::vector 等容器类的使用变得方便起来,例如之前的 Join 可以改写为:
std::string Join(const std::string& s1, const std::string& s2) {
std::string out = s1 + s2;
return std::move(out);
}
std::string out = Join("123", "456");
这种方式更加易读。需要注意的是,C++ 11 对返回值经过了编译器优化,会在返回的地方直接构造值,因此其实这样才是最高效和简单的:
std::string Join(const std::string& s1, const std::string& s2) {
return s1 + s2;
}
std::string out = Join("123", "456");
另外一方面,在参数传递或 lambda 捕获的时候也可以使用 move,这是一个 lambda 捕获时的例子:
std::string Join(const std::string& s1, const std::string& s2) {
return s1 + s2;
}
std::string s1 = "123";
std::string s2 = "456";
auto fun = [s1 = std::move(s1), s2 = std::move(s2)]() {
std::string out = Join(s1, s2);
}
fun();
捕获时使用 std::move 传递,是因为:
- 值捕获,因为使用深拷贝,所以效率低。
- 引用捕获,在使用 lambda 的时候可能引用已经不再可用了。
当然,捕获后,被 std::move 的原本的值,都不再可用了。
虽然 C++ 的类都实现了 move 语义,那我们实现的类如何快速支持 move 语义呢?答案是,自定义类原本就支持。
自定义类,会生成默认的移动构造函数和移动赋值函数,也被称为合成移动构造函数
和合成移动赋值函数
,其效果是将所有的成员变量做 std::move
,例如:
struct A {
int a;
int b;
std::string c;
std::vector<int> d;
};
其生成的默认移动构造函数和移动赋值函数是:
struct A {
int a;
int b;
std::string c;
std::vector<int> d;
A(A&& rhs) {
a = std::move(rhs.a);
b = std::move(rhs.b);
c = std::move(rhs.c);
d = std::move(rhs.d);
}
A& operator=(A&& rhs) {
a = std::move(rhs.a);
b = std::move(rhs.b);
c = std::move(rhs.c);
d = std::move(rhs.d);
return *this;
}
};
当然,生成合成版本的移动函数是有条件的,C++ Primer 指出:
只有当一个类没有定义任何版本的拷贝函数,且所有非 static 的数据都能移动构造,或移动赋值时,编译器才会生成合成移动构造函数和合成移动赋值函数。
C++ 中的基本类型,int a = std::move(b)
等价于赋值 int a = b
。
完美转发主要是用于在模版中使用右值引用,所引发的问题:
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int&)
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1 是右值, 输出左值
std::cout << "传递左值:" << std::endl;
int m = 1;
pass(m); // l 是左值, 输出左值
return 0;
}
因为右值引用其变量也是左值,所以即便传入了右值 1
,最终还是触发了 reference(int& v)
。
为了保持模版中的变量引用类型和传入时的一致,引入了 std::forward()
完美转发:
void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(std::forward<T>(v));
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1 是右值, 输出右值
std::cout << "传递左值:" << std::endl;
int m = 1;
pass(m); // l 是左值, 输出左值
return 0;
}