JasonWang's Blog

重学现代C++

字数统计: 3.6k阅读时长: 14 min
2022/03/31

断断续续看了Bjarne Stroustrup的’C++之旅(a tour of C++)’, 作者把很多原本看起来复杂的概念模型都讲的比较清晰, 也有不少好的代码示例, 有种拨云见雾的感觉. 于是想着写一篇文章来总结重新学习C++的一些经验, 主要阐述下现代C++(>=C++11)中那些容易让人混淆而觉得陌生的技术.

了解C++历史的人都知道, Bjarne Stroustrup是在Bell实验室(就是Unix操作系统与C语言诞生的地方)发明了C++, 初衷是在C中加入类(class)的概念, 增强C语言在系统编程上的效率与灵活性, 也正式因为这个原因, C++在1979年最初的名字是带类的C(c with class), 直到1984年才改名为C++. 到今天, C++的发展历经了快40年历史, 但真正一次大的标准修改是在2011年, 这个版本也称为C++11(C++11之后的版本也统称为Modern C++).接下来我们就来一起来回顾下现代C++中那些曾经让人头疼的技术吧.

history_of_cpp

constants

除了const之外, C++还引入了constexpr这个关键字. const的意思是这个变量不会被修改, 编译器在编译的时候会确保这一点: 如果尝试修改const的赋值, 则会提示错误. 而constexpr是说这个表达式会在编译的时候进行判定(不用等到运行时):

1
2
3
4
5
6
7
8
9
10
11

int sum(const vector<int>& vi) { return 0;}; //示例
constexpr int square(int x) { return x * x;};

constexpr double m1 = 1.6 * square(im);
//constexpr double m2 = 1.6 * square(var); // 不允许使用非const变量

vector<int> vi = {4, 58, 8 , 9};
const int si = sum(vi);
//constexpr int sii = sum(vi); // sum不是constexpr表达式

如果一个函数要在constexpr表达式中使用, 则必须要以constexpr开头, 并且函数的实现需要足够简单: 只由一个返回值的表达式组成,且不能调用任何其他非constexpr的函数.

1
2
3

constexpr int square(int x) { return x * x;};

由于在编译时就完成了计算, 使用constexpr通常可以改善性能, 但据c++ standar library: a tutorial and refernce里边提到的, 引入constexpr是为了解决C++98numeric_limits标准库中的一个bug:std::numeric_limits<short>::max() 无法在常量表达式中使用.

引用与右值

C++针对C中的指针引入了引用(reference)的概念, 引用类似于指针, 其通过&进行声明, 但实际使用的时候无需在通过*来引用对象, 更重要的是引用一旦被初始化就不能再被修改, 引用其他的对象.

引用类似指针,但非指针;引用只是对象的别名。引用与指针存在三个区别: 1) 没有空引用(null reference),就是说引用始终指向一个有效的对象 2) 所有引用需要初始化 3) 一个引用永远指向它初始化的那个对象

引用对于不管理资源的对象来说, 一般不会有什么问题, 但涉及到大量的数据/内存时(比如容器), 单纯的拷贝会变得低效. 有时, 我们只希望在不同对象之间移动(move)数据而不是拷贝, 以避免产生不必要的临时对象. 比如我们有一个String类, 包含了一个字符数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47


class String {

public:
String(): data(new char[1]) {
data[0] = '\0';
}

String(const char* str): data(new char[strlen(str) + 1]) {
if (str != nullptr) {
strncpy(data, str, strlen(str));
data[strlen(str)] = '\0';
}
}

String(const String& str): data(new char[str.size() + 1]) {
strncpy(data, str.c_str(), str.size());
data[str.size()] = '\0';
}

String& operator=(String &rhs) {
swap(rhs);
return *this;
}

~String() {
delete[] data;
}

size_t size() const {
return strlen(data);
}

char* c_str() const {
return data;
}

void swap(String &rhs) {
std::swap(data, rhs.data);
}

private:
char* data;
};


比如我们要往一个容器中插入对象, 那么每次实际容器中实际得到的都是一个拷贝的对象, 这就导致了不必要的内存拷贝, 如果对象本身很大(占用较大内存), 则拷贝会消耗更多的时间.

1
2
3
4
5

String s("agaga");
std::set<String> ss;
ss.insert(s); // get a copy of s

针对这种临时对象的拷贝, C++11中引入了移动(move)语义, 这样就可以支持对象之间数据的转移而不是拷贝了, 从而改善了程序的性能. 要让上述String对象支持move语义, 其实也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// move constructor
String(String &&str): data(str.data) {
str.data = nullptr;
}


// move assignment
String& operator=(String&& rhs) {
data = rhs.data;
rhs.data = nullptr;

return *this;
}

上述代码中&&表示一个右值引用(rval refernece), 就是说该引用跟一个右值绑定, 这通常有别于左值(赋值语句的左边), 由于右值引用无法进行赋值, 因此我们可以偷取它的值与资源. C++标准库中也有一个move函数可以用于操作右值引用(头文件utility):

