01 堆、栈、RAII:C++里该如何管理资源?

今天我们就正式开启了 C++ 的学习之旅,作为第一讲,我想先带你把地基打牢。我们来学习一下内存管理的基本概念,大致的学习路径是:先讲堆和栈,然后讨论 C++ 的特色功能 RAII。掌握这些概念,是能够熟练运用 C++ 的基础。 基本概念 堆,英文是 heap,在内存管理的语境下,指的是动态分配内存的区域。这个堆跟数据结构里的堆不是一回事。这里的内存,被分配之后需要手工释放,否则,就会造成内存泄漏。 C++ 标准里一个相关概念是自由存储区,英文是 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集: new 和 delete 操作的区域是 free store malloc 和 free 操作的区域是 heap 但 new 和 delete 通常底层使用 malloc 和 free 来实现,所以 free store 也是 heap。鉴于对其区分的实际意义并不大,在本专栏里,除非另有特殊说明,我会只使用堆这一术语。 栈,英文是 stack,在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构里的栈高度相似,都满足“后进先出”(last-in-first-out 或 LIFO)。 RAII,完整的英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++ 是唯一一个依赖 RAII 来做资源管理的。 RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。对 RAII 的使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法,也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。...

March 13, 2024 · zlzong

02 自己动手,实现C++的智能指针

上一讲,我们描述了一个某种程度上可以当成智能指针用的类 shape_wrapper。使用那个智能指针,可以简化资源的管理,从根本上消除资源(包括内存)泄漏的可能性。这一讲我们就来进一步讲解,如何将 shape_wrapper 改造成一个完整的智能指针。你会看到,智能指针本质上并不神秘,其实就是 RAII 资源管理功能的自然展现而已。 在学完这一讲之后,你应该会对 C++ 的 unique_ptr 和 shared_ptr 的功能非常熟悉了。同时,如果你今后要创建类似的资源管理类,也不会是一件难事。 回顾 我们上一讲给出了下面这个类: class shape_wrapper { public: explicit shape_wrapper(shape* ptr = nullptr) : ptr_(ptr) {} ~shape_wrapper() { delete ptr_; } shape* get() const { return ptr_; } private: shape* ptr_; }; 这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。但它缺了点东西: 这个类只适用于 shape 类 该类对象的行为不够像指针 拷贝该类对象会引发程序行为异常 下面我们来逐一看一下怎么弥补这些问题。 模板化和易用性 要让这个类能够包装任意类型的指针,我们需要把它变成一个类模板。这实际上相当容易: template <typename T> class smart_ptr { public: explicit smart_ptr(T* ptr = nullptr) : ptr_(ptr) {} ~smart_ptr() { delete ptr_; } T* get() const { return ptr_; } private: T* ptr_; }; 和 shape_wrapper 比较一下,我们就是在开头增加模板声明 template <typename T>,然后把代码中的 shape 替换成模板参数 T 而已。这些修改非常简单自然吧?模板本质上并不是一个很复杂的概念。这个模板使用也很简单,把原来的 shape_wrapper 改成 smart_ptr<shape> 就行。...

March 13, 2024 · zlzong

03 右值和移动究竟解决了什么问题?

从上一讲智能指针开始,我们已经或多或少接触了移动语义。本讲我们就完整地讨论一下移动语义和相关的概念。移动语义是 C++11 里引入的一个重要概念;理解这个概念,是理解很多现代 C++ 里的优化的基础。 值分左右 我们常常会说,C++ 里有左值和右值。这话不完全对。标准里的定义实际更复杂,规定了下面这些值类别(value categories): 我们先理解一下这些名词的字面含义: 一个 lvalue 是通常可以放在等号左边的表达式,左值 一个 rvalue 是通常只能放在等号右边的表达式,右值 一个 glvalue 是 generalized lvalue,广义左值 一个 xvalue 是 expiring lvalue,将亡值 一个 prvalue 是 pure rvalue,纯右值 还是有点晕,是吧?我们暂且抛开这些概念,只看其中两个:lvalue 和 prvalue。 左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有: 变量、函数或数据成员的名字 返回左值引用的表达式,如 ++x、x = 1、cout << ' ' 字符串字面量如 "hello world" 在函数调用时,左值可以绑定到左值引用的参数,如 T&。一个常量只能绑定到常左值引用,如 const T&。 反之,纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为“临时对象”。最常见的情况有: 返回非引用类型的表达式,如 x++、x + 1、make_shared<int>(42) 除字符串字面量之外的字面量,如 42、true 在 C++11 之前,右值可以绑定到常左值引用(const lvalue reference)的参数,如 const T&,但不可以绑定到非常左值引用(non-const lvalue reference),如 T&。从 C++11 开始,C++ 语言里多了一种引用类型——右值引用。右值引用的形式是 T&&,比左值引用多一个 & 符号。跟左值引用一样,我们可以使用 const 和 volatile 来进行修饰,但最常见的情况是,我们不会用 const 和 volatile 来修饰右值。本专栏就属于这种情况。...

