C++ - Basic

Table of Contents

本文是阅读 Professional C++, third edition 中文版1的读书笔记.

预处理指令

  • 预处理指令以 # 字符开始. 如 #pragma once 可以防止文件被包含多次.
  • 头文件最常见的用途是声明在其他地方定义的函数.
  • 宏是C遗留下来的, 类似于内联函数, 但不执行类型检测, 在调用宏时, 预处理器会自动用扩展式替换, 并不会真正地应用函数调用语义, 这一行为可能导致无法预测的结果. 应该全部用内联函数替代宏.

头文件

头文件是为子系统或者代码段提供抽象接口的一种机制. 使用头文件需要注意两个方面

  • 避免循环引用. 使用前置声明(forward declarations).
  • 避免多次引用同一个头文件. 使用头文件保护(include guards), 如 #ifndef 机制或者 #pragma once 指令.

建议尽可能使用前置声明, 而不是包含其他头文件. 这可以减少编译和重编译时间, 因为它破坏了一个头文件对其他头文件的依赖. 当然, 实现文件需要包含前置声明的类型的正确头文件, 否则无法编译.

函数

  • 函数声明通常称为"函数原型"或"签名", 以强调其代表函数的访问方式, 而不是具体的代码.
  • 函数参数的默认值只能存在于函数声明中, 不能放在函数定义中.
  • 与C不同, 在C++中没有形参的函数仅需要一个空的圆括号, 不需要使用 void 指出此处没有形参. 然而, 如果没有返回值, 仍需要使用 void 来指明这一点.
  • main() 函数是程序的入口. main() 函数返回一个 int 值以指示程序的最终执行状态. main() 函数或者没有参数, 或者具有两个参数.
int main(int argc, char* argv[])
  • 自C++11以来, C++通过拖尾返回类型(trailing return type)支持一种替代的函数语法. 函数的返回类型不再位于开头, 而放在行尾的箭头 -> 后面. 这在指定模板函数的返回类型时非常有用.
auto func(int i) -> int
{
    return i + 1;
}
  • C++14允许要求编译器自动推断出函数的返回类型. 为此, 需要指定返回类型为 auto, 并忽略所有拖尾返回类型.

类型

typedef

typedef 为已有的类型声明提供一个新名称. 可将 typedef 看成已有类型声明的同义词, typedef 不会创建新的类型.

  • typedef 的最常见用法是当实际类型的声明过于笨拙时, 提供易于管理的名称, 这一情形通常出现在模板中. STL广泛使用 typedef 提供类型的简短名称. 如 string 实际上是一个 typedef.
typedef basic_string<char, char_traits<char>, allocator<char>> string
  • typedef 可以包括作用域限定符.

类型别名

某些情况下, 类型别名比 typedef 更容易理解. 例如,

typedef int my_int;

可以重写为

using my_int = int;

类型转换

const_cast

const_cast 是唯一可以舍弃常量特性的类型转换. 当然, 只有确保调用的函数不修改对象的情况下才能这么做.

static_cast

  • 显式地执行C++语言直接支持的转换.
  • 在继承层次结构中执行向下转换. 这种转换可以用于指针和引用, 而不适用于对象本身.

注意, static_cast 不执行运行期间的类型检测. 它允许将任何基类对象的指针或引用转换为派生类对象的指针或引用.

reinterpret_cast

reinterpret_caststatic_cast 功能更强大, 同时安全性也更差. 可以用它执行一些在技术上不被C++类型规则允许, 但在某些情况下又需要的类型转换. 如

  • 可将某种引用类型转换为其他引用类型, 即使这两个引用并不相关.
  • 可将某种指针类型转换为其他指针类型, 即使这两个指针并不存在继承层次上的关系. 比如指针和 void* 之间的相互转换.

理论上, 可以使用 reinterpret_cast 将指针和 int 相互转换. 但是, 这种程序是不正确的, 因为许多平台(特别是64位平台), 指针和 int 的大小不一样. 例如, 在64位平台上, 指针是64位, int 是32位. 将64位的指针转换为32位的整数会导致丢失32个重要的位.

使用 reinterpret_cast 时要特别小心, 因为在执行转换时不会执行任何类型检测.

dynamic_cast

dynamic_cast 为继承层次结构内的类型转换提供运行时检测. 可用来转换指针或者引用. dynamic_cast 在运行时检测底层对象的类型信息. 如果类型转换没有意义, dynamic_cast 返回一个空指针(对于指针转换)或者抛出一个 std::bad_cast 异常(对于引用转换).

由于运行时类型信息存储在对象的虚表中, 所以为了使用 dynamic_cast, 类至少有一个虚方法. 如果类没有虚表, 尝试使用 dynamic_cast 会导致编译错误.

static_castreinterpret_cast 相比, dynamic_cast 会执行运行时(动态)类型检测, 而 static_castreinterpret_cast 甚至会执行不正确的类型转换.

类型推断

类型推断允许编译器自动推断出表达式的类型. 类型推断有两个关键字: autodecltype.

auto

  • 告诉编译器, 在编译时自动推断变量的类型.
  • 用于替代函数语法.
  • 用于函数返回类型的推断.
  • 用于通用的 lambda 表达式.

decltype

  • 关键字 decltype 把表达式作为实参, 可以计算出该表达式的类型.
  • 使用 auto 推断表达式的类型, 就不需要引用限定符和 const 限定符了. C++14引入了 decltype(auto) 来解决这个问题.

指针

  • 堆是与当前函数或堆栈帧完全没有关系的内存区域. 如果想在函数调用结束之后仍然保存其中声明的变量, 可以将变量放到堆中.
  • 在C++中, 应避免C中的 malloc()free(), 而使用 newdelete, 或者 new[]delete[]. 每次调用 new/new[], 都必须相应地调用 delete/delete[] 确保释放(删除)在堆上分配的任何内存以避免内存泄漏. 此外, 最好将指针重置为 nullptr, 这并非强制要求, 但这样做可以防止在删除内存后意外使用这个指针. 这个过程不会自动完成, 除非使用了智能指针.
  • 在任何时候都应避免使用未初始化的变量, 尤其是未初始化的指针, 因为它们会指向内存中的每个随意位置. 使用这种指针很可能导致程序崩溃. 因此, 必须显式地同时声明和初始化指针. 如果不希望立即分配内存, 可以将其初始化为空指针(nullptr).
  • 从技术角度看, 如果指针指向某个结构, 可以首先用 * 对指针解除引用, 然后使用 . 访问结构中的字段. 箭头运算符 -> 允许同时对指针解除引用并访问字段.

智能指针

C/C++常常会发生与内存有关的问题, 如

内存泄漏
没有删除对象(没有释放内存).
多重释放
一段代码释放了一块内存, 而另一段代码试图释放同一块内存.
悬挂指针
一段代码删除了一块内存, 而另一段代码仍然引用了这块内存.

为了避免这些的内存问题, 应使用智能指针替代通常C风格的"裸"指针. 智能指针是指向动态分配内存的一个指针, 当超出作用域时(如在函数执行完毕后), 会自动释放内存. C++中有如下三种智能指针, 定义在头文件 <memory> 中. 智能指针可以像普通指针那样解除引用(使用 *->)

std::unique_ptr
通用的智能指针, 可以指向任意类型的内存, 是一个模板. 自从C++14以来, 可以使用 std::make_unique<>() 创建. 在尖括号中必须指定 unique_ptr 要指向的内存类型. unique_ptr 意味着所有权. 单个 unique_ptr 离开作用域时, 会释放底层的内存.
std::shared_ptr
引用计数的智能指针, 可以使用 std::make_shared<>() 创建. 可以有多个 shared_ptr 实例指向同一块动态分配的内存. 当最后一个 shared_ptr 离开作用域时, 才释放这块内存. shared_ptr 也是线程安全的. 默认的智能指针应该是 unique_ptr. 函数 const_pointer_cast(), dynamic_pointer_cast(), 和 static_pointer_cast() 可用于转换 shared_ptr 的类型. 引用计数的智能指针跟踪了为引用一个真实指针(或某个对象)建立的智能指针的数目. 通过这种方式, 智能指针可以避免双重删除. 如果程序在使用智能指针时进行了复制, 赋值或作为参数按值传入函数, 那么 shared_ptr 是完美的选择.
std::weak_ptr
weak_ptr 可以包含由 shared_ptr 管理的内存的引用. weak_ptr 不拥有这个内存, 所以不能阻止 shared_ptr 释放内存. weak_ptr 离开作用域时不会销毁它指向的内存. 然而, 它可以用于判断内存释放已经被关联的 shared_ptr 释放了. weak_ptr 的构造函数要求将一个 shared_ptr 或另一个 weak_ptr 作为参数. 为了访问 weak_ptr 中保存的指针, 需要将 weak_ptr 转换为 shared_ptr. 为了访问 weak_ptr 中保存的指针, 需要通过以下两种方法将 weak_ptr 转换为 shared_ptr. 如果与 weak_ptr 关联的 shared_ptr 已经释放, 新的 shared_ptr 就是nullptr.
  • 使用 weak_ptr 实例的 lock() 方法, 该方法返回一个 shared_ptr.
  • weak_ptr 作为 shared_ptr 构造函数的参数, 创建一个新的 shared_ptr 实例.

