关键词:C++


假定读者有一定的 C 语言基础

Reference:https://hackingcpp.com/cpp/beginners_guide.html

从C语言到C++

C++开发设置

编辑器 & 集成开发环境IDE

  • Visual Studio Code

  • Visual Studio

  • VIM

  • Qt Creator

  • CLion

  • ……

编译器

  • gcc/g++

  • clang/clang++

  • Microsoft Visual Studio(msvc)

  • ……

第一个程序Hello World

1
2
3
4
5
6
7
8
// hello.cpp
#include <iostream>
// 注释
int main()
{
std::cout << "Hello World!\n";
return 0;
}
  • #include <iostream>

    • 包含头文件,这行将会被头文件 iostream 所替换;
    • iostream 是编译器目录中的一个头文件,其提供了基本的输入和输出方法。
    • #include "filename" 可以引入头文件;
    • #include <filename> 同上,但在包含目录中查找。
    • 发生在编译之前,编译器只能看到已经预处理的文件。
  • 注释

    • // 表示单行注释;
    • /* */ 表示多行注释。
  • int main()

    • 定义了主函数;
    • 主函数是每个程序的入口;
    • int 表明主函数的返回类型是整型;
    • () 表示主函数的参数列表,此处为空。
  • {}

    • 表示语句块
  • std::cout << "Hello World!\n";

    • 在控制台输出 Hello World。
    • std 是标准库的命名空间;
    • cout 表示控制台标准输出,是“character out”的缩写。
    • Hello World 是一个字符串,即字符组成的串。
    • \n 表示换行。
  • return 0;

    • 函数出口,返回值0。

注:少用甚至不用 using namespace std;

可能大多数的代码都会附带上 using namespace std;

但使用名称空间将该名称空间中的所有符号拖放到全局名称空间中。这可能会导致名称冲突和歧义,在某些情况下甚至会导致只在运行时才会出现并且很难检测到的bug。

使用来自其他名称空间的所有符号污染全局名称空间在任何生产代码库中都是一个严重的问题,应该从一开始就避免使用这种模式。


编译hello.cpp

  1. 预处理,在源代码中处理头文件等;

  2. 编译:将源代码转化成机器码;

  3. 链接:结合多个二进制机器码文件,生成可执行文件。

编译术语:

  • 编译错误(Compiler Error,CE): 编译器无法正确处理源代码,一般为语法错误;

  • 编译警告(Compiler Warning):程序可编译,编译器将继续,但有一段有问题的代码可能导致运行时错误;

  • 静态(static):在编译时固定(固定到可执行文件中,在运行时不可更改);

  • 动态(dynamic):在运行时可更改(可能由用户输入)。

编译器参数标记

使用 g++ 进行编译时,有一些可选的选项。下面是一条编译指令:

1
g++ -std=c++20 -Wall -Wextra -Wpedantic -Wshadow input.cpp -o output
  • -std=c++20 表示使用 C++20 标准。

  • -Wall -Wextra -Wpedantic -Wshadow 表示额外的警告信息。

  • -o output 表示输出可执行文件名。


都这个年代了,尽量使用高版本 C++


C++的I/O

I/O流

对于数据而言,其可以从程序中产生并输出到显示终端,也可以从输入设备中输入到程序中。

1
2
3
4
5
6
7
8
// IO流.cpp
#include <iostream>
int main()
{
int i;
std::cin >> i; // 输入 i
std::cout << i; // 输出 i
}
  • std::cin:表示从输入流中读取字符,从外界(缓冲区)读入字符;

  • std::cout:表示把字符放入输出流,首先写入缓冲区,缓冲区满时输出到控制台;

  • std::clog:表示把字符放入错误流,首先写入缓冲区,缓冲区满时输出到控制台;

  • std::cerr:表示把字符放入错误流,但立刻输出到控制台。

  • >><<:流符号,尖端表示数据的流向,如 源 >> 目标

    • 支持基本类型和字符串(可以添加对其他类型的支持);
    • >> 读取直到下一个空白字符(空格,制表符,换行符,…)
    • 可以连续使用,如 std::cin >> i >> j;

注:在必要的时候才用 std::endl

也许会见到代码中出现 std::endl,其也是流处理中的操作,但是每次调用 std::endl 都会刷新输出缓冲区并立即写入输出。C++的I/O流使用缓冲区来减轻系统输入或输出操作对性能的影响。将收集输出,直到可以写入最小数量的字符为止。

如果经常这样做,可能会导致严重的性能下降。过度使用 std::endl 会干扰这一机制。

使用 \n 代替或只有一次对操作符 << 的调用(每次额外的调用会产生很小的开销)


基本类型

变量声明

1
2
type variable = value;
type variable {value}; // C++11后的初始化

但基本类型的变量默认情况下不会初始化。

1
2
int i;
cout << i << '\n'; // i未被初始化,值不可知

变量类型

  1. 布尔类型:值只有真(true)和假(false)。

  2. 字符类型:一个字节大小,通常范围在-128~127。

  3. 整型类型:一般的整数,shortintlonglong long

    • 带符号整型
    • 无符号整型
    • C++14中可支持数字分隔符,如 long num = 512'232'697'499;
  4. 浮点类型:一般的小数

    • float:32位,4字节
    • double:64位,8字节
    • long double:80位,10字节
    • C++11支持强制转换为 long double,如 long double num = 3.5e38L
    • C++14中也支持数字分隔符
  • std::numeric_limits<type> 查看变量可表示范围。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <limits>

int main ()
{
std::cout << "lowest: " << std::numeric_limits<double>::lowest() << '\n';
// lowest: -1.79769e+308
std::cout << "min: " << std::numeric_limits<double>::min() << '\n';
// min: 2.22507e-308
std::cout << "max: " << std::numeric_limits<double>::max() << '\n';
// max: 1.79769e+308
std::cout << "epsilon: " << std::numeric_limits<double>::epsilon() << '\n';
// epsilon: 2.22045e-16
return 0;
}
类型窄化

从可以表示更多值的类型转换为可以表示更少值的类型,可能导致信息丢失。

类型提升

涉及浮点类型的提升:

  • 小类型转换成大类型

两种整数类型的操作:

  1. 整数提升:

    • 基本上任何小于int的值都会被提升为int或unsigned int(取决于哪一种类型可以表示未提升类型的所有值)
  2. 如果两个操作数类型不同,则应用整数转换

    • 两种符号:小类型转换成大类型
    • 都是无符号的:将较小的类型转换为较大的类型
    • 有符号⊕无符号:
      • 如果两者宽度相同,则有符号转换为无符号
      • 否则,如果可以表示所有值,则将无符号转换为有符号
      • 否则都转换为无符号
const修饰符

使用 const 限定变量为常量。

  • 值一旦赋值就不能更改。
  • 如果不需要在初始赋值后改变变量的值,总是将变量声明为 const
    • 避免错误:如果稍后不小心更改值,则不会编译
    • 帮助更好地理解你的代码:清楚地传达值将在代码中保持不变
    • 可以提高性能(可能进行更多编译器优化)
constexpr常量表达式

C++11支持,常量表达式必须在编译时可计算

如果未在constexpr上下文中调用,则可以在运行时进行计算

constexpr上下文中的所有表达式必须是constexpr本身

Constexpr函数可能包含:

  • C++ 11:只有一条返回语句
  • C++ 14:多个语句
auto关键字

使用如下:

1
auto variable = expression;
  • 从赋值的右侧推导出变量类型
  • 往往更方便、更安全、更经得起未来考验
  • 对于泛型(与类型无关)编程也很重要
类型别名
1
2
3
4
5
// C++11支持
using NewType = OldType;

// C++98支持
typedef OldType NewType;

算术运算符

  • ++=:算术加

  • --=:算术减

  • **=:算术乘

  • //=:算术除

  • %%=:算术取余

自增自减符

  • 作用:将值更改+/- 1
    • 前缀表达式 ++x / --x 返回新的(递增/递减)值;
    • 后缀表达式 x++ / x-- 增加/减少值,但返回旧值。

比较运算符

  • 返回值只有真(true)和假(false)。

  • ==:判断相等

  • !=:判断不相等

  • <:小于

  • >:大于

  • <=:小于或等于

  • >=:大于或等于

  • C++20引入 <=>

    • 当 a < b 时, (a <=> b) < 0
    • 当 a > b 时, (a <=> b) > 0
    • 当 a = b 时, (a <=> b) == 0

逻辑运算符

  • 返回值只有真(true)和假(false)。

  • 0 永远是假,其他值都是真。

  • &&and:逻辑与

  • ||or:逻辑或

  • !not:逻辑非

  • 短路评估:如果布尔比较的第二个操作数在计算第一个操作数后已经知道结果,则不计算第二个操作数。

位运算符

  • &:按位与

  • |:按位或

  • ^:按位异或

  • ~:按位取非

  • <<<<=:左移

  • >>>>=:右移

将类型为N位的对象的位移位 N 位或 N 位以上是未定义的行为!