March 13, 2024 · zlzong

04 容器汇编 I:比较简单的若干容器

上几讲我们学习了 C++ 的资源管理和值类别。今天我们换一个话题,来看一下 C++ 里的容器。 关于容器,已经存在不少的学习资料了。在 cppreference 上有很完备的参考资料([1])。今天我们采取一种非正规的讲解方式,尽量不重复已有的参考资料,而是让你加深对于重要容器的理解。 对于容器,学习上的一个麻烦点是你无法直接输出容器的内容——如果你定义了一个 vector<int> v,你是没法简单输出 v 的内容的。有人也许会说用 copy(v.begin(), v.end(), ostream_iterator(…)),可那既啰嗦,又对像 map 或 vector<vector<…>> 这样的复杂类型无效。因此,我们需要一个更好用的工具。在此,我向你大力推荐 xeus-cling [2]。它的便利性无与伦比——你可以直接在浏览器里以交互的方式运行代码,不需要本机安装任何编译器(点击“Trying it online”下面的 binder 链接)。下面是在线运行的一个截图: xeus-cling 也可以在本地安装。对于使用 Linux 的同学,安装应当是相当便捷的。有兴趣的话,使用其他平台的同学也可以尝试一下。 如果你既没有本地运行的条件,也不方便远程使用互联网来运行代码,我个人还为本专栏写了一个小小的工具 [3]。在你的代码中包含这个头文件,也可以方便地得到类似于上面的输出。示例代码如下所示: #include <iostream> #include <map> #include <vector> #include "output_container.h" using namespace std; int main() { map<int, int> mp{{1, 1}, {2, 4}, {3, 9}}; cout << mp << endl; vector<vector<int>> vv{{1, 1}, {2, 4}, {3, 9}}; cout << vv << endl; } 我们会得到下面的输出:...

March 13, 2024 · zlzong

05 容器汇编 II:需要函数对象的容器

上一讲我们学习了 C++ 的序列容器和两个容器适配器,今天我们继续讲完剩下的标准容器([1])。 函数对象及其特化 在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,less 和 hash。 我们先看一下 less,小于关系。在标准库里,通用的 less 大致是这样定义的: template <class T> struct less : binary_function<T, T, bool> { bool operator()(const T& x, const T& y) const { return x < y; } }; 也就是说,less 是一个函数对象,并且是个二元函数,执行对任意类型的值的比较,返回布尔类型。作为函数对象,它定义了函数调用运算符(operator()),并且缺省行为是对指定类型的对象进行 < 的比较操作。 有点平淡无奇,是吧?原因是因为这个缺省实现在大部分情况下已经够用,我们不太需要去碰它。在需要大小比较的场合,C++ 通常默认会使用 less,包括我们今天会讲到的若干容器和排序算法 sort。如果我们需要产生相反的顺序的话,则可以使用 greater,大于关系。 计算哈希值的函数对象 hash 就不一样了。它的目的是把一个某种类型的值转换成一个无符号整数哈希值,类型为 size_t。它没有一个可用的默认实现。对于常用的类型,系统提供了需要的特化 [2],类似于: template <class T> struct hash; template <> struct hash<int> : public unary_function<int, size_t> { size_t operator()(int v) const noexcept { return static_cast<size_t>(v); } }; 这当然是一个极其简单的例子。更复杂的类型,如指针或者 string 的特化,都会更复杂。要点是,对于每个类,类的作者都可以提供 hash 的特化,使得对于不同的对象值,函数调用运算符都能得到尽可能均匀分布的不同数值。...