在默认情况下, unique_ptrshared_ptr 使用标准的new和delete运算符来分配和释放内存. unique_ptr 适用于存储动态分配的旧C风格数组, 而 shared_ptr 却不能. unique_ptrshared_ptr 都支持移动语义, 使它们非常高效. 因此, 从函数返回 unique_ptrshared_ptr 也很高效. C++会在函数的return语句中自动调用 std::move(). unique_ptr 不支持普通的复制赋值运算符和复制构造函数, 但支持移动赋值运算符和移动构造函数, 因此可以从函数中返回 unique_ptr.

引用

在C++中, 引用是变量的别名. 所有对引用的修改都会改变被引用的变量的值. 可将引用当作隐式指针, 这个指针没有取变量地址和解除引用的麻烦.

  • 在初始化引用之后无法改变引用所指的变量; 但可以改变该变量的值.
  • 无法声明引用的引用, 或者指向引用的指针.

引用变量

  • 可以创建单独的引用变量, 当作原始变量的另一个名称. 因此对引用取地址的结果与对被引用变量取地址的结果相同.
  • 创建引用时, 必须总是初始化它, 通常会在声明引用时对其初始化.

引用数据成员

引用可以作为类的数据成员, 也即引用数据成员. 引用数据成员必须在类的构造函数初始化器中初始化它, 而不是在构造函数体内.

引用参数

引用经常作为函数和方法的参数. 默认的参数传递机制是按值传递: 函数接受参数的副本. 修改这些副本时, 原始的参数保持不变. 引用允许指定另一种向函数传递参数的语义: 按引用传递. 当使用引用参数时, 不需要将参数的副本复制到函数. 而且如果引用被修改, 原始的参数也会被修改.

只有在参数是简单的内建类型, 如 int 或者 double, 且不需要修改参数的情况下, 才应该使用按值传递. 在其他所有情况下都应该使用按引用传递.

引用返回值

  • 可以让函数或方法返回引用. 这样做的主要原因是提高效率, 因为返回对象的引用而不是返回整个对象可以避免不必要的复制.
  • 如果变量的作用域局限于函数或者方法(如堆栈中自动分配的变量, 在函数结束时会被销毁), 绝不能返回这个变量的引用.
  • 如果从函数返回的类型支持移动语义, 按值返回就几乎和按引用返回一样高效.

引用与指针

在C++中, 引用可以认为是多余的, 因为几乎所有使用引用可以完成的任务都可以用指针完成. 然而, 引用比指针安全: 不可能存在无效引用, 也不需要显式地解除引用. 对象的引用甚至可以像指向对象的指针那样支持多态性. 因此, 除了以下两种情况需要使用指针而非引用外, 其他大多数情况应该使用引用而不是指针. 也即如果不需要改变所指的地址, 就应该使用引用而不是指针.

  1. 只有在需要改变所指的地址时, 才需要使用指针, 因为无法改变引用所指的对象.
  2. 需要使用指针的第二种情况是可选参数, 如指针参数可以定义默认值为 nullptr 的可选参数, 而引用参数不能这样定义.

关于使用引用还是指针作为参数和返回类型, 主要取决于是否拥有内存: 如果接受变量的代码负责释放相关对象的内存, 必须使用指向对象的指针, 最好是智能指针, 这是传递所有权的推荐方式. 如果接受变量的代码不需要释放内存, 那么应该使用引用.

右值引用

在C++中, 左值(lvalue)是可以获取其地址的一个量, 例如一个有名称的变量. 由于经常出现在赋值语句的左边, 因此将其称作左值. 另一方面, 所有不是左值的量都是右值(rvalue), 通常右值位于赋值运算符的右边, 例如常量值, 临时对象或者临时值.

右值引用是一个对右值(rvalue)的引用. 特别地, 这是一个当右值是临时对象时适用的概念. 右值引用的目的是提供在涉及临时对象时可以选用的特定方法. 函数可将 && 作为参数说明的一部分(例如 type&&)来指定右值引用参数. 通常, 临时对象被当作 const type&, 但当函数重载使用了右值引用时, 可以解析临时对象, 用于该重载.

右值引用并不局限于函数的参数. 可以声明右值引用类型的变量, 并对其赋值.

名称空间

  • :: 称为作用域解析运算符.
  • using 指令可以用来引用名称空间的所有项或者特定项.
  • 切勿在头文件中使用 using 指令或者 using 声明.

作用域

程序中的所有名称, 包括变量, 函数和类名, 都具有某种作用域. 可以使用名称空间, 函数定义, 花括号界定的块和类定义创建作用域. 当试图访问某个变量, 函数或者类时, 首先在最近的作用域中查找这个名称, 然后是相邻的作用域, 以此类推, 直到全局作用域. 任何不在名称空间, 函数, 花括号界定的块和类中的名称都被认为在全局作用域中. 如果在全局作用域也找不到该名称, 那么编译器会给出一个未定义符号的错误.

如果不想用默认的作用域解析某个名称, 可以使用作用域解析运算符 :: 和特定的作用域限定这个名称.

数组

  • 在C++中声明数组时, 必须声明数组的大小. 数组的大小不能用变量来表示–必须用常量或常量表达式(constexpr)来表示数组大小.
  • 数组也可以用初始化列表来初始化, 此时编译器可以自动推断出数组的大小.
  • C++有一种大小固定的特殊容器 std::array, 定义在 <array> 头文件中, 其具有迭代器, 可以方便地遍历元素. 必须在尖括号中指定两个参数, 第一个表示数组中元素的类型, 第二个参数表示数组的大小.

