关键词:C++
假定读者有一定的 C 语言基础
Reference:https://hackingcpp.com/cpp/beginners_guide.html
从C语言到C++
C++开发设置
编辑器 & 集成开发环境IDE
Visual Studio Code
Visual Studio
VIM
Qt Creator
CLion
……
编译器
第一个程序Hello World
1 2 3 4 5 6 7 8 #include <iostream> int main () { std::cout << "Hello World!\n" ; return 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
都这个年代了,尽量使用高版本 C++
C++的I/O
I/O流
对于数据而言,其可以从程序中产生并输出到显示终端,也可以从输入设备中输入到程序中。
1 2 3 4 5 6 7 8 #include <iostream> int main () { int i; std::cin >> i; std::cout << 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};
但基本类型的变量默认情况下不会初始化。
1 2 int i;cout << i << '\n' ;
变量类型
布尔类型:值只有真(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 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' ; std::cout << "min: " << std::numeric_limits<double >::min () << '\n' ; std::cout << "max: " << std::numeric_limits<double >::max () << '\n' ; std::cout << "epsilon: " << std::numeric_limits<double >::epsilon () << '\n' ; return 0 ; }
类型窄化
从可以表示更多值的类型转换为可以表示更少值的类型,可能导致信息丢失。
类型提升
涉及浮点类型的提升:
两种整数类型的操作:
整数提升:
基本上任何小于int的值都会被提升为int或unsigned int(取决于哪一种类型可以表示未提升类型的所有值)
如果两个操作数类型不同,则应用整数转换
两种符号:小类型转换成大类型
都是无符号的:将较小的类型转换为较大的类型
有符号⊕无符号:
如果两者宽度相同,则有符号转换为无符号
否则,如果可以表示所有值,则将无符号转换为有符号
否则都转换为无符号
const修饰符
使用 const
限定变量为常量。
值一旦赋值就不能更改。
如果不需要在初始赋值后改变变量的值,总是将变量声明为 const
。
避免错误:如果稍后不小心更改值,则不会编译
帮助更好地理解你的代码:清楚地传达值将在代码中保持不变
可以提高性能(可能进行更多编译器优化)
constexpr常量表达式
C++11支持,常量表达式必须在编译时可计算
如果未在constexpr上下文中调用,则可以在运行时进行计算
constexpr上下文中的所有表达式必须是constexpr本身
Constexpr函数可能包含:
C++ 11:只有一条返回语句
C++ 14:多个语句
auto关键字
使用如下:
1 auto variable = expression;
从赋值的右侧推导出变量类型
往往更方便、更安全、更经得起未来考验
对于泛型(与类型无关)编程也很重要
类型别名
1 2 3 4 5 using NewType = OldType;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
逻辑运算符
位运算符
&
:按位与
|
:按位或
^
:按位异或
~
:按位取非
<<
、<<=
:左移
>>
、>>=
:右移
将类型为N位的对象的位移位 N 位或 N 位以上是未定义的行为!
控制流
条件结构
1 2 3 4 5 6 7 8 9 10 11 12 if (condition1) { } else if (condition2) { } 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
循环:
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 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); int j = 1 ;day d = static_cast <day>(j);
数据类型聚合
基础数据类型: void
、bool
、char
、int
、float
、double
等。
聚合的例子:
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; std::cout << i << " " << r << "\n" ; i = 10 ; std::cout << i << " " << r << "\n" ; r = 20 ; std::cout << i << " " << r << "\n" ;
引用必须总是指向一个对象
变量的一个引用总是指向与变量相同的内存位置
引用类型必须与被引用对象的类型一致
const
引用:
1 2 3 4 5 6 7 8 int i = 2 ;const int &r = i; i = 10 ; std::cout << i << " " << r << "\n" ; 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' ;
1 2 3 void swap (int & i, int & j) { … }int i = 0 ; swap (i, 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 ];std::cout << s;
悬空引用:引用不再有效的内存位置的引用。
C++的默认动态数组 std::vector
数组:可以存放多个相同类型的值;
动态:长度可以动态变化。
std::vector 的使用需要包含头文件:#include <vector>
std::vector的使用
std::vector
的定义和初始化:
1 2 3 4 5 6 std::vector<int > v; std::vector<int > v1 = {1 , 2 , 3 }; std::vector<int > v2{1 , 2 , 3 }; std::vector<int > v3 (10 ) ; std::vector<int > v4 (10 , 0 ) ; std::vector<int > v5{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) { std::cout << x << ' ' ; } for (int &x : v1) { x = 1 ; std::cout << x << ' ' ; } for (auto const & x : v1) { std::cout << x << " " ; } std::cout << v1.front () << '\n' << v1.back () << '\n' ;
添加元素:
1 2 std::vector<int > v; v.push_back (1 );
删除元素:
1 2 3 std::vector<int > v{1 , 2 , 3 }; v.pop_back (); v.clear ();
std::vector
的长度调整:
1 2 std::vector<int > v{1 , 2 , 3 }; v.resize (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 << " " ; v.push_back (7 ); std::cout << std::endl; for (int x : v) std::cout << x << " " ; 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[0 ] = 9 ;
另外,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" ; a.push_back (4 ); std::cout << a.size () << " " << a.capacity () << "\n" ;
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 }; v.insert (begin (v), 0 ); v.insert (end (v), {4 , 5 }); std::vector<int > v1{7 , 8 , 9 }; v.insert (begin (v), begin (v1), end (v1));
通过迭代器在 std::vector
中删除元素:(从vector中擦除元素不会改变容量,因此不会释放任何内存。)
1 2 3 4 5 6 7 8 std::vector<int > v{1 , 2 , 9 , 3 , 4 , 5 }; v.erase (begin (v) + 2 ); v.erase (begin (v), begin (v) + 2 );
在使用迭代器进行元素操作后,如添加删除,原迭代器并未更新,如:
1 2 3 4 5 6 std::vector<int > v{1 , 2 , 3 , 4 , 5 , 6 }; auto i = begin (v) + 3 ; v.insert (i, 8 );
同时,经过增删元素后,std::vector
的长度可能变短或者变长。当长度变短时,其容量并不会变小,仍保持之前操作中的最大值,此时可能需要“刷新”一下容量,减少空间消耗:
1 2 3 4 std::vector<int > v; v = std::vector <int >(v); v.swap (std::vector <int >(v));
做一个临时的副本,通过交换内存缓冲区更新容量,临时变量自动销毁。
std::vector 的工作原理
vector 的数据总是在堆上的,但对象的地址根据定义的方式不同可能在堆上,也可能在栈上。
vector元素保证驻留在一个连续的内存块中。
内存块一旦分配后不能调整大小。
动态数组增长方式:
动态分配新的(≈1.1-2倍)更大的内存块
复制/移动旧值到新块
摧毁旧的内存块
当在某位置擦除(删除)元素时,方式如下:
析构(销毁)元素
剩下的元素前移
长度减少,但容量不变
当在某位置添加(插入)元素时,方式如下:
判断容量大小是否允许,允许则不需再开辟空间增长,不允许则进行增长。
将插入位置及后面的元素后移
在插入位置复制上新元素
字符串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 += " World!" ; cout << hw << '\n' ; cout << hw[4 ] << '\n' ; cout << s << '\n' ; }
字符串的操作,对于 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" ; auto b = "hello" ;using namespace std::string_literals;auto c = "hello" s;
另外,仅用空格分隔的字符串字面值将被连接起来:
1 2 std::string s = "hello" " world" ;
如果想让字符串的转义字符失效:
1 2 3 using namespace std::string_literals;auto s = R"(\n)" s; auto t = R"(\\n)" ;
函数 std::getline()
:该函数需要包含头文件
1 2 3 4 5 6 7 std::string s; std::getline (std::cin, s); std::getline (std::cin, s, '\t' ); 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);
函数
与C语言类似,函数实现细节的封装;通过将问题分解为单独的函数,更容易对正确性和测试进行推理;避免为常见任务重复代码。
函数结构:
函数参数的默认值:
1 2 3 4 5 6 7 8 int add (int a, int b = 0 ) { return a + b; } int num1 = add (1 , 2 ); int num2 = add (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
表示命令行传入的参数字符串数组。
比如有一程序代码:
1 2 3 4 5 6 7 8 #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 #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>
。
打开和关闭文件
在输入输出流中,使用文件输入输出流 ifstream
和 ofstream
操作文件。
函数 open()
和 clost()
分别控制文件的打开和关闭。
打开文件操作如下:
1 2 3 4 5 6 7 8 9 std::ifstream in1 ("test.txt" ) ; std::string path = "test.txt" ; std::ifstream in2 (path) ; 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
。
更多参考官方文档:
类的初接触
引例
实现一个单调计数器,支持自增和读取计数值。
分析要求,如果是 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 (); std::cout << pcnt1.get (); return 0 ; }
成员变量在 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 { }
初始化
成员初始化
成员变量初始化,C++11 下:
1 2 3 4 5 6 class Counter { int count = 0 ; public : }
构造函数的初始化列表
构造函数是创建对象时执行的特殊成员函数
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;} void inc () { count++; } int get () { return count; } }; Counter cnt1; Counter cnt2 (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; } }
定义构造函数时,加上关键字 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" ; std::cout << fun (Counter (2 )) << "\n" ; return 0 ; }
可以尝试把 explicit
去掉,体验如何隐式调用构造函数。
拷贝构造函数
默认情况下,类也提供默认拷贝构造函数
默认拷贝构造函数:简单来说就是从源复制到新的地方,进行变量之间的复制
默认拷贝构造函数是浅复制
浅复制(拷贝):拷贝者和被拷贝者是同一个地址,改变其中一个,另一个也改变
深复制(拷贝):拷贝者和被拷贝者不是同一个地址,改变其中一个,另一个不变
拷贝构造函数的函数名就是其类型名,参数为拷贝源
拷贝函数的形式:
可以重载拷贝构造函数,进行一些自定义的复制操作。
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; 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::operator =(const T& rhs);
析构函数
析构函数:当对象的生命周期结束时,会调用析构函数,用于释放对象的资源。
如果不定义默认构造函数和析构函数,编译器会生成它们。
函数形式:
析构函数的执行顺序:所有数据成员的析构函数将以其构造函数相反的声明顺序执行
资源获取即初始化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的对象的指针
存储类型为 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; 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; int *p1, *p2;
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; int * const pc = &i;*pc = 8 ; pc = &j; const int * const cpc = &i;*cpc = 8 ; cpc = &j;
还有代码风格的一致性问题:使用像是 int const
而不是 const int
。
1 2 3 4 5 6 int const c = ...; int const &cr = ...; int const *pc = ...; int * const cp = ...; int const * const cpc = ...;
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_; } int right () const { return this ->r_; } IntRange& shift (int by) { l_ += by; r_ += by; return *this ; } };
少使用指针
推荐合适使用 引用 代替指针。
指针容易悬空
悬空:指针指向无效或不可访问的内存地址
指针中的值可以是任意地址,程序员必须确保指针目标是有效的/仍然存在
容易出现错误参数传递
指针让代码更难理解
*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 ; }
通过 throw
关键字抛出异常。
通过 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 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
异常用处
报告违规行为。
输入与期望或规定不符(违法输入,或违法的函数参数)。
定义或保留不变量失败。
如:公共成员函数无法设置有效的成员值、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 2 3 std::vector<int > v{ 0 , 1 , 2 }; int a = v[3 ]; int b = v.at (3 );
处理异常
重复抛出:
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++类型和库的设计产生重大影响。
资源/内存泄漏的潜在来源:
进行自己的内存管理的外部C库;
不使用 RAII 进行自动资源管理的C++库(设计存在缺陷);
在销毁时不清理资源的类型(设计存在缺陷);
如:
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); 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 (noexcept (B))
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); }
计数未捕获的异常
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 g++ -Wall -o test.exe test.cpp
MS Visual Studio 的编译设置:
/W1
:严重的警告。
/W2
:重要的警告。
/W3
:生产级别警告。
/W4
:并不能真正启用所有警告,而是最重要的警告,新项目推荐。
/Wall
:启用比级别4更多的警告。
/WX
:把所有的警告当成错误行为。
断言
头文件:#include <cassert>
使用案例:
在运行时检查预期值/条件
验证前提条件(输入值)
验证不变量(例如,中间状态/结果)
验证后置条件(输出/返回值)
注意,逗号需要加上括号:assert
是一个预处理器宏,逗号将被解释为宏参数分隔符。
1 2 assert ( min (1 , 2 ) == 1 ); assert ((min (1 , 2 ) == 1 ));
可以使用自定义宏添加:
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
测试中最好不要 直接 用 cin
、cout
、cerr
。
直接使用全局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 ; }
使用命令:
编译后,使用 gdb 调试:
输出如下:
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 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 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:
内存泄漏
访问已释放的内存
访问不正确的堆栈区域
…
用附加指令对代码进行检测:
如有代码:
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++而言:
在运行时检测许多类型的未定义行为:
解引用空指针
从未对齐的指针中读取
整数溢出
除零
…
用附加指令检测代码:
如有代码:
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 << ' ' ;
函数 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); }); }
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; };std::transform (v.begin (), v.end (), v.begin (), squared);
函数 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; };