March 13, 2024 · zlzong

06 异常:用还是不用,这是个问题

到现在为止,我们已经有好多次都提到异常了。今天,我们就来彻底地聊一聊异常。 首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。 下面我们就开始说说异常。 没有异常的世界 我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。 假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构: typedef struct { float* data; size_t nrows; size_t ncols; } matrix; 我们至少需要有初始化和清理的代码: enum matrix_err_code { MATRIX_SUCCESS, MATRIX_ERR_MEMORY_INSUFFICIENT, … }; int matrix_alloc(matrix* ptr, size_t nrows, size_t ncols) { size_t size = nrows * ncols * sizeof(float); float* data = malloc(size); if (data == NULL) { return MATRIX_ERR_MEMORY_INSUFFICIENT; } ptr->data = data; ptr->nrows = nrows; ptr->ncols = ncols; } void matrix_dealloc(matrix* ptr) { if (ptr->data == NULL) { return; } free(ptr->data); ptr->data = NULL; ptr->nrows = 0; ptr->ncols = 0; } 然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:...

March 13, 2024 · zlzong

07 迭代器和好用的新for循环

我们已经讲过了容器。在使用容器的过程中,你也应该对迭代器(iterator)或多或少有了些了解。今天,我们就来系统地讲一下迭代器。 什么是迭代器? 迭代器是一个很通用的概念,并不是一个特定的类型。它实际上是一组对类型的要求([1])。它的最基本要求就是从一个端点出发,下一步、下一步地到达另一个端点。按照一般的中文习惯,也许“遍历”是比“迭代”更好的用词。我们可以遍历一个字符串的字符,遍历一个文件的内容,遍历目录里的所有文件,等等。这些都可以用迭代器来表达。 我在用 output_container.h 输出容器内容的时候,实际上就对容器的 begin 和 end 成员函数返回的对象类型提出了要求。假设前者返回的类型是 I,后者返回的类型是 S,这些要求是: I 对象支持 * 操作,解引用取得容器内的某个对象。 I 对象支持 ++,指向下一个对象。 I 对象可以和 I 或 S 对象进行相等比较,判断是否遍历到了特定位置(在 S 的情况下是是否结束了遍历)。 注意在 C++17 之前,begin 和 end 返回的类型 I 和 S 必须是相同的。从 C++17 开始,I 和 S 可以是不同的类型。这带来了更大的灵活性和更多的优化可能性。 上面的类型 I,多多少少就是一个满足输入迭代器(input iterator)的类型了。不过,output_container.h 只使用了前置 ++,但输入迭代器要求前置和后置 ++ 都得到支持。 输入迭代器不要求对同一迭代器可以多次使用 * 运算符,也不要求可以保存迭代器来重新遍历对象,换句话说,只要求可以单次访问。如果取消这些限制、允许多次访问的话,那迭代器同时满足了前向迭代器(forward iterator)。 一个前向迭代器的类型,如果同时支持 --(前置及后置),回到前一个对象,那它就是个双向迭代器(bidirectional iterator)。也就是说,可以正向遍历,也可以反向遍历。 一个双向迭代器,如果额外支持在整数类型上的 +、-、+=、-=,跳跃式地移动迭代器;支持 [],数组式的下标访问;支持迭代器的大小比较(之前只要求相等比较);那它就是个随机访问迭代器(random-access iterator)。 一个随机访问迭代器 i 和一个整数 n,在 *i 可解引用且 i + n 是合法迭代器的前提下,如果额外还满足 *(addressdof(*i) + n) 等价于 *(i + n),即保证迭代器指向的对象在内存里是连续存放的,那它(在 C++20 里)就是个连续迭代器(contiguous iterator)。...

March 13, 2024 · zlzong

08 易用性改进 I:自动类型推断和初始化