字符串

  • 在C语言中, 字符串表示为字符的数组. 字符串中的最后一个字符是空字符(\0), 官方将这个空字符定义为 NUL. 这样, 操作字符串的代码就知道字符串在哪里结束.
  • 与字面量(literal)关联的真正内存在内存的只读部分中.
  • 原始字符串字面量(raw string literal)是可以跨越多行代码的字符串字面量, 形式为 R"d(character sequence)d", 其中 d 表示分隔符序列, 无歧义(字符串字面量中无特殊符号, 如 ")时可以省略.
  • C++的 string 类定义在 std 名称空间的头文件 <string> 中. 从技术角度看, C++ string实际上是 basic_string 模板 char 实例化的 typedef 名称. 此外, C++还包含了一些来自C语言的字符串操作函数, 定义在头文件 <cstring> 中.
  • 为了兼容, 可以使用 stringc_str() 方法获得一个表示C风格字符串的 const 字符指针. 不过, 一旦 string 对象被销毁或执行了任何内存重分配, 这个返回的 const 字符指针就失效了.
  • 数值转换
// Transformation to string
string to_string(int val);
string to_string(unsigned val);
string to_string(long val);
string to_string(unsigned long val);
string to_string(long long val);
string to_string(unsigned long long val);
string to_string(float val);
string to_string(double val);
string to_string(long double val);

// Transformation from string
int stoi(const string &str, size_t *idx = 0, int base = 10);
long stol(const string &str, size_t *idx = 0, int base = 10);
unsigned long stoul(const string &str, size_t *idx = 0, int base = 10);
long long stoll(const string &str, size_t *idx = 0, int base = 10);
unsigned long long stoll(const string &str, size_t *idx = 0, int base = 10);
float stof(const string &str, size_t *idx = 0, int base = 10);
double stof(const string &str, size_t *idx = 0, int base = 10);
long double stof(const string &str, size_t *idx = 0, int base = 10);

switch

switch 语句中, 表达式必须是整型或能转换为整型的类型, 必须与一个常量进行比较.

基于区间的 for 循环

基于区间的 for 循环(range-based for loop)允许方便地迭代容器中的元素. 这种循环类型可以用于C风格的数组, 初始化列表, 也可以用于具有返回迭代器的 begin()end() 函数的类型, 如 std::array 和其他所有STL容器.

std::array<int, 3> arr = {1, 2, 3};
for (int i : arr)
    std::cout << i << std::endl;

统一初始化

  • 在C++11之前, 初始化类型并不总是统一的. 但是从C++11以后, 允许一律使用 {...} 语法初始化类型.
  • 等号也是可选的.
  • 统一初始化并不局限于结构和类, 它还可以用于初始化C++中的任何内容.
  • 统一初始化可以把变量初始化为0, 只需指定一系列空花括号.
  • 统一初始化可以用来初始化动态分配的数组. 如
int *p = new int[3]{0, 1, 2};
  • 统一初始化可以在构造函数初始化器中初始化类成员数组.

初始化列表

初始化列表 std::initializer_list<T> 定义在头文件 <initializer_list> 中, 简化了参数数量可变函数的编写. 与变长参数列表不同, 初始化列表中所有的元素都应该是同一种预定义类型. 由于定义了列表中允许的类型, 初始化列表是类型安全的.

显式转换运算符

  • 从C++11以后, 可以用关键字 explicit 禁止编译器执行隐式转换.
  • 从C++11以后, explicit 的使用并不局限于构造函数, 还可以用于转换运算符.
  • 如果定义了显式转换运算符, 如果想要使用它, 必须显式地调用(如 static_cast).

异常

C++语言提供了一个名为"异常"的特性,用来处理不正常的但能预料的情况。程序不是孤立存在的。它们都依赖于外部工具,如操作系统界面、网络和文件系统、外部代码(如第三方库)和用户输入。所有这些领域都可能出现这样的状况:需要响应所遇到的错误。这些潜在的问题就是异常情况(exceptional situations)。遇到错误的代码抛出异常,处理异常的代码捕获异常。异常不遵循逐步执行的规则,当某段代码抛出异常时,程序控制立即停止逐步执行,并转向异常处理程序(exception handler),异常处理程序可以在任何地方,可以位于同一函数的下一行,也可以在堆栈中相隔好几个函数调用。

使用异常要在程序中包括两个部分:处理异常的try/catch结构和抛出异常的throw语句。二者都必须以某种形式出现,以进行异常处理。然而在许多情况下,throw在一些库的深处(包括C++运行时)发生,无法看到,但仍然不得不用try/catch结构处理抛出的异常。try/catch结构如下所示:

try
{
    // ... code which may result in an exception being thrown
}
catch (exception-type1 exception-name)
{
    // ... code which responds to the exception of type 1
}
catch (exception-type2 exception-name)
{
    // ... code which responds to the exception of type 2
}
// ... remaining code

throw是C++的关键字,这是抛出异常的唯一方法。导致抛出异常的代码可能直接包含throw,也可能调用一个函数,这个函数可能直接抛出异常,也可能经过多层的调用后调用一个抛出异常的函数。如果没有抛出异常,catch块中的代码不会执行,其后“剩余的代码”将在try块最后执行的语句之后执行。如果抛出了异常,在throw之后或者在抛出异常的函数之后的代码不会执行,根据抛出异常的类型,控制会立刻转移到对应的catch块。如果catch块没有执行控制转移(例如返回一个值,抛出新的异常或者重新抛出异常),会执行catch块最后语句之后的“剩余代码”。

标准库中的所有异常构成了一个层次结构。该层次结构中的每个类都支持 what() 方法,该方法返回一个描述异常的 const char* 字符串。该字符串在异常的构造函数中提供。throw关键字还可以用于再次抛出当前异常。

异常处理是这样一种方法:“尝试”执行一段代码,并且用另一块代码响应可能发生的任何错误。如果try块结束时没有抛出异常,catch块将被忽略。可以将try/catch块当作if语句。如果在try块中抛出异常,就会执行catch块,否则就忽略catch块。

可以抛出任何类型的异常,但通常应该将对象作为异常抛出。因为对象的类名称可以传递信息,而且对象可以存储信息,包括描述异常的字符串。C++标准库包含了许多预定义的异常类,也可以编写自己的异常类。

cpp_stdexcept.png

Figure 1: C++标准异常层次结构

当某段代码抛出一个异常时,会在堆栈中寻找catch处理程序。catch处理程序可以是在堆栈执行的0个或者多个函数调用。当发现一个catch时,堆栈会释放所有中间堆栈帧,直接跳到定义catch处理程序的堆栈层。

堆栈释放(stack unwinding)意味着调用所有具有局部作用域的名称的析构函数,并忽略在当前执行点之前的每个函数中所有的代码。然而当堆栈释放时,并不释放指针变量,也不会执行其他清理。如果基于堆栈的内存分配不可用,就应使用智能指针。在处理异常时,智能指针可以使编写的代码自动防止内存或者资源的泄露。智能指针对象在堆栈中分配,无论什么时候销毁智能指针对象,都会释放底层的资源。使用智能指针时,永远不必考虑释放底层的资源:智能指针的析构函数会自动完成这一操作,无论是正常退出函数,还是抛出异常退出函数,都是如此。避免内存和资源泄露的另一种技术是对于每个函数,捕获可能抛出的所有异常,执行必要的清理,并重新抛出异常,供堆栈中更高层的函数处理。显然,智能指针是比捕获、清理和重新抛出异常更好的解决方案。

接下来是使用异常时最常见的错误处理问题。

内存分配错误

如果无法分配内存, newnew[] 的默认行为是抛出 bad_alloc 类型的异常。这个类型在头文件 <new> 中定义。代码应该捕获这些异常,并正确地处理。虽然不可能把 newnew[] 的调用都放在try/catch块中,但是至少在分配大块内存时应该这么做。

为了方便调试,预定义了预处理符号 __FILE____LINE__ ,这两个符号将被文件名和当前行号替换掉。

C++提供了 newnew[]nothrow 版本 new(nothrow) ,如果内存分配失败,将返回 nullptr ,而不是抛出异常。默认情况下不存在new handler,因此new和new[]只是抛出 bad_alloc 异常。C++允许指定new handler回调函数。如果存在new handler,当内存分配失败时,内存分配会调用new handler,而不是抛出异常。C++标准指出,如果new handler抛出异常,它必须是 bad_alloc 异常或者其派生类。new handler可以记录错误信息,并抛出一个约定好的异常。绝不要显式地使用 exit() 或者 abort() 终止程序,而应该从顶层函数返回。在顶层函数中(例如main())捕获这个异常。定义在头文件 <new> 中的 set_new_handler()2 函数可以设置new handler。

构造函数中的错误

如果异常离开构造函数,那么对象的析构函数将无法调用。因此在异常离开构造函数之前,必须在构造函数中清理所有资源并释放分配的所有内存。

C++保证会运行任何构建完整“子对象”的析构函数。因此,任何没有发生异常的构造函数所对应的析构函数都会执行。基类的构造函数在派生类构造函数之前运行,如果派生类构造函数抛出一个异常,C++会运行任何构建完整的基类的析构函数。

构造函数的 function-try-blocks

如果在构造函数的初始化器(ctor-initializer)中抛出异常,需要 function-try-blocks 来捕获。=function-try-blocks= 用于普通函数和构造函数。构造函数 function-try-blocks 的基本语法为

My_Class::My_Class()
try
    : <ctor-initializer>
{
    /* ... constructor body ... */
}
catch (const exception &e)
{
    /* ... */
}

try关键字在ctor-initializer之前。catch语句应该在构造函数的右花括号之后,实际上是将catch块放在构造函数体外部。当使用构造函数的 function-try-blocks 时,需注意:

  • catch语句将捕获任何异常,无论是构造函数体或ctor-initializer直接或者间接抛出的异常,都是如此。
  • catch语句必须重新抛出当前异常或者抛出新异常。如果catch语句没有这么做,运行时将自动重新抛出当前异常。
  • catch语句可以访问传递给构造函数的参数。
  • 当catch语句捕获了一个 function-try-blocks 内的异常时,构造函数已经构建的所有对象都会在执行catch语句之前销毁。
  • 在catch语句中,不应访问对象成员变量,因为它们在执行catch语句前就销毁了。如果有这样的裸资源,就必须在catch语句后释放它们。
  • 对于 function-try-blocks 中的catch语句而言,其中包含的函数不能使用 return 关键字返回值。

因此,构造函数的 function-try-blocks 只在下列少数情况下有用:

  • 将ctor-initializer抛出的异常转换为其他异常。
  • 将信息记录到日志文件。
  • 释放在抛出异常之前就在ctor-initializer中分配了内存的裸资源。

function-try-blocks 并不局限于构造函数,也可以用于普通函数。然而,对于普通函数而言没有理由使用 function-try-blocks ,因为 function-try-blocks 可以方便地转换为函数体内部的try/catch块。与构造函数相比,对普通函数使用 function-try-blocks 的明显不同在于catch语句不需要抛出当前异常或者新的异常,C++运行时也不会自动重新抛出异常。

析构函数中的错误

必须在析构函数内部处理析构函数引起的所有错误。不应该让析构函数抛出任何异常。如果在堆栈释放过程中有其他未解决的异常,析构函数是可以运行的。如果在堆栈释放过程中析构函数抛出一个异常,C++运行库会调用 std::terminate() ,终止应用程序。析构函数是释放对象使用的内存和资源的一个机会。如果因为异常而提前退出函数,将永远没有办法返回来释放内存或者资源。所以在析构函数中要小心捕获调用析构函数时可能抛出的任何异常。

关键字

const

const 是constant的缩写, 指保持不变的量. 编译器会执行这一要求, 任何尝试改变常量的行为都会被当做错误处理. 此外, 当启用了优化时, 编译器可以利用此信息生成更好的代码. const 关键字可以用来标记变量或者参数, 也可以用来标记方法.

可以使用 const 来"保护"变量不被修改. 可以取代 #define 来定义常量. 可以将任何变量标记为 const, 包括全局变量和类数据成员. const 关键字采用右置修饰, 也即应用于直接位于它左边的任何内容.

const 应用于引用通常比应用于指针更简单, 因为

  • 引用默认为 const, 无法改变引用所指的对象. 因此, 无需显式地将引用标记为 const.
  • 无法创建一个引用的引用, 所以引用通常只有一层间接取值. 获取多层间接取值的唯一方法是创建指针的引用.

const 引用经常用作参数. 如果按引用传递某个值(比如为了提高效率), 但不想修改这个值, 可将其标记为 const 引用. 将对象作为参数传递时, 默认选择应该是 const 引用. 只有在明确需要修改对象时, 才能忽略 const.

在类的定义中, 可以将类方法标记为 const, 以禁止方法修改类的任何非可变(non-mutable)数据成员. 因此, 最好将不改变对象的任何数据成员的成员函数声明为 const.

static

C++中 static 关键字的最终目的是创建离开和进入作用域时都可以保留值的局部变量.

程序中所有的全局变量和类的静态数据成员都会在 main() 开始之前初始化. 给定源文件中的变量以在源文件中出现的顺序初始化. 不同源文件中非局部变量的初始化顺序是不确定的.

非局部变量按照其初始化的逆序进行销毁.由于不同源文件中非局部变量的初始化顺序是不确定的, 所以其销毁顺序也是不确定的.

静态数据成员和方法

可以声明类的静态数据成员和方法.

  • 静态数据成员与非静态数据成员不同, 它不是对象的一部分, 而是类的一部分. 静态数据成员只有一个副本, 而且该副本存在于类的任何对象之外.
  • 类似地, 静态方法也是存在于类层次(而不是对象层次). 静态方法不会在某个特定对象环境中执行.

静态链接(static linkage)

C++每个源文件都是单独编译的, 编译得到的目标文件会彼此链接. C++源文件中的每个名称(包括函数和全局变量), 都有一个内部或者外部的链接. 外部链接意味着这个名称在其他源文件中也有效, 内部链接(也称为静态链接)意味着在其他源文件中无效. 默认情况下, 函数和全局变量都拥有外部链接. 然而, 可在声明前使用关键字 static 指定内部链接.

函数中的静态变量

函数中的静态变量就像是一个只能在函数内部访问的全局变量.

extern

关键字 extern 好像是 static 的反义词, 将它后面的名称指定为外部链接. 例如, consttypedef 在默认情况下是内部链接, 可以使用 extern 使其变为外部链接.

面向对象思想

面向对象编程(object oriented programming, OOP)的基本观念不是将程序分割为若干任务, 而是将其分为自然对象的模型.

对象之间的关系

"有一个(has a)"
"有一个"关系或者聚合关系的模式是A有一个B, 或者A包含一个B. 可以认为一个对象是另外一个对象的一部分.
"是一个(is a)"
"是一个"关系或者派生或者子类或者扩展或者继承, 表明一种层次关系. 当需要提供相关类型的不同行为时, 应该使用继承.

层次结构

优秀的面向对象层次结构能够做到以下几点

  • 使类之间存在有意义的功能关系.
  • 将共同的功能放入基类, 从而支持代码重用.
  • 避免子类过多地重写父类的功能, 除非父类是一个抽象类.

抽象

抽象的关键在于有效地分离接口和实现. 实现是用来完成任务的代码, 接口是其他用户使用代码的方式. 优秀的接口只包含公有行为, 类的属性/变量绝不应该是公有, 但是可以通过 gettersetter 公有行为公开.

代码重用

高聚合
当设计库或者框架时, 应该关注单个任务或者一组任务. 避免组合不相干的概念或者逻辑上独立的概念.
低耦合
将子系统设计为可以单独重用的分立组件.
模板
C++模板的概念允许以类型或者类的形式创建泛型结构. 如果打算为不同的类型提供相同的功能, 或者要创建一个可以存储任何类型的容器, 应该使用模板. 模板不是编写泛型数据结构的唯一机制. 在C和C++中, 可以通过存储 void* 指针(而不是特定类型)来编写泛型数据结构. 通过将类型转换为 void*, 用户可以用这个结构存储他们想要的任何类型. 然而这不是类型安全的: 容器无法检测或者强迫指定存储元素的类型. 可以将任何类型转换为 void*, 存储在这个结构中, 当从这个数据结构中删除指针时, 必须将它们转换为对应的类型.

类和对象

定义

类可以有许多成员, 可以是成员变量(数据成员), 也可以是成员函数(方法, 构造函数或析构函数). 成员函数和成员变量不能同名. 最好将不改变对象的成员函数声明为 const.

访问控制

类中的每个方法和成员都可以用如下访问说明符(access specifiers)来说明. 访问说明符将应用于其后声明的所有成员, 直到遇到另一个访问说明符.

public
将属性或者行为设置为 public 意味着其他代码可以访问它们.
protected
意味着其他代码不能访问这个属性或者行为, 但是子类可以访问. 也即, 派生类的成员函数可以调用基类的 protected 成员.
private
最严格的控制, 意味着不仅其他代码不能访问这个属性或者行为, 子类也不能访问. 也即, 派生类的成员函数不能访问基类的 private 成员.

建议不要使用公有数据成员, 应该通过公有的 getter/setter 方法来访问. 如果要访问静态数据成员, 应该相应地使用静态的 getter/setter.

与类相似, C++中的结构(struct)也可以拥有方法. 实际上, 结构与类的唯一区别在于结构的默认访问说明符是 public, 而类的默认访问说明符是 private.

this

每个普通的方法调用都会传递一个指向对象的指针, 即称为隐藏参数的 this 指针.

构造函数(constructor)

基本概念

当创建对象时(同时也会创建内嵌的对象), 会执行一个构造函数. 从语法上讲, 构造函数是与类同名的方法, 没有返回类型, 可以有也可以没有参数. 没有参数的构造函数称为默认构造函数或者零参数构造函数.

  • 在堆栈中创建对象时, 调用默认构造函数不需要使用圆括号.
  • 如果没有指定任何构造函数, 编译器将自动生成一个默认构造函数. 然而, 如果声明了构造函数(默认构造函数或者其他构造函数), 编译器就不会再自动生成默认构造函数.

构造函数初始化器

除了构造函数体内, C++还支持在构造函数初始化器中初始化数据成员. 构造函数初始化器出现在构造函数参数列表和构造函数函数体之间, 以冒号开始, 由逗号分隔, 允许在创建数据成员时执行初始化. 值得注意的是, 初始化数据成员的顺序是按照在类定义中出现的顺序, 而不是在构造函数初始化器中的顺序.

但是, 下列数据类型必须在构造函数初始化器中初始化.

  • const 数据成员. 因为 const 变量创建之后无法对其正确赋值, 所以必须在创建时赋值.
  • 引用数据成员. 因为不指向一个量, 引用将无法存在.
  • 没有默认构造函数的对象数据成员.
  • 没有默认构造函数的基类.

复制构造函数

复制构造函数(copy constructor)是一种特殊的构造函数, 允许所创建的对象是另一个对象的精确副本. 如果没有定义复制构造函数, C++会自动生成一个, 用源对象中相应数据成员的值初始化新对象的每个数据成员. 如果数据成员是对象, 初始化意味着调用它们的复制构造函数.

初始化列表构造函数

初始化列表构造函数将初始化列表作为第一个参数, 并且没有任何其他参数(或者其他参数有默认值). C++11 STL完全支持初始化列表构造函数.

类内成员初始化

在C++11之前, 只有在构造函数体内或者构造函数初始化器中才能初始化成员变量, 只有 static const 整形成员变量才能在类定义中初始化. 从C++11开始, 允许在定义类时直接初始化成员变量.

委托构造函数

委托构造函数(delegating constructor)允许构造函数调用同一个类的其他构造构造函数. 但是, 该调用不能放在构造函数体内, 而必须放在构造函数初始化器中, 且必须是构造函数初始化列表中唯一的成员初始化器.

当使用委托构造函数是, 要避免出现构造函数的递归, 如两个构造函数互相委托.

编译器自动生成的构造函数

默认构造函数和复制构造函数之间缺少对称性, 即

  • 只要定义了任何构造函数, 编译器就不会再自动生成默认构造函数.
  • 只要没有显式定义复制构造函数, 编译器就会自动生成一个.

但是, 可以通过定义显式默认构造或者显式删除构造函数, 来影响自动生成的默认构造函数和复制构造函数.

析构函数

基本概念

  • 析构函数与类(以及构造函数)同名, 前面有一个波浪号(~).
  • 析构函数没有参数, 并且一个类只能有一个析构函数.
  • 通常, 析构函数释放在构造函数中分配的内存. 虽然并没有规则要求这么做, 在析构函数中可以编写任何代码, 但是最好让析构函数只释放内存或者清理其他资源.
  • 在构造函数中抛出异常时, 不会调用析构函数.

销毁对象时会发生两件事: 调用对象的析构函数, 释放对象占用的内存. 如果没有声明析构函数, 编译器会自动生成一个, 析构函数会逐一销毁成员, 然后删除对象.

当堆栈中的对象超出作用域时, 意味着当前的函数, 方法或者其他执行代码块结束, 对象会被销毁. 换句话说, 当代码遇到结束花括号时, 这个花括号中所有创建在堆栈上的对象都会被销毁. 堆栈上对象的销毁顺序与声明顺序(也即构建顺序)相反.

对象赋值

C++为所有的类提供了赋值的方法, 也即赋值运算符 operator= (assignment operator), 其本质是对类重载了运算符=.

  • 如果没有编写自己的赋值运算符, C++将自动生成一个, 从而允许将对象赋给另一个对象. 默认的C++赋值行为几乎与默认的赋值行为相同: 以递归的方式用源对象的每个数据成员赋值给目标对象. 可以显式地默认或者删除编译器生成的赋值运算符.
  • 实际上, 可以让赋值运算符返回任意类型, 包括 void. 然而, 通常返回被调用对象的引用更有用.
  • 赋值运算符的实现与复制构造函数类似, 但两者也存在重要的区别: 复制构造函数只有在初始化时才调用, 此时目标对象还没有有效的值. 赋值运算符可以改写对象的当前值.
  • 在类中动态分配内存时, 如果只想禁止其他人复制对象或者为对象赋值, 只需显式地将 operator= 和复制构造函数标记为 delete.
  • 无论什么时候, 只要在类中动态分配了内存, 就应该编写自己的析构函数, 复制构造函数和赋值运算符(以提供深层的内存复制).

数据成员

静态数据成员

  • 静态(static)数据成员是属于类而不是对象的数据成员, 可将静态数据成员当作类的全局变量.
  • 不仅要在类定义中列出静态成员, 还需要在源文件(通常是定义类方法的那个源文件)中为其分配内存(与声明全局变量类似, 但是需要使用作用域解析运算符 ::), 还可以对其进行初始化. 与普通的变量和数据成员不同, 在默认情况下, 静态数据成员会初始化为0, 静态指针会初始化为 nullptr.
  • 在类方法内部, 可以像使用普通数据成员那样使用静态数据成员. 类方法外的访问取决于访问控制限定符: 如果是 private, 不能在类方法外访问; 如果是 public, 就可以在类方法外访问(需要使用作用域解析运算符 ::).

常量数据成员

类中的数据成员可以声明为 const, 意味着在创建并初始化之后, 数据成员的值不能再改变.

引用数据成员

在初始化一个引用之后, 不能改变它引用的对象. 因此不可能在赋值运算符中对引用赋值. 就像普通引用可以引用常量对象一样, 引用成员也可以引用常量对象.

方法

static 方法

  • 静态方法就像一个普通函数, 唯一区别在于这个方法可以访问类的 privateprotected 静态数据成员.
  • 类中任何方法都可以像调用普通函数那样调用静态方法. 如果要在类的外面调用静态方法, 需要使用类名称和作用域解析运算符来限定方法的名称(就像静态数据成员那样). 静态方法的访问控制和普通方法一样.
  • 在方法定义前不需要重复 static 关键字.
  • 静态方法不属于特定对象, 因此没有 this 指针. 当用某个特定对象调用静态方法时, 静态方法不会访问这个对象的非静态数据成员.
  • 如果同一类型的其他对象对于静态方法可见(例如传递了对象的指针或者引用), 静态方法也可以访问其他对象的 privateprotected 非静态数据成员.

const 方法

  • 为了保证方法不改变数据成员, 可以用 const 关键字标记方法本身. 应该将不修改对象的所有方法声明为 const.
  • 不能将静态方法声明为 const, 因为这是多余的. 静态方法没有类的实例, 因此不可能改变内部的值.
  • const 的工作原理是将方法内用到的数据成员都标记为 const 引用, 因此如果试图修改数据成员, 编译器会报错.
  • const 对象可以调用 const 方法和非 const 方法; 然而, const 对象只能调用 const 方法.
  • const 也会被销毁, 它们的析构函数也会被调用, 因此不应该将析构函数标记为 const.

重载

  • 将函数或者方法的名称用于多个函数, 但是参数的类型或者数目不同.
  • C++不允许仅根据方法的返回类型重载方法名称, 但是可以根据 const 重载方法3.
  • 重载参数可以被显式地删除, 可以用这种方法禁止调用具有特定参数的成员函数.

运算符重载

  • 当C++编译器分析一个程序, 遇到运算符(如 +/-=/<<)时, 就会试着查找名为 operator+-/=/<<, 且具有适当参数的函数或者方法.
  • 在C++中, 无法改变运算符的优先级. 例如, *和/总是在+和-之前求值. 用于定义的运算符唯一能做的就是在运算符优先级已经确定的情况下编写实现. 而且, C++还不允许开发新的运算符符号.
  • 运算符重载是函数重载的一种形式. 编译器允许用作 operator+ 参数的对象类型与编写 operator+ 的类不同. 也可以任意指定 operator+ 的返回值类型.
  • 当编写运算符重载的对象在运算符的左边时, 可以隐式转换; 但是当对象在运算符右边时, 不能隐式转换. 除非使用全局友元函数.
  • 编写了基本运算符(如+和-), 并没有提供相应的简写运算符(如+=和-=). 必须显式地重载简写运算符.
  • 简写运算符与基本运算符不同, 它们会改变运算符左边的对象, 而不是创建一个新对象. 它们生成的结果是对被修改对象的引用, 这一点与赋值运算符类似.
  • 比较运算符(如>,<,==)与基本的算术运算符类似, 它们也应该是全局友元函数.
  • C++允许重载函数调用运算符4, 即 operator(). 如果自定义类中编写了一个 operator(), 那么这个类的对象就可以当做函数指针使用.
    • 带有函数调用运算符的类的对象称为函数对象, 也称为仿函数(functor).
    • 相比标准的对象方法, 函数对象的好处是: 这些对象可以伪装成函数指针. 只要函数指针类型是模板化的, 就可以把这些函数对象当成回调函数传入需要接受函数指针的例程.
    • 相比全局函数, 函数对象的好处是:
      • 对象可以在函数对象运算符的重复调用之间在数据成员中保存信息.
      • 可以通过设置数据成员自定义函数对象的行为.
    • 遵循一般的方法重载规则, 可为类编写任意数量的 operator(). 当然, 不同的 operator() 需要参数数目或者类型不同.
    • C++提供了一些预定义的仿函数类, 执行最常用的回调操作. 定义在头文件 <functional> 中. C++14支持透明运算符仿函数, 允许忽略模板类型参数. 建议总是使用透明运算符仿函数.
      • 算数函数对象: C++提供了5类二元算术运算符的仿函数模板. 算数函数对象只不过是算数运算符的简单包装. 如果在算法中使用函数对象作为回调, 务必保证容器中的对象重载了相应的运算符.
        • plus
        • minus
        • multiplies
        • divides
        • modulus
      • 比较函数对象
        • equal_to
        • not_equal_to
        • less
        • greater
        • less_equal
        • greater_equal
      • 逻辑函数对象
        • logical_not (operator!)
        • logical_and (operator&&)
        • logical_or (operator||)
      • 按位函数对象
        • bit_and (operator&)
        • bit_or (operator|)
        • bit_xor (operator^)
        • bit_not (operator~)
  • 解除引用运算符
    • ->* 解除引用之后再接 . 成员选择操作的简写.
    • 在类中重载解除引用运算符可以使这个类的对象行为和指针一致. 主要用途是实现智能指针.
    • 一般情况下, operator*operator-> 不要只实现其中的一个, 应该同时实现这两个运算符.
  • 内存分配和释放运算符
    • C++允许重定义程序中内存分配和释放的方式,既可以在全局层次也可以在类层次进行这种自定义。
    • new 表达式完成两件事情:分配内存空间,调用构造函数。
    • delete 表达式完成两件事情:调用析构函数,释放内存空间。

默认参数

  • 在原型中可以指定函数或者方法的参数默认值.
  • 只能从最右边的参数开始提供连续的默认参数列表.
  • 默认参数可以用于函数, 方法和构造函数.
  • 所有参数都有默认值的构造函数等同于默认构造函数. 如果试图同时声明默认构造函数和有参数但所有参数都有默认值的构造函数, 编译器会报错. 因为如果不指定任何参数, 编译器不知道该调用哪个构造函数.
  • 任何默认参数能做到的事情, 都可以用方法重载做到.

内联(inline)

  • 内联指编译器将方法体或者函数体直接插入到调用方法或者函数的位置.
  • 在方法或者函数定义的名称之前使用 inline 关键字. 注意, inline 关键字仅仅是提示编译器, 如果编译器认为这会降低性能, 就会忽略该关键字.
  • 如果编写了内联函数或方法, 应将定义与原型一起放在头文件中.
  • C++提供了另一种声明内联函数的语法: 直接将方法定义放在类定义中, 不使用 inline 关键字.

友元(friend)

  • C++允许某个类将其他类, 其他类的成员函数或者非成员函数声明为友元. 友元可以访问类的 private, protected 数据成员和方法.
  • 在函数定义中不需要再使用 friend 关键字.
  • 友元可以违反封装的原则.

移动语义

对象的移动语义(move semantics)需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator). 移动构造函数和移动赋值运算符将成员变量从源对象复制/移动到新对象, 然后将源对象的变量设置为空值. 这样做实际上将内存的所有权从一个对象移动到另一个对象. 这两个方法基本上只对成员变量进行表层复制(shallow copy), 然后转换已分配内存的所有权, 从而防止了悬挂指针和内存泄漏.

