碎玉零珠———— C++
volatile
volatile
是 C 语言中的一个关键字,用于修饰变量,表示该变量的值可能在任何时候被外部因素更改,例如硬件设备
、操作系统
或其他线程
。- 当一个变量被声明为
volatile
时,编译器会禁止对该变量进行优化,以确保每次访问变量时都会从内存中读取其值,而不是从寄存器或缓存中读取。避免因为编译器优化而导致出现不符合预期的结果。
explicit
- 在 C++ 中,
explicit
通常用于构造函数的声明中,用于防止隐式转换。 当将一个参数传递给构造函数时,如果构造函数声明中使用了explicit
关键字,则只能使用显式转换进行转换,而不能进行隐式转换。这种机制可以防止编译器自动执行预期外的类型转换,提高代码的安全性。
什么是隐式类型转化
- 当你只有一个类型T1,但是当前表达式需要类型为T2的值,如果这时候T1自动转换为了T2,那么这就是隐式类型转换。如下:
1 | int a = 0; |
explicit的作用
- 有一个类
MyInt
,表示一个整数,并且有一个构造函数可以将int
类型的参数转换为MyInt
类型:
1 | class MyInt |
- 我们可以使用下面的代码来创建一个
MyInt
对象(不考虑编译器优化,编译器会用复制省略(copy elision)优化这段代码,最终不会有中间这个临时对象产生):
1 | MyInt a = 10; // 注意,这段代码有两个步骤: 1. int 类型的 10 先隐式类型转换为 MyInt 的一个临时对象 |
- 在一些情况下,上面这种隐式转换可能会导致问题。 例如,考虑下面的函数:
1 | void f(MyInt n) |
- 这也会编译通过,因为编译器会将
int
类型的值隐式转换为MyInt
类型的对象。 - 但或许,有些情况下,我们并不期望
f
函数可以接受一个int
类型的参数,这是预期外的,可能会导致错误的结果。那么如果希望只接受MyInt
类型的参数,就可以将构造函数声明加上explicit
:
1 | class MyInt |
- 这样,上面的调用语句将会导致编译错误,因为不能使用隐式转换将
int
类型的值转换为MyInt
类型。必须使用显式转换。 - 所以大家日常可以使用
explicit
关键字可以防止不必要的隐式转换,提高代码的可读性和安全性。尤其是构造函数参数只有一种类型的,强烈建议加上explicit
。
extern
- 一般而言,C++全局变量的作用范围仅限于当前的文件,但同时C++也支持分离式编译,允许将程序分割为若干个文件被独立编译。于是就需要在文件间共享变量数据,这里
extern
就发挥了作用。 extern
用于指示变量或函数的定义在另一个源文件中,并在当前源文件中声明。 说明该符号具有外部链接(external linkage)
属性。也就是告诉编译器: 这个符号在别处定义了,你先编译,到时候链接器会去别的地方找这个符号定义的地址。
符号的声明与定义
- 声明:告诉编译器某个符号的存在,在程序变量表中记录类型和名字。
- 定义:为该符号分配内存空间或实现其代码逻辑。
- 凡是没有带
extern
的声明同时也都是定义。对函数而言,带有{}
是定义,否则是声明。如果想声明一个变量而非定义它,就在变量名前添加关键字extern
,且不要显式的初始化变量。
变量的声明与定义
1 | // 声明 |
- 在上面的示例中,
global_var
变量的声明使用extern
关键字告诉编译器它的定义在当前或其它源文件中,而定义则是为变量分配内存空间并初始化为42
。
函数的声明和定义
1 | // 声明 |
- 在上面的示例中,
sum
函数的声明告诉编译器该函数的存在及其参数和返回值类型,而定义则是实现函数的代码逻辑。
C/C++中的链接属性
- 编译与链接(待补充)
- 在 C++ 中,链接属性是指程序在编译、链接和执行阶段如何处理符号(变量、函数、类等)的可见性和重复定义。 C++ 语言规定有以下链接属性:
- 外部链接(External Linkage):外部链接的符号可以在不同的源文件之间共享,并且在整个程序执行期间可见。全局变量和函数都具有外部链接。
- 内部链接(Internal Linkage):内部链接的符号只能在当前源文件内部使用,不能被其他源文件访问。用
static
修饰的全局变量和函数具有内部链接。 - 无链接(No Linkage):无链接的符号只能在当前代码块(函数或代码块)内部使用,不能被其他函数或代码块访问。用
const
或constexpr
修饰的常量具有无链接属性( 通常情况下编译器是不会为const对象分配内存,也就无法链接) - 外部 C 链接(External C Linkage):外部 C 链接的符号与外部链接类似,可以在不同的源文件之间共享,并且在整个程序执行期间可见。它们具有 C 语言的名称和调用约定,可以与 C 语言编写的代码进行交互。在 C++ 中,可以用
extern "C"
关键字来指定外部 C 链接,从而使用一些 C 的静态库。这些链接属性可以通过关键字extern
、static
、const
和extern "C"
来显式地指定。
extern 的作用
声明变量但不定义
- 声明变量或函数的存在,但不进行定义,让编译器在链接时在其他源文件中查找定义。这使得不同的源文件可以共享相同的变量或函数。当链接器在一个全局变量声明前看到
extern
关键字,它会尝试在其他文件中寻找这个变量的定义。这里强调全局且非常量的原因是,全局非常量的变量默认是外部链接的。
1 | //fileA.cpp |
常量全局变量的外部链接
- 全局常量默认是内部链接的,所以想要在文件间传递全局常量量需要在定义时指明
extern
,如下所示:
1 | //fileA.cpp |
- 而下面这种用法则会报链接错误,找不到
i
的定义:
1 | //fileA.cpp |
编译和链接过程
- 编译链接过程中,
extern
的作用如下:- 在编译期,
extern
用于告诉编译器某个变量或函数的定义在其他源文件中,编译器会为它生成一个符号表项,并在当前源文件中建立一个对该符号的引用。这个引用是一个未定义的符号,编译器在后续的链接过程中会在其他源文件中查找这个符号的定义。 - 在链接期,链接器将多个目标文件合并成一个可执行文件,并且在当前源文件中声明的符号,会在其它源文件中找到对应的定义,并将它们链接起来。
- 在编译期,
1 | // file1.cpp |
- 在上面的示例中,
file1.cpp
文件中的main
函数使用了全局变量global_var
,但是global_var
的定义是在file2.cpp
中的,因此在file1.cpp
中需要使用extern
声明该变量。 - 在编译时,编译器会为
global_var
生成一个符号表项,并在file1.cpp
中建立一个对该符号的引用。 - 在链接时,链接器会在其他源文件中查找
global_var
的定义,并将其链接起来。
extern “C”
- 正如这篇文章 extern 所说,
extern
是指示链接可见性和符号规则,而extern "C"
则是 C++ 语言提供的一种机制,用于在 C++ 代码中调用 C 语言编写的函数和变量。如果不用extern "C"
,由于 C++ 和 C 语言在编译和链接时使用的命名规则不同,这会导致 C++ 代码无法调用 C 语言编写的函数或变量(链接时找不到符号)。
函数的命名规则
- 对于 C++ 语言,由于需要支持重载,所以一个函数的链接名(Linkage Name)是由函数的名称、参数类型和返回值类型等信息组成的,用于在编译和链接时唯一标识该函数。
- 函数的链接名的生成规则在不同的编译器和操作系统上可能有所不同,一般是由编译器自动处理,不需要手动指定,这个规则常常叫做 Name Mangling
- 下面介绍一些常见的规则:
- Microsoft Visual C++ 编译器(Windows):函数的名称会被编译器修改为一个以 “_” 开头的名称,并加上参数类型和返回值类型等信息,以避免链接冲突。例如,函数
int add(int a, int b)
的链接名可能是_add_int_int
。 - GCC 编译器(Linux):也会加上参数类型和返回值类型等信息。例如,函数
int add(int a, int b)
的链接名可能是_Z3addii
。 - Clang 编译器(MacOS):函数的链接名的生成规则与 GCC 编译器类似,但稍有不同。例如,函数
int add(int a, int b)
的链接名可能是_Z3addii
。
- Microsoft Visual C++ 编译器(Windows):函数的名称会被编译器修改为一个以 “_” 开头的名称,并加上参数类型和返回值类型等信息,以避免链接冲突。例如,函数
- 而 C 语言的链接函数名规则又和 上面三个 C++ 不一样,通过在 C++ 代码中使用
extern "C"
关键字,可以将 C++ 编译器的命名规则转换为 C 语言的命名规则,从而使得 C++ 代码可以调用 C 语言的函数或变量。
extern “C”语法
1 | // extern "C" 的语法格式如下: |
- 使用
extern "C"
声明的函数或变量会采用 C 语言的链接规则,即符号的名称和调用约定与 C 语言相同。下面是一个代码示例。
1 | // C 语言代码 |
1 | // C++ 代码 |
- 在上面的代码中,使用
extern "C"
声明了 C 语言编写的print_message
函数,使得它可以在 C++ 代码中被调用。在main
函数中,使用 C 语言的语法和命名规则来调用print_message
函数,输出"Hello, world!"
。 - 需要注意
extern "C"
关键字只对函数的名称和调用约定起作用,对于函数的参数类型和返回值类型没有影响。所以,在使用extern "C"
声明函数时,需要保证函数的参数类型和返回值类型与 C 语言的定义相同,否则可能会导致编译错误或运行时错误。
mutable
mutable
是C++中的一个关键字,用于修饰类的成员变量,表示该成员变量即使在一个const
成员函数中也可以被修改。mutable
的中文意思是“可变的,易变的”,跟constant
(即 C++ 中的const
)是反义词。因为在 C++ 中,如果一个成员函数被声明为const
,那么它不能修改类的任何成员变量,除非这个成员变量被声明为mutable
。- 这个关键字主要应用场景是:如果需要在
const
函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable
来修饰,并且放在函数后后面关键字位置。
1 |
|
- 上面定义了一个
Counter
类,该类具有一个计数成员变量count
。还有两个mutable
成员变量:cache_valid
和cached_value
。这两个变量用于在get_count
函数中缓存计算结果,从而提高性能。get_count
函数被声明为const
,因为它在逻辑上不会更改类的状态。然而,需要更新cache_valid
和cached_value
变量以提高性能。为了在const
成员函数中修改这两个变量,将它们声明为mutable
。 - 这个例子不那么贴切的展示了
mutable
关键字的用途:即允许在const
成员函数中修改特定的成员变量,以支持内部实现所需的功能,同时仍然保持外部不变性。
malloc 与 new
- C使用malloc / free, C++使用new / delete, 前者是C语言中的库函数,后者是C++语言的运算符
- 对于自定义对象,malloc/free只进行分配内存和释放内存,无法调用其构造函数和析构函数
- 只有new/delete能做到,完成对象的空间分配和初始化,以及对象的销毁和释放空间
- 所以二者不可混用
- 具体区别如下:
- new分配内存空间无需指定分配内存大小,malloc需要;
- new返回类型指针,类型安全,malloc返回void*,再强制转换成所需要的类型;
- new是从自由存储区获得内存,malloc从堆中获取内存;
- 对于类对象,new会调用构造函数和析构函数,malloc不会(核心)。
inline 与 define
inline
- 使编译器在函数调用点上展开函数,可以避免函数调用的栈开销;
- 内联函数的缺点是可能造成代码膨胀,尤其是递归的函数,会造成大量内存开销,exe太大,占用CPU资源。此外,内联函数不方便调试,每次修改会重新编译文件,增加编译时间。
inline 一定会展开吗?
- 内联函数仅仅是对编译器的内联建议,编译器是否觉得采取你的建议取决于函数是否符合内联的有利条件。如果函数体非常大(超过10行),那么编译器将忽略函数的内联声明,而将内联函数作为普通函数处理。
构造函数和析构函数适合内联吗?
- 构造函数和析构函数不适合内联。
- 构造函数不适合的原因是:即使是看似琐碎或空的构造函数通常也可能包含大量由编译器隐式生成的代码,而实际的构造函数可能会非常大,这可能会导致代码膨胀。
- 析构函数不适合的原因是:它可能是虚函数。
虚函数可以内联吗?
- 虚函数可能可以内联(会不会内联要分情况讨论)。
- 可以被内联:当虚函数被调用时它的入口地址是在编译阶段静态确定的,那么就可能会被内联。参考 虚调用
- 不可以被内联:当虚函数使用父类的指针或者引用,动态地调用子类的虚函数功能时,由于inline是在编译器将函数内容替换到函数调用处,是静态编译的,而此时虚函数是动态调用的,编译器并不知道需要调用的是父类还是子类的虚函数,所以不能够inline声明展开,故编译器会忽略。
其它不适合内联的情况
- 内联那些包含循环或 switch 语句的函数常常是得不偿失 (除非在大多数情况下, 这些循环或 switch 语句从不被执行):因为如果内联函数本身就很复杂,那么将导致调用该内联函数的函数更为复杂,内存处理上更麻烦,可能会让程序整体的效率更低下。
- 通常递归函数不应该声明成内联函数,大多数编译器都不支持内联递归函数:因为递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的。
二者区别
- define宏命令是在预处理阶段对命令进行替换,inline是在编译阶段在函数调用点处直接展开函数,节省了函数调用的栈开销;
- define不会对参数的类型进行检查的,因此会出现类型安全的问题。比如定义一个max命令,但是传递的时候可能会传递一个整数和一个字符串,就会出错;
- 内联函数在编译阶段会进行类型检查;
- 使用宏的时候可能要添加很多括号,比较容易出错。
define 与 typedef 的区别
- 语法和实现机制:
- 宏定义 #define 在编译期间将宏展开,并替换宏定义中的代码。预处理器只进行简单的文本替换,不涉及类型检查。
- typedef 是一种类型定义关键字,用于为现有类型创建新的名称(别名)。与宏定义不同,typedef 是在编译阶段处理的,有更严格的类型检查。
- 作用域限制:
- 宏定义没有作用域限制,只要在宏定义之后的地方,就可以使用宏。
- typedef 遵循 C++ 的作用域规则,可以受到命名空间、类等结构的作用域限制。
模板支持:
- 宏定义不支持模板,因此不能用于定义模板类型别名。
- typedef 可以与模板结合使用,但在 C++11 之后,推荐使用 using 关键字定义模板类型别名。
1
2
3
4
5
6
7
8
9
10
11
12
13// 使用 typedef 定义模板类型别名
template <typename T>
struct MyContainer
{
typedef std::vector<T> Type;
};
// 使用 using 定义模板类型别名(C++11 及以后)
template <typename T>
struct MyContainer
{
using Type = std::vector<T>;
};
C++的声明定义与内存之间的关系
- 局部变量:声明和定义在调用的时候同时进行内存分配。
- 全局变量:声明的时候不分配内存,定义的时候分配内存(注意,此处的全局变量是指多个文件调用,使用extern声明的。如果只单个文件调用,还是局部变量一样)。
- 函数:声明和定义的时候不分配内存,调用的时候分配内存。
- 结构体:声明和定义的时候不分配内存,实例化的时候分配内存。
- 类:声明和定义的时候不分配内存,实例化的时候分配内存。
Struct与Class的区别
动态库与静态库的区别
两个线程各进行100次i++操作后i的值是多少
继承时一般要写类的哪些成员函数?
怎样让对象只能创建在栈/堆/内存池中
- 在c++中,类的对象建立分为两种,一种是静态建立,比如
1 | A a; |
- 另一种是动态建立,比如
1 | A* ptr = new A; |
- 这两种方式是有区别的。
- 静态建立类对象: 是由编译器为对象在栈空间中分配内存,通过移动栈顶指针挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。这种方式是直接调用类的构造函数。
- 动态建立类对象: 是用new关键字将对象建立在堆空间上,这个过程分两步走。首先是执行 operator new() 函数,在堆空间上搜索合适的内存并分配;第二步是调用构造函数构造对象,初始化这片内存空间。这种方式是间接的调用类的构造函数。
只在栈上分配内存
- 只有使用new运算符,对象才会建立在堆上,因此,只要禁用new运算符就可以实现类对象只能建立在栈上。虽然你不能影响new operator的能力(因为那是C++语言内建的),但是你可以利用一个事实:new operator 总是先调用 operator new,而后者我们是可以自行声明重写的。
- 因此,将operator new()设为私有即可禁止对象被new在堆上。
- 代码如下:
1 | class A |
只在堆上分配内存
- 首先要知道,当对象建立在栈上面时, 是由编译器分配内存空间的,当对象使用完以后,编译器会调用析构函数来释放对象所占的空间。
- 实际上,编译器在为类对象分配栈空间时, 会检查类的析构函数的访问性(其他非静态函数也会检查),如果类的析构函数是私有的, 则编程器不会在栈空间上为类对象分配内存。
- 因此, 我们只需要将析构函数设为私有,类对象就无法建立在栈上了。
- 代码如下:
1 | class A |
- 注意:由于new表达式会在分配内存以后调用构造函数,因此构造函数必须是公有的。同时由于delete此时无法访问私有的析构函数,因此必须提供一个destroy函数,来进行内存空间的释放。
由此引发的其它问题
- 无法解决继承问题:为了实现多态,析构函数通常要设为virtual,因此析构函数不能设为private,此时我们可以使用protected, 这样,子类可以访问析构函数,而外部无法访问。
- new 和 destroy 的对应关系容易引起误解,解决办法是将构造函数也设置为protected,然后提供一个create函数和destroy对应。
红黑树的插入与删除(速记版)
字节对齐规则
自然对齐规则
- 对于基本数据类型,其自然对齐边界通常为其大小。
- 例如,char 类型的自然对齐边界为 1 字节,short 为 2 字节,int 和 float 为 4 字节,double 和 64 位指针为 8 字节。具体数值可能因编译器和平台而异。
结构体对齐
- 结构体内部的每个成员都根据其
自然对齐
边界进行对齐。也就是可能在成员之间插入填充字节。 - 结构体本身的总大小也会根据其最大对齐边界的成员进行对齐(比如结构体成员包含的最长类型为int类型,那么整个结构体要按照4的倍数对齐),以便在数组中正确对齐。
联合体对齐
- 联合体的对齐边界取决于其最大对齐边界的成员。联合体的大小等于其最大大小的成员,因为联合体的所有成员共享相同的内存空间。
编译器指令
- 可以使用编译器指令(如
#pragma pack
)更改默认的对齐规则。这个命令是全局生效的。这可以用于减小数据结构的大小,但可能会降低访问性能。
对齐属性
- 在 C++11 及更高版本中,可以使用
alignas
关键字为数据结构或变量指定对齐要求。这个命令是对某个类型或者对象生效的。例如,alignas(16) int x
; 将确保x
的地址是 16 的倍数。
动态内存分配
- 大多数内存分配函数(如 malloc 和 new)会自动分配足够对齐的内存,以满足任何数据类型的对齐要求。
例子
1 |
|
大小端 ( 字节序 )
- 字节序是指在多字节数据类型(如整数、浮点数等)中,字节在内存中的存储顺序。
- 主要有两种字节序:大端字节序(Big-endian)和小端字节序(Little-endian)。
大端字节序(Big-endian)
- 高位字节存储在低地址处,低位字节存储在高地址处。例如,一个4字节的整数0x12345678,在大端字节序的系统中,内存布局如下(从左侧的低地址到右侧的高地址):
1 | 0x12 | 0x34 | 0x56 | 0x78 |
- 大端字节序是符合人类阅读习惯的顺序。
小端字节序(Little-endian)
- 低位字节存储在低地址处,高位字节存储在高地址处。
- 例如,一个4字节的整数0x12345678,在小端字节序的系统中,内存布局如下(从左侧的低地址到右侧的高地址):
1 | 0x78 | 0x56 | 0x34 | 0x12 |
- 判断系统的字节序的方法有多种,下面是一个简单的 C++ 代码示例:
1 |
|
- 这段代码的原理就是,整数
num
值初始化为1(0x00000001)。然后将其指针类型从int*
转换为char*
,这样我们就可以访问该整数的第一个字节。
常见的大小端字节序
- 在计算机领域中,不同的系统、平台和协议使用不同的字节序。下面是一些常见情况的字节序:
网络传输
- 在网络传输过程中,通常使用大端字节序(Big-endian),也称为网络字节序,这是 TCP/IP 协议的规定,多字节数据在网络上传输时使用大端字节序。
- 因此,如果本地系统使用的是小端字节序,那么就需要在传输之前将其转换为大端字节序。一般通过使用
htonl()
、htons()
、ntohl()
和ntohs()
等函数来完成。
概括
- 在网络传输中,通常使用大端字节序(网络字节序)。
- 在具体的操作系统中,字节序取决于底层硬件架构。例如,Linux和Windows操作系统主要运行在x86和x86_64(Intel和AMD处理器)架构上,这些处理器使用小端字节序。
- 而其他硬件平台,如PowerPC和SPARC等,可能使用大端字节序。
栈的效率为什么比堆高?为什么栈的运行速度比堆快?
- 这里说的
堆
和栈
,并不是数据结构上的Heap
跟Stack
,而是程序运行中的不同内存空间。(例如 C++ 的内存四区:代码区、全局区、堆、栈)
- 申请速度快:栈是程序运行前就已经分配好的空间,所以运行时分配几乎不需要时间。而堆是运行时动态申请的,相当于将分配内存的耗时由编译阶段转嫁到了机器运行阶段,将分配过程从编译器搬到了运行的代码中。于是动态分配的速度不仅与分配算法有关,还与机器运行速度有关。(栈是编译时分配空间,而堆是动态分配(运行时分配空间),所以栈的申请速度快)
- 存储寻址速度快:栈的物理地址空间是连续的,而堆未必,查找堆的链表也会耗费较多时间,所以存储寻址速度慢。
- CPU 硬件操作速度快:CPU 有专门的寄存器(
esp
,ebp
)来操作栈,堆是使用间接寻址的,所以栈快。
既然栈的运行速度更快,为什么不多用栈呢?
- 栈的地址空间必须连续,如果任其任意成长,会给内存管理带来困难。
- 对于多线程程序来说,每个线程都必须分配一个栈,因此没办法让默认值太大。
- 现代化的内存分配器通过类似slab allocator这样的设计已经尽可能令相关数据尽可能放在一起,从 CPU 数据缓存角度,绝大多数程序并不需要在栈上分配内存,且栈缓冲区溢出的后果比堆缓冲区溢出要严重许多,而在堆上分配缓冲区则可以避免前者。
- PS:任何情况下必须满足下列不等式:
堆栈地址最大值 × 线程数目最大值 < 用户态内存地址最大值
C++ 编译和链接
- 预处理:在这个阶段,预处理器将对代码进行处理,主要包括处理以
#
开头的预处理指令,如#include
,#define
等。预处理器会将头文件包含进来,展开宏定义等,生成一个经过预处理的源代码文件。 - 编译:在这个阶段,编译器将预处理后的源代码翻译成中间代码(通常是汇编代码),这个中间代码仍然是针对特定的硬件平台的抽象代码。
- 汇编:在这个阶段,汇编器将编译器生成的中间代码翻译成目标机器的机器码,即汇编语言。汇编语言是特定于计算机体系结构的低级语言。
- 链接:在这个阶段,链接器将编译后的目标文件与所需的库文件链接在一起,生成可执行文件。这些库文件可能包含标准库,第三方库。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 SleepyLoser's Blog!
评论