在实际开发过程中,会碰到 C++ 中一些”奇怪”的用法,好像跟我们学的不一样,这里对 C++ 的知识做一个延伸。
1、重新认识宏
宏是 C/C++ 所支持的一种语言特性,我们对它最初的认识,可能就是替换代码中的符号,比如定义一个圆周率 PI
,之后在代码中使用PI
来代替具体圆周率的值。
确实如此,宏提供了一种机制,能够使你在编译期替换代码中的符号或者语句。当你的代码中存在大量相似的、重复的代码时,使用宏可以极大的减少代码量,便于书写。
在 C++ 基础知识的学习中,了解了宏的基本使用,但是真正进入职场,这些远远不够,因此有必要对宏进行一个系统的了解。
宏分为 系统预定义的宏 ,和程序员 自定义的宏。
1.1 初识宏
自定义宏使用 #define
定义,也称为宏常量,是一个全局常量。
一种简单的定义如下:
#define 宏名 常量表达式
常量表达式可以是:数值,字符串,运算表达式等,只要是一个常量就行。
#define PI 3.14
#define NAME "Lisa"
#define SIZE (4*512)
#define RANDOM (-1.0 + 2.0*(double)rand() / RAND_MAX)
double perimeter = diameter * PI;
代码在编译时,编译器会把 宏名 替换为它所定义的值进行编译。
1.2 再认识宏
1.2.1 空宏
有时候我们会看见,只指定了宏名,却没有指定宏值:
#define m_DDL
class m_DDL class{...}
// 等价于
class class{...}
空宏 (未定义的宏) 都展开为空字符串。但定义为空字符串的宏一般被视为是在预处理表达式定义的,这个后面会介绍。
1.2.2 空指针
我们常用 NULL
表示一个空指针,其实它是一个已经预定义的宏。预定义指的是你不必亲自定义,编译器在编译时,已经提前定义好了。
#define NULL ((void*)0)
空指针:指向地址 0
1.2.3 取消宏定义
# undef 宏名
1.3 带参数的宏
1.3 1 带参宏的形式
带参数的宏的定义如下:
#define 宏名称([形参列表] ) 表达式
#define 宏名称([形参列表,] …) 表达式
C99 标准允许定义有省略号的宏,省略号必须放在参数列表的后面,以表示可选参数。1.3.3 章节介绍。
#define MUL(x, y) x * y
int ret = MUL(2, 3); // ==> int ret = 2 * 3;
注意宏是直接替换的关系。举一个例子:
#define MUL(x, y) x * y
int ret = MUL(2+3, 3); // ==> int ret = 2 + 3 * 3;
1.3.2 三个符号
#
符号把一个宏参数直接转换为字符串
#define STR(x) #x
string a = STR(qwer); //a 就是“qwer”
##
符号会连接两边的值,产生一个新的值
#define VAR(x) index_##x
int VAR(1); // ==> int index_1;
#@
符号会把一个宏参数直接转换为字符
#define CHR(x) #@x
char m_a = CHR(s); // m_a 就是 's'
1.3.3 可变参数
#define PLog(fmt, ...) printf(fmt, __VA_ARGS__)
// 这样我们就可以使用我们自己定义的宏 trace 来打印日志了
PLog("got a number %d", 34);
当调用有可选参数的宏时,预处理器会将所有可选参数连同分隔它们的逗号打包在一起作为一个参数。在替换文本中,标识符 VA_ARGS 对应一组前述打包的可选参数。
预处理器把上面第四行代码替换成如下形式:
printf(fmt, "got a number %d", 34)
__VA_ARGS__
为空时,之前的逗号不会删除,会报错,故可以在__VA_ARGS__
之前添加##
符号
1.4 多行的宏
如果宏的内容很长,很多,那么可以写成多行,每行的末尾添加\
,以表明后面的一行依然是宏的内容。比如
#define ADD(x, y) do { int sum = (x) + (y); return sum; } while (0)
// 宏的内容比较长,也没有缩进,易读性较差,因此转为多行
#define ADD(x, y) \
do \
{\
int sum = (x) + (y);\
return sum;\
} while (0)
1.5 预定义宏
为了方便处理一些有用的信息,预处理器定义了一些预处理标识符,也就是预定义宏。预定义宏的名称都是以 __
两条下划线开头和结尾的。
如果宏名是由两个单词组成,那么中间以_
(一条下划线)进行连接。并且,宏名称一般都由大写字符组成。
宏 | 描 述 |
---|---|
__DATE__ |
当前所编译的文件名称(绝对路径) |
__FILE__ |
当前源文件的名称,用字符串常量表示 |
__LINE__ |
当前源文件中的行号,用十进制整数常量表示,它可以随 #line 指令改变 |
__TIME__ |
当前源文件的最新编译吋间,用“hh:mm:ss”形式的字符串常量表示 |
各种编译器的预定义宏不尽相同,但是一般都会支持上面的四种宏。
获取不同平台下 gcc(mingw)编译器预定义宏的方式:
- Linux:
gcc -posix -E -dM - < /dev/null
- Windows:
gcc -posix -E -dM - < nul
1.5 预处理指令
通过预定义宏,配合程序员使用 #ifdef
与#endif
等预处理指令,就可使平台相关代码只在适合于当前平台的代码上编译,从而在同一套代码中完成对多平台的支持。
我们通过预定义宏和预处理指令配合,达到一定的 代码开关控制,对不同的操作系统启用不同的代码。
#ifdef _WIN32 // 查看是否定义了该宏,Windows 默认会定义该宏
// 如果是 Windows 系统则会编译此段代码
OutputDebugString("this is a Windows log");
#else
// 如果是 mac,则会编译此段代码
NSLog(@"this is a mac log");
#endif
宏条件语句关键词有:
#ifdef
#if
#else
#endif
1.6 宏的调试
宏的调试语法:
#pragma message(“输出的内容”)
该指令必须接收一个字符串,而不能是其他的对象
#define SOMEMACRO 123456
#define MACROTOSTR2(x) #x
#define PRINTMACRO(x) #x " = " MACROTOSTR2(x)
#pragma message(PRINTMACRO(SOMEMACRO))
编译上述代码便会在输出窗口打印 SOMEMACRO = 123456
的内容。
2、奇怪的单冒号
2.1 在类的声明中
在单冒号熟知的用法中,一般是这样的:
class Son: public Father{}
这里的单冒号作用为:表示类的继承。Son 继承 Father 的属性和方法。
2.2 在构造函数中
class Father{
public:
Father(){};
~Father(){}
int a;
const int b;
}
Father::Father():a(1),b(2){};
第 10 行的单冒号,表示成员属性的初始化。成员属性 a 初始化为 1,b 初始化为 2。
注意:
初始化成员属性的作用相当于在构造函数内进行相应成员属性的赋值,但两者是有差别的。
在初始化列表中是对变量进行初始化,而在构造函数内是进行赋值操作。两都的差别在对于像 const 类型数据的操作上表现得尤为明显。const 类型的变量必须在定义时进行初始化,而不能对 const 型的变量进行赋值。因此 const 类型的成员变量只能(而且必须)在初始化列表中进行初始化。
初始化的顺序与成员属性声名的顺序相同。
Father::Father():a(1),b(a){}; // 这样 b 是一个随机数,并不是 1
对于继承的类来说,在初始化列表中也可以进行基类的初始化,初始化的顺序是先基类初始化,然后再根据该类自己的变量的声明顺序进行初始化。
2.3 在结构体中
单冒号接数字。
struct Mt{
int A:2;
int B:1;
}
第 2 和 3 行的单冒号:表示该变量所占几个 bit 的空间。变量 A 占 2 个 bit 空间,变量 B 占 1 个 bit 空间。
举个例子:
#include<bitset>
using namespace std;
struct student {
int aa:1;
int bb:1;
int cc:2;
int dd:2;
};
void main() {
student s1;
s1.aa = 1;
s1.bb = 2;
s1.cc = 1;
s1.dd = 2;
cout << bitset<1>(s1.aa) << endl; //1
cout << bitset<1>(s1.bb) << endl; //0
cout << bitset<2>(s1.cc) << endl; //01
cout << bitset<2>(s1.dd) << endl; //10
}
3、奇怪的双冒号
3.1 在类 / 结构体 / 命名空间后
这个是最常见的用法:
std::cout // 命名空间 std 中的 cout 类对象
Person::A //Person 类走过的成员属性 A
这里的双冒号:表示作用域。某作用域中的某对象。
3.2 在函数 / 变量前
string name="lisa";
void get_info() {
string name = "jack";
name = "Mr " + name; // 局部变量 name
::name = "Miss " + ::name; // 全局变量 name
cout << name << endl;
cout << ::name << endl;
}
这里第 8 行和第 12 行中的双冒号:表示该函数 / 变量是全局的。以区分局部变量。
4、inline 内联函数
函数是一个可以重复使用的代码块,CPU 会一条一条地挨着执行其中的代码。CPU 在执行主调函数代码时如果遇到了被调函数,主调函数就会暂停,CPU 转而执行被调函数的代码;被调函数执行完毕后再返回到主调函数,主调函数根据刚才的状态继续往下执行。
一个 C/C++ 程序的执行过程可以认为是多个函数之间的相互调用过程,它们形成了一个或简单或复杂的调用链条,这个链条的起点是 main(),终点也是 main()。当 main() 调用完了所有的函数,它会返回一个值(例如return 0;
)来结束自己的生命,从而结束整个程序。
函数调用是有时间和空间开销的。程序在执行一个函数之前需要做一些准备工作,要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。
一般情况下,这个开销可以忽略不计。但是,如果一个函数内部没有几条语句,执行时间本来就非常短,那么这个函数调用产生的额外开销和函数本身执行的时间相比,就显得不能忽略了。假如这样的函数在一个循环中被上千万次地执行,函数调用导致的时间开销可能就会使得程序运行明显变慢。
为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换。
这种在函数调用处直接嵌入函数体的函数称为内联函数(Inline Function),又称内嵌函数或者内置函数。
语法:
- inline 函数声明 / 定义 :在函数声明或定义时,加一个inline 关键字即可
inline void fuc(int a, int b){...}
内联函数和普通函数的区别在于:当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样。这样做也是有代价的,占用了更多内存。
一般只将那些短小的、频繁调用的函数声明为内联函数。
在【代码执行时间】和【处理函数调用机制的时间】之间寻找平衡。
内联函数不能递归
5、Boost 库
5.1 Boost 智能指针
Boost 是一个功能强大、构造精巧、跨平台、开源并且完全免费的 C++ 程序库。该库涵盖字符串处理、正则表达式、容器与数据结构、并发编程、函数式编程、泛型编程、设计模式实现等许多领域,极大地丰富了 C++ 的功能和表现力,能够使 C++ 软件开发更加简洁、优雅、灵活和高效。
该库需要手动安装。
解压至目标文件夹:如
D:\boost\boost_1_62_0
安装:常使用的 boost 库函数是不需要安装的。
配置 VS2019:
在【项目】- 【项目名属性】中添加包含目录 Configuration Properties > C/C++ > General > Additional Include Directories,例如D:\boost\boost_1_62_0
更改配置 将Configuration Properties > C/C++ > Precompiled Headers 改为Not Using Precompiled Headers。
做完以上配置即可在 VS2019 中使用。
在 windows 下安装 Boost 1.62.0 - 简书 (jianshu.com)
5.1.2 shared_ptr 智能指针
#include <boost/shared_ptr.hpp>
boost::shared_ptr<int> p1 // 声明一个 int 类型的 p1 指针,注意这里不需要 * 号指明是个指针
p1 指针不用手动去释放资源,它会智能地在合适的时候去自动释放。
boost::shared_ptr 的实现机制其实比较简单,就是对指针引用的对象进行引用计数,当引用计数为 0 时,会自动释放。
创建指针例子:
boost::shared_ptr<int> p2(nullptr); // 传入空指针 nullptr
boost::shared_ptr<int> p3(new int(10)); //p3 指向值为 10 的堆空间
目前仅作了解即可。
boost::shared_ptr
消除了显式的 delete
调用,很大程度上避免了程序员忘记 delete
而导致的内存泄漏。但 shared_ptr
的构造依然需要new
,这导致了代码中的某种不对称性,它应该使用工厂模式来解决。
5.1.3 make_shared 工厂函数
Boost
库提供了一个自由工厂函数 make shared<T>()
,来消除显式的 new
调用,声明如下:
template<class T, class... Args>
boost::shared_ptr<T> make_shared(Args && ... args);
make_shared()函数模板使用变长参数模板,最多可接受 10 个参数然后把它们传递给 T 的构造函数,创建一个 shared_ptr
举个例子:
#include <boost/make_shared.hpp>
class A
{
public:
A(int a, float b, char c, string d)
{
m_a = a; m_b = b; m_c = c; m_d = d;
cout << " 构造 A 类对象!" << endl;
}
~A()
{
cout << " 析构 A 类对象!" << endl;
}
int m_a;
float m_b;
char m_c;
string m_d;
};
int main()
{
// 原始的方式构造 shared_ptr,需要 new,产生一种不对称性
boost::shared_ptr<A> p1(new A(100, 1.234f, 'C', "hello"));
cout << p1->a << ", " << p1->b << ", " << p1->c << ", " << p1->d << endl;
// 推荐使用工厂函数,屏蔽 new , 更高效
boost::shared_ptr<A> p2 = boost::make_shared<A>(100, 1.234f, 'C', "hello");
cout << p2->a << ", " << p2->b << ", " << p2->c << ", " << p2->d << endl;
return 0;
}
5.2 Boost geometry
Boost.Geometry(又名 Generic Geometry Library,GGL)是 Boost C++ Libraries 集合的一部分,定义了解决几何问题的概念、基元和算法。
Boost.Geometry 包含一个与维度无关、与坐标系无关且可扩展的内核,基于概念、元函数和标签调度。在该内核之上,构建了算法:面积,长度,周长,质心,凸壳,交叉点(修剪),包含(多边形中的点),距离,包络(边界框),简化,变换等等。该库支持高精度算术数字,如 ttmath。
5.2.1 空间索引中的 rtree
描述:这是一种自平衡空间索引,能够存储各种类型的值和平衡算法。
参数:用户必须传递一个定义参数的类型,这些参数将在 rtree 创建过程中使用。该类型用于指定具有特定参数的平衡算法,如节点中的最小和最大元素数量。
带有编译时参数的预定义算法如下:
boost::geometry::index::linear
,boost::geometry::index::quadratic
,boost::geometry::index::rstar
.
带有运行时参数的预定义算法如下:
boost::geometry::index::dynamic_linear
,boost::geometry::index::dynamic_quadratic
,boost::geometry::index::dynamic_rstar
.
这里记录一个代码中的 rtree:
typedef bgi::rtree<pt_value, bgi::quadratic<64> > pt_vrtree;
pt_vrtree boundary_pt_tree(pt_values.begin(), pt_values.end());
////define boundary_pt_tree and initialization, pt_values={[pt]={patchid1,segid1},... }
boundary_pt_tree.query(bgi::intersects(util::Box(pts.left_bottom, pts.top_right)), back_inserter(queried_result));
//query the same pt sharing the same [patchid, segid] and give result to queried_result{{pt,{patchid1,segid1}}, {pt,{patchid2,segid2}}
6、断言函数 assert
断言函数,用于在调试过程中捕 捉程序错误。断言(assertion)是编程中的一种常用手段,在通常情况下,断言就是将一个返回值总是真(或者我们需要是真)的判别式放在语句中,用以排除在设计逻辑上不应该出现的情况。
从本质上看,assert 是个宏,其用法像是一种”契约式编程”。
- 如果其值为假,那么它先向 stderr 打印一条出错信息,然后通过调用 abort 来终止程序运行。
- 如果其值为真,不做任何处理,程序继续。
# include<iostream>
# include<string>
#include <assert.h>
using namespace std;
int main() {
int m, n, result;
cin >> m >> n;
assert(n);
result = m / n;
cout << result << endl;
return 0;
}
使用 assert 的缺点是,频繁的调用会极大的影响程序的性能,增加额外的开销。
在调试结束后,可以通过在包含 #include 的语句之前插入 #define NDEBUG 来禁用 assert 调用,示例代码如下:
#include <bitset>
#define NDEBUG
#include "logger.h"
7、传参赋值
一般情况下,我们给函数定义形参,是需要传值使用,但是有时候,定义形参是为了赋值。
class A
{
public:
int m_a;
int m_b;
};
void setA(int* arr, int& a, int& b) {
a = arr[0];
b = arr[1];
}
int main()
{
A* p = new A;
int arr[2] = { 10, 20 };
setA(arr, p->m_a, p->m_b);
cout << p->m_a << endl;
return 0;
}
8、C 库函数
8.1 复制
C 库函数提供了两种复制函数:strcpy 和memcpy
strcpy和 memcpy 主要有以下 3 方面的区别。
复制的内容不同。strcpy 只能复制字符串,而 memcpy 可以复制任意内容,例如字符数组、整型、结构体、类等。
复制的方法不同。strcpy 不需要指定长度,它遇到被复制字符的串结束符”\0”才结束,所以容易溢出。memcpy 则是根据其第 3 个参数决定复制的长度。
用途不同。通常在复制字符串时用 strcpy,而需要复制其他类型数据时则一般用 memcpy。
8.1.1 memcpy
函数原型:
void *memcpy(void *dest, const void *src, size_t n)
- dest – 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
- src – 指向要复制的数据源,类型强制转换为 void* 指针。
- n – 要被复制的字节数。
该函数 返回 一个指向目标存储区 dest 的指针。
memcpy() 会复制 src 所指的内存内容的前 n 个字节到 dest 所指的内存地址上。
- memcpy() 并不关心被复制的数据类型,只是逐字节地进行复制,这给函数的使用带来了很大的灵活性,可以面向任何数据类型进行复制。也就是说,可以把结构体复制给 int 类型的变量。
- dest 指针要分配足够的空间,也即大于等于 num 字节的空间。如果没有分配空间,会出现断错误。
- dest 和 src 所指的内存空间不能重叠(如果发生了重叠,使用 [memmove()]会更加安全)。
完整复制示例:
// 将字符串复制到数组 dest 中
#include <stdio.h>
#include <string.h> // 必须包含这个头文件
int main ()
{
const char src[50] = "http://www.runoob.com";
char dest[50];
memcpy(dest, src, strlen(src)+1); // 这里加 1 是为了把 src 字符串的结尾符 \0 也复制给 dest,
printf("dest = %s\n", dest); // 以让 dest 输出时遇到结尾符停止输出,不然会输出 50 个字符。
return(0);
}
切片复制:
#include <stdio.h>
#include<string.h>
int main()
{
char *s="http://www.runoob.com";
char d[20];
memcpy(d, s+11, 6); // 从第 11 个字符 (r) 开始复制,连续复制 6 个字符(runoob)
// 或者 memcpy(d, s+11*sizeof(char), 6*sizeof(char));
d[6]='\0';
printf("%s", d);
return 0;
}
8.1.2 strcpy
函数原型:
char * strcpy(char * dest, const char * src) // 实现 src 到 dest 的复制
strcpy 只用于字符串复制,并且它不仅复制字符串内容之外,还会复制字符串的结束符。
memcpy 提供了一般内存的复制。即 memcpy 对于需要复制的内容没有限制,因此用途更广。
示例:
#include <stdio.h>
#include <string.h>
int main()
{
char src[40];
char dest[100];
memset(dest, '\0', sizeof(dest)); // 赋值为 '\0' 和 0 是等价的,因为字符 '\0' 在内存中就是 0
strcpy(src, "This is runoob.com");
strcpy(dest, src);
printf(" 最终的目标字符串: %s\n", dest);
return(0);
}
memset() 的作用是在一段内存块中填充某个给定的值。因为它只能填充一个值,所以该函数的初始化为原始初始化,无法将变量初始化为程序中需要的数据。用 memset 初始化完后,后面程序中再向该内存空间中存放需要的数据。
memset 一般使用“0”初始化内存单元,而且通常是给数组或结构体进行初始化。一般的变量如 char、int、float、double 等类型的变量直接初始化即可,没有必要用 memset。如果用 memset 的话反而显得麻烦。
8.2 数据持久化
一个文件,无论它是文本文件还是二进制文件,都是代表了一系列的字节。C 语言不仅提供了访问顶层的函数,也提供了底层(OS)调用来处理存储设备上的文件。
8.2.1 打开 / 创建文件
可以使用 fopen() 函数来创建一个新的文件或者打开一个已有的文件,这个调用会初始化类型 FILE 的一个对象,类型 FILE 包含了所有用来控制流的必要的信息。下面是这个函数调用的原型:
FILE *fopen(const char * filename, const char * mode);
mode 支持:
- r 打开一个已有的文本文件,允许读取文件。
- w 打开一个文本文件,以覆盖模式写入文件。如果文件不存在,则会创建一个新文件。
- a 打开一个文本文件,以追加模式写入文件。如果文件不存在,则会创建一个新文件。
- r+ 打开一个已有文本文件,允许读写文件。
- w+ 打开一个文本文件,允许读写文件。
- a+ 打开一个文本文件,允许追加读写文件。如果文件不存在,则会创建一个新文件。
如果处理的是二进制文件,则需使用下面的访问模式来取代上面的访问模式:加 b,代表二进制。
"rb", "wb", "ab", "rb+", "r+b", "wb+", "w+b", "ab+", "a+b"
示例:
string outfile="layer_main.dat";
FILE* fp = fopen(outfile.c_str(), "wb");
8.2.2 读取文件
8.2.2.1 字符读取函数
- fgetc()从指定的文件中读取一个字符:
int fgetc (FILE *fp);
fp 为文件指针。fgetc() 读取成功时返回读取到的字符,读取到文件末尾或读取失败时返回 EOF。EOF 是在 stdio.h 中定义的宏,它的值是一个负数。
- fputc()向指定的文件中写入一个字符:
int fputc (int ch, FILE *fp);
ch 为要写入的字符,fp 为文件指针。fputc() 写入成功时返回写入的字符,失败时返回 EOF。
8.2.2.2 字符串读取函数
- fgets() 函数用来从指定的文件中读取一个字符串,并保存到字符数组中。
char *fgets (char *str, int n, FILE *fp);
str 为字符数组,n 为要读取的字符数目,fp 为文件指针。
- fputs() 函数用来向指定的文件写入一个字符串,它的用法为:
int fputs(char *str, FILE *fp);
str 为要写入的字符串,fp 为文件指针。写入成功返回非负数,失败返回 EOF。
fgets() 有局限性,每次最多只能从文件中读取一行内容,因为 fgets() 遇到换行符就结束读取。如果希望读取多行内容,需要使用 fread() 函数;相应地写入函数为 fwrite()。
8.2.2.3 块数据读取函数
- fread() 函数用来从指定文件中读取块数据。所谓块数据,也就是若干个字节的数据,可以是一个字符,可以是一个字符串,可以是多行数据,并没有什么限制。fread() 的原型为:
size_t fread (void *ptr, size_t size, size_t count, FILE *fp);
- fwrite() 函数用来向文件中写入块数据,它的原型为:
size_t fwrite (void * ptr, size_t size, size_t count, FILE *fp);
ptr 为内存区块的指针,它可以是数组、变量、结构体等。fread() 中的 ptr 用来存放读取到的数据,fwrite() 中的 ptr 用来存放要写入的数据。
size:表示每个数据块的字节数。
count:表示要读写的数据块的块数。
fp:表示文件指针。
理论上,每次读写 size*count 个字节的数据。
返回值:返回成功读写的块数,也即 count。如果返回值小于 count:
对于 fwrite() 来说,肯定发生了写入错误,可以用 ferror() 函数检测。
对于 fread() 来说,可能读到了文件末尾,可能发生了错误,都会返回 EOF。可以用 ferror() 或 feof() 检测到底是错误还是读到了文件结尾。
feof():
int feof(FILE *stream)
,检查文件的结束标志(是否读到了文件结尾处),没有返回 0,检查到了结束标志就返回非 0。
ferror():int ferror(FILE *stream)
,检测文件读取操作是否出错,当 ferror 函数返回为真时就表示有错误发生。在实际的程序中,应该每执行一次文件操作,就用 ferror 函数检测是否出错。
8.2.3 关闭文件
使用 fclose() 函数。函数的原型如下:
int fclose(FILE *fp);
如果成功关闭文件,fclose() 函数返回零,如果关闭文件时发生错误,函数返回 EOF。
9、隐式转化和显示转换
在 CPP 中,编译器会自动帮我们进行隐式转换。下面举例说明:
#include <iostream>
using namespace std;
class Point {
public:
int x, y;
Point(int x = 0, int y = 0): x(x), y(y) {}
};
void displayPoint(const Point& p)
{
cout << "(" << p.x << ","
<< p.y << ")" << endl;
}
int main()
{
displayPoint(1); // 输出(1,0)
Point p = 1;
}
在第 18 行中,函数 displayPoint 应该传入一个 poin 类型的数据,但是这里传入了一个 int 类型数据,程序不会报错,而是正常运行完毕。这是因为这隐式调用。
另外, 在第 19 行中,在对象刚刚定义时, 即使你使用的是赋值操作符 =
, 也是会调用构造函数, 而不是重载的operator=
运算符。
这样悄悄发生的事情, 有时可以带来便利。而有时却会带来意想不到的后果, explicit
关键字用来避免这样的情况发生。
explicit 关键字
CPP 官方参考手册解释如下:
- 指定构造函数或转换函数 (C++11 起)为显式, 即它不能用于隐式转换和复制初始化。
- explicit 指定符可以与常量表达式一同使用。函数若且唯若该常量表达式求值为 true 才为显式。(C++20 起)
什么情况下使用 explicit:能用就用。
如果我们能预料到某种情况的发生, 就不要把隐式转换的控制权交给编译器。
GDB 调试工具
这里介绍 linux 下的 C++ 调试工具 GDB。
概述
GDB 全称“GNU symbolic debugger“,是 Linux 下常用的程序调试器。当下的 GDB 支持调试多种编程语言编写的程序,包括 C、C++、Go、Objective-C、OpenCL、Ada 等。实际场景中,GDB 更常用来调试 C 和 C++ 程序。一般来说,GDB 主要帮助我们完成以下四个方面的功能:
- 启动你的程序,可以按照你的自定义的要求随心所欲的运行程序。
- 在某个指定的地方或条件下暂停程序。
- 当程序被停住时,可以检查此时你的程序中所发生的事。
- 在程序执行过程中修改程序中的变量或条件,将一个 bug 产生的影响修正从而测试其他 bug。
调试之前
在启动调试之前,需要先将源码编译。编译有两种工具:GCC 和 G++。
默认情况下,用 gcc 只能编译 c 代码,g++ 编译 c++ 代码
# 生成可执行文件 a.out gcc test.c g++ test.cpp
gcc 命令不能自动和 C++ 程序使用的库链接接,需要这样写:
gcc test.cpp -lstdc++ #这样和 g++ test.cpp 结果是一样的
执行 a.out:./a.out
下面正式介绍 GDB:
使用 GDB 调试程序,有以下两点需要注意:
要使用 GDB 调试某个程序,该程序编译时必须加上编译选项
-g
,否则该程序是不包含调试信息的;g++ -g test.cpp # 编译生成 a.out 可执行文件 g++ -g test.cpp -o test1 # 编译生成 test1 可执行文件
如果没有
-g
,你将看不见程序的函数名,变量名,所代替的全是运行时的内存地址。GCC 编译器支持
-O
和-g
一起参与编译。GCC 编译过程对进行优化的程度可分为 5 个等级,具体可自行百度 :
启用 GDB 调试
在 C++ 源码编译后,可以对编译后的可执行文件启用 GDB 调试。
GDB 调试主要有三种方式:
- 直接调试目标程序:gdb a.out
- 附加进程 id:gdb attach pid
- 调试 core 文件:gdb filename corename
启动调试:
>>> gdb a.out
#输出如下代表成功:
...
Reading symbols from ./practice_p1/a.out...done.
(gdb)
这样就进入调试模式:如果在第 6 行继续输入 r,会全部运行完毕。我们应该添加断点。
添加断点:
>>> (gdb) b 14 #在第 14 行添加断点
Breakpoint 1 at 0x40086f: file p1.cpp, line 14.
>>> (gdb) b 17
Breakpoint 2 at 0x40088d: file p1.cpp, line 17.
>>> (gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x000000000040086f in main() at p1.cpp:14
2 breakpoint keep y 0x000000000040088d in main() at p1.cpp:17
开始调试:
>>> (gdb) r #开始执行
Starting program: ./practice_p1/a.out
10
Breakpoint 1, main () at p1.cpp:14
14 cout<<b<<endl;
>>> (gdb) c # 继续执行
Continuing.
20
Breakpoint 2, main () at p1.cpp:17
17 cout<<i<<endl;
#当程序执行完毕,会显示:[Inferior 1 (process 389700) exited normally]
退出 GDB
- 可以用命令:q(quit 的缩写)或者 Ctr + d 退出 GDB。
- 如果 GDB attach 某个进程,退出 GDB 之前要用命令 detach 解除附加进程。
常用命令
命令名称 | 命令缩写 | 命令说明 |
---|---|---|
run | r | 运行一个待调试的程序 |
continue | c | 让暂停的程序继续运行到下一个断点处 |
next | n | 运行到下一行,单步调试(遇到函数不进入内部) |
step | s | 运行到下一行,单步执行(遇到函数会进入) |
finish | finish | 跳出当前函数,常与 s 配合使用 |
return | return | 跳出当前函数,并返回指定值,到上一层函数调用处 |
until | u | 退出循环体(自动一次性执行完剩余的循环) |
jump | j | 将当前程序执行流跳转到指定行或地址 |
p | 打印变量或寄存器值,执行完毕到下一行才能 p 前一行的变量值 | |
backtrace | bt | 查看当前线程的调用堆栈 |
frame | f | 切换到当前调用线程的指定堆栈 |
thread | thread | 切换到指定线程 |
break | b | 添加断点 |
tbreak | tb | 添加临时断点 |
delete | d | 删除断点 |
enable | enable | 启用某个断点 |
disable | disable | 禁用某个断点 |
watch | watch | 监视某一个变量或内存地址的值是否发生变化 |
list | l | 显示源码,默认 10 行 |
info | i | 查看断点 / 线程等信息 |
ptype | ptype | 查看变量类型 |
disassemble | dis | 查看汇编代码 |
set args | set args | 设置程序启动命令行参数 |
show args | show args | 查看设置的命令行参数 |
wh | wh | 打开监视窗口,窗口上半部分为代码区,下半部分为调试区,退出为 ctrl+x+a |
下面做一些详细的介绍:
显示源代码
list 打印源码,默认 10 行
list line_num 打印指定行的代码以及前 5 行,后 4 行
list func #打印函数名为 func 的函数
show listsize,查看 list 命令显示的代码行数;
set listsize count,设置 list 命令显示的代码行数为 count;
设置断点
break 命令(简写为 b)用于添加断点,可以使用以下几种方式添加断点:
- break LineNo,在 当前文件 行号为 LineNo 处添加断点;
- break FunctionName,在函数的入口处添加一个断点;
- info break,显示所有断点。
- disable 断点编号,禁用某个断点,使得断点不会被触发;
- enable 断点编号,启用某个被禁用的断点;
- delete 断点编号,删除某个断点。
- clear 行号,删除该行的断点
- break FileName:LineNo,在 FileName 文件行号为 LineNo 处添加一个断点;
- break FileName:FunctionName,在 FileName 文件的 FunctionName 函数的入口处添加断点;
- break -/+offset,在当前程序暂停位置的前 / 后 offset 行处下断点;
- break … if cond,设置条件断点;
Tips:
如果有时发现用函数名打不上,记得加作用域(类名或命名空间)。
条件断点
当我们进入一个循环时,需要分析第 50 次循环时,不可能手动循环 50 次去查看,这时候就需要使用【条件断点】
(gdb) b test.cc:120 if i==50
注意断点的位置,要有 i
这个值。
另外,gdb 也支持修改断点的条件:condition 断点编号 条件表达式
(gdb) condition 1 i==100 && j==20
修改断点的条件时,只能向后修改,不能返回。修改完后,按 c
执行到指定条件的断点。
(gdb) condition < 断点编号 >
:删除该断点的条件。
断点与打印
每次断点发生时候,想要查看的变量很多时,如果每个变量都手动 print 需要浪费很多时间。断点命令可以在断点发生时批量执行 GDB 命令。下面是断点命令的设置方式:
(gdb) commands 断点编号
(gdb) >print x
(gdb) >print y
(gdb) >end
首先输入 GDB 命令 commands < 断点编号 > 然后回车,这时候会出现 > 提示符。出现 > 提示符后可以输入断点发生时需要执行的 GDB 命令,每行一条,全部输入完成后输入 end 结束断点命令。
设置变量值
对变量的值进行控制,可以更快的调试自己的程序。下面就是设置变量值的方法:
(gdb) set variable < 变量 > = < 表达式 >
:将变量的值设定为指定表达式的值。例如set variable x=10
查看堆栈
命令格式及作用:
- backtrace,也可简写为 bt,用于查看当前调用堆栈。
- frame 堆栈编号,也可简写为 f 堆栈编号,用于切换到其他堆栈处。
查看结果
命令格式及作用:
- print param,用于在调试过程中查看变量的值;注意执行完毕到下一行才能 p 前一行的变量值
- print param=value,用于在调试过程中修改变量的值;
- print a+b+c,可以进行一定的表达式计算,这里是计算 a、b、c 三个变量之和;
- print func(),输出
func
函数执行的结果,常见的用途是打印系统函数执行失败原因:print strerror(errno)
; - print *this,在 c++ 对象中,可以输出当前对象的各成员变量的值;
- set print elements 0,使得打印字符数不受限制。默认为 200 个字符。
- show print elements,显示当前打印字符的限制数。
- set print pretty on,格式化显示结构体。
查看变量类型
命令格式及功能:
- whatis val,用于查看变量类型;
- ptype val,作用和 whatis 类似,但功能更强大,可以查看复合数据类型,会打印出该类型的成员变量。
查看线程
命令格式及作用:
- info thread,查看当前进程的所有线程运行情况;
- thread 线程编号,切换到具体编号的线程上去;
单步执行
next 和 step 都是单步执行,但也有差别:
- next 是 单步步过(step over),其最大的特点是当遇到包含调用函数的语句时,无论函数内部包含多少行代码,
next
指令都会一步执行完。也就是说,对于调用的函数来说,next
命令只会将其视作一行代码。 - step 是 单步步入(step into),当
step
命令所执行的代码行中包含函数时,会进入该函数内部,并在函数第一行代码处停止执行。
退出局部函数
return 和 finish 都是退出函数,但也有差别:
- return 命令是立即退出当前函数,剩下的代码不会执行了,return 还可以指定函数的返回值。
- finish 命令是会继续执行完该函数剩余代码再正常退出。
跳转
命令格式及作用:
jump LineNo,跳转到代码的 LineNo 行的位置;
jump +10,跳转到距离当前代码下 10 行的位置;
jump *0x12345678,跳转到 0x12345678 地址的代码处,地址前要加星号;
jump 命令有两点需要注意的:
- 中间跳过的代码是不会执行的;
- 跳到的位置后如果没有断点,那么 GDB 会自动继续往后执行;
参数传递
很多程序启动需要我们传递参数,set args 就是用来设置程序启动参数的,show args 命令用来查询通过 set args 设置的参数,命令格式:
- set args args1,设置单个启动参数 args1;
- set args “-p” “password”,如果单个参数之间有空格,可以使用引号将参数包裹起来;
- set args args1 args2 args3,设置多个启动参数,参数之间用空格隔开;
- set args,不带参数,则清除之前设置的参数;
临时断点
tbreak: 该命令时添加一个临时断点,断点一旦被触发就自动删除,使用方法同 break。
监视
watch 命令用来监视一个变量或者一段内存,当这个变量或者内存的值发生变化时,GDB 就会中断下来。被监视的某个变量或内存地址会产生一个 watch point(观察点)。
命令格式:
- watch 整型变量;
- watch 指针变量,监视的是指针变量本身;
- watch * 指针变量,监视的是指针所指的内容;
- watch 数组变量或内存区间;
回退
GDB7.0 以上版本的调试器才支持。
能够回退的代码行,必须事先 record 才行。注意:启动 record 会导致程序运行变慢。
>>> gdb a.out
>>> (gdb) b 14
>>> (gdb) c
>>> (gdb) record #启动进程记录回放
>>> (gdb) n
>>> (gdb) n
>>> (gdb) n
>>> (gdb) reverse-next #向上回退一行
reverse-continue: 反向运行程序到 record 的位置,或者观察点
reverse-step:反向运行程序到 上一行(如有函数会进入)
reverse-next:反向运行程序到 上一行(不会进入函数)
reverse-finish:反向运行程序回到调用当前函数的地方。
reverse-stepi:反向运行程序到上一条机器指令
reverse-nexti:反向运行到上一条机器指令,除非这条指令用来返回一个函数调用、否则整个函数将会被反向执行。
帮助
通过 help 命令可以查看目标命令的具体用法。
GDB 多线程调试
概述
多线程程序的编写更容易产生异常或 Bug(例如线程之间因竞争同一资源发生了死锁、多个线程同时对同一资源进行读和写等等)。GDB 调试器不仅仅支持调试单线程程序,还支持调试多线程程序。本质上讲,使用 GDB 调试多线程程序的过程和调试单线程程序类似,不同之处在于,调试多线程程序需要监控多个线程的执行过程。
用 GDB 调试多线程程序时,该程序的编译需要添加 -lpthread
参数。
一些命令
- info thread,查看当前调试程序启动了多少个线程,并打印出各个线程信息;
- thread 线程编号,将该编号的线程切换为当前线程;
- thread apply 线程编号 1 线程编号 2 … command,将 GDB 命令作用指定对应编号的线程,可以指定多个线程,若要指定所有线程,用 all 替换线程编号;
- break location thread 线程编号,在 location 位置设置普通断点,该断点只作用在特定编号的线程上;
一些术语
all-stop mode,全停模式,当程序由于任何原因在 GDB 下停止时,不止当前的线程停止,所有的执行线程都停止。这样允许你检查程序的整体状态,包括线程切换,不用担心当下会有什么改变。
non-stop mode,不停模式,调试器(如 VS2008 和老版本的 GDB)往往只支持 all-stop 模式,但在某些场景中,我们可能需要调试个别的线程,并且不想在调试过程中影响其他线程的运行,这样可以把 GDB 的调式模式由 all-stop 改成 non-stop,7.0 版本的 GDB 引入了 non-stop 模式。在 non-stop 模式下 continue、next、step 命令只针对当前线程。
record mode,记录模式;
replay mode,回放模式;
scheduler-locking ,调度锁;
schedule-multiple,多进程调度;
设置线程锁
使用 GDB 调试多线程程序时,默认的调试模式是: 一个线程暂停运行,其他线程也随即暂停;一个线程启动运行,其他线程也随即启动 。但在一些场景中,我们希望只让特定线程运行,其他线程都维持在暂停状态,即要防止 线程切换,要达到这种效果,需要借助 set scheduler-locking 命令。
命令格式及作用:
- set scheduler-locking on,锁定线程,只有当前或指定线程可以运行;
- set scheduler-locking off,不锁定线程,会有线程切换;
- set scheduler-locking step,当单步执行某一线程时,其他线程不会执行,同时保证在调试过程中当前线程不会发生改变。但如果在该模式下执行 continue、until、finish 命令,则其他线程也会执行;
- show scheduler-locking,查看线程锁定状态;
调试 core 文件
当程序运行过程中出现 Segmentation fault (core dumped)错误时,程序停止运行,并产生 core 文件。
core 文件是程序运行状态的内存映象。
使用 gdb 调试 core 文件,可以帮助我们快速定位程序出现段错误的位置。
该错误产生的主要原因有:(1)访问不存在的内存地址;(2)访问系统保护的内存地址;(3)数组访问越界等。
控制 core 文件是否生成
- 使用 ulimit -c 命令可查看 core 文件的生成开关。若结果为 0,则表示关闭了此功能,不会生成 core 文件。
- 使用 ulimit -c unlimited,则表示生成 core 文件的大小不受限制。ulimit -c filesize 命令,可以限制 core 文件的大小(filesize 的单位为 KB)
调试 core
方法 1:
- 进入 gdb 模式:gdb binary , 我们一般是定义好了 debug.sh, 直接运行 sh debug.sh
- 输入:core-file core.xxxx
- 输入 bt
类似的可以这样做:
- gdb binary core_XXXX
- bt
参考文档
编译 log 重定向
Linux 上有时候我们编译,屏幕上会打印超级多的 error message,使得我们很难去定位问题,这时候可以把屏幕输出的 log 重定向到指定文件里,然后进行搜索,找到问题所在:
make -j10 > ../error-message.txt 2>&1
- 2>&1 表示错误信息输出到 &1 中,而 &1 又代表了 error-message.txt
linux 系统中默认有 3 个输出设备,分别为 stdin、stdout、sdterr,分别表示标准输入设备、标准输出设备和标准错误设备。
设备名称 | 标准叫法 | 代号 |
---|---|---|
标准输入设备 | stdin = standard input | 0 |
标准输出设备 | stdout = standard output | 1 |
标准错误设备 | stderr = standard error | 2 |
声明全局变量
很多人可能直接把全局变量写进.h 文件,然后用多个文件包含这个头文件,编译时就会报错:变量重定义…
正确的做法是:
首先在.c/cpp 文件中声明
int g_a;
然后在对应头文件里:
extern int g_a;
多线程和锁 Mutex
忘了系统性总结,后面再写
Lambda 表达式
使用 STL 时,往往会大量用到函数对象,为此要编写很多函数对象类。有的函数对象类只用来定义了一个对象,而且这个 对象也只使用了一次,编写这样的函数对象类就有点浪费。
对于只使用一次的函数对象类,直接在使用它的地方定义:Lambda 表达式能够解决这个问题。使用 Lambda 表达式可以减少程序中函数对象类的数量,使得程序更加优雅。
Lambda 表达式实际上是一个函数,只是它没有名字。
Lambda 表达式的定义形式如下:
[捕获列表] (参数表) -> 返回值类型
{
语句块
}
- 捕获列表:可省略
-> 返回值类型
可省略
举个例子:
auto Add = [](int a, int b) {return a+b;}
std::cout<< Add(1,2);
std::cout<< [](int a, int b) {return a+b;}(1,2); 与上等价,可以取名,也可以不取名直接使用
再举个例子:
auto Cmpt = [](const db::Point &p1, const db::Point &p2)
{
return p1.x<p2.x || (p1.x==p2.x && p1.y<p2.y);
}
std::set<db::Point, Cmpt> s_points;
捕获列表
作用是:提供一种访问外部变量的接口
int a = 12;
auto Add = [a](int b, int c)->int {
return a; // 访问了外部变量,必须将 a 通过捕获列表传进来
};
std::cout << Add(1, 2) << std::endl;
上述写法,不能改变 a 的值,因为 a 是按值传递的。
int a = 12;
auto Add = [&a](int b, int c)->int { // 得按引用传递
a=a+1;
return a;
};
std::cout << Add(1, 2) << std::endl;
捕获列表使用总结:
符号 | 解释 |
---|---|
[] | 空捕获列表,Lambda 不能使用所在函数中的变量。 |
[names] | names 是一个逗号分隔的名字列表,这些名字都是 Lambda 所在函数的局部变量。默认情况下,这些变量会被拷贝,然后按值传递,名字前面如果使用了 &,则按引用传递 |
[&] | 隐式捕获列表,Lambda 体内使用的局部变量都按引用方式传递 |
[=] | 隐式捕获列表,Lanbda 体内使用的局部变量都按值传递 |
[&,identifier_list] | identifier_list 是一个逗号分隔的列表,包含 0 个或多个来自所在函数的变量,这些变量采用值捕获的方式,其他变量则被隐式捕获,采用引用方式传递,identifier_list 中的名字前面不能使用 &。 |
[=,identifier_list] | identifier_list 中的变量采用引用方式捕获,而被隐式捕获的变量都采用按值传递的方式捕获。identifier_list 中的名字不能包含 this,且这些名字面前必须使用 &。 |
欢迎各位看官及技术大佬前来交流指导呀,可以邮件至 jqiange@yeah.net