移动语义是通过右值引用实现的. 为了对类增加移动语义, 需要实现移动构造函数和移动赋值运算符. 移动构造函数和移动赋值运算符应使用限定符 noexcept 标记(于声明末尾), 这告诉编译器, 它们不会抛出任何异常.

与普通的构造函数和复制赋值运算符一样, 还可将移动构造函数和移动赋值运算符显式删除或者设置为默认.

继承

基本概念

  • 继承的运行方式是单向的. 基类并不知道派生类的任何信息.
  • 指向某个对象的指针或者引用可以指向声明类的对象, 也可以指向其任意派生类的对象. 指向基类对象的指针可以指向派生类对象, 对于引用也是如此. 然而, 不能通过基类对象的指针调用派生类的方法. 即使基类的引用或者指针知道所指的是一个派生类, 也无法访问没有在基类中定义的派生类方法或者成员.
  • 当设计基类时, 应该考虑派生类之间的关系. 根据这些信息, 可以提取共有特性并将其放到基类中.

访问控制

派生类可以访问基类中声明为 public, protected 的数据成员和方法, 就好像这些数据成员和方法是派生类自己的. 如果类将数据成员和方法声明为 protected, 派生类就可以访问它们; 如果声明为 private, 派生类就不能访问它们. 建议将所有数据成员都默认声明为 private, 这会提供最高级别的封装. 如果希望任何代码都能访问这些数据成员, 就可以提供 publicgetter/setter; 如果只希望派生类访问它们, 就可以提供 protectedgetter/setter.

