CPP编程-CPP11中的内存管理策略模型与名称空间管理探幽
CPP的四大内存分区模型
在 C++ 中,**内存分区是一种模型,用于描述程序运行时内存的逻辑组织方式,但在底层操作系统中,并不存在严格意义上的内存分区。**操作系统通常将内存分配给进程,并管理这些内存块的分配和释放,但不会像内存分区模型那样将内存划分为堆、栈、全局/静态存储区等。这些概念是 C++ 中用来理解和管理内存的模型,有助于开发者编写高效且可靠的代码。
在 C++11 之前的版本,C++ 的内存模型通常被描述为包含四个主要区域:栈、堆、全局/静态存储区和常量存储区。这些区域用于存储不同类型的数据,并在程序执行过程中发挥不同的作用:
- 栈(Stack):用于存储局部变量和函数调用的上下文信息。每次函数调用时,都会在栈上创建一个新的栈帧,用于存储函数的参数、局部变量和返回地址等信息。当函数执行完毕时,其对应的栈帧会被销毁,释放其占用的内存空间。
- 堆(Heap):用于动态分配内存。程序员可以使用
new
和delete
运算符来在堆上分配和释放内存。堆上分配的内存生存期由程序员控制,直到显式释放为止。 - 全局/静态存储区(Global/Static Storage Area):用于存储全局变量、静态变量和静态常量。全局变量和静态变量在程序整个执行过程中都存在,而静态常量的值在程序生命周期内保持不变。
- 常量存储区(Constant Storage Area):用于存储常量值,如字符串常量。这部分内存通常位于只读内存段,防止程序意外修改常量值。
不过需要强调的是:虽然操作系统中不存在严格意义上的内存分区模型,**但在 C++ 编写的程序运行过程中,会根据内存分区模型来管理内存。**在程序运行时,操作系统会为程序分配内存,而 C++ 的内存分区模型描述了这些内存的逻辑组织方式,包括堆、栈、全局/静态存储区等
需要注意的是,某些文章会提出一个代码区的概念,代码区(Code Area)也称为文本区(Text Area)或者只读代码区(Read-Only Code Area)。这个区域用于存储程序的机器指令,即编译后的程序代码。代码区通常是只读的,因为程序运行时不应该修改其包含的指令。**代码区的内存分配是由操作系统和编译器共同管理的。编译器负责将源代码转换为机器指令,并将这些指令存储在代码区。**操作系统则负责将程序加载到内存中,并确保代码区是只读的,以防止程序意外修改代码。
需要注意的是,代码区不是 C++ 标准中明确定义的术语,而是在描述内存模型时用于说明程序代码存储的区域之一。在一些文献或讨论中,可能会将代码区归类到全局/静态存储区或者提到它作为一个独立的区域
CPP11新增内存模型特性
CPP11相对于传统内存模型进行了许多有用的内存模型拓展,主要集中在以下方面:
- 原子操作(Atomic Operations):C++11引入了原子操作的支持,允许程序员使用
std::atomic
模板来创建原子变量,以实现多线程间的安全访问。原子操作确保对共享数据的操作是不可分割的,避免了竞态条件(Race Condition)的发生。 - 线程内存模型(Threading Memory Model):C++11定义了一套严格的内存模型,它明确了在多线程环境下对共享内存的访问规则。内存模型定义了内存操作的顺序和可见性,确保多线程程序的正确性和可移植性。
- 智能指针(Smart Pointers):C++11引入了智能指针,如
std::shared_ptr
和std::unique_ptr
,用于管理动态分配的内存。智能指针可以自动管理内存的生命周期,避免了内存泄漏和悬空指针的问题。 - 内存管理工具(Memory Management Tools):C++11引入了一些新的内存管理工具,如
std::allocator
和std::pointer_traits
,用于更灵活地管理内存分配和释放。 - 新的内存分配函数(New Memory Allocation Functions):C++11引入了一些新的内存分配函数,如
std::allocator_traits
,用于支持对齐内存分配和自定义内存分配器。
不过新特性并不是此次重点内容,因为篇幅实在过大,我们着重讨论传统内存模型的划分
CPP的翻译单元特性
为了提高程序的可维护性和模块功能独立化,我们一般会将大型程序拆分成多个模块,那么此时会出现一个问题,如果一段代码需要在多个文件中进行复用,我们总不可能在每段代码中都复制一份吧,翻译单元则可以完美地解决这个问题。
在编译单元中,通常情况下每份CPP代码将会被认为是一个独立的翻译单元,编译器将每个翻译单元分别编译,然后进行链接组建,最后得到我们的可执行文件
include预处理指令及其作用
对于翻译单元特性,有个问题是无法规避的,既然进行了分离,那么我们怎样才能够在当前翻译单元中获得其他翻译单元中声明的代码段呢?
答案是使用#include
预处理指令,该指令会将引入的头文件内容在预处理时拷贝一份到当前翻译单元之中,然后再进行后续操作,藉此实现同一份代码段在不同翻译单元之间的共享。即实现了一个典型的三角形结构:
- 编写头文件
xxx.h
内部包含:函数原型、使用#define
或const
定义的符号常量、结构声明、类声明、模板声明、内联函数。 - 编写
xxx.h
的实现文件,即头文件声明的代码段的真正实现xxx.cpp
- 编写引入
xxx.h
的主程序文件
include指令作用
- 声明函数和类:头文件包含了函数和类的声明,使得其他文件可以使用这些函数和类而不需要知道其实现细节。
- 引入外部代码:头文件可以引入外部库或模块的代码,使得当前文件可以使用外部代码提供的功能。
- 共享常量和宏定义:头文件可以包含常量和宏定义,使得这些常量和宏可以在多个文件中共享。
- 提高代码可读性:通过将相关的声明放在一起,头文件可以提高代码的可读性和维护性。
- 减少编译时间:使用头文件可以减少编译时间,因为只有头文件或其对应的编译单元发生了变化才需要重新编译相应的文件。
避免二义性的机制
- 为什么在一个程序的多个部分引入同一头文件不会引起二义性?
实质上头文件虽然与翻译单元关系紧密**,但是其本质上并不是一个翻译单元,其利用的是CPP中的声明语义,即声明与实现相分离**。真正的翻译单元实质上是其对应的xxx.cpp
文件,因为xxx.cpp
文件包含有该头文件声明内容的真正实现,链接时链接器操作的实质上也是该cpp
文件编译后产生的文件。
- 为什么在头文件中定义的宏和
const
常量,内联函数也可以加入多个翻译单元,不会引起二义性?
对于这个问题,我们需要明白include
指令的拷贝对于每一个翻译单元来讲都是独立的,即当前翻译单元中引入的头文件内容对于其他翻译单元是不可见的,这里涉及到翻译单元的静态存储持续性,稍后我们将进行解释。定义宏就更好理解了,毕竟它就是个单纯的文本替换,内联函数待后文函数部分细说。
关于include指令的误区
需要强调的是,include指令处理头文件时,并不会为编译器指明该头文件实现的cpp文件,它仅仅是将头文件拷贝一份到当前的编译单元中。你可能会问那编译器怎样找到我们头文件中声明的结构实现呢?实质上编译器会尝试寻找代码中每一个声明对应的实现,否则将会抛出一个未定义错误,这一机制与头文件管理在一定程度上共同保证了编译、链接、组建的正常进行。而要为编译器指明对应编译单元路径一般有两种方式:
-
在编译指令中指明所有翻译单元的路径:
g++ file1.cpp file2.cpp -o output
-
借助构建系统,如
Cmake
,Makefile
等工具进行组建,在对应的构建列表加入翻译单元即可
定义头文件示例
#ifndef UNTITLED_STUDENTSINFOCONTROLLER_H
#define UNTITLED_STUDENTSINFOCONTROLLER_H
#include <iostream>
#include <string>
class StudentsInfoController {
private:
std::string studentID;
std::string studentName;
std::string stuTeacherName;
std::string stuProfession;
public:
StudentsInfoController();
std::string getStuInfo();
std::string getName();
~StudentsInfoController();
};
#endif //UNTITLED_STUDENTSINFOCONTROLLER_H
在定义头文件时需要注意以下几点:
- 在头文件中可以使用
include
引入其他所需要的库或头文件,但是引入的库文件必须是与当前的编译单元相同的编译器编译出的,因为不同的编译器实现同样的代码段可能结果是不一样的 - 注意
#ifndef ... #define ... #endif
宏编程结构,其表示检测该头文件是否被定义,用于在第一次处理时进行引入 UNTITLED_STUDENTSINFOCONTROLLER_H
是Clion自动生成的头文件名表示,更常见的是头文件名_H_
- 注意不要引入头文件对应的编译单元(即
xxx.cpp
文件),避免造成多重定义
使用自定义头文件
#include "StudentsInfoController.h"
这里只需要注意一点:
- 使用尖括号
<>
表示包含的是标准库头文件或系统头文件,编译器会在系统目录中查找这些头文件。 - 使用双引号
""
表示包含的是用户自定义的头文件,编译器会先在当前源文件所在目录中查找,如果找不到再去系统目录中查找
内存模型基石-作用域与链接性
作用域的定义与案例
作用域描述的是在翻译单元中该名称在多大的范围内是可见的,例如一个函数中定义的变量在另一个函数中是不能使用的;但是在整个翻译单元中,在所有函数定义之前的变量是可以被所有函数使用的,一般情况下某名称的定义处就是其作用域起始点(排除一些特殊声明),并且局部代码块中的相同名称具有隐藏全局名称的特性:
#include<iostream>
const int FLAG_STR = 114514;
void varCheckoutFuncOne() {
int varOfFuncOne = 996;
std::cout << FLAG_STR <<" "<< varOfFuncOne << std::endl;
}
void varCheackoutFuncTwo() {
int varOfFuncTwo = 007;
std::cout << FLAG_STR << " " << varOfFuncTwo << std::endl;
}
int main() {
varCheckoutFuncOne();
varCheackoutFuncTwo();
return 0;
}
- 普通变量作用域只在其定义的代码块中(又被称为自动变量),即从其定义处开始到代码块结束处,例如上述中的
int varOfFuncOne = 996;
- 作用域为全局的变量在定义位置起始处到文件尾部都可以使用,所以又称文件作用域,例如上述中的
const int FLAG_STR = 114514;
- 静态变量作用域取决于其定义的方式,稍后细谈
- 在类中声明的成员,其作用域为整个类;在名称空间中声明的名称作用域为整个名称空间。
- 函数的形参列表中的变量名在函数外部是不可见的(函数原型作用域),只在包含参数列表的括号中可见(函数定义的代码块与函数声明部分的参数列表)
- 函数的作用域可以是整个类或整个名称空间,甚至于是整个文件作用域,不过却不是全局的,因为不能在代码块中声明定义函数
链接性的定义与案例
外部链接性案例
在多文件程序中,如果一个名称在连接时可以与其他编译单元交互,那么该名称就具有外部链接,具有外部链接的名称将被引入到目标翻译单元中,并且经由连接程序处理,同时这个名称必须是唯一的:
// file1.cpp
#include<iostream>
extern int global_var;
int main(int argc, char const *argv[])
{
std::cout << global_var ;
return 0;
}
在file1.cpp
中我们使用了extern关键字声明该变量来自于外部的其他编译单元,编译器将会自动查找其定义,接下来我们直接在file2.cpp中输入以下内容:
// file2.cpp
#include<iostream>
int global_var = 2333;
接下来我们以指令进行生成,并运行,发现可以成功访问:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
D:\Code\CPP> .\out.exe
2333
内部链接性案例
在程序中一个名称对于它自身所在的翻译单元是可见的,进行连接时不可能与其他编译单元中的相同名称相冲突,即其他单元不可见,则称为具有内部链接性。具有内部连接性的名称不会被链接到目标翻译单元中,也就是不能够与其他翻译单元交互,如以下示例:
// file1.cpp
#include<iostream>
extern int inner_var;
int main(int argc, char const *argv[])
{
std::cout << inner_var ;
return 0;
}
在file1.cpp
中我们使用了extern关键字声明该变量来自于外部的其他编译单元,编译器将会自动查找其定义,接下来我们直接在file2.cpp中输入以下内容:
// file2.cpp
#include<iostream>
static int inner_var = 233;
接下来我们以指令进行生成,并运行,发现抛出错误 undefined reference to 'inner_var'
:
D:\Code\CPP> g++ file1.cpp file2.cpp
ccWJirC7.o:file2.cpp:(.rdata$.refptr.inner_var[.refptr.inner_var]+0x0): undefined reference to 'inner_var'
collect2.exe: error: ld returned 1 exit status
D:\Code\CPP>
无链接性则是体现在自动变量上,其生命周期和作用域决定了其无链接性
内存模型管理-四大存储策略
注意:CPP11标准才拥有这四种存储策略,以前只有三种策略
自动存储持续性策略
自动存储持续性指的是在程序执行到包含变量定义的块时自动创建,在代码块执行结束时自动销毁的变量的存储策略。这种变量通常称为自动变量,也就是我们在函数中定义的变量(对应前文提到的:普通变量作用域只在其定义的代码块中(又被称为自动变量),即从其定义处开始到代码块结束处),为了更加清晰,我这里使用一个类对象来进行演示:
#include <iostream>
using namespace std;
class LocalVarTest{
public:
LocalVarTest() { cout << "A Test object is created" << endl; }
~LocalVarTest() { cout << "A Test object is deleted" << endl; }
};
void LocalVarCheckoutFunc() {
cout << "LocalVarCheckoutFunc is running" << endl;
LocalVarTest functionObject;
}
int main(int argc, char const *argv[]) {
cout << endl;
LocalVarCheckoutFunc();
cout << endl;
return 0;
}
得到运行结果:
LocalVarCheckoutFunc is running
A Test object is created
A Test object is deleted
可以看到函数中的对象存在的生命周期非常的短,函数的代码块一结束内存就被回收了,在这种情况下作用域为局部,且不具备链接性
CPP11中auto的复用
在C语言与CPP之前的版本中,auto
关键字用于显式声明对应变量为自动变量(几乎没有使用场景),但是CPP中对其进行了复用,和Java的var
类似,拥有了自动推断的作用,使得其拥有了更多场景使用,例如对传入的某一个可迭代对象实现遍历时就非常方便:
std::vector<int> myTestVector(5, 233);
for (auto& _ : myTestVector)
std::cout << _ << " ";
还有个关键字
register
,由C语言引入,它显式声明编译器使用寄存器储存该变量,它和早期的auto用途实质上是相同的,现在许多编译器都会自动优化,没删的原因是防止老架构中的代码出现错误
自动变量与栈的关系
由于自动变量的数目随函数的开始和结束而增减,因此程序必须在运行时对自动变量进行管理。常用的方法是留出一段内存,并将其视为栈,以管理变量的增减。之所以被称为栈,是由于新数据被象征性地放在原有数据的上面(也就是说,在相邻的内存单元中,而不是在同一个内存单元中)。当程序使用完后,将其从栈中删除。栈的默认长度取决于实现,但编译器通常提供改变栈长度的选项。程序使用两个指针来跟踪栈,一个指针指向栈底(即栈的开始位置),另一个指针指向栈顶(下一个可用内存单元)。当函数被调用时,其自动变量将被加入到栈中,栈顶指针指向变量后面的下一个可用的内存单元。函数结束时,栈顶指针被重置为函数被调用前的值,从而释放新变量使用的内存,然后继续跟踪新的自动变量。这便是CPP的内存分区栈区的来源,我们可以做个实验来进行测试:
#include<iostream>
int checkAddressFunc(){
int funcTestVarOne = 233;
int funcTestVarTwo = 233;
std::cout << &funcTestVarOne << std::endl;
std::cout << &funcTestVarTwo << std::endl;
return 233;
}
int main(int argc, char const *argv[])
{
int mainTestVar = 233;
std::cout << &mainTestVar << std::endl;
int varOffuncRe = checkAddressFunc();
std::cout << &varOffuncRe << std::endl;
}
得到运行结果:
0x45755ffb9c
0x45755ffb5c
0x45755ffb58
0x45755ffb98
可以看到返回值在最后储存在了0x45755ffb98
,即0x45755ffb9c
处,两者为连续地址,符合栈的特征。地址值逐渐减小是因为采取了小端程序表示法
静态存储持续性策略
相较于作用域与生命周期短的多的自动变量,静态存储的变量自然更需要明确的链接性管理
在函数定义外定义的变量与使用 static
修饰的变量的存储持续性均为静态,他们作用时间是整个程序的存活周期,并且他们有三种链接性:外部链接性,内部链接性,无链接性,并且他们在运行期间是唯一的,所以程序无需使用栈来管理它,并且在未显式声明它时,编译器将会把它默认设置为0。
#include <iostream>
int globalVar = 233; // 链接性为外部
static int theVarOfFiel = 344; // 链接性为内部
void showNoLinkVar(){
static int theVarOfFunc = 455; // 无链接性
}
静态变量初始化方式
-
零初始化:在未初始化状态下的静态持续性变量,系统会默认采取零初始化,这种初始化对于标量类型将会进行转化,如空指针,结构体,他们的表示可能是0,但是其内部表示却有可能不是0。如指针的NULL与结构体填充位被置为零
-
常量初始化:对于已有的静态持续性变量,我们采用常量对其进行初始化,如上述代码段中的
int global = 233;
就是使用的常量初始化,同时常量表达式也可用于其初始化(constexpr
也增加创建常量表达式的方式) -
动态初始化:即使用静态变量去接收某些方法的返回值来初始化:
#include <iostream> int function(); int data = function();
静态变量与外部链接
拥有静态存储性的变量常被简称为外部变量,但是因为其定义所有函数之外,在同一工程中只要是使用了extern
声明了该外部变量,并且没有二义性,就可以被声明了的翻译单元正常使用,不过值得注意的是,它也是可以被自动变量所掩盖的,此时需要使用作用域控制符来指定访问全局命名空间中的变量等,因此它也得名全局变量:
#include <iostream>
int globalVar = 233; // 链接性为外部
static int theVarOfFiel = 344; // 链接性为内部
void showNoLinkVar(){
static int theVarOfFunc = 455; // 无链接性
}
int main(){
int globalVar = 2333;
std::cout << ::globalVar;
}
静态变量与内部链接
在同一项目中,如果在当前翻译单元中定义一个和声明为外部链接性的全局变量,在另一个翻译单元定义一个同名的外部链接性全局变量,将会导致二义性。此时我们可以将某个编译单元中的变量声明为内部链接性的全局变量,使得其只能够在当前翻译单元中可见。即达成以下效果:
- 使用外部链接性的静态变量让多个不同的翻译单元共享数据,
- 使用内部链接性的静态变量让当前翻译单元中的函数共享数据
// file1.cpp
#include<iostream>
static int globalVar = 233;
int main(int argc, char const *argv[])
{
std::cout << "Static-glabalVar: " << globalVar << std::endl;
return 0;
}
// file2.cpp
#include<iostream>
int globalVar = 233;
组建后运行可执行文件得到如下结果:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
D:\Code\CPP> .\out.exe
Static-glabalVar: 233
去掉file1.cpp中的static则会出现编译错误:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
ccII6LwP.o:file2.cpp:(.data+0x0): multiple definition of 'globalVar';
ccf8fxCm.o:file1.cpp:(.data+0x0): first defined here
collect2.exe: error: ld returned 1 exit status
静态变量与无链接性
在代码块中创建的静态变量无链接性,在代码块中使用static修饰的变量具有静态持续性,它的存活周期贯穿整个程序存活周期,在代码块不活跃时仍然存在于内存之中,在两次代码块调用期间仍然保持不变,并且作用域仅有该代码块,作用时间也仅有代码块运行时间,并且它仅在程序启动时进行一次初始化,不受到循环结构等的干扰,经典应用就是传值求和:
#include <iostream>
void sum(int x){
static int sum = 0;
sum += x;
std::cout << sum <<" ";
}
int main(int argc, char const *argv[]){
for(int i=0; i<5; i++)
sum(i);
return 0;
}
深入const全局常量
前文提到头文件中定义的const
类型常量(在同一翻译单元中只定义了一次该名称时)不会引起二义性,原因是因为其作为const
全局常量来讲是静态存储持续性的,它对于外部翻译单元不可见,不过这里要提出的是,extern不仅仅是用来声明外部变量的,它还可以使得当前编译单元中的const
常量对其他编译单元可见:
// file1.cpp
#include<iostream>
extern int globalVar;
int main(int argc, char const *argv[]) {
std::cout << "Static-glabalVar: " << globalVar << std::endl;
return 0;
}
// file2.cpp
#include<iostream>
extern const int globalVar = 233;
组建运行,得到结果,发现该const
常量已对其他编译单元可见:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
D:\Code\CPP> .\out.exe
Static-glabalVar: 233
线程存储持续性策略
线程存储持续性指变量在线程的生命周期内保持其值的持续性。在多线程编程中,每个线程都有自己的线程栈(Thread Stack),用于存储局部变量和函数调用信息。线程存储持续性描述了变量在线程栈中的生命周期,使用thread_local
关键字声明变量,使其具有线程存储持续性。这种变量类似于具有静态存储持续性的变量,但每个线程都有自己的副本,该副本会随着线程的回收而回收,所谓的线程存储持续性实质上是指的该副本的生命周期:
#include <iostream>
#include <thread>
thread_local int galobalThreadVar = 233;
int normalThreadVar = 2333;
void checkThreadVarFunc(){
std::cout << "The address of galobalThreadVar in the thread: " << &galobalThreadVar << std::endl;
std::cout << "The address of normalThreadVar in the thread: " << &normalThreadVar << std::endl;
galobalThreadVar += 233;
normalThreadVar += 2333;
}
int main(int argc, char const *argv[]){
std::cout << "The address of galobalThreadVar in the main: " << &galobalThreadVar << std::endl;
std::cout << "The address of galobalThreadVar in the main: " << &normalThreadVar << std::endl;
std::thread thread1(checkThreadVarFunc);
thread1.join();
std::cout << "The value of galobalThreadVar in the end: " << galobalThreadVar << std::endl
<< "The value of normalThreadVar in the end: " << normalThreadVar << std::endl;
return 0;
}
我们会得到如下结果:
The address of galobalThreadVar in the main: 0x1caedbd1c28
The address of galobalThreadVar in the main: 0x7ff681d39020
The address of galobalThreadVar in the thread: 0x1caedbd41a8
The address of normalThreadVar in the thread: 0x7ff681d39020
The value of galobalThreadVar in the end: 233
The value of normalThreadVar in the end: 4666
可以发现线程中的galobalThreadVar
与外部定义的galobalThreadVar
地址是不一致的,而normalThreadVar
却是一致的。线程中的galobalThreadVar
的地址是:0x1caedbd41a8
,在线程结束后其内存被回收。
注意:每个线程拥有自己独立的线程栈,即在线程中运行的函数所创建的一般变量仍然是自动变量,存在于线程栈上,其符合的是自动存储持续性,而非线程存储持续性。堆区,全局/静态区,常量存储区为当前程序中所有线程共享
动态存储持续性策略
CPP中的动态存储性策略涉及到动态内存分配和释放,主要通过new
和delete
操作符来实现。动态存储的特点是对象的生存期不由其所在作用域的开始和结束决定,而是由程序员在运行时显式地控制,即new与delete的时机所决定,也就是我们习惯上称之为堆区的部分:
#include <iostream>
int main(int argc, char const *argv[]) {
int *aIntPtr = new int(233); // 申请一个int大小的空间 初始化为233
int *blockIntPtr = new int[3]{2333, 2333, 2333}; // 申请三个int大小的空间 初始化为2333
int *currentIntPtr = blockIntPtr; // 使用一个新指针来遍历数组
while (currentIntPtr != blockIntPtr + 3) {
std::cout << *currentIntPtr << " ";
currentIntPtr++;
}
delete[] blockIntPtr;
delete aIntPtr;
return 0;
}
需要注意的是,在某些操作系统环境中如果等待程序结束系统自动回收内存,可能会出现回收不及时的问题,建议还是记得手动释放,下面我们介绍几个相关内容
std::size_t类型
std::size_t
是CPP标准库中定义的一个类型,用于表示对象的大小或数组的索引。通常情况下,std::size_t
被用作无符号整数类型,其大小足以容纳任何对象的大小。在使用std::size_t
时,通常用来表示数组的大小、循环的计数器等。例如,在遍历数组时,可以使用std::size_t
来表示循环计数器:
#include <iostream>
int main() {
int arr[] = {1, 2, 3, 4, 5};
for (std::size_t i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
std::cout << arr[i] << " ";
}
return 0;
}
这个类型出现在new
操作符的实现位置,这些函数又叫分配函数,存在于全局命名空间中:
void * operator new(std::size_t)
void * operator new[](std::size_t)
而delete
操作符则没有使用它:
void operator delete(void *);
void operator delete[] (void *);
std::bad_alloc类型
std::bad_alloc
是CPP标准库中定义的一个异常类,用于表示内存分配失败的情况。当使用new
表达式进行内存分配时,如果无法分配所请求的内存大小,就会抛出std::bad_alloc
异常。
#include <iostream>
#include <new>
int main() {
try {
int* arr = new int[1000000000000]; // 尝试分配一个非常大的数组
// 使用arr
delete[] arr;
} catch (const std::bad_alloc& e) {
std::cerr << "Failed to allocate memory: " << e.what() << std::endl;
}
return 0;
}
nullptr与NULL常量
在CPP中,NULL
通常被定义为整数常量0或者被定义为nullptr
。在较早的C++标准中,NULL
通常被定义为整数常量0,用于表示空指针。然而,从CPP11开始,推荐使用nullptr
来表示空指针,因为nullptr
具有更好的类型安全性和可读性。在使用NULL
或nullptr
时,可以将它们赋值给指针变量,用于表示该指针不指向任何有效的对象:
int* ptr = NULL;
int* ptr = nullptr;
在CPP中,空指针表示指针不指向任何有效的对象,因此在解引用空指针或者尝试访问空指针指向的对象时,会导致未定义的行为。因此,在使用指针之前,应该始终检查指针是否为空,以避免潜在的错误。这里需要注意的是,在平常遇到了内存越界的情况,并不表示越界的内存是一个空指针,在进行判断时需要注意,一般空指针都是编写人员主动声明的
new操作符定位内存
new操作符不仅仅只能够将数据定义到堆空间,它还可以修改其内存定位处:
#include<iostream>
#include<new>
struct MemoryBlock{
int intVar;
char charVar;
};
int globalBuffer[3];
void getAddress(int *block, int len){
for(int i=0; i<len; i++)
std::cout << &block[i] << " ";
std::cout << std::endl;
}
int main(int argc, char const *argv[]){
int localBuffer[3];
getAddress(globalBuffer, 3);
getAddress(localBuffer, 3);
MemoryBlock *ptrOfGlobal = new (globalBuffer) MemoryBlock;
MemoryBlock *ptrOfLocal = new (localBuffer) MemoryBlock;
std::cout << ptrOfGlobal << " " << ptrOfLocal << std::endl;
ptrOfGlobal->~MemoryBlock();
ptrOfLocal->~MemoryBlock();
return 0;
}
得到如下运行结果:
0x7ff6057e8040 0x7ff6057e8044 0x7ff6057e8048
0xa97e1ffd84 0xa97e1ffd88 0xa97e1ffd8c
0x7ff6057e8040 0xa97e1ffd84
需要注意的是,这里需要手动调用编译器自动提供的析构函数,因为内存不在堆区中
函数的存储持续与链接
在默认情况下函数的存储持续性都是静态的,即在整个程序的生命周期中都存在,链接性也是外部链接性,不过在使用其他文件中的函数时,需要对其进行声明:
// file1.cpp
#include<iostream>
extern void greetingForWorld();
int main(int argc, char const *argv[]){
greetingForWorld();
return 0;
}
// file2.cpp
#include<iostream>
void greetingForWorld(){
std::cout << "Hello World!!!" << std::endl;
}
得到运行结果:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
D:\Code\CPP> .\out.exe
Hello World!!!
和全局变量一样我们也可以使用static
来将其链接性转为内部链接性,并且掩盖外部名称,我们修改file1.cpp
演示:
// file1.cpp
#include<iostream>
static void greetingForWorld(){
std::cout << "Hello World!!! and CPP" << std::endl;
}
int main(int argc, char const *argv[]){
greetingForWorld();
return 0;
}
加上static
组建后运行得到:
D:\Code\CPP> g++ file1.cpp file2.cpp -o out
D:\Code\CPP> .\out.exe
Hello World!!! and CPP
否则你将得到一个错误:
ccSUrq13.o:file2.cpp:(.text+0x0): multiple definition of 'greetingForWorld()';
ccM6iYYT.o:file1.cpp:(.text+0x0): first defined here
内联函数的特殊地位
内联函数在 CPP中具有内部链接性与静态存储持续性,这意味着每个包含该内联函数定义的编译单元都会有其自己的副本。但是需要注意的是,内联函数并不符合CPP的单定义原则,内联函数可以在任何需要插入它的地方生成一份自己的定义,但是CPP规定,同一个内联函数的所有定义必须完全相同。这意味着,如果**在多个包含了相同头文件的源文件中都定义了相同的内联函数,那么这些函数的定义必须完全一致,否则会导致链接错误。**在程序编译时内联函数的定义必须在每个调用点展开,所以它的链接性是不能够被extern
转换的
单定义原则(Single Definition Rule,SDR)是指在C++中,每个非内联函数或对象只能在程序中定义一次。如果违反了单定义原则,会导致链接错误。单定义原则的目的是确保每个函数或对象在程序中只有一个定义,避免重复定义导致的冲突和错误。
特殊链接性之语言链接性
在C/CPP程序中,链接器要求每一个名称都必须是唯一的,于是编译器会对翻译单元中的内容进行一定的转义操作,这就是语言链接性。在实际情况下C与CPP的二进制文件中命名协议大概率是不一致的(不排除部分编译器的实现),此时如果CPP想要使用C库的内容,就必须知道C的命名协议,于是CPP有了以下外部原型声明,来表明命名协议:
- 显式声明为CPP协议
extern void checkoutFunction();
extern "C++" void checkoutFunction();
- 显式声明为C协议
extern "C" void checkoutFunction();
其他内存模型控制关键字
constexpr关键字
constexpr
是 CPP11 引入的关键字,用于声明可以在编译时求值的常量表达式。constexpr
可以用于变量、函数以及构造函数上。
变量: 可以使用 constexpr
来声明变量,以使其成为编译时常量。如下声明要求 x
在编译时被赋值,并且不能修改。
constexpr int x = 5;
函数: 可以使用 constexpr
来声明函数,以指示该函数可以在编译时求值。在调用 add
函数时,如果参数是编译时常量,那么它会在编译时被求值,而不是在运行时。
constexpr int add(int a, int b) {
return a + b;
}
构造函数: 可以在构造函数上使用 constexpr
,以使得对象在编译时就被视为常量。如下代码中obj
被声明为 constexpr
,因此在编译时就被视为常量对象。
class MyClass {
public:
constexpr MyClass(int x) : value(x) {}
int getValue() const { return value; }
private:
int value;
};
int main() {
constexpr MyClass obj(42);
std::cout << obj.getValue();
return 0;
}
constexpr
与普通常量的区别在于它的值必须在编译时就能确定,并且能用于编译时计算。这使得 constexpr
常量可以在编译时进行优化和检查,而普通常量则只是在运行时保持不变。另一个区别是,constexpr
变量可以作为数组的长度、枚举的值等编译时常量的位置使用,而普通常量则不能。
举例来说,对比以下两种情况:
constexpr int x = 5;
int arr[x]; // 合法,x 在编译时就能确定
const int y = 5;
int arr[y]; // 错误,y 是运行时才能确定的常量
volatile关键字
volatile
是 CPP中的早期关键字,用于告诉编译器不要对其所修饰的对象进行优化。它通常用于修饰那些可能被意外修改的变量,例如硬件寄存器或多线程共享的变量。volatile
告诉编译器不要对被修饰的变量进行任何缓存、寄存器优化或者重排序等操作,因为这些操作可能会导致与程序预期不符的行为。但是可能会影响程序的性能。在大多数情况下,应该尽量避免使用 volatile
-
多线程共享的变量:当一个变量被多个线程访问并且可能被修改时,应该将其声明为
volatile
,以确保每次访问都是从内存中读取,而不是从缓存中读取。 -
中断服务程序中的变量:在中断服务程序中,某些变量可能会被中断处理程序修改,因此这些变量应该声明为
volatile
。 -
存储器映射的硬件寄存器:当一个变量代表一个硬件寄存器的值时,应该将其声明为
volatile
,以确保编译器不会对读取或写入该变量的代码进行优化。
mutable关键字
mutable
在较早的 CPP 标准中就已经存在了。mutable
关键字用于声明类的成员变量可以在 const
成员函数中被修改。也可以用于指定在const结构中的某一属性可以被修改。
const
成员函数修改
class MyClass {
private:
int value;
mutable int mutableValue;
public:
int getValue() const {
return value;
}
void setValue(int newValue) const {
mutableValue = newValue; // mutable 成员可以在 const 成员函数中被修改
}
};
const
结构中可修改
#include<iostream>
struct MyStruct{
int intVar;
mutable char charVar;
};
int main(int argc, char const *argv[]){
const MyStruct data1 = MyStruct{233, 'a'};
data1.charVar = 'b';
std::cout << data1.intVar << " " << data1.charVar << std::endl;
return 0;
}
名称空间模型的定义与使用
注意:老式头文件
iostream.h
并不支持使用名称空间
声明与定义可分离语义
声明(Declaration):声明告诉编译器某个实体的存在,但不为其分配内存或定义其具体实现。在编译器看来,声明是一个承诺,它告诉编译器某个实体将在程序的其他地方定义或实现。声明可以包括函数声明、变量声明和类声明等。在代码块外声明的是全局名称(声明区域为当前文件),反之为代码块内的局部名称(声明区域为当前代码块):
// 函数声明
int add(int a, int b);
// 类声明
class MyClass;
// 变量声明
extern int globalVar;
定义(Definition):定义为实体分配内存并指定其实现。定义还可以包含声明的信息,因此它既是声明也是实现。在CPP中,变量和函数需要在使用之前进行定义:
// 函数定义
int add(int a, int b) {
return a + b;
}
// 类定义
class MyClass {
public:
void myMethod();
};
// 变量定义
int globalVar = 10;
名称空间层次规则定义
在解释名称空间层次前需要明确一个概念,即潜在作用域,潜在作用域指的是某一名称从声明位置到自身声明区域结束处之间的部分。而某一名称(包括名称空间中的名称)的实际作用域还可能受到局部名称的遮盖作用,即会出现以下情况:
名称空间层次实质上就是指的在声明区域和作用域范围内某个名称对于该翻译单元的可见关系,保证在某一层次中的某个名称是唯一的。而于此对应的就出现了全局命名空间,即当前翻译单元所对应的文件级区域,全局变量/常量就定义于全局变量空间之中
自定义显式名称空间
我们只需要使用namespace直接定义一个名称空间即可:
namespace SelfDefineSpace{
int intVarOfSpace = 233;
double doubleVarOfSpace = 3.14;
struct SpaceStruct{
int theVarOfSpaceStruct = 23333;
};
void placehoderFnuc(){
std::cout << "abc" << std::endl;
}
}
名称空间中常见内容
内容 | 描述 |
---|---|
变量 | 名称空间可以包含变量的声明和定义。 |
函数 | 名称空间可以包含函数的声明和定义。 |
类和结构体 | 名称空间可以包含类和结构体的声明和定义。 |
嵌套命名空间 | 名称空间可以嵌套在其他命名空间中,形成层级结构。 |
命名空间别名 | 可以使用 namespace 别名语法来为名称空间定义别名,方便引用。 |
引入其他命名空间 | 可以使用 using namespace 来将其他名称空间内容引入 |
访问名称空间中的名称
- 利用作用域解析符访问对应名称即可
直接使用作用域解析符来访问名称空间中的成员,不会引入名称空间中的所有成员到当前作用域,并且对于当前名称也是临时的。
int main(int argc, char const *argv[])
{
std::cout << SelfDefineSpace::doubleVarOfSpace << std::endl;
return 0;
}
- 使用
using namespace
编译指令进行访问名称空间
using namespace
编译指令用于引入一个名称空间中的所有成员到当前作用域,使得可以直接使用该名称空间中的成员而无需使用作用域解析符,这种方式可能造成名称冲突,通常不建议使用
int main(int argc, char const *argv[])
{
using namespace SelfDefineSpace;
std::cout << doubleVarOfSpace << std::endl;
return 0;
}
- 使用
using
声明访问,此方法会将对应名称导入当前编译单元持续存在
使用这种方法需要注意,不要引入多个名称空间中的同名名称或者在当前的声明区域中定义相同名称,否则将会导致二义性
int main(int argc, char const *argv[])
{
using SelfDefineSpace::doubleVarOfSpace;
std::cout << doubleVarOfSpace << std::endl;
return 0;
}
名称空间嵌套的使用
语法非常简单,直接在内部嵌套即可,并且相同名称不会冲突,使用时多加一层作用域解析符即可,或者使用作用域解析符和using指令一起将嵌套名称空间引入翻译单元:
namespace SelfDefineSpace{
int intVarOfSpace = 233;
double doubleVarOfSpace = 3.14;
struct SpaceStruct{
int theVarOfSpaceStruct = 23333;
};
namespace SelfInnerSpace{
int intVarOfSpace = 233;
}
}
int main(int argc, char const *argv[])
{
// using SelfDefineSpace::SelfInnerSpace::intVarOfSpace;
// std::cout<< intVarOfSpace << std::endl;
std::cout<< SelfDefineSpace::SelfInnerSpace::intVarOfSpace << std::endl;
return 0;
}
int main(int argc, char const *argv[])
{
using namespace SelfDefineSpace::SelfInnerSpace;
std::cout<< intVarOfSpace << std::endl;
return 0;
}
名称空间别名语法
namespace SDS = SelfDefineSpace;
namespace SIS = SDS::SelfInnerSpace;
匿名名称空间语法
由于匿名名称空间无法通过using指令来被其他翻译单元使用,所以其只能在当前编译单元中可见,类似于定义于全局命名空间中,可以用于简化定义大量静态变量的过程:
#include <iostream>
namespace {
int intVarOfSpace = 233;
}
int main(int argc, char const *argv[])
{
std::cout << intVarOfSpace << std::endl;
return 0;
}
类与名称空间的链接性
谈完了名称空间的概念与使用,终于可以来细说这一最复杂的部分,联系类的定义和名称空间的定义,你会发现它们的链接性实质上是相当复杂的。
从上文来看名称空间的链接性一般来讲是外部链接性的,在名称空间中无论常量还是函数等,它们的名称都是外部链接性的,但是有一点例外,匿名名称空间不是外部链接性的,而是内部链接性的,因为其对于外部不可见。
而类的链接性则要稍加注意,类的名称确实是外部链接性的,但是其内部的数据可就不一定了,对于其中的公共访问权限部分则是外部链接性的,可以通过对象来从外部访问,对于私有或者保护成员来讲则是内部链接性的,因为只有当前编译单元中的成员方法可以访问。
CPP11中的其他内存模型特性
std::atomi原子操作
原子操作是不可被中断的操作,要么完全执行,要么完全不执行,不存在部分执行的情况。在多线程环境中,原子操作可以确保对共享数据的操作是线程安全的,即使有多个线程同时访问该数据。
对于多线程访问共享资源时,如果不加以调整,两个线程可能会因为竞争资源,而导致资源调度出现错误,例如如下代码:
#include <iostream>
#include <thread>
int counter = 0;
void addToCounter() {
for (int i = 0; i < 10000; ++i) {
counter++;
}
}
int main() {
std::thread t1(addToCounter);
std::thread t2(addToCounter);
t1.join();
t2.join();
std::cout << "Counter value: " << counter << std::endl;
return 0;
}
当两个线程同时增加公共资源的值时,可能导致竞态条件(Race Condition)的发生,因为两个线程可能同时读取counter
的值,然后分别递增后再写回,这样就会导致最终的结果不确定。在多次运行后可能会出现 Counter value: 18803
的非预期结果
使用std::atomic
来创建原子类型的变量,支持的原子操作包括加载、存储、交换、递增、递减等。使用原子操作可以避免出现竞态条件(Race Condition),保证数据的一致性和正确性。修改上述程序:
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> counter(0);
void addToCounter() {
for (int i = 0; i < 10000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
// std::memory_order_relaxed
// 它是最轻量级的内存顺序,表示对其他线程的操作顺序没有严格要求
// 只要最终结果是正确的即可。
}
}
int main() {
std::thread t1(addToCounter);
std::thread t2(addToCounter);
t1.join();
t2.join();
std::cout << "Counter value: " << counter << std::endl;
return 0;
}
std::share_ptr智能指针
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass(int value) : m_value(value) {
std::cout << "Constructor called with value: " << m_value << std::endl;
}
~MyClass() {
std::cout << "Destructor called for value: " << m_value << std::endl;
}
void printValue() {
std::cout << "Value: " << m_value << std::endl;
}
private:
int m_value;
};
int main() {
std::shared_ptr<MyClass> ptr1(new MyClass(1));
std::shared_ptr<MyClass> ptr2 = std::make_shared<MyClass>(2);
ptr1->printValue();
ptr2->printValue();
return 0;
}
make_share的优越性
-
make_shared
的方式会在一次内存分配中同时分配对象和控制块(用于跟踪引用计数等信息),因此make_shared
通常更高效。 -
make_shared
在一定程度上可以提高代码的安全性,因为它可以避免直接使用new
来创建对象,从而避免了可能的内存泄漏和资源管理问题。 -
make_shared
更具可读性,因为它明确地显示了正在创建一个 shared_ptr,并且不需要指定删除器,因为它会使用默认的删除器。
指定删除器
在使用 std::shared_ptr
创建时,可以指定一个删除器(deleter),用于在智能指针的引用计数归零时释放资源。指定删除器的方法是通过构造函数或 reset
方法中的额外参数来实现:
std::shared_ptr<MyClass> ptr(new MyClass(42), [](MyClass* p) {
std::cout << "Deleting MyClass object with value: " << p->value << std::endl;
delete p;
});
std::unique_ptr智能指针
std::unique_ptr
是一个独占所有权的智能指针,它不能被复制,只能通过移动(move)来转移所有权(即CPP移动语义实现)。因此,它不需要引用计数器来追踪多个指针共享一个对象的情况。当 std::unique_ptr
被析构时,它所管理的对象会被自动释放:
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass(int val) : value(val) {}
void print() { std::cout << "Value: " << value << std::endl; }
private:
int value;
};
int main() {
std::unique_ptr<MyClass> ptr(new MyClass(42));
ptr->print();
// 编译错误:std::unique_ptr 不支持复制构造
// std::unique_ptr<MyClass> ptr2 = ptr;
// 移动所有权给另一个 std::unique_ptr
std::unique_ptr<MyClass> ptr2 = std::move(ptr);
// ptr 此时为空指针
if (!ptr) {
std::cout << "ptr is nullptr" << std::endl;
}
// ptr2 指向 MyClass 对象
ptr2->print();
return 0;
}
代码中我们先实例化一个 std::unique_ptr
对象 ptr
,它指向一个 MyClass
对象。然后复制 ptr
给另一个 std::unique_ptr ptr2
,导致编译错误,因为 std::unique_ptr
不支持复制构造。相反,我们使用 std::move
将 ptr
的所有权转移给了 ptr2
,这样 ptr
就变成了空指针。
std::allocator与std::pointer_traits介绍
std::allocator
是 CPP标准库提供的一个用于分配和释放内存的模板类(也被称之为分配器)。它是标准库容器(如std::vector
、std::list
等)的默认分配器类型,用于在容器内部分配元素的内存空间,主要提供以下功能:
步骤 | 方法 | 描述 |
---|---|---|
分配内存 | std::allocator::allocate | 分配一块内存,并返回指向该内存起始位置的指针。 |
构造对象 | std::allocator::construct | 在分配的内存空间上构造对象。 |
销毁对象 | std::allocator::destroy | 销毁对象,但不释放内存。 |
释放内存 | std::allocator::deallocate | 释放先前分配的内存。 |
使用 std::allocator
可以使容器更加灵活,因为它可以替换为自定义的分配器类型,以满足特定需求(例如,使用内存池来优化内存分配)。但在大多数情况下,使用默认的 std::allocator
即可满足需求。
分配器(allocator)是用于管理内存分配和释放的对象。它是标准库容器的一个重要组成部分,负责为容器分配和释放内存。分配器可以自定义,以满足特定的需求和优化内存管理。
std::pointer_traits
是 C++ 标准库提供的一个模板类,用于提供与指针相关的属性和操作。它定义了一组类型和函数,用于在编译时获取指针类型的属性,而不需要实际的指针实例。
方法 | 描述 |
---|---|
std::pointer_traits::element_type | 获取指针指向的元素类型。 |
std::pointer_traits::pointer | 获取指针的指针类型。 |
std::pointer_traits::reference | 获取指针的引用类型。 |
std::pointer_traits::difference_type | 获取指针的差值类型。 |
std::pointer_traits::rebind | 将指针类型重新绑定到另一个类型,返回新类型的指针类型。 |
std::allocator_trait介绍
std::allocator_traits
是一个模板类,用于提供对分配器(allocator)的统一访问接口。它提供了一组模板函数,用于管理和操作分配器,std::allocator_traits
可以用于自定义容器,使其能够与不同类型的分配器一起工作,而无需直接操作底层分配器。
std::allocator_traits
主要用于处理分配器的底层细节,使得分配器更容易与标准库的容器一起使用,同时也提高了代码的可读性和可移植性。
属性 | 描述 |
---|---|
allocator_type | 获取分配器类型。 |
value_type | 获取分配器分配的对象类型。 |
pointer | 获取指向分配器分配的对象类型的指针类型。 |
const_pointer | 获取指向分配器分配的对象类型的常量指针类型。 |
void_pointer | 获取指向未指定类型的指针类型。 |
const_void_pointer | 获取指向未指定类型的常量指针类型。 |
difference_type | 获取指针的差值类型。 |
size_type | 获取分配器的大小类型。 |
propagate_on_container_copy_assignment | 确定分配器是否应该在容器拷贝赋值时传播。 |
propagate_on_container_move_assignment | 确定分配器是否应该在容器移动赋值时传播。 |
propagate_on_container_swap | 确定分配器是否应该在容器交换时传播。 |