关键词: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 | // hello.cpp |
-
#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
-
预处理,在源代码中处理头文件等;
-
编译:将源代码转化成机器码;
-
链接:结合多个二进制机器码文件,生成可执行文件。
编译术语:
-
编译错误(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 | // IO流.cpp |
-
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 | type variable = value; |
但基本类型的变量默认情况下不会初始化。
1 | int i; |
变量类型
-
布尔类型:值只有真(
true
)和假(false
)。 -
字符类型:一个字节大小,通常范围在-128~127。
-
整型类型:一般的整数,
short
、int
、long
、long long
。- 带符号整型
- 无符号整型
- C++14中可支持数字分隔符,如
long num = 512'232'697'499;
-
浮点类型:一般的小数
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 |
|
类型窄化
从可以表示更多值的类型转换为可以表示更少值的类型,可能导致信息丢失。
类型提升
涉及浮点类型的提升:
- 小类型转换成大类型
两种整数类型的操作:
-
整数提升:
- 基本上任何小于int的值都会被提升为int或unsigned int(取决于哪一种类型可以表示未提升类型的所有值)
-
如果两个操作数类型不同,则应用整数转换
- 两种符号:小类型转换成大类型
- 都是无符号的:将较小的类型转换为较大的类型
- 有符号⊕无符号:
- 如果两者宽度相同,则有符号转换为无符号
- 否则,如果可以表示所有值,则将无符号转换为有符号
- 否则都转换为无符号
const修饰符
使用 const
限定变量为常量。
- 值一旦赋值就不能更改。
- 如果不需要在初始赋值后改变变量的值,总是将变量声明为
const
。- 避免错误:如果稍后不小心更改值,则不会编译
- 帮助更好地理解你的代码:清楚地传达值将在代码中保持不变
- 可以提高性能(可能进行更多编译器优化)
constexpr常量表达式
C++11支持,常量表达式必须在编译时可计算
如果未在constexpr上下文中调用,则可以在运行时进行计算
constexpr上下文中的所有表达式必须是constexpr本身
Constexpr函数可能包含:
- C++ 11:只有一条返回语句
- C++ 14:多个语句
auto关键字
使用如下:
1 | auto variable = expression; |
- 从赋值的右侧推导出变量类型
- 往往更方便、更安全、更经得起未来考验
- 对于泛型(与类型无关)编程也很重要
类型别名
1 | // C++11支持 |
算术运算符
-
+
、+=
:算术加 -
-
、-=
:算术减 -
*
、*=
:算术乘 -
/
、/=
:算术除 -
%
、%=
:算术取余
自增自减符
- 作用:将值更改+/- 1
- 前缀表达式
++x
/--x
返回新的(递增/递减)值; - 后缀表达式
x++
/x--
增加/减少值,但返回旧值。
- 前缀表达式
比较运算符
-
返回值只有真(
true
)和假(false
)。 -
==
:判断相等 -
!=
:判断不相等 -
<
:小于 -
>
:大于 -
<=
:小于或等于 -
>=
:大于或等于 -
C++20引入
<=>
- 当 a < b 时,
(a <=> b) < 0
- 当 a > b 时,
(a <=> b) > 0
- 当 a = b 时,
(a <=> b) == 0
- 当 a < b 时,
逻辑运算符
-
返回值只有真(
true
)和假(false
)。 -
0
永远是假,其他值都是真。 -
&&
或and
:逻辑与 -
||
或or
:逻辑或 -
!
或not
:逻辑非 -
短路评估:如果布尔比较的第二个操作数在计算第一个操作数后已经知道结果,则不计算第二个操作数。
位运算符
-
&
:按位与 -
|
:按位或 -
^
:按位异或 -
~
:按位取非 -
<<
、<<=
:左移 -
>>
、>>=
:右移
将类型为N位的对象的位移位 N 位或 N 位以上是未定义的行为!
控制流
条件结构
1 | if (condition1) |
C++17支持以下语法:
1 | // 即在条件判断前可执行一句语句 |
另外还有 switch
:
1 | switch(variable) |
C++17同样支持多执行一句语句:
1 | switch(statement; variable) |
三元运算符 condition ? statement1 : statement2
同样可用于分支结构。
循环结构
for
循环:
1 | for (initialization; condition; step) |
在C++11支持针对可迭代对象的迭代循环,即
1 | for(variable : range) |
while
循环:
1 | while(condition) |
do-while
循环:
1 | do |
枚举
普通枚举: enum 枚举名 {枚举元素1,枚举元素2,……};
如:
1 | enum day {mon, tue, wed, thu, fri, sat, sun}; |
但 C++11 中允许带有作用域的枚举: enum class 枚举名 {枚举元素1,枚举元素2,……};
如:
1 | enum class day {mon, tue, wed, thu, fri, sat, sun}; |
枚举的内在类型:必须是整型类型,默认情况下枚举是 int
类型。
如:
1 | // 枚举只有7个值,使用char类型足够 |
枚举可以自定义映射值,如:
1 | enum class day : char {mon = 1, tue = 2, wed = 3, thu = 4, fri = 5, sat = 6, sun = 7}; |
枚举可以与基本数据类型进行转换,如:
1 | enum class day : char {mon = 1, tue = 2, wed = 3, thu = 4, fri = 5, sat = 6, sun = 7}; |
数据类型聚合
基础数据类型: void
、bool
、char
、int
、float
、double
等。
聚合的例子:
1 | struct point |
为什么要自定义类型/数据聚合?
- 接口变得更容易正确使用
- 语义数据分组:点、日期、…
- 避免了许多函数参数,因此,混淆
- 可以从一个专用类型的函数返回多个值,而不是多个非const引用输出参数
聚合后的初始化:
1 | Type {arg1 arg2 ... argn} |
如:
1 | struct point |
可以多重聚合:
1 | struct point |
引用
使用引用:定义一个变量的引用,引用相对于一个变量的别名。
1 | int i = 2; |
- 引用必须总是指向一个对象
- 变量的一个引用总是指向与变量相同的内存位置
- 引用类型必须与被引用对象的类型一致
const
引用:
1 | int i = 2; |
引用可应用于:
- 基于范围的循环,改变值
- 函数参数传入,不会进行复制减少开销,且改变值,还能达到返回值的效果
- 当只想减少开销,但不想改变值,可以考虑
const
的引用
- 当只想减少开销,但不想改变值,可以考虑
- 等等
引用的绑定:
&
:只能绑在左值上;const &
:能绑定在左值和右值上。
1 | bool is_palindrome (std::string const& s) { … } |
1 | void swap (int& i, int& j) { … } |
使用引用的陷阱:
- 不要返回对函数局部对象的引用:函数局部对象函数结束时会被销毁,返回的引用也会变得无效。
- 引用
std::vector
要小心:在任何改变vector中元素数量的操作之后,对std::vector中元素的引用都可能失效。- 在一些vector操作期间,
std::vector
存储元素的内部内存缓冲区可以被交换为一个新的,因此对旧缓冲区的任何引用都可能是悬空的。
- 在一些vector操作期间,
- 引用能延长临时变量(或右值)的生存期:如
const auto& r = vector<int>{1, 2, 3}
,引用r存在,右边vector则一直存在。- 不要通过引用去延长变量生存期,请使用合适的变量。
- 但当对临时的vector成员进行引用时,则生存期不会延长。如:
1 | std::vector<std::string> foo () { … } |
悬空引用:引用不再有效的内存位置的引用。
C++的默认动态数组 std::vector
- 数组:可以存放多个相同类型的值;
- 动态:长度可以动态变化。
std::vector 的使用需要包含头文件:#include <vector>
std::vector的使用
std::vector
的定义和初始化:
1 | std::vector<int> v; // 定义一个空,元素类型为int的vector |
遍历 std::vector
:
1 | std::vector<int> v1{1, 2, 3}; |
添加元素:
1 | std::vector<int> v; |
删除元素:
1 | std::vector<int> v{1, 2, 3}; |
std::vector
的长度调整:
1 | std::vector<int> v{1, 2, 3}; |
- 示例代码:
1 |
|
std::vector
中的复制都是深复制。
- 深度复制:创建一个新的对象并复制源的所有包含对象;
- 深度赋值:将所有包含的对象从源复制到赋值目标;
- 深度比较:比较两个向量,比较所包含对象的值;
- 深层所有权:销毁vector将销毁所有包含的对象。
深复制和浅复制(深拷贝和浅拷贝):简单来说,深拷贝在内存上独立,复制内容在新的内存空间上。浅拷贝在内存上共享。比如把A复制到B,如果是深复制,则A和B独立互不影响;如果是浅复制,在修改A,B也会改变。
1 | std::vector<int> a{1, 2, 3}; |
另外,C++对 std::vector
进行了一系列的运算符重载,即可以对 std::vector
使用 ==
(判断相等)、!=
(判断不相等)、>
(判断大小)等运算符。
std::vector
的判断大小:比较两个vector上每个位置上的元素,当发现不同的且字典序小的,拥有该元素的vector判定为小。
std::vector
的大小和容量:
- 大小:指元素个数,函数
.size()
可以获取,同时函数.resize(newSize)
可以改变大小。 - 容量:指能容纳的元素个数,函数
.capacity()
可以获取,同时函数.resize(newCapacity)
可以改变最大容纳元素个数。
1 | std::vector<int> a{1, 2, 3}; |
std::vector迭代器
优先使用迭代器而不是索引器。
begin(vector)
:指向vector的第一个元素end(vector)
:指向vector的最后一个元素的后面,只能用作位置指示符,不能用于访问元素。
迭代器:类似一个指针,指向容器的某个位置,便于迭代循环
1 | std::vector<int> a{1, 2, 3}; |
所以迭代器也可以进行自增自减,加法减法运算。
除了正向迭代器,还有反向迭代器,其作用与正向迭代器类似:
rbegin(vector)
:指向vector的最后一个元素rend(vector)
:指向vector的第一个元素的前面,只能用作位置指示符,不能用于访问元素。
用迭代器表示范围的 std::vector
初始化和赋值:
1 | std::vector<int> u{1, 2, 3}; |
通过迭代器在 std::vector
中插入元素:
1 | std::vector<int> v{1, 2, 3}; |
通过迭代器在 std::vector
中删除元素:(从vector中擦除元素不会改变容量,因此不会释放任何内存。)
1 | // 函数结构 |
在使用迭代器进行元素操作后,如添加删除,原迭代器并未更新,如:
1 | std::vector<int> v{1, 2, 3, 4, 5, 6}; |
同时,经过增删元素后,std::vector
的长度可能变短或者变长。当长度变短时,其容量并不会变小,仍保持之前操作中的最大值,此时可能需要“刷新”一下容量,减少空间消耗:
1 | std::vector<int> v; |
做一个临时的副本,通过交换内存缓冲区更新容量,临时变量自动销毁。
std::vector 的工作原理
vector 的数据总是在堆上的,但对象的地址根据定义的方式不同可能在堆上,也可能在栈上。
vector元素保证驻留在一个连续的内存块中。
- 大小:指元素个数。
- 容量:指能容纳的元素个数。
内存块一旦分配后不能调整大小。
动态数组增长方式:
- 动态分配新的(≈1.1-2倍)更大的内存块
- 复制/移动旧值到新块
- 摧毁旧的内存块
当在某位置擦除(删除)元素时,方式如下:
- 析构(销毁)元素
- 剩下的元素前移
- 长度减少,但容量不变
当在某位置添加(插入)元素时,方式如下:
- 判断容量大小是否允许,允许则不需再开辟空间增长,不允许则进行增长。
- 将插入位置及后面的元素后移
- 在插入位置复制上新元素
字符串std::string
基本特性:
- 是动态的
char
数组(类似于vector<char>
) - 支持
+
或+=
进行字符串之间的连接 - 支持使用
[下标]
进行单字符访问 - 深复制
- 支持
==
和!=
进行比较
1 |
|
字符串的操作,对于 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 或者 falses.starts_with("")
:判断字符串是否以 “” 开头,返回 true 或者 false
在定义并初始化时:
1 | std::string a = "hello"; // std::string 类型 |
另外,仅用空格分隔的字符串字面值将被连接起来:
1 | std::string s = "hello" " world"; |
如果想让字符串的转义字符失效:
1 | using namespace std::string_literals; |
函数 std::getline()
:该函数需要包含头文件
1 | std::string s; |
当需要把 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 |
|
函数
与C语言类似,函数实现细节的封装;通过将问题分解为单独的函数,更容易对正确性和测试进行推理;避免为常见任务重复代码。
函数结构:
1 | 返回类型 函数名 (参数列表) |
函数参数的默认值:
1 | // a 默认值为0,b 默认值为0 |
注意:第一个默认值之后的每个参数也必须有默认值。
函数相关的知识点还有:函数定义、函数声明、函数签名、函数递归。这些与C语言中的知识互通。
函数重载
具有相同名称但不同参数列表的函数,不能单独重载返回类型。
如:
1 | int add(int a, int b) |
函数设计
约定:
- 前提条件:您对输入值的期望/要求是什么?
- 后置条件:对于输出值应该给出什么保证?
- 不变量:函数的调用者/用户希望不改变什么?
- 目的:你的职能有明确的目的吗?
- 名称:函数的名称是否反映了它的目的?
- 参数:调用者/用户是否容易混淆它们的含义?
C++17中,支持使用 [[nodiscard]]
鼓励编译器在发现返回值被丢弃时发生警告:
1 | [[nodiscard]] bool odd(int num) |
C++11及以后支持使用关键字 noexcept
,指定函数承诺永远不会抛出异常/让异常逃逸。如果一个异常从noexcept函数中逃逸,程序将被中止。
内存模型(部分)
- 堆
- 用于动态存储持续时间的对象,例如std::vector的内容
- 空间大,可用于大容量存储(大部分主存)
- 可以按需分配和解除分配任何对象
- 不按特定顺序分配(取消)资源
- 缓慢分配:需要为新对象找到连续的未占用空间
- 栈(先进后出)
- 用于对象的自动存储期限:局部变量、函数参数等。
- 空间小(通常只有几MB)
- 快速分配:新对象总是放在最上面
- 对象按其创建的相反顺序解除分配
- 无法取消分配最顶层(=最新)以下的对象
对象存储生存期:
类型 | 生存期 | 举例 |
---|---|---|
自动回收型 | 对象生存期绑定到语句块范围的开始和结束 | 如局部变量,函数参数 |
动态变化型 | 用特殊语句控制的对象生存期 | 按需创建/销毁的对象 |
线程生存型 | 对象生存期绑定到线程的开始和结束 | |
静态生存型 | 对象生存期与程序的开始和结束有关 | 静态变量(static) |
输入和输出
命令行的输入输出
Windows 系统中,打开控制台(命令提示符,CMD),可以在里面输入一些命令。
C++ 也支持通过命令输入一些参数。有时候会遇到下面的代码:
1 |
|
其中,argc
表示命令行传入参数的个数, argv
表示命令行传入的参数字符串数组。
argv[0]
为当前程序名
比如有一程序代码:
1 | // test.cpp |
在经过编译后,可以在 cmd 中进行调用可执行文件:
1 | g++ -o test.exe test.cpp |
上述 test.cpp
代码中,功能是将程序的命令行输入都输出到控制台。
实际上 C++ 程序的输出(返回值)也是可以获取的。
比如有代码:
1 | // test.cpp |
经过编译后运行有:
1 | g++ -o test.exe test.cpp |
输入输出流
一些标准输入输出流有:
- 输入流
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>
。
打开和关闭文件
在输入输出流中,使用文件输入输出流 ifstream
和 ofstream
操作文件。
函数 open()
和 clost()
分别控制文件的打开和关闭。
打开文件操作如下:
1 | // 1. 初始化流时打开文件 |
关闭文件操作如下:
1 | std::ifstream in4; |
文件在打开时,可以选择打开的模式:
- 默认情况下,文件输入流的模式为
std::ios::in
,即只读模式;文件输出流的模式为std::ios::out
,即只写模式; - 追加到现有文件:
std::ios::app
; - 以二进制方式打开文件:
std::ios::binary
;
只需要在初始化时声明打开模式即可:
1 | // 以二进制方式打开文件 |
读文件
使用文件输入流 ifstream
:
1 | std::ifstream in("test.txt"); |
当打开模式为二进制打开时,读文件使用 std::istream::read()
。
- 函数参数为指针和长度,将文件读入到指针的空间中,返回读取的字节数;
1 | std::ifstream in("test.txt", std::ios::in | std::ios::binary); |
写文件
使用文件输出流 ofstream
:
1 | std::ofstream out("test.txt"); |
当打开模式为二进制打开时,写文件使用 std::ostream::write()
。
- 函数参数为指针和长度,将指针指向的内容写入文件,返回写入的字节数;
1 | std::ofstream out("test.txt", std::ios::out | std::ios::binary); |
输入流的错误
当有代码:
1 |
|
如果输入的是: 1 2
这没有问题;
但如果输入的是: asd 2
,此时将中断 j 的输入并输出 0 0
。
当进行输入时,读取不能转换为 int 的字符(非0~9):
cin
将会置错误位;cin
的缓冲区内容不会被丢弃,并且仍然包含有问题的输入;- 任何随后从
cin
读取int
的尝试也将失败。
要想解决这个问题,需要清除 cin
的错误位以及输入缓冲区。
1 |
|
此时再次输入 asd 2
,将输出 0 2
。
更多参考官方文档:
类的初接触
引例
- 实现一个单调计数器,支持自增和读取计数值。
分析要求,如果是 C 语言,可以包装成结构体:
1 | struct Counter |
可是应当考虑到:
- 成员变量未显式初始化;
- 可以自由地修改任何整数成员
- 甚至跟基础的
int
无差别
在 C++ 中,考虑实现为一个类。
C++ 的类可以有构造函数,析构函数,成员函数,成员变量,以及成员函数的重载,成员变量的默认初始化等。
注:虽然结构体 struct 在 C++ 中也支持成员函数,但此处介绍类 class
类成员的受限制访问
成员函数
成员函数可用于
- 操作或查询数据成员,通过成员函数访问成员变量
- 控制/限制对数据成员的访问,通过成员函数访问私有成员变量
- 隐藏低级实现详细信息
- 确保正确性:保持/保证不变量
- 确保清晰:为类型的用户提供结构良好的界面
- 确保稳定性:大部分内部数据表示独立于接口
- 避免重复/样板:对于潜在的复杂操作封装成成员函数,只需要一个调用
1 | class Counter |
公有与私有
私有成员只能通过成员函数访问!!!
结构体与类的主要区别是默认的成员访问权限:
- 结构体默认为公有
- 类默认为私有。
const限定的成员函数
非 const
对象不管是否 const
限定都可以调用,const
对象只能调用 const
限定的函数。
1 |
|
成员变量在 const
限定的成员函数内 也具有 const
属性。
如果一个函数是常量限定的,另一个不是,则两个成员函数可以有相同的名称(和参数列表)。这使得可以清楚地区分只读访问和读/写操作。
- 即成员函数可以被
const
重载
1 | int getAndSet() const { return count; } // 只读访问 |
成员函数的定义
当类的成员函数较为复杂时,一般不会在类内定义,而是定义在类的外部,此时加上作用域:
1 | class A |
初始化
成员初始化
- 成员变量初始化,C++11 下:
1 | class Counter |
- 构造函数的初始化列表
构造函数是创建对象时执行的特殊成员函数
1 | class Counter |
确保初始化列表中的成员顺序始终与成员声明顺序相同
构造函数
构造函数:创建对象时执行的特殊成员函数。
- 构造函数名就是其类型名
- 没有返回类型
- 可以通过初始化列表初始化数据成员
- 确保初始化列表中的成员顺序始终与成员声明顺序相同
- 可以在首次使用对象之前执行代码
- 可以用来建立不变量
- 调用顺序自上而下
默认构造函数
类默认提供 默认构造函数,其不带参数。但是当显式定义构造函数时,需要手动提供一个默认构造函数,默认构造函数只能有一个(避免二义性),但构造函数可以有多个。
如:
1 | class Counter |
或者使用 TypeName() = default;
,编译器提供默认构造函数的实现。
1 | class Counter |
默认构造函数还可以通过给函数参数设置默认值提供:
1 | class Counter |
定义构造函数时,加上关键字 explicit
表示构造函数只能用于显式转换,即不会被隐式调用,隐式调用的构造是很难找到的 bug 的主要来源。如:
1 |
|
可以尝试把 explicit
去掉,体验如何隐式调用构造函数。
拷贝构造函数
默认情况下,类也提供默认拷贝构造函数
默认拷贝构造函数:简单来说就是从源复制到新的地方,进行变量之间的复制
- 默认拷贝构造函数是浅复制
- 浅复制(拷贝):拷贝者和被拷贝者是同一个地址,改变其中一个,另一个也改变
- 深复制(拷贝):拷贝者和被拷贝者不是同一个地址,改变其中一个,另一个不变
- 拷贝构造函数的函数名就是其类型名,参数为拷贝源
拷贝函数的形式:
1 | T::T(const T& t) {} |
可以重载拷贝构造函数,进行一些自定义的复制操作。
1 | class Counter |
赋值运算符函数
默认赋值运算符函数:就是重载了赋值运算符
- 具有其返回值类型,函数名字以及参数列表
赋值运算符函数形式如下:
1 | T& T::operator=(const Counter& rhs) |
具体如:
1 | class Counter |
除了赋值运算符,其他大部分运算符也可以重载。但不可重载的运算符有:
.
:成员访问运算符.*
,->*
:成员指针访问运算符::
:域运算符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 | class Hub; |
指向类型为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 | char c = 65; |
解引用(取值)符 *
: 访问地址中的值
1 | char c = 65; |
成员访问符 ->
: 访问指针指向的对象的成员
1 | struct coord |
*
和 &
的语法:
用处 | * |
& |
---|---|---|
作类型修饰符 | 声明指针:Type *ptr = nullptr |
声明引用:Type &ref = variable |
作一元运算符 | 解引用:value = *pointer |
取地址:pointer = &variable |
作二元运算符 | 乘法:ans = expr1 * expr2 |
按位与:bitand = expr1 & expr2 |
指针声明时注意:
1 | int* p1, p2; // p1 是 int*,p2 是 int |
const 指针
目的:
- 对于目标只读访问
- 防止指针重定向
语法:
T类型的指针 | 指向的值能否修改 | 指针能否重定向 |
---|---|---|
T * |
能 | 能 |
T const * |
不能 | 能 |
T * const |
能 | 不能 |
T const * const |
不能 | 不能 |
从右向左读:(是否const修饰的) 指针指向一个(是否const修饰的)类型
1 | int i = 5; |
还有代码风格的一致性问题:使用像是 int const
而不是 const int
。
1 | // const 修饰它的左边 |
this 指针
this
:
- 成员函数内部可用
this
返回对象本身的地址this->
可用于访问成员*this
访问对象本身
如:
1 | class IntRange |
少使用指针
推荐合适使用 引用 代替指针。
- 指针容易悬空
- 悬空:指针指向无效或不可访问的内存地址
- 指针中的值可以是任意地址,程序员必须确保指针目标是有效的/仍然存在
- 容易出现错误参数传递
- 指针让代码更难理解
*p = *p * *p + (2 * *p + 1);
异常
什么是异常
对象可以在调用层次结构中向上抛出:
- 通过“抛出”将控制转回到当前函数的调用方。
- 如果不处理,异常会一直传播,直到它们到达
main
函数。但如果在主函数中中没有处理异常,将会调用std::terminate
,即终止程序。
1 | void fun1() |
- 通过
throw
关键字抛出异常。 - 通过
try-catch
语句捕获异常。
例子:
1 | // 定义除法函数, a / b |
1 | 输入:1 2 |
异常用处
报告违规行为。
-
输入与期望或规定不符(违法输入,或违法的函数参数)。
- 如:负数的平方根、下标越界等。
-
定义或保留不变量失败。
- 如:公共成员函数无法设置有效的成员值、
vector
扩充空间期间爆内存。
- 如:公共成员函数无法设置有效的成员值、
-
输出、返回值与期望或规定不符,函数无法生成有效的返回值或损坏全局。
- 如:构造函数失败、无法返回除以零的结果。
异常的优劣:
- 错误处理代码与业务逻辑的分离
- 错误处理的集中化
- 当不引发异常时,性能影响可以忽略不计
- 抛出异常时通常会影响性能,由于额外的有效性检查而导致的性能影响
- 容易产生资源/内存泄漏
异常替代方案
-
输入值无效:输入前进行检查,用参数类型排除无效值。
-
定义或保留不变量失败:设置错误状态/标志,将对象设置为特殊,无效值/状态。
-
不能返回有效值:通过单独的输出参数(引用或指针)返回错误代码、返回特殊的有效值、返回特殊类型
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 | std::vector<int> v{ 0, 1, 2 }; |
处理异常
重复抛出:
1 | try |
捕获所有异常:
1 | try |
集中异常处理:
- 如果在许多不同的地方抛出相同的异常类型,可以避免代码重复。
1 | void handle_init_errors() |
异常的问题
几乎任何一段代码都可能引发异常,对C++类型和库的设计产生重大影响。
资源/内存泄漏的潜在来源:
- 进行自己的内存管理的外部C库;
- 不使用 RAII 进行自动资源管理的C++库(设计存在缺陷);
- 在销毁时不清理资源的类型(设计存在缺陷);
如:
1 | void add_to_database (database const& db, std::string_view filename) |
这个例子可以使用 RAII,在类析构时断开连接释放资源。
- 但也不要让异常逃离析构函数,如果在析构函数运行时发生异常,可能导致析构函数终止,但对象还没完全释放。
- 需要在析构函数作成套的
try-catch
。
异常保障
为了避免抛出异常:
-
当没有保障时:
- 操作可能会失败
- 资源可能会泄露
- 可能违反不变量(=成员可能包含无效值)
- 部分执行失败的操作可能会产生副作用(例如输出)
- 异常可能向外传播
-
存在基本保障时:
- 不变量被保留,没有资源泄露
- 所有成员都将包含有效值
- 部分执行失败的操作可能会产生副作用(例如,值可能已写入文件)
-
强保障时:
- 操作可能会失败,但不会产生明显的副作用
- 所有成员都保留其原始值
- 内存分配容器应提供这种保证,即如果增长期间内存分配失败,容器应保持有效且不变
-
使用无抛出保障时:
- 行动一定会成功
- 无法从外部观察到的异常,即没有抛出或内部捕获
- 使用
noexcept
关键字进行记录和强制执行
无抛出保障关键字: noexcept
(C++11)
1 | int f() noexcept |
f
函数承诺永远不抛出异常,不允许任何转义- 如果从
noexcept
函数中逃脱出异常,则程序将终止
带条件的 noexcept
语句:
1 | A noexcept(exp) // 如果表达式产生真值,声明'A'为noexcept |
noexcept()
默认是 true
。
终止处理程序
当在主函数有未捕获的异常时:
- 调用终止函数
std::terminate
。 - 它调用终止处理程序,默认调用
std::abort
,从而正常终止程序。
可以自定义处理程序:std::set_terminate(handler);
如:
1 |
|
异常指针
std::current_exception
:- 捕获当前异常对象
- 返回一个
std::exception_ptr
引用该异常 - 如果没有异常,则返回空的
std::exception_ptr
std::exception_ptr
- 保存一个异常副本或对异常的引用
std::rethrow_exception(exception_ptr)
- 抛出异常指针所引用的异常对象
1 |
|
计数未捕获的异常
C++17中,std::uncaught_exceptions
返回当前线程中当前未处理的异常数。
1 |
|
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
,则中止程序。
使用案例:
- 在运行时检查预期值/条件
- 验证前提条件(输入值)
- 验证不变量(例如,中间状态/结果)
- 验证后置条件(输出/返回值)
注意,逗号需要加上括号:assert
是一个预处理器宏,逗号将被解释为宏参数分隔符。
1 | assert( min(1, 2) == 1 ); // error |
可以使用自定义宏添加:
1 |
|
对于 g++/clang,通过定义预处理器宏 NDEBUG
来停用断言,例如,使用编译器开关:g++-DNDEBUG…
对于 MS Visual Studio:
- 断言会被显式激活的情况:
- 如果定义了预处理器宏
_DEBUG
,例如使用编译器开关/D_DEBUG
。 - 如果提供了编译器开关
/MDd
。
- 如果定义了预处理器宏
- 断言会被显式停用的情况:
- 如果定义了预处理器宏
NDEBUG
。 - 在项目设置中或使用编译器开关
/DNDEBUG
。
- 如果定义了预处理器宏
静态断言
C++11支持。
1 | static_assert(bool 表达式); |
C++17下:
1 | static_assert(bool 表达式); |
功能:如果编译时常数表达式产生 false
,则中止编译。
测试
测试准则:
- 使用断言:检查类型无法表达、保证的期望或假设,如
- 仅在运行时可用的预期值
- 先决条件(输入值)
- 不变量(例如,中间状态/结果)
- 后置条件(输出/返回值)
Release版本中应该去掉断言。
- 编写测试用例:一旦确定了函数或类型的基本目的和接口即可开始准备。
- 使用测试框架:
- 小项目可以使用:doctest
- 大工程可以使用:Catch2
测试中最好不要 直接 用 cin
、cout
、cerr
。
直接使用全局I/O流使得函数或类型难以测试。
- 函数中用引用传递流:
1 | struct State {std::string msg; ...}; |
- 类作用域中使用流指针存储:
1 | class Logger |
使用 gdb 进行调试
gdb,GNU Debugger,是一种开源的调试器,与在 Visual Studio 上进行调试类似。不同的是,gdb 通过命令进行调试。
现有代码:
1 |
|
使用命令:
1 | g++ -o test test.cpp |
编译后,使用 gdb 调试:
1 | gdb test |
输出如下:
1 | GNU gdb (GDB) 11.2 |
接着输入:
1 | run 5 |
输出如下:
1 | Starting program: F:\Program\C++\\test.exe 5 |
这就完成了输入为 5 的测试。
可以设置断点:
1 | 在当前源代码的第12行添加断点 |
也可以使用条件型断点:
1 | break 20 if i == 2000 |
控制断点:
1 | 下一行 |
还有一些控制断点的操作:
1 | 输出所有断点 |
可以监视和设置变量值:
1 | 监视局部变量 |
常用命令还有:
1 | 跳转 |
清理器
C++ 功能强大,但会遇到一些 bug,所以需要清理器(善后)。
地址清理器ASAN
对于 g++ 和 clang++而言:
- 检测内存损坏 bug:
- 内存泄漏
- 访问已释放的内存
- 访问不正确的堆栈区域
- …
- 用附加指令对代码进行检测:
- 运行时间大约增加70%
- 内存使用量大约增加了3倍
如有代码:
1 |
|
在 Ubuntu 中使用 gcc 9.4.0,输入下列命令:
1 | g++ test.cpp -o test -fsanitize=address |
后提示:
1 | AddressSanitizer:DEADLYSIGNAL |
对于 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 |
|
在 Ubuntu 中使用 gcc 9.4.0,输入下列命令:
1 | g++ test.cpp -o test -fsanitize=undefined |
后提示:
1 | test.cpp:9:7: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' |
内存泄漏检测工具valgrind
Valgrind是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合。
- 检测常见的运行时错误:
- 读/写释放内存或不正确的堆栈区域
- 使用未初始化的值
- 不正确的内存释放,如双重释放
- 错误地使用函数来分配内存
- 内存泄漏——通常与程序有关的无意内存消耗
- 导致内存指针在释放之前丢失的逻辑缺陷
更多查看:https://blog.csdn.net/weixin_45518728/article/details/119865117
Lambda函数
Lambda 函数的形式如下:
1 | [捕获列表] (参数列表) -> 返回值类型 { 代码块 } |
举几个例子:
1 | [] { return 1; } |
Lambda 函数可以看作匿名函数,它没有名字。
关于变量捕获:
[=]
:捕获所有变量,值传递。[&]
:捕获所有变量,引用传递。[x, &y]
:x为值传递,y为引用传递。[=, &y]
:除了y是引用传递,其他都是值传递。
在某些情况下,可以使用 Lambda 函数。
- 函数
std::partition(@first, @last, p)
,定义于头文件<algorithm>
。- 其一个功能用法是:重排序范围
[first, last)
中的元素,使得谓词p
对其返回true
的元素前于谓词p
对其返回false
的元素。不保持相对顺序。
- 其一个功能用法是:重排序范围
1 | std::vector<int> v{5, 3, -3, 2, 7, 1, 0, 99, 3}; |
- 函数
std::transform(@first, @last, @result, @op)
,定义于头文件<algorithm>
。- 其一个功能用法是:将范围
[first, last)
中的元素应用op
变化 ,结果存储在result
中。
- 其一个功能用法是:将范围
1 | // 字符串转全大写 |
1 | // 对序列每个数字求平方 |
- 函数
std::generate(@first, @last, @op)
,定义于头文件<algorithm>
。- 其中一个用法是:为
[first, last)
范围内的每个元素分配一个由给定函数对象g
生成的值。
- 其中一个用法是:为
1 |
|
在C++14及以后,如果变量的类型复制代价昂贵,可以使用std::move
1 | class Expensive {...}; |