禁止继承

  • C++允许通过将类标记为 final 来禁止继承: 在类名的后面加上关键字 final, 继承这个类就会导致编译错误.
  • 类似地, 也可以将方法标记为 final, 这意味着无法在派生类中重写这个方法.

方法和类的虚化

从某个类继承的主要原因是为了添加或者替换功能. 在许多情况下, 可能需要替换或者重写某个方法来修改类的行为. 只有在基类中声明为 virtual 的方法才能被派生类正确地重写. 为了重写某个方法, 需要在派生类定义中重新声明这个方法, 就像在基类中声明的那样, 并在派生类的实现文件中提供新的定义. 建议在派生类对重写方法的声明末尾添加 override 关键字, 但不需要再重复使用 virtual 关键字5. 根据经验, 为了避免遗漏 virtual 关键字引发的问题, 可将所有方法设置为 virtual (包括析构函数, 但不包括构造函数).

实际上, 在C++编译类时, 会创建一个包含类中所有方法的二进制对象. 如果方法声明为 virtual, 会使用名为虚表(vtable)的特定内存区域调用正确的实现. 每个具有一个或者多个虚方法的类都有一张虚表, 这种类的每个对象都包含指向虚表的指针, 这个虚表包含了指向虚方法实现的指针. 通过这种方法, 当使用某个对象调用方法时, 指针也进入虚表, 然后根据实际的对象类型执行正确版本的方法.