控制流

条件结构

1
2
3
4
5
6
7
8
9
10
11
12
if (condition1) 
{
// 条件1为真则执行
}
else if (condition2)
{
// 条件2为真则执行
}
else
{
// 否则执行
}

C++17支持以下语法:

1
2
3
4
5
// 即在条件判断前可执行一句语句
if(statement; condition)
{
// ……
}

另外还有 switch

1
2
3
4
5
6
7
8
switch(variable)
{
case value1:
break;
case value2:
break;
default:
}

C++17同样支持多执行一句语句:

1
2
3
4
5
6
7
8
switch(statement; variable)
{
case value1:
break;
case value2:
break;
default:
}

三元运算符 condition ? statement1 : statement2 同样可用于分支结构。

循环结构

for 循环:

1
2
3
4
for (initialization; condition; step)
{
// 循环体
}

在C++11支持针对可迭代对象的迭代循环,即

1
2
3
4
for(variable : range)
{
// 循环体
}

while 循环:

1
2
3
4
while(condition)
{
// 循环体
}

do-while 循环:

1
2
3
4
do
{
// 循环体
} while(condition);

枚举

普通枚举: enum 枚举名 {枚举元素1,枚举元素2,……};

如:

1
2
3
4
enum day {mon, tue, wed, thu, fri, sat, sun};
day d;
d = mon; // 正确
d = tue; // 正确

但 C++11 中允许带有作用域的枚举: enum class 枚举名 {枚举元素1,枚举元素2,……};

如:

1
2
3
4
enum class day {mon, tue, wed, thu, fri, sat, sun};
day d;
d = day::mon; // 正确
d = tue; // 错误

枚举的内在类型:必须是整型类型,默认情况下枚举是 int 类型。

如:

1
2
// 枚举只有7个值,使用char类型足够
enum class day : char {mon, tue, wed, thu, fri, sat, sun};

枚举可以自定义映射值,如:

1
enum class day : char {mon = 1, tue = 2, wed = 3, thu = 4, fri = 5, sat = 6, sun = 7};

枚举可以与基本数据类型进行转换,如:

1
2
3
4
enum class day : char {mon = 1, tue = 2, wed = 3, thu = 4, fri = 5, sat = 6, sun = 7};
int i = static_cast<int>(month::tue); // i = 2
int j = 1;
day d = static_cast<day>(j); // d = tue

数据类型聚合

基础数据类型: voidboolcharintfloatdouble 等。

聚合的例子:

1
2
3
4
5
6
7
8
struct point
{
int x;
int y;
};

point p = {1, 2};
std::cout << p.x << "," << p.y << "\n";

为什么要自定义类型/数据聚合?

  • 接口变得更容易正确使用
  • 语义数据分组:点、日期、…
  • 避免了许多函数参数,因此,混淆
  • 可以从一个专用类型的函数返回多个值,而不是多个非const引用输出参数

聚合后的初始化:

1
Type {arg1 arg2 ... argn}

如:

1
2
3
4
5
6
7
struct point
{
int x;
int y;
};

point p{1, 2};

可以多重聚合:

1
2
3
4
5
6
7
8
9
10
11
struct point
{
int x;
int y;
};

struct line
{
point _begin;
point _end;
}

引用

使用引用:定义一个变量的引用,引用相对于一个变量的别名。

1
2
3
4
5
6
7
8
9
10
11
12
13
int i = 2;
int &r = i; // 定义 i 的引用 r

std::cout << i << " " << r << "\n"; // i 与 r 是一样的值
// 2 2

i = 10
std::cout << i << " " << r << "\n"; // i 与 r 是一样的值
// 10 10

r = 20;
std::cout << i << " " << r << "\n"; // i 与 r 是一样的值
// 20 20
  • 引用必须总是指向一个对象
  • 变量的一个引用总是指向与变量相同的内存位置
  • 引用类型必须与被引用对象的类型一致

const 引用:

1
2
3
4
5
6
7
8
int i = 2;
const int &r = i; // 定义 i 的常量引用 r

i = 10; // 不报错
std::cout << i << " " << r << "\n"; // i 与 r 是一样的值
// 10 10

r = 20; // 报错

引用可应用于:

  • 基于范围的循环,改变值
  • 函数参数传入,不会进行复制减少开销,且改变值,还能达到返回值的效果
    • 当只想减少开销,但不想改变值,可以考虑 const 的引用
  • 等等

引用的绑定:

  • & :只能绑在左值上;
  • const &:能绑定在左值和右值上。
1
2
3
4
bool is_palindrome (std::string const& s) { … }
std::string s = "uhu";
cout << is_palindrome(s) << ", " << is_palindrome("otto") << '\n';
// 左值变量 s 和 右值 "otto" 都可以执行
1
2
3
void swap (int& i, int& j) { … }
int i = 0;
swap(i, 5); // 5 是右值,不能绑定引用,编译错误

使用引用的陷阱:

  • 不要返回对函数局部对象的引用:函数局部对象函数结束时会被销毁,返回的引用也会变得无效。
  • 引用 std::vector 要小心:在任何改变vector中元素数量的操作之后,对std::vector中元素的引用都可能失效。
    • 在一些vector操作期间,std::vector 存储元素的内部内存缓冲区可以被交换为一个新的,因此对旧缓冲区的任何引用都可能是悬空的。
  • 引用能延长临时变量(或右值)的生存期:如 const auto& r = vector<int>{1, 2, 3},引用r存在,右边vector则一直存在。
    • 不要通过引用去延长变量生存期,请使用合适的变量。
    • 但当对临时的vector成员进行引用时,则生存期不会延长。如:
1
2
3
4
std::vector<std::string> foo () { … }
const std::string &s = foo()[0];
// 对函数返回的临时 vector 的成员进行引用,并不会延长生存期
std::cout << s; // 未定义的行为

悬空引用:引用不再有效的内存位置的引用。

C++的默认动态数组 std::vector

  • 数组:可以存放多个相同类型的值;
  • 动态:长度可以动态变化。

std::vector 的使用需要包含头文件:#include <vector>

std::vector的使用

std::vector 的定义和初始化:

1
2
3
4
5
6
std::vector<int> v;                 // 定义一个空,元素类型为int的vector
std::vector<int> v1 = {1, 2, 3}; // 定义一个vector并初始化
std::vector<int> v2{1, 2, 3}; // 定义一个vector并C++11的初始化
std::vector<int> v3(10); // 定义一个长度为10,未初始化的vector
std::vector<int> v4(10, 0); // 定义一个长度为10,且都初始化为0的vector
std::vector<int> v5{v1}; // 定义vector,并用v1的值和长度初始化

遍历 std::vector

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
std::vector<int> v1{1, 2, 3};
for (int i = 0; i < v1.size(); i++)
{
// 下标访问
std::cout << v1[i] << ' ';
}

for (int x : v1) // 此时 x 只能从 v1 中读,并不能修改值
{
// 基于范围for循环
std::cout << x << ' ';
}

for (int &x : v1) // 此时 x 附加了引用,可以修改值
{
// 基于范围for循环
x = 1;
std::cout << x << ' ';
}

// 对于x的变量类型很复杂时的只读,减少开销
for (auto const& x : v1)
{
std::cout << x << " ";
}

// .front()首元素,.back()尾元素
std::cout << v1.front() << '\n' << v1.back() << '\n';

添加元素:

1
2
std::vector<int> v;
v.push_back(1); // 向v的后面添加一个元素

删除元素:

1
2
3
std::vector<int> v{1, 2, 3};
v.pop_back(); // 删除v的最后一个元素
v.clear(); // 清空v中的所有元素

std::vector 的长度调整:

1
2
std::vector<int> v{1, 2, 3};
v.resize(5); // 将v的长度调整为5
  • 示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <vector>

int main ()
{
std::vector<int> v{1, 2, 3};
v.resize(6);
for(int x : v) std::cout << x << " ";
// 1 2 3 0 0 0
v.push_back(7);
std::cout << std::endl;
for(int x : v) std::cout << x << " ";
// 1 2 3 0 0 0 7
return 0;
}

std::vector 中的复制都是深复制。

  • 深度复制:创建一个新的对象并复制源的所有包含对象;
  • 深度赋值:将所有包含的对象从源复制到赋值目标;
  • 深度比较:比较两个向量,比较所包含对象的值;
  • 深层所有权:销毁vector将销毁所有包含的对象。

深复制和浅复制(深拷贝和浅拷贝):简单来说,深拷贝在内存上独立,复制内容在新的内存空间上。浅拷贝在内存上共享。比如把A复制到B,如果是深复制,则A和B独立互不影响;如果是浅复制,在修改A,B也会改变。

1
2
3
std::vector<int> a{1, 2, 3};
std::vector<int> b = a; // 深度赋值,a和b独立,互不影响
a[0] = 9; // a:9 2 3;b:1 2 3