在之前的几讲里,我们已经多多少少接触到了一些 C++11 以来增加的新特性。下面的两讲,我会重点讲一下现代 C++(C++11/14/17)带来的易用性改进。 就像我们 [开篇词] 中说的,我们主要是介绍 C++ 里好用的特性,而非让你死记规则。因此,这里讲到的内容,有时是一种简化的说法。对于日常使用,本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况,你可能还是需要查阅参考资料里的明细规则。 自动类型推断 如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话,那它就是“舍我其谁”的第一了。 auto 自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明([1])。但需要说明的是,auto 并没有改变 C++ 是静态类型语言这一事实——使用 auto 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。 自动类型推断使得像下面这样累赘的表达式成为历史: // vector<int> v; for (vector<int>::iterator it = v.begin(), end = v.end(); it != end; ++it) { // 循环体 } 现在我们可以直接写(当然,是不使用基于范围的 for 循环的情况): for (auto it = v.begin(), end = v.end(); it != end; ++it) { // 循环体 } 不使用自动类型推断时,如果容器类型未知的话,我们还需要加上 typename(注意此处 const 引用还要求我们写 const_iterator 作为迭代器的类型): template <typename T> void foo(const T& container) { for (typename T::const_iterator it = v....

March 13, 2024 · zlzong

09 易用性改进 II:字面量、静态断言和成员函数说明符

本讲我们继续易用性的话题,看看现代 C++ 带来的其他易用性改进。 自定义字面量 字面量(literal)是指在源代码中写出的固定常量,它们在 C++98 里只能是原生类型,如: "hello",字符串字面量,类型是 const char[6] 1,整数字面量,类型是 int 0.0,浮点数字面量,类型是 double 3.14f,浮点数字面量,类型是 float 123456789ul,无符号长整数字面量,类型是 unsigned long C++11 引入了自定义字面量,可以使用 operator"" 后缀,来将用户提供的字面量转换成实际的类型。C++14 则在标准库中加入了不少标准字面量。下面这个程序展示了它们的用法: #include <chrono> #include <complex> #include <iostream> #include <string> #include <thread> using namespace std; int main() { cout << "i * i = " << 1i * 1i << endl; cout << "Waiting for 500ms" << endl; this_thread::sleep_for(500ms); cout << "Hello world"s.substr(0, 5) << endl; } 输出是:...

March 13, 2024 · zlzong

10-1 C++ 函数可以直接返回一个对象吗?

内存和资源管理是 C++ 最强的能力之一,也是 C++ 最复杂和最需要思考的地方。写 Java 的时候,我们只需要无脑地把所有对象都 new 出来。反正所有的对象只能放在堆区,又反正又垃圾回收器帮我们管理内存。然而,在 C++ 中,我们需要思考是把对象放在栈上,还是用 new 把对象放在堆上。默认情况下,对象会放在栈上,这样的好处是我们不会忘记释放对象的内存而造成内存泄漏。不过如果我们把一个大对象放在栈上,又将其作为参数或者返回值传递,就必须要考虑对象拷贝的开销了。C++ 由于和 C 兼容,默认情况下参数是按值传递 (call by value) 的,在传递参数和返回值的时候都会拷贝一遍对象。对于参数,我们尚可以将参数声明为引用类型 T& 来避免对象拷贝。而对于返回值的拷贝开销,则是不能声明为引用类型来解决的。 何时必须返回一个对象 假设我们想写一个 range 函数: vector<int> range(int begin, int end, int step=1) { vector<int> res; for (int i = begin; i < end; i += step) { res.push_back(i); } return res; } 这段代码会返回一个 vector<int> 对象,也就是我们不希望看到的:放在栈上的大对象。调用这个函数会产生返回值的临时对象,从而需要拷贝列表中的所有元素。很显然,你不能直接把返回值类型改成 vector<int>& 来避免对象拷贝——编译器会产生一个警告:warning: reference to local variable ‘res’ returned,你返回了一个临时变量的引用,这个引用指向了一个栈上的地址,而这个地址随时可能被回收。这也是 C++ 初学者容易犯的一个错误。既然不能返回一个引用,又想避免对象拷贝的开销,很多“老” C++ 程序员会进行一个人肉优化:把返回值作为引用参数传进去。按这种方法,range() 函数可以改写如下: vector<int> range(vector<int>& out, int begin, int end, int step=1) { for (int i = begin; i < end; i += step) { out....

March 13, 2024 · zlzong