纯虚方法(pure virtual methods)在类定义中显式说明该方法不需要定义. 如果将某个方法设置为纯虚的(方法声明后紧接着=0, 不需要编写任何代码), 就是告诉编译器当前类中不存在这个方法的定义. 因此这个类就是 抽象类, 因为这个类没有具体实例. 如果某个类包含了一个或者多个纯虚方法, 就无法构建这种类型的对象. 抽象类提供了一种禁止其他代码实例化对象的方法, 而它的派生类可以实例化对象. 如果派生类没有实现从基类继承的所有纯虚方法, 派生类就也是抽象的, 也不能实例化对象.

在C++中, 如果原始的返回类型是某个类的指针或者引用, 重写的方法可以将返回类型改为派生类的指针或者引用. 这种类型称为协变返回类型(covariant return type). 但是不能将返回类型修改为完全不相关的类型, 如 void*.

如果在派生类的定义中使用基类虚方法的名称, 但参数与基类中同名方法的参数不同, 那么这不是重写基类的方法, 而是创建了一个新方法.

可以在派生类中使用 using 关键字显式地包含基类中定义的方法. 这适用于普通类方法, 也适用于构造函数, 允许在派生类中继承基类的构造函数. 当使用继承的构造函数时, 要确保所有的成员变量都正确地初始化. 使用 using 子句从基类继承构造函数有一些限制:

  1. 当从基类继承构造函数时, 会继承全部的构造函数, 而不可能只继承基类的部分构造函数.
  2. 在多重继承中, 如果一个基类的某个构造函数与另一个基类的构造函数具有相同的参数列表, 就不可能从基类继承构造函数, 因为那样会产生歧义.

在C++中, 不能重写静态方法, 因为方法不可能既是静态的又是虚的. 如果派生类中存在的静态方法与基类中的静态方法同名, 那么实际上这是两个独立的方法.

当指定名称和一组参数, 以重写某个方法时, 编译器会隐式地隐藏基类中的同名方法的所有其他实例. 如果只想改变一个方法, 可以使用 using 关键字继承基类的所有同名方法, 然后显式地重写想要改变的方法. 从而避免重载该方法的所有版本.

派生类虽然无法调用基类的 private 方法, 但是却可以对其重写.

C++根据描述对象的表达式类型在编译时绑定默认参数, 而不是根据实际的对象类型绑定参数. 在C++中, 默认参数不会被"继承". 当重写具有默认参数的方法时, 也应该提供默认参数, 这个参数的值应该与基类版本相同. 建议使用符号常量做默认值, 这样可以在派生类中使用同一个符号常量.

向上转型和向下转型

  • 基类的指针或者引用指向派生类对象时, 派生类保留其重写方法. 但是通过类型转换将派生类对象转换为基类对象时, 就会丢失其特征. 重写方法和派生类数据的丢失称为截断(slicing).
  • 对象可以转换为其基类对象, 或者赋值给基类. 如果类型转换或者赋值是对某个普通对象执行, 会产生截断; 如果用派生类对基类的指针或者引用赋值, 则不会产生截断.
  • 将派生类转换为其基类称为向上转型(upcasting), 是通过基类使用派生类的正确途径, 也是让方法和函数使用类的引用而不是使用类对象的原因. 使用引用时, 派生类在传递时没有截断. 因此, 当向上转型时, 使用基类指针或者引用以避免截断.
  • 将基类转换为其派生类称为向下转型(downcasting). 仅在必要的情况下才使用向下转型, 一定要使用 dynamic_cast. 如果针对某个指针的 dynamic_cast 失败, 这个指针的值就是 nullptr; 如果针对对象引用的 dynamic_cast 失败, 将抛出 std::bad_cast 异常.

基类的构造函数

创建对象时必须同时创建基类和包含于其中的对象. C++定义了如下的创建顺序:

  1. 如果某个类具有基类, 执行基类的构造函数. 具体地, 如果基类存在默认构造函数, 将自动调用; 如果基类不存在默认构造函数, 或者存在默认构造函数但希望使用其他构造函数, 可在构造函数初始化器中调用所想要的非默认函数.
  2. 类的非静态数据成员按照声明的顺序创建.
  3. 执行该类自身的构造函数.

基类的析构函数

由于析构函数没有参数, 因此始终可以自动调用基类的析构函数. 析构函数的调用顺序刚好与构造函数相反:

  1. 调用类的析构函数.
  2. 销毁类的数据成员, 与创建的顺序相反.
  3. 如果有父类, 调用父类的析构函数.

将所有析构函数声明为 virtual. 编译器生成的默认析构函数不是 virtual, 因此应该定义自己的虚析构函数, 以避免析构函数调用链被破坏, 至少在父类中应该这么做 (因为派生类会自动虚化).

多重继承

当定义一个既"是一个"事物A, 又"是一个"事物B的时候, 就会用到多重继承, 同时从类A和类B派生, 记为类C. 多重继承通常被认为是面向对象编程中一种复杂且不必要的部分.

当类A和类B都有方法F, 就会产生歧义. 为了消除歧义, 可以采用如下方法:

  1. 显式地向上转型, 其本质是向编译器隐藏多余的方法版本.