另外,C++对 std::vector 进行了一系列的运算符重载,即可以对 std::vector 使用 == (判断相等)、!=(判断不相等)、>(判断大小)等运算符。

  • std::vector 的判断大小:比较两个vector上每个位置上的元素,当发现不同的且字典序小的,拥有该元素的vector判定为小。

std::vector 的大小和容量:

  • 大小:指元素个数,函数 .size() 可以获取,同时函数 .resize(newSize) 可以改变大小。
  • 容量:指能容纳的元素个数,函数 .capacity() 可以获取,同时函数 .resize(newCapacity) 可以改变最大容纳元素个数。
1
2
3
4
5
6
std::vector<int> a{1, 2, 3};
std::cout << a.size() << " " << a.capacity() << "\n";
// 3 3
a.push_back(4);
std::cout << a.size() << " " << a.capacity() << "\n";
// 4 6

std::vector迭代器

优先使用迭代器而不是索引器。

  • begin(vector):指向vector的第一个元素
  • end(vector):指向vector的最后一个元素的后面,只能用作位置指示符,不能用于访问元素。

迭代器:类似一个指针,指向容器的某个位置,便于迭代循环

1
2
3
4
std::vector<int> a{1, 2, 3};
std::vector<int>::iterator p = begin(a);
for(p; p != end(a); p ++)
std::cout << *p << " ";

所以迭代器也可以进行自增自减,加法减法运算。

除了正向迭代器,还有反向迭代器,其作用与正向迭代器类似:

  • rbegin(vector):指向vector的最后一个元素
  • rend(vector):指向vector的第一个元素的前面,只能用作位置指示符,不能用于访问元素。

用迭代器表示范围的 std::vector 初始化和赋值:

1
2
3
4
5
std::vector<int> u{1, 2, 3};
std::vector<int> v{begin(u), begin(u) + 1};

std::vector<int> w;
w.assign(begin(u) + 1, end(u));

通过迭代器在 std::vector 中插入元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::vector<int> v{1, 2, 3};

// 函数结构1
//.insert(插入位置, 插入元素)
//.insert(插入位置, {插入元素1, 插入元素2, ……})

v.insert(begin(v), 0); // 在v的第一个位置前插入0
// 0 1 2 3
v.insert(end(v), {4, 5}); // 在v的最后一个位置后插入{4, 5}
// 0 1 2 3 4 5

// 函数结构2
//.insert(插入位置, 起始位置, 结束位置),范围左闭右开

std::vector<int> v1{7, 8, 9};
// 在v的第一个位置前插入v1的所有元素
v.insert(begin(v), begin(v1), end(v1));
// 7 8 9 0 1 2 3 4 5

通过迭代器在 std::vector 中删除元素:(从vector中擦除元素不会改变容量,因此不会释放任何内存。)

1
2
3
4
5
6
7
8
// 函数结构
//.erase(位置)
//.erase(起始位置, 结束位置),范围左闭右开
std::vector<int> v{1, 2, 9, 3, 4, 5};
v.erase(begin(v) + 2);
// 1 2 3 4 5
v.erase(begin(v), begin(v) + 2);
// 3 4 5

在使用迭代器进行元素操作后,如添加删除,原迭代器并未更新,如:

1
2
3
4
5
6
std::vector<int> v{1, 2, 3, 4, 5, 6};

auto i = begin(v) + 3; // auto 表示编译器自动推导类型
v.insert(i, 8);
// 输出 *i 为 4,是原来vector的第3个元素,不计刚刚插入的8,因为当前 i 已经失效了
// 使用 i = v.insert(i, 8); 更新迭代器,输出 *i 才为 8

同时,经过增删元素后,std::vector 的长度可能变短或者变长。当长度变短时,其容量并不会变小,仍保持之前操作中的最大值,此时可能需要“刷新”一下容量,减少空间消耗:

1
2
3
4
std::vector<int> v;
// 一系列增删改操作后
v = std::vector<int>(v); // C++11~20支持
v.swap(std::vector<int>(v)); // C++98~20支持

做一个临时的副本,通过交换内存缓冲区更新容量,临时变量自动销毁。

std::vector 的工作原理

vector 的数据总是在堆上的,但对象的地址根据定义的方式不同可能在堆上,也可能在栈上。

vector元素保证驻留在一个连续的内存块中。

  • 大小:指元素个数。
  • 容量:指能容纳的元素个数。

内存块一旦分配后不能调整大小。

动态数组增长方式:

  1. 动态分配新的(≈1.1-2倍)更大的内存块
  2. 复制/移动旧值到新块
  3. 摧毁旧的内存块

当在某位置擦除(删除)元素时,方式如下:

  1. 析构(销毁)元素
  2. 剩下的元素前移
  3. 长度减少,但容量不变

当在某位置添加(插入)元素时,方式如下:

  1. 判断容量大小是否允许,允许则不需再开辟空间增长,不允许则进行增长。
  2. 将插入位置及后面的元素后移
  3. 在插入位置复制上新元素

字符串std::string