1
2
3
4
5
6
7
8

String s1("gaga");
String s2 = s1; // copy
String s3 = std::move(s2); // move

std::set<String> ss;
ss.insert(std::move(s1)); // move

需要留意的时, 对于函数返回值不应该返回右值或使用move()要返回的值, 对于如下函数:

1
2
3
4
5
6
7

String fcn() {
String s("hi, move");
...
return s;
}

C++会确保返回值按照如下规则进行:

  • 如果String有move构造函数或者赋值函数, 编译器可能会忽略拷贝对象, 这种技术被称为返回值优化(returned value optimization), 在C++11之前就有了.
  • 否则就使用move构造函数返回对象
  • 如果没有move构造函数, 则使用拷贝构造函数返回拷贝对象
  • 否则就会抛出编译时异常

有关更多右值引用、移动语义可以参考如下两篇文章:

lamda表达式与函数对象

C++11引入了lambda表达式; lambda表达式可以用于创建简单的函数对象(function object), 构建匿名函数, 其返回的对象被称为闭包对象(closure object). 一般来说, lambda表达式由如下三个部分组成:

  • 捕获列表(caputure list): [] , 一个对象被捕获以后就可以在lambda函数内部使用(值复制或者引用的形式), =用于值的形式捕获所有本地变量;&则表示以引用的形式捕获所有本地变量. 如果需要指定捕获的变量, 则需要在符号后面加上对应变量的名字, 如=a, &a等. 不指定捕获符号则默认的捕获是值形式.

  • 参数列表(parameter list): (), 参数列表与普通函数完全一致, 如果没有参数, 可以选择直接忽略表达式中的()(只有C++14以上的版本支持)

  • 函数体(function body): {}表示函数体, 这与普通函数是一样的.

1
2
3

[=(&)] (){}

lambda表达式源自于lambda calculus, 是Alonzo Church在1930s用于研究逻辑与计算时使用的数学形式语言, 这也是函数式编程语言LISP的基础.

比如实现判断一个数值是否大于10lamda表达式, 可以这么做:

1
2
3
4
5
6
7


int a = 10;
[=a](int x) { return x > a; };

[&a](int x) { return x > a; };

默认情况下, 未被显式指定捕获本地变量都无法在表达式函内使用, 但可以隐式的在函数体内捕获这些变量:

1
2
3
4
5
6
7
8

int a = 1;
int b = 2;

[=]() { return a + b; }; // a and b caputured by value

[&]() { return a + b; }; // a and b caputured by reference

如果同时使用了两种形式的捕获, 显式捕获会覆盖默认的捕获:

1
2
3
4
5
6
7
8
9
10

int a = 0;
int b = 1;

[=, &b] () {
a = 2; // Illegal, a is caputured by value and lamda is not mutable
b = 3; // OK , b is caputured by value
}


需要注意的是, lambda表达式可以使用泛型参数, 这个在本质上是等同于函数式对象, 不妨来看个示例:

1
2
3
4
5
6
7

auto copy = [] (auto x) { return x + x; };

int i = copy(2); // i = 4
std::string s = copy("hi"); // s = "hihi"


上述表达式可以写成如下形式的函数对象(标准库中很多算法广泛使用了函数对象来扩展实现, 如for_each, bind):

1
2
3
4
5
6

struct CopyValue {
template<typename T>
auto operator() (T x) const { return x + x; }
};

智能指针(smart pointers)

长期以来, C被大家所诟病的一个缺陷就是指针引发的一系列内存管理问题, 比如空指针, 内存泄漏等, C++虽然通过引入了引用(reference)在一定程度上避免了指针带来内存管理的困扰, 但并没有从根源上解决指针所引发的资源管理问题. 现代C++普遍采用RAII(Resource Acquisition Is Initialization)原则;从C++11开始, 标准库引入了智能指针(smart pointers)避免内存泄漏: unique_ptr/shared_ptr, 用以替换之前的auto_ptr.

  • unique_ptr: 表示一个唯一的所属关系, 始终只有一个指针指向该对象
  • shared_ptr: 表示一个共有的所属关系, 可以有多个指针共享对象资源

标准库中提供了接口用于方便的创建unique_ptr/shared_ptr:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

std::unique_ptr<X> up { new X }; // or

// since C++14
std::unique_ptr<X> up = std::make_unique<X>();
// move unique_ptr to another
std::unique_ptr<X> up1 = std::move(up); // now up is undefined

std::shared_ptr<X> sp {new X}; // or

// since C++14
std::shared_ptr<X> sp = std::make_shared<X>();

std::shared_ptr<X> sp1(sp); // shared with sp1

类型推断(auto/decltype)

C++11引入了auto关键字用于告诉编译器对变量, 函数以及模板类进行推断, 从而避免让用户自己显式的声明类型(auto变量必须初始化,这样可以避免手动声明变量未被初始化的问题). 比如, 相比写一堆嵌套的模板实例类型, 使用auto可以简化声明:

1
2
3
4
5
6
7
8

map<int,list<string>>::iterator i = m.begin(); // C-style
auto i = m.begin(); // modern C++

//using auto to simplify code
for (std::vector<int>::const_iterator itr = myvec.cbegin(); itr != myvec.cend(); ++itr)

for (auto itr = myvec.cbegin(); itr != myvec.cend(); ++itr)

需要注意的是,C++11开始支持括号列表初始化方式,如下的声明实际得到的两个变量x1/x2是不一样的,编译器会把x1的类型推断为int值,而x2推断为std::initializer_list, 这类似与变量x3的类型(具体可以参考Effective Modern C++书中的第二条有关auto类型推断的解释):

1
2
3
4
5
6

auto x1 = 33; // or auto x1(33), x1 is int
auto x2 = {35}; // or auto x2{35}, x2 is std::initializer_list

auto x3 = {2, 9, 10, 13}; // x3 is std::initializer_list

另外, C++11还增加了一个关键字decltype, 用于在编译的时候获取某个变量或者表达式的类型, 例如:

1
2
3
4

int a = 5
decltype(a) c = 3

不过需要注意的是, decltype推断出来的类型与auto的实际类型可能有差异(示例来自wikipedia):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

#include <vector>
int main()
{
const std::vector<int> v(1);
auto a = v[0]; // a has type int
decltype(v[0]) b = 1; // b has type const int&, the return type of
// std::vector<int>::operator[](size_type) const
auto c = 0; // c has type int
auto d = c; // d has type int
decltype(c) e; // e has type int, the type of the entity named by c
decltype((c)) f = c; // f has type int&, because (c) is an lvalue
decltype(0) g; // g has type int, because 0 is an rvalue
}

标准库

C++标准库中已经实现了大量通用的算法, 包括查找, 排序, 过滤以及随机算法等, 如果你正瞅着自己实现某个算法时, 可以直接看看标准模板库(STL, Stardard Template Libarary)的算法. 举几个例子:

  • for_each: 可以利用迭代器iterator很方便的对容器进行遍历操作
  • transform: 对容器元素根据某个特定规则进行变换
  • find_if: 根据某个函数对象对进行搜索查找
  • sort/lower_bound: 排序/搜索算法

标准模板库STL包含了三大组件:容器(Container)、算法(Algorithm)以及迭代器(Interator)。

  • 容器用于容纳/组织对象,是一种数据结构的抽象,以类模板的方式实现
  • 算法执行对象的操作,是一种函数的抽象,通过函数模板来实现
  • 迭代器用于访问容器中的元素,为容器与算法提供一种协同工作的机制。

更多有关标注库的算法可以参考The C++ Standard Library, 2nd Edition.

转型操作符

C++中引入了很多类型转换(cast)操作,比如const_cast, dynamic_cast等,为转换操作提供了明确的语法表达。

  • const_cast: 去除类型中的const信息,只是去掉转型类型修饰符。
1
2
3
4
5

const Shape *sp = new Shape
Share *sp1 = const_cast<Shape *>(sp)


  • static_cast: 用于相对而言可跨平台的移植的转型,最常的用于将一个基类指针或者引用,向下转型为一个派生类指针与引用
1
2
3
4
5
6

Shape *sp = new Circle;

Circle *cp = static_cast<Circle *>(sp)


  • dynamic_cast: 基类指针向下(downcast)转型为派生类指针, 与static_cast不同的是, dynamic_cast仅用于多态类型的向下转型(就是说被转型的类型中,必须是一个指向带有虚函数的类类型的指针),并且会执行运行期的检查,确保了转型的正确。这也为dynamic_cast带来了额外的开销。
1
2
3
4
5
6

const Circle *cp = dynamic_cast<const Circle*>(getNextShape());
if (cp) {
...
}

如果转型失败,会抛出一个std::bad_cast的异常。

  • reinterpret_cast: 允许从bit位的角度重新看待一个对象,将其看做是完全不同的东西
1
2
3
4

char *cp = reinterpret_cast<char *>(0x00ff0000);
int *ip = reinterpret_cast<int *>(cp)

通常来说,reinterpret_cast并不具备可移植性,因此使用时需要谨慎。用于类类型转换时,注意其与static_cast的区别,reinterpret_cast只是将基类指针假装成一个派生类指针而不改变其值,而static_cast则会执行地址操作。

更多关于类型转换可以参考The genesis of casting..

参考资料

原文作者:Jason Wang

更新日期:2023-06-10, 08:54:39

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. constants
  2. 2. 引用与右值
  3. 3. lamda表达式与函数对象
  4. 4. 智能指针(smart pointers)
  5. 5. 类型推断(auto/decltype)
  6. 6. 标准库
  7. 7. 转型操作符
  8. 8. 参考资料