C c;
dynamic_cast<A&>(c).F();
  1. 使用与访问基类方法相同的语法(:: 运算符).
C c;
c.A::F();
  1. 在类C的声明中使用 using 语句显式指定.
using A::F;

派生类中的复制构造函数和赋值运算符

当在类中使用了动态内存分配时, 提供复制构造函数和赋值运算符是一个好的编程习惯. 如果在派生类中指定了复制构造函数, 就需要显式地链接到基类的复制构造函数, 否则将使用默认构造函数初始化对象的基类部分.

运行时类型工具

在C++中, 有些特性提供了对象的运行时视角. 这些特性通常归属于一个名为运行时类型信息(run time type information, RTTI)的特性集. RTTI提供了许多有用的特性, 来判断对象所属的类. 其中一种特性是 dynamic_cast, 可以在面向对象层次结构中进行安全的类型转换. 另一种特性是 typeid 运算符, 这个运算符可以在运行时查询对象, 从而判别对象的类型. 多数情况下, 不应该使用 typeid, 因为最好用虚方法处理对象类型运行的代码, 虚基类是在类层次结构中避免歧义的好方法.

值得注意的是, 类至少有一个虚方法, typeid 运算符才能正常运行. 如果在没有虚方法的类上使用 dynamic_cast, 会导致编译错误.

lambda 表达式

lambda 表达式可以编写内嵌的匿名函数, 而不必编写独立函数或函数对象, 使代码更容易阅读和理解. lambda 表达式以方括号 [] 开始, 其后是花括号 {}, 其中包含了 lambda 表达式体. 完整的语法为

[capture_block] (parameters) mutable exception_specification attribute_specifier -> return_type {body}

[capture_block]{body} 外, 其他参数均为可选参数.

  • lambda 表达式可以接受参数. 参数在圆括号中指定, 用逗号分隔开, 与普通函数相同.
  • 如果 lambda 表达式不接受参数, 就可以指定空圆括号或忽略它们.
  • lambda 表达式可以返回值. 返回类型在箭头后面指定, 也即拖尾返回类型. 即使 lambda 表达式返回了值, 也可以忽略返回类型, 此时编译器就根据函数返回类型推断规则来推断 lambda 表达式的返回类型.
  • lambda 表达式可以在其封装的作用域内捕捉变量. 方括号部分称为 lambda 捕捉块(capture block), 可以指定任何从 lambda 表达式所在的作用域中捕捉变量. 这里捕捉变量指可以在 lambda 表达式体中使用这个变量. 空白的捕捉块表示不从所在作用域中捕捉变量. 捕捉方式可以是按值捕捉, 也可以按引用捕捉(lambda 表达式可以在其内部作用域修改 lambda 表达式所在作用域内的变量). 捕捉的对象既可以是所在作用域中的所有变量, 也可以通过捕捉列表酌情捕捉需要的变量以及相应的捕捉方法6. 例如:
    [=]
    通过值捕捉所有变量.
    [&]
    通过引用捕捉所有变量.
    [x]
    只通过值捕捉变量 x.
    [&x]
    只通过引用捕捉变量 x.
    [&, x]
    默认通过引用捕捉, 但 x 是按值捕捉.
    [this]
    捕捉周围的对象. 即使没有使用 this->, 也可以在 lambda 表达式体中访问这个对象.

模板

通过指定要使用的类型对模板进行实例化, 这称为泛型编程(generic programming). 其目的是编写可重用的代码, 最大的优点是类型安全. 在C++中, 泛型编程的基本工具是模板. 模板将参数化的概念推进了一步, 不仅允许参数化值, 还允许参数化类型. 使用模板, 不仅可以编写不依赖特定值的代码, 还能编写不依赖特定类型的代码.

类模板

模板"参数化"类型的方式和函数"参数化"值的方式相同. 例如:

 1: template <typename T>
 2: class Template_Name
 3: {
 4: private:
 5:     T prop;
 6: public:
 7:     explicit Template_Name(T p);
 8:     Template_Name(const Template_Name<T>& src);
 9:     Template_Name<T>& operator=(const Template_Name<T>& rhs);
10:     T get_prop() const;
11: };
12: 
13: template <typename T>
14: Template_Name<T>::Template_Name(T p)
15: {
16:     prop = p;
17: }
18: 
19: template <typename T>
20: Template_Name<T>::Template_Name(const Template_Name<T>& src)
21: {
22:     prop = src.get_prop();
23: }
24: 
25: template <typename T>
26: Template_Name<T>& Template_Name<T>::operator=(const Template_Name<T>& rhs)
27: {
28:     prop = rhs.get_prop();
29:     return *this;
30: }
31: 
32: template <typename T>
33: T Template_Name<T>::get_prop() const
34: {
35:     return prop;
36: }
  • 就像在函数中通过参数名表示调用者要传入的参数一样, 在模板中使用模板参数名称(例如 T)表示调用者要指定的类型. 名称 T 没有什么特别之处, 可以使用任何名称. 按照惯例, 只使用一个类型时, 将这个类型称为 T, 但这只是一个历史约定, 就像把索引数组的整数命名为 ij 一样.
  • template <typename T> 说明符必须在 Template_Name 模板的每一个方法定义前面.
  • 模板要求将方法的实现也放在头文件中, 因为编译器在创建模板的实例之前, 需要知道完整的定义, 包括方法的定义.
  • 必须在所有的方法和静态数据成员定义中将 Template_Name<T> 指定为类名. 只有构造函数和析构函数应该使用 Template_Name 而不是 Template_Name<T>.
  • 为某个类型创建一个模板类对象的过程称为模板的实例化. 如果要声明一个接受模板类对象的函数或方法, 必须在模板类中指定模板参数的类型.
  • 编译器遇到模板方法定义时, 会进行语法检查, 但并不编译模板. 编译器无法编译模板定义, 因为它不知道要使用什么类型. 如果在程序中没有将类模板实例化为任何类型, 就不编译类方法定义.
  • 编译器总是为泛型类的所有虚方法生成代码. 但是对非虚方法, 编译器只会为那些实际为某个类型调用的非虚方法生成代码.

方法模板

  • C++允许模板化类中的单个方法. 这些方法可以在类模板中, 也可以在非模板化的类中. 在编写模板化的类方法时, 实际在为很多不同的类型编写很多不同版本的方法. 在类模板中, 方法模板对赋值运算符和复制构造函数非常有用.
  • 必须将类模板的声明放在成员模板的声明之前, 而且二者不能合并.
  • 在模板化的赋值运算符中不需要检查自赋值, 因为相同类型的赋值仍然是通过老的, 非模板化的, 编译器生成的 operator= 版本进行, 因此在这里不可能进行自赋值.

模板类特例化

模板的另一个实现称为模板特例化(template specialization). 例如:

template <>
class Template_Name<int>
{
    ...
}
  • 特例化一个模板时, 并没有"继承"任何代码: 特例化和派生类化不同. 必须重新编写类的整个实现. 不要求提供具有相同名称或者行为的方法.
  • 与模板定义不同, 不必在每个方法或者静态成员定义之前重复 template <> 语法.

从类模板派生

可以从类模板派生. 如果派生类从模板本身继承, 那么这个派生类也必须是一个模板. 还可以派生自类模板的某个特定实例, 在这种情况下, 这个派生类不需要是模板. 继承的语法和普通继承一样.

继承 v.s. 特例化

通过继承来扩展实现和使用多态. 通过特例化自定义特定类型的实现.

  继承 特例化
是否重用代码 是: 派生类包含基类的所有成员和方法 否: 必须在特例化中重写所有代码
是否重用名称 否: 派生类名必须和基类名不同 是: 特例化的名称必须和原始名称一致
是否支持多态 是: 派生类的对象可以代替基类的对象 否: 模板对一个类型的每个实例化都是一个不同类型

函数模板

可以为独立函数编写模板. 函数模板可以通过如下两种方式调用这个函数:

  • 通过尖括号显示地指定类型.
  • 忽略类型, 让编译器根据参数自动推断类型.
  • 与类模板方法定义一样, 函数模板定义(不仅仅是原型)必须能用于使用它们的所有源文件. 因此, 如果多个源文件使用函数模板, 就应该把其定义放在头文件中.

函数模板特例化

  • 就像类模板的特例化一样, 函数模板也可以特例化.
  • 编译器总是优先选择非模板化的函数, 而不是模板化的版本. 然后, 如果显式地指定模板的实例化, 那么会强制编译器使用模板化的版本.

I/O

所有的流都可以看成是数据滑槽. 流的方向不同, 关联的来源和目的地也不同. 所有输入流都有一个关联的来源, 所有输出流都有一个关联的目标. 流不仅包含数据, 还包含一个称为 当前位置 的数据, 指的是流将要进行下一次读或写操作的位置. C++预定义了如下四个流.