基本特性:

  • 是动态的 char 数组(类似于 vector<char>
  • 支持 ++= 进行字符串之间的连接
  • 支持使用 [下标] 进行单字符访问
  • 深复制
  • 支持 ==!= 进行比较
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <string> // 字符串操作的头文件

int main ()
{
using std::cout;
std::string hw = "Hello";
std::string s = hw; // 复制 hw 到 s
hw += " World!";
cout << hw << '\n'; // Hello World!
cout << hw[4] << '\n'; // o
cout << s << '\n'; // Hello
}

字符串的操作,对于 std::string s = "Hello World";

  • s.insert(5, ","):在下标为 5 的位置插入字符串 “,”,变成 “Hello, World”
  • s.erase(6, 7):删除下标为 5 的位置后的 7 个字符,变成“Hello,”
  • s.replace(5, 3, " C++"):将下标为 5 的位置的后 3 个字符替换为 “C++”,变成“Hello C++”
  • s.resize(5):调整字符串长度为5,即变成“Hello”
  • s.resize(8, '!'):调整字符串长度为8,多出来的部分用 ! 代替,变成“Hello!!!”
  • s.find("l"):字符串中从头到尾寻找“l”,返回 l 所在的下标 2 ,找不到返回 string::npos
  • s.rfind("l"):字符串中从尾到头寻找“l,返回 l 所在的下标 3 ,找不到返回 string::npos
  • s.find('e', 5):字符串从第5个位置往后寻找“e”。
  • s.substr(0, 2):在字符串 s 中从下标为0到2(左闭右开)截取子字符串,返回“He”
  • s.ends_with(""):判断字符串是否以 “” 结尾,返回 true 或者 false
  • s.starts_with(""):判断字符串是否以 “” 开头,返回 true 或者 false

在定义并初始化时:

1
2
3
4
5
6
7
8
9
std::string a = "hello";    // std::string 类型
// 使用 auto:
auto b = "hello";
// 此时 b 会被推导为 const char[] 类型,而不是std::string

// 在 C++14及以后
using namespace std::string_literals;
auto c = "hello"s;
// 此时 c 是 std::string 类型

另外,仅用空格分隔的字符串字面值将被连接起来:

1
2
std::string s = "hello" " world";
// s 现在是 "hello world"

如果想让字符串的转义字符失效:

1
2
3
using namespace std::string_literals;
auto s = R"(\n)"s; // C++14及之后支持,类型为 std::string
auto t = R"(\\n)"; // C++11支持,类型为 const char[]

函数 std::getline() :该函数需要包含头文件

1
2
3
4
5
6
7
std::string s;
// 从标准输入中读取一行
std::getline(std::cin, s);
// 从标准输入中读取一行,直到下一个制表符
std::getline(std::cin, s, '\t');
// 从标准输入中读取一行,直到下一个 'a'
std::getline(std::cin, s, 'a');

当需要把 std::string 作为函数参数传入时,有以下选择:

要求 使用形式 优势
总是需要复制值时 std::string 值传参
在C++17/20下只读 std::string_view(#include <string_view>) 省去大部分复制
在C++98/11/14下只读 const std::string & 引用传递,省去大部分复制
原地修改输入字符串 std::string & 非const的引用传递

const表示把变量常量化,不允许改变值


C++还提供了关于 std::string 与基本类型转换的函数:

1
2
3
4
5
6
7
#include <string>
std::to_string(5); // 数字(整型和浮点型)转字符串

std::string s = "123";
int num = std::stoi(s); // 字符串转整型
// 类似的还有 std::stol, std::stoll, std::stof, std::stod
// 分别是字符串转long,转long long,转float,转double

函数

与C语言类似,函数实现细节的封装;通过将问题分解为单独的函数,更容易对正确性和测试进行推理;避免为常见任务重复代码。

函数结构:

1
2
3
4
返回类型 函数名 (参数列表)
{
// 函数体
}

函数参数的默认值:

1
2
3
4
5
6
7
8
// a 默认值为0,b 默认值为0
int add(int a, int b = 0)
{
return a + b;
}

int num1 = add(1, 2); // num1 = 3;
int num2 = add(1); // num2 = 1;

注意:第一个默认值之后的每个参数也必须有默认值。

函数相关的知识点还有:函数定义、函数声明、函数签名、函数递归。这些与C语言中的知识互通。

函数重载

具有相同名称但不同参数列表的函数,不能单独重载返回类型。

如:

1
2
3
4
5
6
7
8
9
int add(int a, int b)
{
return a + b;
}

double add(double a, double b)
{
return a + b;
}

函数设计

约定:

  • 前提条件:您对输入值的期望/要求是什么?
  • 后置条件:对于输出值应该给出什么保证?
  • 不变量:函数的调用者/用户希望不改变什么?
  • 目的:你的职能有明确的目的吗?
  • 名称:函数的名称是否反映了它的目的?
  • 参数:调用者/用户是否容易混淆它们的含义?

C++17中,支持使用 [[nodiscard]] 鼓励编译器在发现返回值被丢弃时发生警告:

1
2
3
4
5
6
7
8
[[nodiscard]] bool odd(int num)
{
return num % 2 == 1;
}

bool yes = odd(3); // 正常

odd(4); // 警告,因为返回值被丢弃

C++11及以后支持使用关键字 noexcept,指定函数承诺永远不会抛出异常/让异常逃逸。如果一个异常从noexcept函数中逃逸,程序将被中止。

内存模型(部分)

    • 用于动态存储持续时间的对象,例如std::vector的内容
    • 空间大,可用于大容量存储(大部分主存)
    • 可以按需分配和解除分配任何对象
    • 不按特定顺序分配(取消)资源
    • 缓慢分配:需要为新对象找到连续的未占用空间
  • 栈(先进后出)
    • 用于对象的自动存储期限:局部变量、函数参数等。
    • 空间小(通常只有几MB)
    • 快速分配:新对象总是放在最上面
    • 对象按其创建的相反顺序解除分配
    • 无法取消分配最顶层(=最新)以下的对象

对象存储生存期:

类型 生存期 举例
自动回收型 对象生存期绑定到语句块范围的开始和结束 如局部变量,函数参数
动态变化型 用特殊语句控制的对象生存期 按需创建/销毁的对象
线程生存型 对象生存期绑定到线程的开始和结束
静态生存型 对象生存期与程序的开始和结束有关 静态变量(static)

输入和输出

命令行的输入输出

Windows 系统中,打开控制台(命令提示符,CMD),可以在里面输入一些命令。

C++ 也支持通过命令输入一些参数。有时候会遇到下面的代码:

1
2
3
4
5
#include <iostream>
int main(int argc, char* argv[])
{
return 0;
}

其中,argc 表示命令行传入参数的个数, argv 表示命令行传入的参数字符串数组。

  • argv[0] 为当前程序名

比如有一程序代码:

1
2
3
4
5
6
7
8
// test.cpp
#include <iostream>
int main(int argc, char* argv[])
{
for(int i = 0; i < argc; i++)
std::cout << argv[i] << " ";
return 0;
}

在经过编译后,可以在 cmd 中进行调用可执行文件:

1
2
g++ -o test.exe test.cpp
test.exe 1 2 3

运行截图

上述 test.cpp 代码中,功能是将程序的命令行输入都输出到控制台。


实际上 C++ 程序的输出(返回值)也是可以获取的。

比如有代码:

1
2
3
4
5
6
7
// test.cpp
#include <iostream>
int main(int argc, char* argv[])
{
if(argc <= 1) return 0;
else return argc;
}

经过编译后运行有:

1
2
3
4
5
g++ -o test.exe test.cpp
test.exe
echo %errorlevel%
test.exe 1 2 3 4 5
echo %errorlevel%

运行截图


输入输出流

一些标准输入输出流有:

  • 输入流 istream
  • 输出流 ostream
  • 文件输入流 ifstream:从文件中读取提取的数据
  • 文件输出流 ofstream:插入的数据存储在文件中
  • 字符串输入流 istringstream:从字符串缓冲区读取提取的数据
  • 字符串输出流 ostringstream:插入的数据存储在字符串缓冲区中

一些关于流的控制格式函数:

冒号表示进入命名空间,表示该函数或内容属于某命名空间,防止命名冲突

  • std::getline(istream&, string&, stopat='\n'):读取到下一个停止字符(默认直到行尾)
  • std::istream::ignore(n, c):忽略字符,直至忽略 n 个字符或字符 c 被发现
  • std::setprecision(n):定义保留精度,对于小数表示共保留 n 位。需要包含头文件 <iomanip>
  • std::fixed:修改浮点输入/输出为默认格式
  • std::scientific:修改浮点输入/输出为科学计数法格式
  • std::boolalpha:修改 bool 类型的输入/输出为字母格式

文件的输入输出

需要包含头文件 <fstream>

打开和关闭文件

在输入输出流中,使用文件输入输出流 ifstreamofstream 操作文件。

函数 open()clost() 分别控制文件的打开和关闭。

打开文件操作如下:

1
2
3
4
5
6
7
8
9
// 1. 初始化流时打开文件
std::ifstream in1("test.txt"); // 使用文件名打开文件
std::string path = "test.txt";
std::ifstream in2(path); // 文件名使用字符串和字符数组都可以
// 文件将自动关闭

// 2. 使用open函数打开文件
std::ifstream in3;
in3.open("test.txt");

关闭文件操作如下:

1
2
3
4
std::ifstream in4;
in4.open("test.txt");
// ……
in4.close();

文件在打开时,可以选择打开的模式:

  • 默认情况下,文件输入流的模式为 std::ios::in,即只读模式;文件输出流的模式为 std::ios::out,即只写模式;
  • 追加到现有文件: std::ios::app
  • 以二进制方式打开文件: std::ios::binary

只需要在初始化时声明打开模式即可:

1
2
// 以二进制方式打开文件
std::ifstream in("test.txt", std::ios::in | std::ios::binary);

读文件

使用文件输入流 ifstream

1
2
3
4
std::ifstream in("test.txt");
int x;
while(in >> x)
std::cout << x << '\n';

当打开模式为二进制打开时,读文件使用 std::istream::read()

  • 函数参数为指针和长度,将文件读入到指针的空间中,返回读取的字节数;
1
2
3
std::ifstream in("test.txt", std::ios::in | std::ios::binary);
unsigned int i;
in.read(reinterpret_cast<char*>(&i), sizeof(i));

写文件

使用文件输出流 ofstream

1
2
3
4
5
std::ofstream out("test.txt");
if(out.good()) // 判断流是否正常可写文件
{
out << "Hello World!\n";
}

当打开模式为二进制打开时,写文件使用 std::ostream::write()

  • 函数参数为指针和长度,将指针指向的内容写入文件,返回写入的字节数;
1
2
3
std::ofstream out("test.txt", std::ios::out | std::ios::binary);
unsigned int i = 10;
out.write(reinterpret_cast<char*>(&i), sizeof(i));

输入流的错误

当有代码:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
int main()
{
int i = 0, j = 0;
std::cout << "input i:";
std::cin >> i;
std::cout << "input j:";
std::cin >> j;
std::cout << i << " " << j << std::endl;
return 0;
}

如果输入的是: 1 2 这没有问题;

但如果输入的是: asd 2,此时将中断 j 的输入并输出 0 0

当进行输入时,读取不能转换为 int 的字符(非0~9):

  • cin 将会置错误位;
  • cin 的缓冲区内容不会被丢弃,并且仍然包含有问题的输入;
  • 任何随后从 cin 读取 int 的尝试也将失败。

要想解决这个问题,需要清除 cin 的错误位以及输入缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
void resetCin()
{
// 清空错误状态
std::cin.clear();
// 清空输入缓冲区
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
int main()
{
int i = 0, j = 0;
std::cout << "input i:";
std::cin >> i;
if(std::cin.fail()) resetCin();
std::cout << "input j:";
std::cin >> j;
std::cout << i << " " << j << "\n";
return 0;
}

此时再次输入 asd 2,将输出 0 2


更多参考官方文档:

类的初接触

引例

  1. 实现一个单调计数器,支持自增和读取计数值。

分析要求,如果是 C 语言,可以包装成结构体:

1
2
3
4
5
6
7
8
9
struct Counter
{
int count;
}

Counter cnt;
std::cout << cnt.count; // 访问
cnt.count++; // 自增
cnt.count = 10; // 访问

可是应当考虑到:

  • 成员变量未显式初始化;
  • 可以自由地修改任何整数成员
  • 甚至跟基础的 int 无差别

在 C++ 中,考虑实现为一个类。

C++ 的类可以有构造函数,析构函数,成员函数,成员变量,以及成员函数的重载,成员变量的默认初始化等。

注:虽然结构体 struct 在 C++ 中也支持成员函数,但此处介绍类 class

类成员的受限制访问

成员函数

成员函数可用于

  • 操作或查询数据成员,通过成员函数访问成员变量
  • 控制/限制对数据成员的访问,通过成员函数访问私有成员变量
  • 隐藏低级实现详细信息
  • 确保正确性:保持/保证不变量
  • 确保清晰:为类型的用户提供结构良好的界面
  • 确保稳定性:大部分内部数据表示独立于接口
  • 避免重复/样板:对于潜在的复杂操作封装成成员函数,只需要一个调用
1
2
3
4
5
6
7
8
9
10
11
12
class Counter
{
int count; // 成员变量
public:
void inc() { count++; } // 成员函数
int get() { return count; }
};

Counter cnt;
std::cout << cnt.get();
cnt.inc();
std::cout << cnt.get();

公有与私有

私有成员只能通过成员函数访问!!!

结构体与类的主要区别是默认的成员访问权限:

  • 结构体默认为公有
  • 类默认为私有。

const限定的成员函数

const 对象不管是否 const 限定都可以调用,const 对象只能调用 const 限定的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class Counter
{
int count; // 成员变量
public:
Counter() : count(0) {}
explicit Counter(int _count) { count = _count;} // 构造函数
void inc() { count++; } // 成员函数
int get() const { return count; }
};

int main()
{
Counter cnt1;
auto const &pcnt1 = cnt1;
pcnt1.inc(); // 编译错误,inc() 是非const函数
std::cout << pcnt1.get();
return 0;
}

成员变量在 const 限定的成员函数内 也具有 const 属性。

如果一个函数是常量限定的,另一个不是,则两个成员函数可以有相同的名称(和参数列表)。这使得可以清楚地区分只读访问和读/写操作。

  • 即成员函数可以被 const 重载
1
2
int getAndSet() const { return count; } // 只读访问
void getAndSet(int newcount) { count = newcount; } // 写

成员函数的定义

当类的成员函数较为复杂时,一般不会在类内定义,而是定义在类的外部,此时加上作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A
{
int value;
public:
void setValue(int v);
int getValue() const;
}

void A::setValue(int v)
{
// ……
}

int A::getValue() const
{
// ……
}

初始化

成员初始化

  1. 成员变量初始化,C++11 下:
1
2
3
4
5
6
class Counter
{
int count = 0;
public:
//……
}
  1. 构造函数的初始化列表

构造函数是创建对象时执行的特殊成员函数

1
2
3
4
5
6
7
class Counter
{
int count = 0;
public:
Counter() : count(0) {}
// ……
}

确保初始化列表中的成员顺序始终与成员声明顺序相同

构造函数

构造函数:创建对象时执行的特殊成员函数。

  • 构造函数名就是其类型名
  • 没有返回类型
  • 可以通过初始化列表初始化数据成员
    • 确保初始化列表中的成员顺序始终与成员声明顺序相同
  • 可以在首次使用对象之前执行代码
  • 可以用来建立不变量
  • 调用顺序自上而下
默认构造函数

类默认提供 默认构造函数其不带参数。但是当显式定义构造函数时,需要手动提供一个默认构造函数,默认构造函数只能有一个(避免二义性),但构造函数可以有多个。
如:

1
2
3
4
5
6
7
8
9
10
11
12
class Counter
{
int count; // 成员变量
public:
Counter() : count(0) {} // 默认构造函数,采用初始化列表,确保初始化列表中的成员顺序始终与成员声明顺序相同!
Counter(int _count) { count = _count;} // 构造函数,且默认用1初始化
void inc() { count++; } // 成员函数
int get() { return count; }
};

Counter cnt1; // 默认构造函数,并初始化count = 0
Counter cnt2(10); // 构造函数,初始化count = 10

或者使用 TypeName() = default;,编译器提供默认构造函数的实现。

1
2
3
4
5
6
7
8
9
class Counter
{
int count; // 成员变量
public:
Counter() = default; // 默认构造函数,未初始化
Counter(int _count) { count = _count;} // 构造函数
void inc() { count++; } // 成员函数
int get() { return count; }
};

默认构造函数还可以通过给函数参数设置默认值提供:

1
2
3
4
5
6
class Counter
{
public:
Counter(int _count = 0) { count = _count; } // 默认构造
// Counter() = default; // 默认构造函数
}

定义构造函数时,加上关键字 explicit 表示构造函数只能用于显式转换,即不会被隐式调用,隐式调用的构造是很难找到的 bug 的主要来源。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class Counter
{
int count; // 成员变量
public:
Counter() : count(0) {}
explicit Counter(int _count) { count = _count;} // 构造函数
void inc() { count++; } // 成员函数
int get() { return count; }
};

int fun (Counter c) { return c.get(); }

int main()
{
std::cout << fun(2) << "\n"; // 编译错误,避免了由2隐式转换为Counter的bug
std::cout << fun(Counter(2)) << "\n"; // 正确
return 0;
}

可以尝试把 explicit 去掉,体验如何隐式调用构造函数。

拷贝构造函数

默认情况下,类也提供默认拷贝构造函数

默认拷贝构造函数:简单来说就是从源复制到新的地方,进行变量之间的复制

  • 默认拷贝构造函数是浅复制
    • 浅复制(拷贝):拷贝者和被拷贝者是同一个地址,改变其中一个,另一个也改变
    • 深复制(拷贝):拷贝者和被拷贝者不是同一个地址,改变其中一个,另一个不变
  • 拷贝构造函数的函数名就是其类型名,参数为拷贝源

拷贝函数的形式:

1
T::T(const T& t) {}

可以重载拷贝构造函数,进行一些自定义的复制操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Counter
{
int count; // 成员变量
public:
Counter() : count(0) {} // 默认构造函数
explicit Counter(int _count) { count = _count;} // 构造函数
Counter(const Counter &c) { count = c.get(); } // 拷贝构造函数
void inc() { count++; } // 成员函数
int get() const { return count; }
};

Counter cnt1; // 默认构造函数,并初始化count = 0
Counter cnt2 = cnt1; // 调用拷贝构造函数
赋值运算符函数

默认赋值运算符函数:就是重载了赋值运算符

  • 具有其返回值类型,函数名字以及参数列表

赋值运算符函数形式如下:

1
T& T::operator=(const Counter& rhs)

具体如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Counter
{
int count; // 成员变量
public:
Counter() : count(0) {} // 默认构造函数
explicit Counter(int _count) { count = _count;} // 构造函数
Counter(const Counter &c) { count = c.get(); } // 拷贝构造函数
Counter& operator =(const Counter &c) // 赋值运算符函数
{
if(this != &c) // 判断赋值是否为本身,若为本身则无需操作
{
this->set(c.get());
}
return *this;
}
void inc() { count++; } // 成员函数
void set(int _count) { count = _count; }
int get() const { return count; }
};

除了赋值运算符,其他大部分运算符也可以重载。但不可重载的运算符有:

  • .:成员访问运算符
  • .*->*:成员指针访问运算符
  • :::域运算符
  • sizeof:长度运算符
  • ?::条件运算符
  • #: 预处理符号
移动构造函数和移动赋值运算符函数

C++引入了移动语义,也产生了移动构造函数和移动赋值运算符函数。

移动构造函数:能够从一个右值引用创建新的对象,而无需进行深拷贝

假设你搬家,有一堆家具需要装进卡车。传统的深拷贝(复制构造函数)就像是你把每一件家具都精心地复制一份,然后放进卡车上。这个过程费时费力,而且你原本的家具还要保留。但是,如果你找来一位勇敢的快递员(移动构造函数),他们可以直接将你的家具移动到新的屋子里,而不用复制。这样,节省了时间和精力,而且你原本的家具可以顺利放进新的屋子。

移动构造函数形式:

1
T::T(T&&) noexcept;

移动赋值运算符函数:允许将一个对象的资源转移到另一个对象上

想象一下,你在一家公司工作,有一天你被调往另外一个部门。传统的方式是,你将自己的工作内容复制一份,再将新工作的内容复制回来,形成了两份一样的工作内容。这样的操作显然很冗余。然而,通过移动赋值操作符,你可以直接将自己的工作内容交给新的员工,并且接管他们原本的工作,省去了不必要的复制步骤。

移动赋值运算符函数形式:

1
T& T::operator=(const T& rhs);

析构函数

析构函数:当对象的生命周期结束时,会调用析构函数,用于释放对象的资源。

  • 如果不定义默认构造函数和析构函数,编译器会生成它们。

函数形式:

1
Type::~Type() { ... }

析构函数的执行顺序:所有数据成员的析构函数将以其构造函数相反的声明顺序执行

  • 调用顺序自下而上

资源获取即初始化RAII

  • 对象构建:获取资源
  • 对象销毁:释放资源

std::vector

  • 每个 vector 对象都是堆中存储实际内容的单独缓冲区的所有者。
  • 该缓冲区按需分配,如果vector对象被销毁则取消分配。

如果一个对象对其生命周期(初始化/创建、结束/销毁)负责,则该对象被称为资源(内存、文件句柄、连接、线程、锁等)的所有者。

注意资源的使用,避免资源泄漏。

零规则

The Rule of Zero:尽量不要自己写特殊成员函数。

  • 避免编写特殊的成员函数,除非需要进行 RAII 风格的资源管理或跟踪生命周期。
    • 编译器生成的默认构造函数和析构函数在大多数情况下就足够了。
  • 初始化并不总是依赖编写构造函数。
    • 大多数数据成员都可以用成员初始化器初始化(声明定义时初始化)。
  • 不要给类型添加空析构函数。
    • 用户定义析构函数的存在阻止了许多优化,并可能严重影响性能。
    • 如果不需要在析构函数体中做任何事情,那么就不要定义它。
  • 几乎不需要编写析构函数。
    • 在现代 C++ 中,内存管理策略大多封装在专用类(容器、智能指针、分配器等)中。

指针

为什么需要指针?

  • 观察对象
    • 引用/跟踪对象
    • 在运行时更改间接的目标
  • 访问动态内存
    • 访问动态存储持续时间的对象,即生命周期不与变量/作用域绑定的对象
  • 构建动态、基于结点的数据结构
    • 动态数组
    • 链表
    • 树/图

有时候可以用于前向声明:定义一个类型,它的所有成员的内存大小必须是已知的。

例子中,Hub 类和 Device 类相互类型引用。

  • 因为,所有指针类型都具有相同的大小。
  • 所以先声明 Hub 的存在。
  • 然后 Device 只需要一个指向 Hub 的指针,即已知成员内存大小。
1
2
3
4
5
6
7
8
9
class Hub;
class Device {
Hub* hub_;

};
class Hub {
std::vector<Device const*> devs_;

};

指向类型为T的对象的指针

1
T* ptr;
  • 存储类型为 T 的对象 ptr 的内存地址;
  • 可以用来检查/观察/修改目标对象;
  • 可以重定向到不同的目标(不同于引用,引用不可以重定向);
  • 也可能根本不指向任何对象,为空指针。

原始指针:T *

  • 本质:一个存储内存地址的(无符号)整数变量
  • 大小:64位,8个字节(64位机)
  • 许多原始指针可以指向相同的地址/对象
  • 指针和目标(被指向)对象的生存期是独立的,可能会出现野指针。
    • 野指针:指向一个已经销毁的对象的指针或指向一个未定义内容的内存地址。

智能指针:(C++11及以后)

  • std::unique_pointer<T>
    • 用于访问动态存储,即堆上的对象;
    • 每个对象只能有一个 unique_pointer
    • 指针与指向对象具有相同的生存期。
  • std::shared_pointer<T>
    • 用于访问动态存储,即堆上的对象;
    • 每个对象可以有多个 shared_pointer
    • 只要至少有一个 shared_pointer 指向目标对象,目标对象就存在
  • std::weak_pointer<T>
    • 用于访问动态存储,即堆上的对象;
    • 每个对象可以有多个 weak_pointer

C++11及以后: nullptr

  • 特殊指针值;
  • 可隐式转换为 false
  • 在内存中不一定用0表示(取决于平台)
  • nullptr 表示值不可用
    • 在初始化时设置指向空指针或有效地址的指针
    • 取消引用前检查是否为nullptr

指针相关的运算符

取地址符 & :返回内存地址。

1
2
char c = 65;
char *pc = &c;

解引用(取值)符 * : 访问地址中的值

1
2
3
char c = 65;
char *pc = &c;
*pc = 66;

成员访问符 -> : 访问指针指向的对象的成员

1
2
3
4
5
6
7
8
9
10
struct coord
{
char x = 0;
char y = 0;
}

coord a{1, 2};
coord *pa = &a;
char v = pa->x; // 访问指针pa指向地址中的x成员的值
char w = (*pa).y; // 解引用后使用.访问成员

*& 的语法:

用处 * &
作类型修饰符 声明指针:Type *ptr = nullptr 声明引用:Type &ref = variable
作一元运算符 解引用:value = *pointer 取地址:pointer = &variable
作二元运算符 乘法:ans = expr1 * expr2 按位与:bitand = expr1 & expr2

指针声明时注意:

1
2
int* p1, p2;    // p1 是 int*,p2 是 int
int *p1, *p2; // p1 是 int*,p2 是 int*

const 指针

目的:

  • 对于目标只读访问
  • 防止指针重定向

语法:

T类型的指针 指向的值能否修改 指针能否重定向
T *
T const * 不能
T * const 不能
T const * const 不能 不能

从右向左读:(是否const修饰的) 指针指向一个(是否const修饰的)类型

1
2
3
4
5
6
7
8
9
10
11
int i = 5;
int j = 8;
const int *cp = &i;
*cp = 8; // 编译器错误:指向的值是常量
cp = &j; // OK
int * const pc = &i;
*pc = 8; // OK
pc = &j; // 编译器错误:指针本身是常量
const int * const cpc = &i;
*cpc = 8; // 编译器错误:指向的值是常量
cpc = &j; // 编译器错误:指针本身是常量

还有代码风格的一致性问题:使用像是 int const 而不是 const int

1
2
3
4
5
6
// const 修饰它的左边
int const c = ...; // const 修饰它的左边(int)
int const &cr = ...; // const 修饰它的左边(int)
int const *pc = ...; // const 修饰它的左边(int)
int * const cp = ...; // const 修饰它的左边(*)
int const * const cpc = ...;// const 修饰它的左边(int和*)

this 指针

this

  • 成员函数内部可用
  • this 返回对象本身的地址
  • this-> 可用于访问成员
  • *this 访问对象本身

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class IntRange
{
int l_ = 0;
int r_ = 0;
public:
explicit
IntRange (int l, int r): l_{l}, r_{r}
{
if (l_ > r_) std::swap(l_, r_);
}
int left () const { return l_; }
// 也可以使用“this”访问成员:
int right () const { return this->r_; }
// 返回对象本身的引用
IntRange& shift (int by)
{
l_ += by;
r_ += by;
return *this;
}
};

少使用指针

推荐合适使用 引用 代替指针。

  1. 指针容易悬空
    • 悬空:指针指向无效或不可访问的内存地址
    • 指针中的值可以是任意地址,程序员必须确保指针目标是有效的/仍然存在
  2. 容易出现错误参数传递
  3. 指针让代码更难理解
    • *p = *p * *p + (2 * *p + 1);

异常

什么是异常

对象可以在调用层次结构中向上抛出:

  • 通过“抛出”将控制转回到当前函数的调用方。
  • 如果不处理,异常会一直传播,直到它们到达 main 函数。但如果在主函数中中没有处理异常,将会调用 std::terminate,即终止程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void fun1()
{
throw "Exception";
}

void fun2()
{
fun1();
}

int main()
{
fun2(); // 没有处理异常,终止程序
return 0;
}
  1. 通过 throw 关键字抛出异常。
  2. 通过 try-catch 语句捕获异常。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 定义除法函数, a / b 
double division(double a, double b)
{
if(b == 0)
throw std::invalid_argument{"divided by 0"};
return a / b;
}

int main()
{
double number1 = 0, number2 = 0, ans = 0;
std::cin >> number1 >> number2;
try
{
ans = division(number1, number2);
}
catch (std::invalid_argument const &err)
{
std::cout << err.what() << '\n';
return -1;
}
std::cout << number1 << " / " << number2 << " = " << ans;
return 0;
}
1
2
3
4
5
输入:1 2
输出:1 / 2 = 0.5

输入:1 0
输出:divided by 0

异常用处

报告违规行为。

  1. 输入与期望或规定不符(违法输入,或违法的函数参数)。

    • 如:负数的平方根、下标越界等。
  2. 定义或保留不变量失败。

    • 如:公共成员函数无法设置有效的成员值、vector 扩充空间期间爆内存。
  3. 输出、返回值与期望或规定不符,函数无法生成有效的返回值或损坏全局。

    • 如:构造函数失败、无法返回除以零的结果。

异常的优劣:

  1. 错误处理代码与业务逻辑的分离
  2. 错误处理的集中化
  3. 当不引发异常时,性能影响可以忽略不计
  4. 抛出异常时通常会影响性能,由于额外的有效性检查而导致的性能影响
  5. 容易产生资源/内存泄漏

异常替代方案

  1. 输入值无效:输入前进行检查,用参数类型排除无效值。

  2. 定义或保留不变量失败:设置错误状态/标志,将对象设置为特殊,无效值/状态。

  3. 不能返回有效值:通过单独的输出参数(引用或指针)返回错误代码、返回特殊的有效值、返回特殊类型 std::optional(C++17)

标准库异常

std::exception:其子类型有:

  • logic_error
    • invalid_argument
    • domain_error
    • length_error
    • out_of_range
    • ……
  • runtime_error
    • range_error
    • overflow_error
    • underflow_error
    • ……

vector 支持一种“宽规约”函数,通过抛出异常来报告无效的输入值:

1
2
3
std::vector<int> v{ 0, 1, 2 };
int a = v[3]; // 越界且窄规约,即错误
int b = v.at(3); // 越界且宽规约,即抛出异常 std::out_of_range

处理异常

重复抛出:

1
2
3
4
5
6
7
8
try
{
// ...
}
catch(std::exception const &)
{
throw;
}

捕获所有异常:

1
2
3
4
5
6
7
8
try
{
// ...
}
catch(...)
{
// ...
}

集中异常处理:

  • 如果在许多不同的地方抛出相同的异常类型,可以避免代码重复。
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
void handle_init_errors()
{
try
{
throw; // 重抛出
}
catch(err::device_unreachable const &e)
{
//...
}
catch(err::bad_connection const &e)
{
//...
}
catch(err::bad_protocol const &e)
{
//...
}
}

void init_server()
{
try
{
///...
}
catch(...) { handle_init_errors(); }
}

void init_client()
{
try
{
//...
}
catch(...) { handle_init_errors(); }
}

异常的问题

几乎任何一段代码都可能引发异常,对C++类型和库的设计产生重大影响。

资源/内存泄漏的潜在来源:

  1. 进行自己的内存管理的外部C库;
  2. 不使用 RAII 进行自动资源管理的C++库(设计存在缺陷);
  3. 在销毁时不清理资源的类型(设计存在缺陷);

如:

1
2
3
4
5
6
7
8
9
void add_to_database (database const& db, std::string_view filename)
{
DBHandle h = open_dabase_conncection(db); // 建立远程连接
auto f = open_file(filename);
// 如果 open_file 抛出异常,则不会调用 close_database_connection

// do work…
close_database_connection(h); // 断开远程连接
}

这个例子可以使用 RAII,在类析构时断开连接释放资源。

  • 但也不要让异常逃离析构函数,如果在析构函数运行时发生异常,可能导致析构函数终止,但对象还没完全释放。
  • 需要在析构函数作成套的 try-catch

异常保障

为了避免抛出异常:

  • 当没有保障时:

    • 操作可能会失败
    • 资源可能会泄露
    • 可能违反不变量(=成员可能包含无效值)
    • 部分执行失败的操作可能会产生副作用(例如输出)
    • 异常可能向外传播
  • 存在基本保障时:

    • 不变量被保留,没有资源泄露
    • 所有成员都将包含有效值
    • 部分执行失败的操作可能会产生副作用(例如,值可能已写入文件)
  • 强保障时:

    • 操作可能会失败,但不会产生明显的副作用
    • 所有成员都保留其原始值
    • 内存分配容器应提供这种保证,即如果增长期间内存分配失败,容器应保持有效且不变
  • 使用无抛出保障时:

    • 行动一定会成功
    • 无法从外部观察到的异常,即没有抛出或内部捕获
    • 使用 noexcept 关键字进行记录和强制执行

无抛出保障关键字: noexcept (C++11)

1
2
3
4
int f() noexcept
{
...
}
  • f 函数承诺永远不抛出异常,不允许任何转义
  • 如果从 noexcept 函数中逃脱出异常,则程序将终止

带条件的 noexcept 语句:

1
2
A noexcept(exp) // 如果表达式产生真值,声明'A'为noexcept
A noexcept(noexcept(B)) // 如果`B`没有抛出异常,声明`A`为noexcept

noexcept() 默认是 true

终止处理程序

当在主函数有未捕获的异常时:

  • 调用终止函数 std::terminate
  • 它调用终止处理程序,默认调用 std::abort ,从而正常终止程序。

可以自定义处理程序:std::set_terminate(handler);

如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdexcept>
#include <iostream>
void my_handler ()
{
std::cerr << "Unhandled Exception!\n";
std::abort(); // 终止程序
}
int main ()
{
std::set_terminate(my_handler);
throw std::exception{};
}

异常指针

  • std::current_exception
    • 捕获当前异常对象
    • 返回一个 std::exception_ptr 引用该异常
    • 如果没有异常,则返回空的 std::exception_ptr
  • std::exception_ptr
    • 保存一个异常副本或对异常的引用
  • std::rethrow_exception(exception_ptr)
    • 抛出异常指针所引用的异常对象
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
#include <exception>
#include <stdexcept>
void handle_init_errors (std::exception_ptr eptr)
{
try
{
if (eptr) std::rethrow_exception(eptr);
}
catch (err::bad_connection const& e)
{
// ...
}
catch (err::bad_protocol const& e)
{
// ...
}
}
void initialize_client ()
{
if (exp) throw err::bad_connection;
// ...
}
int main ()
{
std::exception_ptr eptr;
try
{
initialize_client();
// ...
}
catch (...)
{
eptr = std::current_exception();
}
handle(eptr);
} // eptr已销毁,则捕获的异常已销毁

计数未捕获的异常

C++17中,std::uncaught_exceptions 返回当前线程中当前未处理的异常数。

1
2
3
4
5
6
7
#include <exception>
void foo ()
{
bar(); // 可能抛出异常
int count = std::uncaught_exceptions();
// ...
}

C++诊断

关于诊断的术语

  • Warnings:编译器指出潜在的有问题的行为,可能在运行时形成错误。
  • Assertions:断言,用于比较和报告表达式的预期值和实际值的语句。
  • Testing:比较部分或整个程序的实际情况和预期行为。
  • Code Coverage:代码覆盖情况,即实际执行或测试了多少代码。
  • Static Analysis:静态分析,通过分析源代码(就看着代码)发现潜在的运行时问题,如未定义行为。
  • Dynamic Analysis:动态分析,通过运行实际的程序(跑下代码)发现潜在的问题,如内存泄漏。
  • Debugging:在运行时逐步执行代码并检查内存中的值。
  • Profiling:找出每个函数、循环、代码块占总运行时间、内存消耗等的比例。
  • Micro Benchmarking:对单个函数或语句块调用的小测试。

记得使用针对性的数据类型,避免出错。

编译警告

  • Compiler Error:CE,编译器错误,程序不能编译。
  • Compiler Warning:程序能够编译,但是有一段有问题的代码可能会导致运行时错误。

一些 gcc/clang 编译器的编译设置:

  • Wall:没有真正启用所有警告,而是启用了最重要的警告,这些警告不会产生太多的干扰。
  • Wextra:启用比 -Wall 更多的警告。
  • Wpedantic:发出严格 ISO C++ 要求的所有警告;拒绝特定于编译器的扩展。
  • Wshadow:当变量或类型声明相互隐藏时发出警告。
  • Werror:把所有警告当作错误行为。
1
gcc [options] file ..

如:

1
g++ -Wall -o test.exe test.cpp

MS Visual Studio 的编译设置:

  • /W1:严重的警告。
  • /W2:重要的警告。
  • /W3:生产级别警告。
  • /W4:并不能真正启用所有警告,而是最重要的警告,新项目推荐。
  • /Wall:启用比级别4更多的警告。
  • /WX:把所有的警告当成错误行为。

断言

头文件:#include <cassert>

1
assert(bool 表达式);
  • 如果表达式产生 false,则中止程序。

使用案例:

  1. 在运行时检查预期值/条件
  2. 验证前提条件(输入值)
  3. 验证不变量(例如,中间状态/结果)
  4. 验证后置条件(输出/返回值)

注意,逗号需要加上括号:assert 是一个预处理器宏,逗号将被解释为宏参数分隔符。

1
2
assert( min(1, 2) == 1 );   // error
assert((min(1, 2) == 1)); // ok

可以使用自定义宏添加:

1
2
#define assertmsg(expr,msg) assert (((void)msg,expr))
assertmsg(1+2=2"1加1必须是2");

对于 g++/clang,通过定义预处理器宏 NDEBUG 来停用断言,例如,使用编译器开关:g++-DNDEBUG…

对于 MS Visual Studio:

  • 断言会被显式激活的情况:
    • 如果定义了预处理器宏 _DEBUG,例如使用编译器开关/D_DEBUG
    • 如果提供了编译器开关 /MDd
  • 断言会被显式停用的情况:
    • 如果定义了预处理器宏 NDEBUG
    • 在项目设置中或使用编译器开关 /DNDEBUG

静态断言

C++11支持。

1
2
static_assert(bool 表达式);
static_assert(1+1==2"1加1必须是2");

C++17下:

1
static_assert(bool 表达式);

功能:如果编译时常数表达式产生 false,则中止编译。

测试

测试准则:

  • 使用断言:检查类型无法表达、保证的期望或假设,如
    • 仅在运行时可用的预期值
    • 先决条件(输入值)
    • 不变量(例如,中间状态/结果)
    • 后置条件(输出/返回值)

Release版本中应该去掉断言。

  • 编写测试用例:一旦确定了函数或类型的基本目的和接口即可开始准备。
  • 使用测试框架:
    • 小项目可以使用:doctest
    • 大工程可以使用:Catch2

测试中最好不要 直接cincoutcerr

直接使用全局I/O流使得函数或类型难以测试。

  • 函数中用引用传递流:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct State {std::string msg; ...};

void log(std::ostream &os, State const& s)
{
os << s.meg;
}

TEST_CASE("State Log")
{
State s{"expected"};
std::ostringstream oss;
log(oss, s);
CHECK(oss.str() == "expected");
}
  • 类作用域中使用流指针存储:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Logger
{
std::ostream *_os;
int _count;
public:
explicit Logger(std::ostream *os) : _os(os), _count(0) {}
// ...

bool add(std::string_view msg)
{
if(!_os) return false;
*_os << _count << ": " << msg << "\n";
++_count;
return true;
}
}

TEST_CASE("Logging")
{
std::ostringstream oss;
Logger log{&oss};
log.add("message");
CHECK(oss.str() == "0: message\n");
}

使用 gdb 进行调试

gdb,GNU Debugger,是一种开源的调试器,与在 Visual Studio 上进行调试类似。不同的是,gdb 通过命令进行调试。

现有代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
#include<string>

int fun(int n)
{
if(n <= 1) return 1;
else return fun(n - 1) * n;
}

int main(int argc, char *argv[])
{
int num = std::stoi(argv[1]);
std::cout << fun(num) << std::endl;
return 0;
}

使用命令:

1
g++ -o test test.cpp

编译后,使用 gdb 调试:

1
gdb test

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GNU gdb (GDB) 11.2
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-w64-mingw32".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from test...
(gdb)

接着输入:

1
run 5

输出如下:

1
2
3
4
5
6
7
8
9
Starting program: F:\Program\C++\\test.exe 5
[New Thread 11572.0x4338]
[New Thread 11572.0x28e8]
[New Thread 11572.0x307c]
120
[Thread 11572.0x4dcc exited with code 0]
[Thread 11572.0x4338 exited with code 0]
[Thread 11572.0x307c exited with code 0]
[Inferior 1 (process 11572) exited normally]

这就完成了输入为 5 的测试。

可以设置断点:

1
2
3
4
5
6
7
8
9
10
11
在当前源代码的第12行添加断点
break 12

在所有源代码文件中第一个执行 fun 函数的那行添加断点
break fun

在 test.cpp 的第12行添加断点
break test.cpp:12

// 在 test.cpp 的 main 函数第一行添加断点
break test.cpp:main

也可以使用条件型断点:

1
break 20 if i == 2000

控制断点:

1
2
3
4
5
下一行
next

单步步进
step

还有一些控制断点的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
输出所有断点
info breakpoints

删除所有断点
delete

删除1号断点
delete 1

禁用2号断点
disable 2

启用2号断点
enable 2

保存断点到 file
save breakpoints file

从 file 中加载断点
source file

可以监视和设置变量值:

1
2
3
4
5
6
7
8
9
监视局部变量
info locals

输出变量(表达式)值
print x
print x + 2

设置变量值
set x = 20

常用命令还有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
跳转
jump <loc>

继续直到下一个断点或结束
continue

继续直到下一个位置(函数、行)
until <loc>

结束(跳出)当前函数
finish

查看调用栈
backtrace

清理器

C++ 功能强大,但会遇到一些 bug,所以需要清理器(善后)。

地址清理器ASAN

对于 g++ 和 clang++而言:

  • 检测内存损坏 bug:
    • 内存泄漏
    • 访问已释放的内存
    • 访问不正确的堆栈区域
  • 用附加指令对代码进行检测:
    • 运行时间大约增加70%
    • 内存使用量大约增加了3倍

如有代码:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using std::cin;
using std::cout;

int main()
{
int *p = nullptr;
cout << *p << "\n";
return 0;
}

在 Ubuntu 中使用 gcc 9.4.0,输入下列命令:

1
2
g++ test.cpp -o test -fsanitize=address
./test

后提示:

1
2
3
4
5
6
7
8
9
10
11
12
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1698==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x56468da812d8 bp 0x7fff3402eec0 sp 0x7fff3402eeb0 T0)
==1698==The signal is caused by a READ memory access.
==1698==Hint: address points to the zero page.
#0 0x56468da812d7 in main (/home/ecs-assist-user/test/tes+0x12d7)
#1 0x7f22b0c18082 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x24082)
#2 0x56468da811cd in _start (/home/ecs-assist-user/test/tes+0x11cd)

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/home/ecs-assist-user/test/tes+0x12d7) in main
==1698==ABORTING

对于 MSVC:

https://learn.microsoft.com/zh-cn/cpp/sanitizers/asan?view=msvc-170

  • 从 Visual Studio 2019 版本 16.9 开始,Microsoft C/C++ 编译器 (MSVC) 和 IDE 支持AddressSanitizer清理器。
  • 检测 bug:
    • alloc/dealloc 不匹配和 new/delete 类型不匹配
    • 分配对堆来说太大
    • calloc 溢出和 alloca 溢出
    • 重复释放和释放后使用
    • 全局变量溢出
    • 堆缓冲区溢出
    • 对齐值对齐无效
    • memcpy 和 strncat 参数重叠
    • 堆栈缓冲区溢出和下溢
    • return 后使用堆栈和限定作用域后使用
    • 在内存中毒后使用内存

未定义行为清理器UBSAN

对于 g++ 和 clang++而言:

  • 在运行时检测许多类型的未定义行为:
    • 解引用空指针
    • 从未对齐的指针中读取
    • 整数溢出
    • 除零
  • 用附加指令检测代码:
    • 调试版运行时间增加25%

如有代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <limits>
using std::cin;
using std::cout;

int main()
{
int i = std::numeric_limits<int>::max();
i += 1;
cout << i << "\n";
return 0;
}

在 Ubuntu 中使用 gcc 9.4.0,输入下列命令:

1
2
g++ test.cpp -o test -fsanitize=undefined
./test

后提示:

1
2
test.cpp:9:7: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
-2147483648

内存泄漏检测工具valgrind

Valgrind是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合。

  • 检测常见的运行时错误:
    • 读/写释放内存或不正确的堆栈区域
    • 使用未初始化的值
    • 不正确的内存释放,如双重释放
    • 错误地使用函数来分配内存
    • 内存泄漏——通常与程序有关的无意内存消耗
    • 导致内存指针在释放之前丢失的逻辑缺陷

更多查看:https://blog.csdn.net/weixin_45518728/article/details/119865117

Lambda函数

Lambda 函数的形式如下:

1
[捕获列表] (参数列表) -> 返回值类型 { 代码块 }

举几个例子:

1
2
3
4
5
[] { return 1; }

[] (int x, int y) { return x * x + y * y; }

[] (int x, int y) -> double { return 1.0 * x * x + y * y; }

Lambda 函数可以看作匿名函数,它没有名字。

关于变量捕获:

  • [=]:捕获所有变量,值传递。
  • [&]:捕获所有变量,引用传递。
  • [x, &y]:x为值传递,y为引用传递。
  • [=, &y]:除了y是引用传递,其他都是值传递。

在某些情况下,可以使用 Lambda 函数。

  • 函数 std::partition(@first, @last, p),定义于头文件 <algorithm>
    • 其一个功能用法是:重排序范围 [first, last) 中的元素,使得谓词 p 对其返回 true 的元素前于谓词 p 对其返回 false 的元素。不保持相对顺序。
1
2
3
4
5
6
std::vector<int> v{5, 3, -3, 2, 7, 1, 0, 99, 3};

std::partition(v.begin(), v.end(), [](int x) { return x > 0; });

for(int x : v) std::cout << x << ' ';
// 5 3 3 2 7 1 99 0 -3
  • 函数 std::transform(@first, @last, @result, @op),定义于头文件 <algorithm>
    • 其一个功能用法是:将范围 [first, last) 中的元素应用 op 变化 ,结果存储在 result 中。
1
2
3
4
5
6
// 字符串转全大写
void upper(std::string &s)
{
std::transform(s.begin(), s.end(), s.begin(), [] (unsigned char c) { return toupper(c); });
// 等价于 std::transform(s.begin(), s.end(), s.begin(), ::toupper);
}
1
2
3
4
5
6
7
// 对序列每个数字求平方
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto squared = [](int x) { return x * x; };
// 匿名函数类型还是用 auto 自动推导吧

std::transform(v.begin(), v.end(), v.begin(), squared);
// 等价于std::transform(v.begin(), v.end(), v.begin(), [](int x) { return x * x; });
  • 函数 std::generate(@first, @last, @op),定义于头文件 <algorithm>
    • 其中一个用法是:为 [first, last)范围内的每个元素分配一个由给定函数对象 g 生成的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <vector>
#include <algorithm>

signed main()
{
std::vector<int> v(10);
int step = 1;
std::generate(v.begin(), v.end(), [&step]
{
step *= 2;
return step;
});
for (int x : v) std::cout << x << ' ';
}

在C++14及以后,如果变量的类型复制代价昂贵,可以使用std::move

1
2
3
class Expensive {...};
Expensive f{1};
auto g = [cf = std::move(f)]() { return cf; };