说明
cin 输入流, 从"输入控制台"中读取数据
cout 缓冲的输出流, 向"输出控制台"写入数据
cerr 非缓冲的输出流, 向"错误控制台"写入数据, "错误控制台"通常等同于"输出控制台"
clog cerr 的缓冲版本

控制台输入流允许程序在运行时从用户那里获得输入, 使程序具有交互性. 控制台输出流向用户提供反馈和输出结果.

文件流从文件系统中读取数据并向文件系统写入数据. 文件输入流适用于读取配置数据, 读取保存的文件以及批处理基于文件的数据等任务. 文件输出流适用于保存状态数据和提供输出等任务.

流式输出

  • 输出流定义在头文件 <ostream>7. 使用输出流最简单的方法是使用 << 运算符. 通过 << 可以输出C++的基本类型.
  • cout 流是写入到控制台(也称为标准输入输出)的内建流. 可将 << 的使用串联起来, 从而输出多个数据段. 这是因为 << 运算符返回一个流的引用, 因此可以立即对同一个流再次应用 << 运算符.
  • \nendl 的区别在于前者仅开始一个新行, 而后者还会刷新缓冲区. 使用 endl 需要注意过多的缓冲区刷新会降低性能.
  • 方法
    • put() 接受的不是定义了输出行为的对象或变量, 而是一个字符. 原样输出, 没有任何特殊的格式化和处理操作.
    • write() 接受的不是定义了输出行为的对象或变量, 而是一个字符数组. 原样输出, 没有任何特殊的格式化和处理操作.
  • 向输出流写入数据时, 流不一定会将数据立即写入目标. 大部分输出流都会进行缓存8, 而不是立即将得到的数据写出去. 在下列情况下, 流进行刷新操作.
    • 到达某个标记时, 如 endl.
    • 流离开作用域被析构时.
    • 要求从对应的输入流输入数据时(即要求从 cin 输入时, cout 会刷新).
    • 流缓存满.
    • 显式要求流刷新缓存, 也即调用 flush() 方法.
  • 输出错误
    • good() 可以判断一个流当前是否处于正常可用状态. 该方法可以方便地获得流的基本验证信息, 但是不能提供流不可用的原因.
    • bad() 提供了稍多信息. 如果返回 true, 意味着发生了致命错误.
    • fail() 如果返回 true, 说明最近一次操作失败. 但没有说明下一次操作是否也会失败.
    • clear() 重置流的错误状态.
  • 操作算子(manipulator)是能够修改流行为的对象, 而不是流能够操作的数据. 大部分操作算子定义在 <ios><iomanip> 标准头文件中. 除 setw() 外, 下列算子对后续输出到流中的内容一直有效.
    • boolalpha/noboolalpha (默认值) 控制 bool 值输出格式: true/false 或者 1/0.
    • oct/dec/hex 控制数字输出格式: 八进制/十进制/十六进制.
    • setprecision() 设置小数输出位数.
    • setw() 设置数值输出的字段宽度. 注意, 该算子只对下一个输出有效.
    • setfill() 设置当数字宽度小于指定宽度时用于填充的字符.
    • showpoint/noshowpoint 强制流总是显示或者总是不显示小数点(对于不带小数部分的浮点数).
    • put_money 向流写入一个格式化的货币值.
    • put_time 向流写入一个格式化的时间值.
    • quoted() 把给定的字符串封装在引号中, 并转义嵌入的引号.

流式输入

  • get() 从流中读入原始输入数据.
  • unget() 将前一个读入的字符放回流中, 流回退一个位置. 是否成功可以通过 fail() 查看.
  • putback()unget() 一样, 允许在输入流中反向移动一个字符. 区别在于 putback() 将放回流中的字符作为参数.
  • peek() 预览调用 get() 返回的下一个值.
  • getline() 用一行数据填充字段缓存区, 数据量最多至指定大小. 指定的大小中包括 \0 字符. 调用 getline() 时, 它从输入流中读取一行, 读到行尾为止(行尾9字符不会出现在字符串中). 还有一个用于C++ string的 getline() 函数. 这个函数定义在 头文件 <string> 和名称空间 std 中. 其接受一个流引用, 一个string引用和一个可选的分隔符作为参数, 不需要指定缓存区的大小.
  • 输入错误
    • 查询输入流状态的最常见方法是在条件语句中访问输入流, 如 while (cin) {...}while (cin >> ch) {...}.
    • good()
    • bad()
    • fail()
    • eof() 如果流到达尾部, 就返回 true.
  • 输入操作算子
    • boolalpha/noboolalpha (默认值) 如果使用了 boolalpha, 字符串 false 会解释为布尔值 false; 其他任何字符都会被解释为布尔值 true. 如果设置了 noboolalpha, 0会解释为 false, 其他任何值都解释为 true.
    • oct/dec/hex 分别以八进制/十进制/十六进制读入数字.
    • get_money() 从流中读入一个货币值.
    • get_time() 从流中读入一个格式化的时间值.

字符串流

  • 可以通过字符串流将流语义用于string. 通过这种方式, 可以得到一个内存内的流(in memory stream)来表示文本数据.
  • ostringstream 类用于将数据写入string, istringstream 用于从string中读出数据. 这两个类都定义在头文件 <sstream> 中. 由于 ostringstreamistringstream 把同样的行为分别继承为 ostreamistream, 因此这些类的使用也非常类似.
  • 相对于标准C++ string, 字符串流的主要优点是除了数据之外, 这个对象还知道从哪里进行下一次读或写操作, 这个位置也称为当前位置.

文件流

  • 文件本身非常符合流的抽象, 因为在读写文件时, 除了数据之外, 还涉及读写的位置.
  • 在C++中, ofstreamifstream 类提供了文件的输出和输入功能, 定义在头文件 <fstream> 中.
  • 输出文件流和其他输出流的区别在于: 文件流的构造函数可以接受文件名以及打开文件的模式作为参数(如下表).
常量 说明
ios_base::app 打开文件, 在每一次写操作之前, 移到文件末尾.
ios_base::ate 打开文件, 打开之后立即移动到文件末尾.
ios_base::binary 以二进制模式执行输入输出操作(相对于文本模式).
ios_base::in 打开文件, 从开头开始读取.
ios_base::out 打开文件, 从开头开始写入, 覆盖已有的数据.
ios_base::trunc 打开文件, 并删除(截断)任何已有数据.
  • 所有的输入输出流都有 seek()tell() 方法, 但是除文件流之外很少有意义.
    • seek() 方法允许在输入或输出流中移动到任意位置.
      • 有两个重载: seek(ios_base::streampos)seek(ios_base::streamoff, ios_base::streampos).
      • 输入流中的 seek() 方法实际上为 seekg() (g 表示 get).
      • 输出流中的 seek() 方法实际上为 seekp() (p 表示 put).
    • tell() 方法返回流的当前位置, 类型为 ios_base::streampos.
      • 输入流使用的是 tellg().
      • 输出流使用的是 tellp().
    • 预定义的位置
位置 说明
ios_base::beg 表示流的开头
ios_base::end 表示流的结尾
ios_base::cur 表示流的当前位置
  • 任何输入和输出流之间都可以建立连接(通过 tie() 方法). 要将输出流连接至输入流, 对输入流调用 tie() 方法, 并传入输出流的地址. 要解除连接, 传入 nullptr.

双向I/O

  • 双向流可以同时以输入流和输出流的方式操作, 支持 >><< 运算符, 还支持输入流和输出流的方法.
  • 双向流是 iostream 的子类, 而 iostreamistreamostream 的子类, 因此这是一个多重继承的实例.
  • fstream 类提供了双向文件流, 特别适合于需要替换文件中数据的应用程序: 可以通过读取文件找到正确的位置, 然后立即切换为写入文件.
  • 可以通过 stringstream 类双向访问字符串流.
  • 双向流用不同的指针保存读位置和写位置. 在读取和写入之间切换时, 需要定位到正确的位置.

Footnotes:

1

Marc Gregoire著, 张永强译. C++高级编程(第3版), 清华大学出版社, 2015.

2

C++有三个函数可以设置回调函数,除了 set_new_handler() ,还有 set_terminate()set_unexpected()

3

例如可以编写两个名称, 参数均相同的两个方法, 一个是 const, 另一个不是. 如果是 const 对象, 就调用 const 方法; 如果是非 const 对象, 就调用非 const 方法.

4

只能将该运算符重载为类中的非 static 方法.

5

一旦在基类中将方法或者析构函数标记为 virtual, 它们在所有派生类中就一直是 virtual, 即使在派生类中没有使用 virtual 关键字也是如此.

6

即使可以, 也不建议在内部作用域中捕捉所有变量, 而应该捕捉需要的变量.

7

通常包含头文件 <iostream>, 该头文件又包含了输入流和输出流的头文件.

8

不是所有的输出流都会缓存, 如 cerr 流就不会缓存其输出.

9

注意, 行尾序列和平台相关, 可以是 \r\n, \n, 或 \n\r.