C++基础语法与概念

编译内存段

C++程序编译后的内存布局可分为 静态内存段(可执行文件中预设的段)运行时动态分配的内存段

静态内存段(在编译时确定,存储在可执行文件中)

  • 代码段(.text 段) 存储程序的 可执行指令(机器码),如函数体的二进制代码。该段是只读的,确保程序运行时的稳定性
  • 数据段(.data 段) 存储 已初始化的全局变量和静态变量(包括 static 修饰的局部变量)。
  • BSS 段(.bss 段) 存储 未初始化的全局变量和静态变量,默认初始化为零值。
  • 常量区(.rodata 段) 存储 只读数据,如字符串常量和 const 修饰的全局常量。

动态内存段(运行时动态分配)

  • 堆(Heap) 由程序员通过 new/malloc 动态分配内存,需手动释放(delete/free)。
  • 栈(Stack) 存储 函数调用的上下文,包括局部变量、参数、返回地址等。

C语言的内存分配{静态内存&动态内存&堆栈}_c语言内存-CSDN博客

静态链接与动态链接

静态链接(Static Linking)

在静态链接中,所有用到的库代码在编译时被直接打包到最终的可执行文件中。生成的可执行文件是独立的,不依赖外部的库文件。

  • 文件格式:静态库通常以 .a(Unix/Linux)或 .lib(Windows)为后缀。
  • 特点:
    • 可执行文件较大,因为它包含了所有依赖的库代码。
    • 运行时不需要外部的库文件。
    • 部署简单,适合小型项目或独立程序。

动态链接(Dynamic Linking)

在动态链接中,库代码在运行时被加载到内存中,而不是在编译时打包到可执行文件中。可执行文件仅包含对库的引用。

  • 文件格式:动态库通常以 .so(Unix/Linux)或 .dll(Windows)为后缀。
  • 特点:
    • 可执行文件较小,因为它不包含库代码。
    • 运行时需要外部的库文件。
    • 适合大型项目或需要共享库的场景

对比

特性 静态链接 动态链接
可执行文件大小 较大,包含所有库代码 较小,仅包含对库的引用
运行时依赖 无需外部库文件 需要外部的动态库文件
内存占用 较高,每个程序都包含库代码 较低,多个程序共享同一份库代码
部署复杂性 简单,可执行文件独立 复杂,需确保动态库存在且版本匹配
更新库代码 需重新编译整个程序 只需替换动态库文件
启动速度 较快,无需加载外部库 较慢,需加载动态库

虚函数

实现原理

  • 虚函数的实现原理基于两个核心概念:虚函数表(virtual function table, vtbl)虚函数表指针(virtual table pointer, vptr)。

  • 每个含有虚函数的类都会有一个虚函数表,这个表中存放着该类所有虚函数的地址。编译器会为每个对象添加一个隐藏成员,即虚表指针,它指向对应的虚函数表。当调用一个虚函数时,程序会通过对象中的虚表指针找到虚函数表,再根据虚函数在表中的偏移量找到并执行正确的函数。

虚函数表属于C++编译程序的哪个段?

常量区/只读数据段(.rodata段),因为其内容在编译时确定且不可修改

虚函数表与虚函数表指针

  1. 虚函数表:当类中包含至少一个虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个静态数组,存储了类的虚函数指针(即虚函数的地址)。
    • 虚函数表编译时生成(编译时遇到虚函数),是类级别的静态数据。
  2. 虚函数表指针:虚函数表指针指向类的虚函数表,类的不同对象通常共用一个虚函数表指针。
    • 虚函数表指针 在对象构造时初始化(构造函数),指向所属类的虚函数表。因此构造函数不能定义为虚函数,构造时创建虚函数表指针。

虚析构

如果不将析构函数定义为虚函数,当基类指针指向子类对象时,调用析构函数会执行基类的析构函数而不是子类的(静态绑定);如果定义为虚函数则会先调用子类的析构函数,再调用基类的析构函数。因此多态场景必须使用虚析构

  • 虚析构函数的主要作用是避免内存泄漏。当基类指针指向子类对象时,如果基类的析构函数不是虚函数,那么在删除基类指针时,只会调用基类的析构函数,而不会调用子类的析构函数。这会导致子类的资源没有被正确释放,从而造成内存泄漏。
  • 虚析构函数的原理是通过虚函数表(vtable)实现的。当一个类包含虚函数时,编译器会为该类生成一个虚函数表,表中存储了虚函数的指针。当通过基类指针调用虚函数时,会根据虚函数表找到实际要调用的函数。

纯虚函数

纯虚函数是一种特殊的虚函数,它在基类中声明但不提供实现,通常用于定义接口规范。纯虚函数的声明方式是在函数声明的末尾添加= 0。包含纯虚函数的类被称为抽象类,这意味着它不能被实例化,而是要求派生类必须实现这些纯虚函数。

变量

全局变量(Global Variables)

定义位置

在所有函数和类外部声明。

作用域

  • 从声明位置开始,到整个程序结束(整个文件内有效)。

  • 可通过extern关键字在其他文件中访问。

生命周期

程序启动时分配内存,程序结束时释放(整个程序运行期间存在)。

存储位置

静态存储区(全局/静态区)。

初始化

  • 若未显式初始化,默认初始化为0(或对应类型的零值)。
  • 只能使用常量表达式初始化(不能依赖运行时数据)。

示例

int globalVar = 10;  // 全局变量

int main() {
cout << globalVar; // 输出 10
return 0;
}

非静态局部变量(Local Variables)

定义位置

在函数、代码块(如{})或类成员函数内部声明。

作用域

仅在定义的函数或代码块内部有效。

生命周期

进入作用域时创建,离开作用域时销毁(栈内存自动释放)。

存储位置

栈内存。

初始化

  • 若未显式初始化,值为随机垃圾数据(未定义行为)。
  • 可用运行时数据初始化。

示例

void func() {
int localVar = 20; // 局部变量
cout << localVar; // 输出 20
} // 函数结束,localVar 被销毁

int main() {
func();
// cout << localVar; // 错误!作用域外无法访问
return 0;
}

静态局部变量(Static Local Variables)

定义位置

在函数内部用static关键字声明。

作用域

仅在函数内部有效(与局部变量相同)。

生命周期

程序启动时分配内存,程序结束时释放(与全局变量相同)。

存储位置

静态存储区。

初始化

  • 仅在第一次进入作用域时初始化一次。
  • 若未显式初始化,默认初始化为0

示例

void counter() {
static int count = 0; // 静态局部变量
count++;
cout << count << " ";
}

int main() {
counter(); // 输出 1
counter(); // 输出 2(保留上次的值)
return 0;
}

应用场景

  • 常用于保留函数调用间的状态(如计数器、单例模式)。

静态成员变量(Static Member Variables)

定义位置

在类内部用static关键字声明,但需在类外定义和初始化(C++17后支持类内初始化)。

作用域

  • 属于类,所有类的对象共享同一个静态成员变量。
  • 可通过类名直接访问(无需对象实例)。

生命周期

程序启动时分配内存,程序结束时释放。

存储位置

静态存储区。

初始化

  • 必须在类外显式初始化(除非是const static整型或枚举类型)。这是因为静态成员变量属于整个类而不是某个对象。如果在类内初始化,每个对象都会包含一个静态成员的副本,这与静态成员的设计初衷相悖。
  • 初始化时不加static关键字。

示例

class MyClass {
public:
static int staticVar; // 声明静态成员变量
};

int MyClass::staticVar = 30; // 类外定义并初始化,不需要static关键字

int main() {
MyClass obj1, obj2;
obj1.staticVar = 40;
cout << obj2.staticVar; // 输出 40(所有对象共享)
cout << MyClass::staticVar; // 直接通过类名访问
return 0;
}

注意事项

  • 静态成员变量不占用对象的内存空间(属于类级别)。

对比总结

特性 全局变量 局部变量 静态局部变量 静态成员变量
作用域 全局 函数/代码块内 函数内部 类作用域
生命周期 程序运行期 函数执行期间 程序运行期 程序运行期
存储位置 静态存储区 栈内存 静态存储区 静态存储区
初始化 默认零值 未初始化随机值 第一次进入作用域初始化 类外显式初始化
访问方式 直接或extern 函数内部 函数内部 类名或对象
共享性 全局共享 每次调用独立 函数内共享 所有对象共享

选择优先级

  1. 避免全局变量:优先使用局部变量或封装为类的静态成员。
  2. 静态局部变量:用于保留函数调用间的状态(如单例模式)。
  3. 静态成员变量:表示类的全局属性(如统计对象数量)。
  4. 局部变量:默认选择,限制作用域以提高安全性。

示例代码

#include <iostream>
using namespace std;

int globalVar = 1; // 全局变量

class MyClass {
public:
static int staticMember; // 静态成员变量声明
};
int MyClass::staticMember = 4; // 类外初始化

void func() {
int localVar = 2; // 局部变量
static int staticLocal = 3; // 静态局部变量
cout << "local: " << localVar
<< ", staticLocal: " << staticLocal
<< ", global: " << globalVar
<< ", staticMember: " << MyClass::staticMember << endl;
localVar++;
staticLocal++;
}

int main() {
func(); // 输出: local: 2, staticLocal: 3, global: 1, staticMember: 4
func(); // 输出: local: 2, staticLocal: 4, global: 1, staticMember: 4
return 0;
}

指针

指针本质也是一个变量,但是它存储的是另一个变量的地址,需要通过*符号来获取地址存储的值

因此指针作为参数传递时,与普通变量一样也会拷贝一份,当指针作为参数传递给函数时,传递的是指针的值(即地址)的副本。这意味着:

  • 函数内部会创建一个新的指针变量(副本),它的值和传入的指针相同(即指向同一个地址)。
  • 函数内对指针副本的修改(如改变指向)不会影响主函数的指针。
  • 函数内通过指针副本修改目标变量的值会影响主函数的目标变量。

alloca函数

alloca 是一个在 C 和 C++ 中可用的函数,用于在栈上动态分配内存空间。它类似于 malloc 函数,但是分配的内存空间在函数返回后会自动释放,无需显式调用 free 函数。

返回类型是void *,函数本身只负责分配空间,使用时需要进行类型转换,例如:

#include <stdio.h>
#include <alloca.h>

void dynamicStackAllocation() {
int* dynamicArray;
int size = 10;

dynamicArray = (int*)alloca(size * sizeof(int));

// 使用动态分配的栈空间
for (int i = 0; i < size; i++) {
dynamicArray[i] = i;
}

// 打印动态分配的栈空间
for (int i = 0; i < size; i++) {
printf("%d ", dynamicArray[i]);
}
}

int main() {
dynamicStackAllocation();
return 0;
}

常量指针与指针常量

  • 常量指针是不能改变指向的指针,指针本身是个常量;
  • 指针常量是指向一个常量的指针,可以改变指向;

野指针和悬空指针

  • 野指针是没被初始化的指针
  • 悬空指针是指向被释放了的内存的指针

函数指针

指向全局函数或静态成员函数的指针。作用域是全局或命名空间作用域,无需依赖类的实例。

函数指针与成员函数指针对比

  • 函数指针的赋值可直接赋值函数名显式取地址
  • 成员函数指针的赋值必须显式使用取地址运算符,不能隐式转换。

代码示例

赋值定义

// 函数指针
int add(int a, int b) { return a + b; }
// 定义时与函数名无关,仅与返回类型和参数类型有关
int (*pf)(int, int) = &add; // 指向全局函数
int (*pf)(int, int) = add; // 隐式转换

// 成员函数指针
class A {
public:
int add(int a, int b) { return a + b; }
};

int (A::*pmf)(int, int) = &A::add; // 指向类成员函数
int (A::*pmf)(int, int) = A::add; // 隐式转换,错误❌

调用方式

// 函数指针
pf = add; // 隐式转换
pf = &add; // 显式取地址

int result = (*pf)(2, 3); // 传统方式
int result = pf(2, 3); // 简化调用

// 成员函数指针
pmf = &A::add; // 正确
pmf = A::add; // 错误,无法隐式转换

A a;
(a.*pmf)(2, 3); // 对象实例调用
A* ptr = &a;
(ptr->*pmf)(2, 3); // 对象指针调用

特性对比

特性 函数指针 成员函数指针
作用域 全局或静态作用域 类作用域,依赖对象实例
声明语法 int (*pf)(int, int); int (A::*pmf)(int, int);
调用方式 直接调用 pf(2, 3) 通过对象 (a.*pmf)(2, 3)
内存占用 普通指针大小(4/8 字节) 可能为双倍或三倍指针大小(8-16 字节)
典型应用 回调函数、函数表 类策略模式、消息处理
赋值限制 可隐式转换函数名 必须显式取地址 &A::add

引用

定义

  • 指针是一个变量,存储另一个对象的内存地址。指针可以为空(nullptr),也可以重新指向其他对象。

  • 引用是一个对象的别名,必须在初始化时绑定到一个对象,且不能重新绑定到其他对象。引用不能为空。

指针与引用的区别

  • 引用必须初始化;指针不必初始化
  • 引用无法重新绑定其他对象;指针可以更改指向
  • 引用不能为空;指针可以指向空值
  • 引用本质是别名,通常不占用额外内存;指针是变量,需要分配内存

union(共同体/联合体)

共同体的所有成员共享同一块内存空间,同一时间只能存储一个成员的值。共同体的大小等于最大成员的大小。

代码示例

#include <iostream>
using namespace std;

// 定义共同体
union Data {
int num; // 4字节
char ch; // 1字节
double value; // 8字节(最大成员)
};

int main() {
Data d;
d.num = 42;
cout << "num: " << d.num << endl; // 输出 42

d.ch = 'Z';
cout << "ch: " << d.ch << endl; // 输出 Z
cout << "num: " << d.num << endl; // 输出垃圾值(内存被覆盖)

// 共同体大小 = 最大成员的大小 = 8 字节
cout << "Size of union Data: " << sizeof(Data) << endl;
return 0;
}

输出

num: 42
ch: Z
num: 90 // 具体值取决于内存覆盖后的结果
Size of union Data: 8

特点

  • 成员共享内存,修改一个成员会影响其他成员。
  • 适用场景:节省内存,同一时间只使用一个成员(如网络协议解析)。
特性 结构体(Struct) 共同体(Union)
内存分配 成员占用独立内存,总大小为各成员之和(含对齐) 成员共享内存,总大小为最大成员的大小
成员访问 所有成员可同时访问 同一时间只能使用一个成员
内存效率 内存占用较高 内存占用较低(仅用最大成员的空间)
典型应用场景 存储多个相关数据(如坐标、学生信息) 节省内存,类型转换(如协议解析)

如何判断当前系统是大端(Big-Endian)还是小端(Little-Endian)?

方法一:使用union定义一个int类型成员变量和一个char类型成员变量,利用共享内存的性质读取低地址存放值判断大小端

#include <iostream>

bool isLittleEndian() {
union {
int num;
char byte;
} test;
test.num = 1;

return (test.byte == 1);
}

int main() {
if (isLittleEndian()) {
std::cout << "系统是小端(Little-Endian)" << std::endl;
} else {
std::cout << "系统是大端(Big-Endian)" << std::endl;
}
return 0;
}

方法二:定义一个值为1多字节整数例如int,将它的地址从int*类型使用reinterpret_cast<char>转换到char*类型,从低地址读取第一个字节的值,如果结果是1则是小端,是0则是大端。

#include <iostream>

bool isLittleEndian() {
int num = 1; // 多字节整数,值为 1
char* bytePtr = reinterpret_cast<char*>(&num); // 获取第一个字节的地址

// 如果第一个字节是 1,则是小端;否则是大端
return (*bytePtr == 1);
}

int main() {
if (isLittleEndian()) {
std::cout << "系统是小端(Little-Endian)" << std::endl;
} else {
std::cout << "系统是大端(Big-Endian)" << std::endl;
}
return 0;
}

变量声明与定义

  • 变量可以多次声明(使用 extern),但只能定义一次。
  • 声明通常放在头文件中,定义放在源文件中。

变量的声明是告诉编译器变量的存在及其类型,但不分配内存。在头文件中声明变量,以便多个源文件可以共享该变量。如果在头文件中定义变量,同时多个源文件包含了该头文件则会出现错误。

多次定义示例

**头文件 example.h**:

// 定义变量(错误用法)
int globalVar = 42; // 在头文件中定义变量

**源文件 example1.cpp**:

#include "example.h"

void func1() {
printf("globalVar in func1: %d\n", globalVar);
}

**源文件 example2.cpp**:

#include "example.h"

void func2() {
printf("globalVar in func2: %d\n", globalVar);
}

**主文件 main.cpp**:

#include "example.h"

int main() {
func1();
func2();
return 0;
}

编译错误

multiple definition of 'globalVar'

分析

  • example.h 中,int globalVar = 42; 是变量的定义。
  • example.h 被多个源文件(example1.cppexample2.cpp)包含时,globalVar 会被多次定义。
  • 这违反了 “一次定义规则”(One Definition Rule, ODR),导致链接错误。

构造函数

执行顺序

在构造函数中,编译器会在用户代码之前依次插入:

  1. 按各虚基类的声明顺序(从左到右)调用构造函数。此步骤只有在继承链最底层(most-derived)的类会执行,而继承链中间的只需共享最底层构建的即可。

  2. 按各非虚基类的声明顺序(从左到右)调用构造函数

  3. 设置本类的vptr指向本类的vtable

  4. 按各成员的声明顺序(从上到下)调用构造函数

以上工作做完之后才开始真正执行用户写的构造函数代码。

为什么虚函数表指针在成员构造之前,基类构造之后进行初始化?

  • vptr的设置位置,在成员的构造之前,因为这样才能保证成员构造时以及用户代码中调用的虚函数是本类的版本。
  • 同时其在各种基类的构造之后,这样才能保证基类构造时使用基类自己的虚函数版本,基类是在其自己的构造函数中设置的vptr的。
  • vptr在构造过程中经过了多次的改变,从指向基类的vtable一直沿继承链向下到最终派生类的vtable

默认构造函数

  • 如果类显式定义了拷贝构造函数、拷贝赋值运算符或析构函数中的任何一个,编译器不会自动生成默认的移动构造函数和移动赋值运算符。
  • 如果定义了拷贝赋值运算符,仍然会生成默认的拷贝构造函数;如果定义了拷贝构造函数,仍然会生成默认的拷贝赋值运算符;
函数 生成默认版本的条件 C++标准
默认构造函数 用户未定义任何构造函数 C++03
析构函数 用户未定义析构函数 C++03
拷贝构造函数 用户未定义拷贝构造函数、移动构造函数、移动赋值运算符或析构函数 C++03
拷贝赋值运算符 用户未定义拷贝赋值运算符、移动构造函数、移动赋值运算符或析构函数 C++03
移动构造函数 用户未定义拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符或析构函数 C++11
移动赋值运算符 用户未定义拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符或析构函数 C++11

深拷贝与浅拷贝

浅拷贝(Shallow Copy)

  • 行为:直接复制对象的成员值(包括指针的地址),但不复制指针指向的实际内存
  • 结果:两个对象的指针成员指向同一块内存地址,修改其中一个对象会影响另一个对象,且可能导致重复释放内存(崩溃)。
  • 默认生成的拷贝操作:如果类未显式定义拷贝构造函数或拷贝赋值运算符,编译器会生成浅拷贝版本。

示例:

class Shallow {
public:
int* data;
Shallow(int val) { data = new int(val); }
// 默认拷贝构造函数和拷贝赋值运算符是浅拷贝
~Shallow() { delete data; }
};

int main() {
Shallow obj1(10);
Shallow obj2 = obj1; // 浅拷贝:obj2.data 指向 obj1.data 的地址
*obj1.data = 20; // 修改 obj1.data 会影响 obj2.data
return 0; // 析构时,同一块内存被 delete 两次 → 崩溃!
}

深拷贝(Deep Copy)

  • 行为:不仅复制对象的成员值,还为指针成员重新分配内存,并复制指针指向的完整内容。
  • 结果:两个对象的指针成员指向不同的内存地址,彼此独立,修改互不影响。
  • 必须显式实现:如果类中有动态分配的资源,需手动定义拷贝构造函数和拷贝赋值运算符。

示例:

class Deep {
public:
int* data;
Deep(int val) { data = new int(val); }
// 显式定义深拷贝构造函数
Deep(const Deep& other) {
data = new int(*other.data); // 重新分配内存并复制值
}
// 显式定义深拷贝赋值运算符
Deep& operator=(const Deep& other) {
if (this != &other) {
delete data; // 释放原有内存
data = new int(*other.data); // 重新分配并复制
}
return *this;
}
~Deep() { delete data; }
};

int main() {
Deep obj1(10);
Deep obj2 = obj1; // 深拷贝:obj2.data 指向新内存
*obj1.data = 20; // obj2.data 的值仍为 10
return 0; // 析构时各自释放自己的内存 → 无问题
}

总结

  • 默认拷贝构造是浅拷贝,只进行值的复制,包括指针值,并不重新分配内存,只适用于不含动态资源的类
  • 深拷贝不仅进行值得复制,还会进行动态资源内存的重新分配,不会出现同一内存重复释放。

const

  • const常量在定义时必须初始化,之后无法更改
  • const形参可以接受const和非const类型的实参
  • const成员变量只能在构造函数初始化列表进行初始化,不能在类声明时初始化因为不同对象的const成员值可以不同
class MyClass {
public:
const int value;
// 通过构造函数初始化列表初始化 const 成员变量
MyClass(int v) : value(v) {
}
};

int main() {
MyClass obj(10);
return 0;
}
  • const对象只能调用const成员函数,另外const成员函数只能修改mutable修饰的变量
  • int constconst int两种写法是等价的(推荐写法是const int),都表示一个常量整型,因此书写指针常量时可以有两种写法int const* p 或者 const int* p
  • 指向常量的常量指针int const* const pconst int* const p

顶层const和底层const

  • 顶层const修饰的变量本身是一个常量,例如常量指针;
  • 底层const指修饰的变量所指对象是一个常量,例如指针常量;

const函数

  • 在C++中,成员函数const是指在成员函数声明的末尾添加const关键字,这表明该成员函数不会修改对象的任何成员变量(mutable除外)。这种函数通常被称为“只读”函数,因为它们不会改变对象的状态。成员函数const的使用不仅提高了代码的可读性,还增强了程序的可靠性,因为编译器会阻止这些函数修改任何成员变量。

  • const成员函数与对象的交互

    • const对象只能调用const成员函数,因为非const成员函数可能会修改对象的状态,这与const对象的定义相矛盾。

    • 非const对象可以调用任何成员函数,无论它是否是const。这是因为非const对象没有限制,它们可以被修改。

this

  • 在普通成员函数中,this是一个指向非const对象的const指针(类型为Base,那么this就是Base* const类型的指针);

  • 在const成员函数中,this指针是一个指向const对象的const指针(类型为Base,那么this就是const Base* const类型的指针)

class Base
{
void func(float arg1);
// 相当于 void func(Base *this, float arg1);

void func(float arg1) const;
// 相当于 void func(const Base *this, float arg1);
};

struct

  • 在C++中视为一种特殊的类,默认成员是public,默认继承也是public;
  • 在C语言中是没有权限控制的,C++中有权限控制;
  • C语言中声明了一个结构体之后无法直接使用普通类型的语法创建该结构体,需要添加struct关键字。C++中可以使用普通类型的创建方式创建对象。

如何计算成员内存偏移量?

方法一:成员地址减去结构体对象地址得到内存偏移量

#include <iostream>

struct MyStruct {
int a;
double b;
char c;
};

int main() {
MyStruct s;

// 获取结构体对象的地址
uintptr_t structAddress = reinterpret_cast<uintptr_t>(&s);

// 获取结构体成员的地址
uintptr_t memberAddress = reinterpret_cast<uintptr_t>(&s.b);

// 计算偏移量
uintptr_t offset = memberAddress - structAddress;

std::cout << "Offset of b in MyStruct: " << offset << " bytes" << std::endl;

return 0;
}

方法二:C++标准库提供了 offsetof 宏,专门用于计算结构体成员的偏移量,无需创建结构体实例。

#include <iostream>
#include <cstddef> // for offsetof

struct MyStruct {
int a;
double b;
char c;
};

int main() {
std::size_t offset = offsetof(MyStruct, b);
std::cout << "Offset of b in MyStruct: " << offset << " bytes" << std::endl;

return 0;
}

参数传递结构体名称与参数名称会返回偏移量

static

用法 作用
静态局部变量 生命周期延长到程序结束,作用域限于函数内部。
静态全局变量 作用域限于当前文件,其他文件无法访问。
静态成员变量 属于类本身,所有实例共享同一个变量。
静态成员函数 属于类本身,只能访问静态成员变量和静态成员函数,可以被非静态成员函数访问。

静态成员函数

  • 静态成员函数不隐含this指针参数,这是它与非静态成员函数的关键区别,没有this指针因此无法访问成员变量,只能直接访问静态成员变量和其他静态成员函数。虽然可以通过对象调用静态成员函数,但这只是语法上的便利,实际上静态成员函数并不属于任何对象
  • 静态成员函数不能被修饰为const函数,因为关键字const是表明:函数不会修改该函数访问的目标对象的数据成员。但是静态成员函数并不单独属于任何一个对象,属于类本身。
  • 静态成员函数不能被修饰为virtual函数,因为虚函数的调用关系是this指针->vptr(4字节)->vtable ->virtual虚函数,但是静态成员函数没有this指针

宏定义

宏定义是 C/C++ 中的一种预处理指令,用于在编译之前对代码进行文本替换。宏定义通过 #define 指令实现,可以用来定义常量、简化代码、实现条件编译等功能。宏定义的本质是文本替换,它在编译之前由预处理器处理。

与const的区别

特性 宏定义 常量
类型检查 无类型检查,纯文本替换。 有类型检查,类型安全。
内存分配 无内存分配,仅替换 有内存分配,值无法更改
作用域 无作用域,全局有效(除非 #undef)。 有作用域,遵循变量作用域规则。
调试 无法直接查看宏的展开结果。 可以直接查看常量的值。
生效阶段 编译前预处理阶段生效 编译时生效
灵活性 可以定义带参数的宏。 只能是固定值。

与inline区别

内联函数是一种常用于提高程序运行效率的函数,其基本思想是在编译阶段将函数调用处用函数体替换,减少函数调用开销

特性 inline 函数 宏定义(#define
处理阶段 编译器处理(语法检查、类型安全) 预处理器处理(文本替换,无语法检查)
类型检查 支持参数和返回值的类型检查 无类型检查(可能引发隐式错误)
调试 可调试(有函数符号) 无法调试(替换后代码不可见)
作用域 遵守作用域和命名空间 全局替换(可能引发命名冲突)
安全性 无副作用(参数只计算一次) 可能因多次展开导致副作用
适用场景 替代简单函数,避免调用开销 条件编译、代码片段复用

封装

作用

封装是将对象的属性和方法隐藏起来,只通过公开的接口与外界进行交互。这样可以保护数据的安全性,防止外部直接访问和修改对象的内部状态。

好处

  • 提高代码的可维护性:内部实现细节对外部隐藏,修改内部实现不会影响外部代码。
  • 增强数据安全性:可以通过设置私有属性和方法,控制数据的访问权限
  • 简化接口:对外提供简洁的接口,方便使用

继承

作用

继承是指一个类(子类)继承另一个类(父类)的属性和方法,从而实现代码的复用。

好处

  • 代码复用:子类可以复用父类的代码,减少重复代码。
  • 逻辑层次化:通过“is-a”关系(如“狗是动物”)组织类结构,使代码更符合现实逻辑。
  • 支持多态:为多态的实现提供基础。

public / protected / private

在 C++ 中,类的继承方式有三种:public 继承protected 继承private 继承。它们的核心区别在于基类(父类)成员在派生类(子类)中的访问权限变化。

假设基类 Base 包含三种访问权限的成员:

class Base {
public:
int publicVar = 1;

void publicMethod() {
cout << "Base public method" << endl;
}

protected:
int protectedVar = 2;

void protectedMethod() {
cout << "Base protected method" << endl;
}

private:
int privateVar = 3;

void privateMethod() {
cout << "Base private method" << endl;
}
};

三种继承方式的区别

(1) public 继承

  • 语法class Derived : public Base { ... };
  • 规则
    • 基类的 public 成员 → 在派生类中保持 public
    • 基类的 protected 成员 → 在派生类中保持 protected
    • 基类的 private 成员 → 不可访问

示例

class PublicDerived : public Base {
public:
void accessCheck() {
publicVar = 10; // ✅ 允许访问(public)
protectedVar = 20; // ✅ 允许访问(protected)
// privateVar = 30; // ❌ 不可访问(private)
}
};

int main() {
PublicDerived obj;
obj.publicVar = 100; // ✅ 允许访问(public)
// obj.protectedVar = 200; // ❌ 外部不可访问(protected)
// obj.privateVar = 300; // ❌ 不可访问(private)
}

(2) protected 继承

  • 语法class Derived : protected Base { ... };
  • 规则
    • 基类的 public 成员 → 在派生类中变为 protected
    • 基类的 protected 成员 → 在派生类中保持 protected
    • 基类的 private 成员 → 不可访问

示例

class ProtectedDerived : protected Base {
public:
void accessCheck() {
publicVar = 10; // ✅ 允许访问(protected)
protectedVar = 20; // ✅ 允许访问(protected)
// privateVar = 30; // ❌ 不可访问(private)
}
};

int main() {
ProtectedDerived obj;
// obj.publicVar = 100; // ❌ 外部不可访问(protected)
// obj.protectedVar = 200;// ❌ 外部不可访问(protected)
}

(3) private 继承

  • 语法class Derived : private Base { ... };
  • 规则
    • 基类的 public 成员 → 在派生类中变为 private
    • 基类的 protected 成员 → 在派生类中变为 private
    • 基类的 private 成员 → 不可访问

示例

class PrivateDerived : private Base {
public:
void accessCheck() {
publicVar = 10; // ✅ 允许访问(private)
protectedVar = 20; // ✅ 允许访问(private)
// privateVar = 30; // ❌ 不可访问
}
};

int main() {
PrivateDerived obj;
// obj.publicVar = 100; // ❌ 外部不可访问(private)
// obj.protectedVar = 200;// ❌ 外部不可访问(private)
}

总结对比

继承方式 基类 public 成员在子类中的权限 基类 protected 成员在子类中的权限 基类 private 成员在子类中的权限
public public protected 不可访问
protected protected protected 不可访问
private private private 不可访问

关键注意事项

  1. 基类的 private 成员无论何种继承都不可访问
  2. protected 继承和 private 继承会降低基类成员的访问权限
    • 一般建议使用 public 继承(体现 “is-a” 关系)。
    • protected/private 继承 通常用于实现细节(类似组合关系)。
  3. 实际开发中
    • 若需要隐藏基类接口,优先使用组合(对象成员)而非 private 继承

final

某个类不希望被继承或者某个虚函数不希望被重写,可以使用final关键字,无法进行override或者继承

override

对父类某个虚函数进行重写时用override修饰,函数名写错时会报错提示

如果不添加 override,代码仍然可以正常运行,但会失去以下好处:

  1. 可读性:其他开发者可能不清楚 Derived::show() 是否是重写父类的函数。
  2. 安全性:如果父类的函数签名发生变化(例如参数类型改变),子类的函数不会报错,可能导致未定义行为。

多继承

多重继承的语法很简单,只需在类定义中用逗号分隔多个基类即可。例如,如果有基类A、B和C,可以这样声明派生类D:

class D : public A, private B, protected C {
// 类D新增加的成员
};

在这个例子中,D类以公有方式继承A类,以私有方式继承B类,以保护方式继承C类。这意味着D类可以访问A类的公有成员,但不能访问B类和C类的私有成员。

菱形继承问题及解决方法

菱形继承是多重继承中的一个特殊情况,其中两个派生类继承自同一个基类,然后又有一个类同时继承这两个派生类。这会导致基类的数据和方法在最终派生类中出现多次,造成资源浪费和潜在的错误。为了解决这个问题,C++提供了虚继承的概念。通过将基类声明为虚基类,可以确保在继承层次结构中只有一个基类实例。

class A {
// 基类A的成员
};
class B : virtual public A {
// 派生类B的成员
};
class C : virtual public A {
// 派生类C的成员
};
class D : public B, public C {
// 最终派生类D的成员
};

在这个例子中,B和C都虚继承自A,这样在D中就只有一个A的实例。

虚继承和虚基类

  • 抽象类是指有纯虚函数的类,而虚基类是指被虚继承的类。编译器检查,如果发生了菱形继承,但同时两个子类都是虚继承同一个父类,则在最终的子类D中只会保留一份A对象。
  • D对象的内存布局:Bvbptr + B的成员 + Cvbptr + C的成员 + D的成员 + 唯一的A成员
  • B和C的虚基类表指针指向虚基类表,记录了 虚基类 A 相对于当前类实例的偏移量,B和C通过这种方式访问A
  • 在构造 D 时,A 的构造函数由 D 直接调用(而非通过 BC),确保 A 只初始化一次

虚继承的主要影响体现在继承子类的类(D) 上,而不是直接体现在 BC 上。

单独的B或C实例,采用与普通继承一样的方式去访问A的成员和函数。

多态

作用

多态就是允许不同类的对象对同一消息或同一接口做出响应,根据对象的不同而采用不同的行为方式。

好处

  • 接口统一:用父类指针或引用调用方法,实际执行子类重写的方法。(最重要的)
  • 灵活扩展:新增子类时,无需修改使用父类接口的代码。
  • 解耦设计:调用方只需依赖抽象接口,而非具体实现,降低模块间耦合度。

原理

继承和成员对象

  • 子类Derive的内存实际就是在其父类的后面再添加上自己的内容,需要注意的是编译器在构造Derive对象时会将vptr指向Derive类的vtable而不是基类Base的,该vtable中存储的是Derive的虚函数,包括重写父类的虚函数,也包括此类新添加的虚函数(如unc3)

  • 继承并不会添加新的vptr项,而是复用父类的vptr。但如果父类没有虚函数(即没有vptr)则会在该有虚函数的子类后面加vptr项。

  • 注意到的基类Base和子类Derive的初始地址相同,这也是为什么可以使用基类指针指向子类。

Derive d;

Base *p_b = &d;
p_b->func2(1.0); // 编译器将其转为 (*p_b->vptr[1])(p_b, 1.0);
  • 此例中将Derive对象d赋值给Base类指针p_b,编译器使用该指针时就当作其是一个Base对象,只能访问Base的成员。这没关系,因为Derive对象的内存中前面本来就是一个完整Base对象,各种偏移也和真正的Base对象保持一样,因此这样访问是没问题的。只是无法访问Derive特有的成员而已。(注意到这里只适用于单继承,多继承时编译器还需要调整指针位置来保证此特性)

  • 虽然编译器是很无脑的看到Base指针就认为是Base对象,但这里却可以实现多态特性,即调用虚函数时会调用Derive类的版本。前面说到在构造Derive对象时,会将其vptr指向Derivevtable,现在虽然改为使用Base指针来访问了,但是其vptr依然存的时Derivevtable的地址。当调用func2时,编译器还是无脑的转换成(*p_b->vptr[1])(p_b, 1.0),这里从vptr便会取出Derive的虚函数版本。

  • 这就是C++多态的实现原理。归根结底是虽然改变了指针的类型为基类指针,改变了对这块内存的解释方式,但是并没有改变这块内存的内容,而因此vptr的得以保留其子类的vtable地址进而调用子类的函数。

virtual

  • 在子类中,重写的函数不需要再加 virtual 关键字,父类的 virtual 属性会自动继承。即使子类不加 virtual,函数仍然是虚函数。
  • virtual 关键字的作用:

示例代码

#include <iostream>
using namespace std;

class Base {
public:
virtual void show() { // 使用 virtual 关键字
cout << "Base class show()" << endl;
}
};

class Derived : public Base {
public:
void show() { // 重写父类的 show() 函数
cout << "Derived class show()" << endl;
}
};

int main() {
Base* basePtr = new Derived(); // 基类指针指向子类对象
basePtr->show(); // 调用子类的 show() 函数
delete basePtr;
return 0;
}

输出

"Derived class show()"

解释

  • virtual 关键字使得 show() 函数成为 虚函数
  • 当基类指针指向子类对象时,调用 show() 会执行子类的实现(动态绑定)。
  • 如果没有 virtualbasePtr->show() 会调用基类的 show() 函数(静态绑定)。

初始化与赋值

  • 如果等号 = 出现在对象声明中,则是初始化,调用拷贝构造函数(不是拷贝赋值运算符)。
  • 如果等号 = 出现在对象已经声明后的语句中,则是赋值操作,调用赋值运算符函数。

volatile

英文翻译是不稳定的,易变的。volatile 关键字用于告诉编译器,某个变量的值可能会在程序之外被修改(例如硬件寄存器或多线程共享变量),因此编译器不应优化对该变量的访问。系统总是重新从该变量所在的内存中读取它,而不是读取某个寄存器中对这个变量的备份。

explicit

只用于修饰类的构造函数,被修饰过的类不能进行隐式的类型转换

emplace和push

  • push方法需要先构造好一个对象,然后将这个对象复制或移动到容器中。
  • emplace方法则是直接在容器内部构造对象,避免了不必要的复制或移动操作。

++i和i++

  • ++i的效率更高,因为i++需要创建临时对象,原对象的值进行修改后,返回临时对象的值;++i直接修改原对象的值并返回引用

  • i++需要调用两次拷贝构造函数与析构函数(将原对象赋给临时对象调用一次,临时对象以值传递方式返回调用一次)

  • ++i可以作为左值,i++不能作为左值

new/delete和malloc/free

new

new操作符的底层行为分为两个阶段:

  • 调用operator new分配内存,内存大小通过sizeof获取
  • 调用构造函数初始化对象

operator new核心功能包括内存分配和异常处理,内存分配基于malloc实现

delete

delete操作符的执行流程与new相反:

  • 调用析构函数清理资源
  • 调用operator delete释放内存

operator delete核心功能包括内存释放和异常处理,内存释放基于free实现

operator newoperator delete的可重载性:可以全局重载,也可以类专属重载

差异与共同点

类型安全

  • new 返回与对象类型匹配的指针(如 int*),无需类型转换;
  • malloc 返回 void*,需强制类型转换,可能导致类型错误

初始化和析构

  • new 在分配内存后自动调用构造函数delete 在释放内存前调用析构函数
  • malloc/free 仅分配和释放内存,不涉及对象的构造与析构

内存大小计算

  • new/delete 根据类型自动计算所需内存(如 new int 分配 sizeof(int));
  • malloc/free 需手动指定字节数(如 malloc(sizeof(int))

分配失败行为

  • new 失败时抛出 std::bad_alloc 异常,需通过 try-catch 处理;
  • malloc 失败时返回 NULL,需显式检查返回值

delete和delete[ ]

  • delete用于释放通过 new分配的单个对象的内存。
  • delete[]用于释放通过 new[] 分配的对象数组的内存。
  • delete[] 会调用数组中 每个元素的析构函数,而 delete 只会调用 第一个元素的析构函数,导致其他对象的资源泄漏。

三种new

  1. plain new即普通的new

  2. nothrow new,空间分配失败时不抛出异常而返回空指针的new

  3. placement new,在已分配的指定内存上重新构造对象或者对象数组

    placement new不能简单使用delete进行销毁,因为指定的内存空间可能比构造对象更大

STL

vector

vector的底层实现是一个动态数组,它通过连续的内存块存储元素。当元素数量超过当前容量时,vector会自动分配更大的内存块,并将原有元素复制到新内存中

扩容策略

当前容量不足以容纳新元素时,vector会申请一块大小为当前容量两倍的新内存,然后将原有元素复制到新内存中,最后释放旧内存 。

为了防止申请内存的浪费,现在使用较多的有2倍与1.5倍的增长方式,而1.5倍的增长方式可以更好的实现对内存的重复利用。

与list的区别

  • vector是基于数组的动态数组,支持快速随机访问,但在中间插入或删除元素时效率较低O(n),因为需要移动后续元素。内存连续,缓存友好,动态扩容。
  • list是基于双向链表的容器,插入和删除效率高,但随机访问效率低O(n),因为需要遍历链表。内存不连续,缓存不友好,自行控制扩容。

迭代器失效

vector进行扩容或插入/删除操作时,原有的迭代器可能会失效,因为它们指向的内存可能已经被重新分配或移动

reserve和resize

  • reserve只改变vector的容量(capacity),不改变其大小(size)。reserve的空间大小比原空间小不做任何操作。
  • resize会改变vector的大小(size),并可能初始化新元素。resize的空间大小比原空间小会释放空间。

deque

deque是一种双向开口的连续线性空间,允许在头部和尾部进行O(1)复杂度的插入和删除操作。deque的存储空间由多段等长的连续空间(称为缓冲区)组成,这些缓冲区在内存中并不一定是连续的。为了管理这些缓冲区,deque使用一个称为map的中央控制器。map是一小块连续空间,其中每个元素都是指针,指向一个缓冲区 。结构如图所示:

img

迭代器

deque的迭代器设计复杂,主要任务是维护“整体连续”的假象,并支持随机访问。迭代器包含以下四个指针:

  • **cur**:指向当前元素。
  • **first**:指向当前缓冲区的首地址。
  • **last**:指向当前缓冲区的尾地址。
  • **node**:指向map中当前缓冲区的位置。

随机访问deque通过计算元素在map中的位置和缓冲区中的偏移量来实现随机访问。例如,查找第n个元素时,首先确定它在map中的第几个节点,然后确定在缓冲区中的具体位置

插入/删除

在头部或尾部插入元素时,deque会检查当前缓冲区是否有足够空间。如果空间不足,会申请新的缓冲区并链接到map中。

  • 头部插入deque会在头部缓冲区的当前位置(即cur指针指向的位置)插入新元素,并将cur指针向first指针移动/靠近。如果头部缓冲区已满,会先申请新的缓冲区,再在新缓冲区的末尾位置插入元素。

  • 尾部插入deque会在尾部缓冲区的末尾位置(即cur指针指向的位置)插入新元素,并将cur指针向last指针移动/靠近。如果头部缓冲区已满,会先申请新的缓冲区,再在新缓冲区的起始位置插入元素。

[!Note]

初始化deque的第一块缓冲区时, _M_start 迭代器与_M_finish迭代器均指向这一块缓冲区,迭代器的cur指向缓冲区的中间位置,执行push_front时在_M_start迭代器的cur位置插入元素并将cur_M_start迭代器的first靠近;执行push_back时在_M_finish迭代器的cur位置插入元素并将cur_M_finish迭代器的last靠近

queue/stack

准确来说不是容器,是适配器(参考适配器模式)。源码封装了一个_Sequence,是一个模板参数,表示底层容器类型,默认是deque

  • queuepush()封装了push_back()pop()封装了pop_front()
  • stackpush()封装了push_back()pop()封装了pop_back()

迭代器

定义

迭代器是C++标准模板库(STL)中用于统一访问容器元素的抽象机制。它通过重载指针操作(如++*->等),为不同容器(如vectorlistmap)提供一致的遍历接口,同时将算法(如sortfind)与容器实现解耦。

作用 / 好处

  • 统一访问方式:无论容器是数组(vector)还是链表(list),迭代器隐藏底层数据结构差异,提供begin()end()接口
  • 算法与容器解耦:例如std::find可通过迭代器操作任意容器,无需关心其内部实现
  • 支持泛型编程:迭代器是模板编程的核心,允许编写与容器无关的代码

分类

  1. 输入迭代器(Input Iterator):只读且单向遍历(如istream_iterator),支持++*操作
  2. 输出迭代器(Output Iterator):只写且单向遍历(如ostream_iterator),支持++和赋值操作
  3. 前向迭代器(Forward Iterator):可读写且单向遍历(如单向链表的迭代器),支持重复访问
  4. 双向迭代器(Bidirectional Iterator):可双向移动(如listmap的迭代器),支持++--
  5. 随机访问迭代器(Random Access Iterator):支持任意步长跳跃(如vectordeque的迭代器),允许+n-n及下标访问[index]

实现原理

迭代器本质是类模板,内部封装了指向容器元素的指针或句柄,并通过重载运算符实现类似指针的行为:

  • 操作符重载:如operator*()返回元素引用,operator++()移动指针
  • 类型别名:STL迭代器通过typedef定义value_typeiterator_category等类型,供算法识别其特性。迭代器本身在容器类内部实现,通常是私有的,使用public别名iterator提供给外部访问

元素删除/迭代器失效

  • 顺序容器删除了一个迭代器,该迭代器以及之后的所有迭代器均失效,因此不能使用erase(it++)的方式进行迭代器删除,但是erase(it)会返回下一个有效的迭代器,可以基于此进行遍历元素删除。
  • 关联容器删除迭代器只有被删除的迭代器会失效,返回值是void,可以采用erase(it++)的方式进行迭代器删除

C++11新特性

智能指针

智能指针是C++11引入的一种解决内存管理问题的方式,它可以自动释放指向的对象,避免内存泄漏。

RAII自动释放原理

智能指针类内部包含一个指向动态分配内存的指针。在构造函数中获取资源(如通过new分配内存),在析构函数中释放资源(如通过delete释放内存)。当智能指针对象超出其作用域时(如函数返回或局部变量被销毁),栈内存的管理是自动的,由编译器负责,编译器会自动调用其析构函数。

unique_ptr

如何保证所有权唯一?

通过删除拷贝构造与拷贝赋值

unique_ptr(const unique_ptr&) = delete;         // 禁用拷贝构造
unique_ptr& operator=(const unique_ptr&) = delete; // 禁用拷贝赋值

shared_ptr

循环引用问题

示例

#include <iostream>
using namespace std;

template <typename T>
class Node
{
public:
Node(const T& value)
:_pPre(NULL)
, _pNext(NULL)
, _value(value)
{
cout << "Node()" << endl;
}
~Node()
{
cout << "~Node()" << endl;
cout << "this:" << this << endl;
}

shared_ptr<Node<T>> _pPre;
shared_ptr<Node<T>> _pNext;
T _value;
};

void Funtest()
{
shared_ptr<Node<int>> sp1(new Node<int>(1));
shared_ptr<Node<int>> sp2(new Node<int>(2));

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;

sp1->_pNext = sp2; //sp2的引用+1
sp2->_pPre = sp1; //sp1的引用+1

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;
}
int main()
{
Funtest();
system("pause");
return 0;
}
//输出结果
//Node()
//Node()
//sp1.use_count:1
//sp2.use_count:1
//sp1.use_count:2
//sp2.use_count:2

  • sp1sp2 的生命周期结束,Node(1)Node(2) 的引用计数各减 1。

  • 最终状态

    • sp1sp2 被析构了,但是Node(1)Node(2) 的内存没能被释放,因为指向Node(1)Node(2) 的智能指针引用计数不为0,当use_count归0时,shared_ptr管理的对象会被销毁,其占用的内存也会被释放。
    • Node(1) 的引用计数 = 1(因为 Node(2)_pPre 指向它)。
    • Node(2) 的引用计数 = 1(因为 Node(1)_pNext 指向它)。
  • 引用计数无法归零 → 内存泄漏。

  • sp1sp2 管理的是不同对象

    • sp1 管理 Node(1)sp2 管理 Node(2)
    • sp2 析构只会减少 Node(2) 的引用计数,与 Node(1) 无关。

解决方案

使用 weak_ptr(弱引用)替代其中一个 shared_ptrweak_ptr 不会增加引用计数,从而打破循环依赖。

template <typename T>
class Node {
public:
Node(const T& value) : _value(value) {
cout << "Node()" << endl;
}
~Node() {
cout << "~Node()" << endl;
}

shared_ptr<Node<T>> _pNext;
weak_ptr<Node<T>> _pPre; // 将其中一个改为 weak_ptr
T _value;
};

void Funtest() {
shared_ptr<Node<int>> sp1(new Node<int>(1));
shared_ptr<Node<int>> sp2(new Node<int>(2));

sp1->_pNext = sp2; // Node(2) 引用计数 +1 → 2
sp2->_pPre = sp1; // Node(1) 引用计数不变 → 1

cout << "sp1.use_count:" << sp1.use_count() << endl; // 输出 1
cout << "sp2.use_count:" << sp2.use_count() << endl; // 输出 2
}

int main() {
Funtest();
// 函数结束时:
// 1. sp2 析构 → Node(2) 引用计数 -1 → 1
// 2. sp1 析构 → Node(1) 引用计数 -1 → 0 → 调用 ~Node(1)
// 3. Node(1) 析构时,其 _pNext(shared_ptr)析构 → Node(2) 引用计数 -1 → 0 → 调用 ~Node(2)
return 0;
}

// 输出
Node()
Node()
sp1.use_count:1
sp2.use_count:2
~Node()
~Node()
  1. **将 _pPre 改为 weak_ptr**:
    • weak_ptr 不会增加 Node(1) 的引用计数。
    • Node(1) 的引用计数始终为 1(仅由 sp1 持有)。
  2. 析构顺序
    • sp1 析构 → Node(1) 引用计数归零 → 调用 ~Node(1)
    • Node(1) 析构时,其 _pNextshared_ptr<Node(2)>)析构 → Node(2) 引用计数减 1 → 归零 → 调用 ~Node(2)

weak_ptr

依赖关联的 shared_ptr,指向 shared_ptr 管理的对象,但不增加引用计数。用于解决循环引用问题

如何判断指向对象是否存活?

weak_ptr通过其内部数据结构与shared_ptr的控制块(Control Block)协同工作,判断指向对象是否存活。

  1. 通过expired()方法:

    • 原理:检查控制块的强引用计数(_Uses)是否为0。若为0,表示所有shared_ptr已释放,对象被销毁。
  2. 通过lock()方法

    • 原理:尝试将weak_ptr提升为shared_ptr,若提升成功则对象存活。

控制块的销毁时机

  • 对象销毁:当强引用计数(_Uses)减为0时,调用_Destroy()销毁被管理对象。
  • 控制块销毁:当弱引用计数(_Weaks)也减为0时,调用_Delete_this()销毁控制块自身

是否存在shared_ptr引用计数归零,内存已释放,weak_ptr仍指向该内存导致内存泄漏的情况?

不存在,weak_ptr在STL的设计中无法直接访问内存,需要先通过lock()函数提升为shared_ptr才能访问该内存,lock()函数在强引用计数为0时会返回空的shared_ptr,即无法将weak_ptr提升为shared_ptr因此不会导致内存泄漏。

std::weak_ptr<int> weak;
{
std::shared_ptr<int> shared = std::make_shared<int>(42);
weak = shared;
} // shared 离开作用域,对象被销毁

if (auto shared = weak.lock()) {
std::cout << *shared << std::endl; // 不会执行,因为对象已销毁
} else {
std::cout << "对象已销毁" << std::endl; // 输出:对象已销毁
}

强制转换(cast)

dynamic_cast

dynamic_cast 是一种用于处理继承体系中类型安全向下转型(downcasting)和交叉转型(cross-casting)的操作符。它依赖于运行时类型信息(RTTI, Run-Time Type Information),能够在运行时检查对象类型是否与目标类型兼容,从而确保转换的安全性。

  1. 向下转型:将基类指针/引用转换为派生类指针/引用。
  2. 交叉转型:在多重继承中,将指向一个基类的指针/引用转换为另一个基类的指针/引用。

实现原理

dynamic_cast 的核心机制依赖于 RTTI虚函数表(vtable)

  1. RTTI 和虚函数表
  • RTTI 结构:每个多态类型(至少有一个虚函数的类)的虚函数表中会包含一个指向其 type_info 对象的指针。type_info 存储了类的名称和继承关系信息。
  • 运行时类型检查:当执行 dynamic_cast 时,编译器生成的代码会通过对象的 type_info 检查类型兼容性。
  1. 类型兼容性检查
  • 单继承:直接比较目标类型的 type_info 是否与对象的实际类型(基类指针指向派生类,将基类指针转换为派生类指针)或其基类一致(将基类指针转换为另一个基类指针)。
  • 多重继承/虚继承:需要遍历继承树,检查目标类型是否在继承路径中。如果存在多个基类子对象,可能需要调整指针偏移量。

RTTI应用场景

  1. 运行时类型检查:通过 typeid 判断对象的实际类型,执行不同的逻辑
  2. 安全类型转换:使用 dynamic_cast 在多态场景下安全地转换类型

向下转型行为

如果检查通过,返回有效的派生类指针/引用;否则返回 nullptr(指针)或抛出 std::bad_cast 异常(引用)。

代码示例

  1. 安全的向下转型
class Base { virtual ~Base() {} };
class Derived : public Base {};

Base* base_ptr = new Derived;
Derived* derived_ptr = dynamic_cast<Derived*>(base_ptr); // 安全转换

目标类型是Derived,实际类型也是Derived,因此转换成功

  1. 处理多重继承中的交叉转型
Base1* base1_ptr = new Derived;
Base2* base2_ptr = dynamic_cast<Base2*>(base1_ptr);
  • base1_ptr 指向 Derived 对象中的 Base1 子对象。
  • dynamic_cast 需要将指针从 Base1 子对象的位置调整到 Base2 子对象的起始位置。
  • 这种调整是通过 RTTI 中的偏移信息实现的。

static_cast

static_cast 是一种显式类型转换运算符,用于在编译时进行类型转换。它适用于相关类型之间的转换,依赖编译器的静态类型检查,不涉及运行时类型信息(RTTI)。

应用场景

  • 基本数据类型转换(如 intdoublefloatint)。
  • 类层次结构中的 上行转换(派生类指针/引用 → 基类指针/引用)。
  • 用户自定义类型转换(通过转换构造函数或类型转换运算符)。
  • void* 转换为具体类型的指针。
  • 枚举类型与整数类型之间的转换。

实现原理

  • static_cast 在编译时完成所有类型检查,不涉及运行时开销。
  • 编译器会验证源类型(source)和目标类型(TargetType)是否具有隐式转换关系明确的转换路径

向下转换(downcasting)时,dynamic_caststatic_cast 编译都能正常通过:

  • dynamic_cast 编译器只会检查源类型和目标类型是不是多态类型(至少有一个虚函数),运行失败会返回nullptr或者抛出异常
  • static_cast编译器只会检查源类型和目标类型之间存在某种转换关系(如继承关系)。

const_cast

const_cast是用于移除对象的constvolatile限定符的类型转换运算符。它主要用于指针和引用的类型转换,允许修改原本被声明为const的变量。这种转换通常在需要对const对象进行写操作时使用,但必须非常小心,因为修改const对象是未定义行为,可能会导致程序错误。

原理

  1. 编译时操作
  • const_cast 是一种编译时操作,不涉及运行时开销。
  • 它仅仅修改编译器对类型的解释方式,而不改变对象的底层二进制表示。
  1. 类型检查
  • 编译器会检查源类型(source)和目标类型(TargetType)是否除了 constvolatile 限定符外完全相同。
  • 如果类型不匹配(例如 int* 转换为 double*),编译器会报错。
  1. 底层实现
  • const_cast 不会生成额外的机器指令。
  • 它只是告诉编译器:“忽略 constvolatile 限定符,将指针/引用视为目标类型”。

注意事项

  • const_cast不能用来修改声明为常量的数据,会产生未定义行为。但是可以用来移除指针常量和引用常量的const,用来修改非常量的值。即只能作用于底层const,不能作用于顶层const。
  • 当我们调用第三方库和一些API时,它们需要使用非const形式的数据,但我们只有const形式数据时候才能使用const_cast

reinterpret_cast

  • reinterpret_cast 是 C++ 中的一个类型转换运算符,它用于在不同类型之间进行低级转换,通常是为了对数据的二进制表示进行重新解释。这种转换不会改变原始数据的比特位,但会改变数据的类型。
  • 通常可用于将地址转化为整数类型,计算地址偏移量(不做类型检查,只检查变量大小匹配)。

原理

  • reinterpret_cast 是一种编译时操作,不涉及运行时开销。
  • 它仅仅告诉编译器:“将源类型的内存表示重新解释为目标类型”
  • reinterpret_cast 几乎不进行类型检查。只要源类型和目标类型的大小兼容,转换就是合法的。

模板元编程

类型萃取(TODO)

类型萃取(Type Traits) 是C++模板元编程的核心技术,用于在编译时提取或操作类型的特性(如是否可拷贝、是否有特定成员函数等)。而 迭代器萃取(Iterator Traits) 是类型萃取的一种具体应用,专门用于获取迭代器的相关类型信息(如元素类型、迭代器类别等),以支持泛型算法的统一操作

作用

  • 检查类型属性:例如判断类型是否为指针、是否可移动构造等。
  • 修改类型属性:例如移除引用(remove_reference<T>)、添加常量(add_const<T>)等。
  • 类型关系判断:例如判断两个类型是否相同(is_same<T, U>

右值引用

左右值定义

  • C++03标准,把具有标识(identity)的表达式规定为左值,不具有标识的表达式规定为右值。因而,名字、指针、引用等是左值,是命名对象,具有确定的内存地址;字面量、临时对象等为右值,右值仅在创建它的表达式中可以被访问。
  • C++11标准分成左值(lvalue),将亡值(xvalue)和纯右值(prvalue),将右值拆分成将亡值和纯右值。在C++11,对于值的分类,要考虑标识(identity)与可移动性(movability)。
    • 左值lvalue:可以用取地址运算符&获取地址的表达式。也可定义为非临时对象或非成员函数。具有标识,但不可移动。
    • 将亡值(xvalue):具有标识,并且可以移动。对应的对象接近生存期结束,但其内容尚未被移走。例如:函数返回的右值引用,static_cast<T&&>进行的左值到右值引用的转换。
    • 纯右值prvalue:不具有标识,但可以移动。对应临时对象或不对应任何对象的值。

[!Note]

具有标识的右值引用被定义为左值

定义

右值引用是C++11引入的一种新的引用类型,用于引用右值。右值引用使用&&符号声明,可以绑定到右值。右值引用的主要目的是支持移动语义,这是一种资源管理技术,允许资源从临时对象转移到另一个对象,而不是进行复制。

介绍一下右值引用

在C++中值通常被分为左值和右值,左值是具有标识并且可以用取地址运算符获取地址的表达式或变量,右值通常是字面量、临时对象等不可重复使用的表达式。右值引用是C++11引入的一种新的引用类型,用于匹配右值,主要是为了解决资源管理效率和性能优化的问题。右值引用允许我们标记某些对象为“可以移动”,而不是只能拷贝。比如,在处理一些临时对象或者即将销毁的对象时,我们可以通过右值引用直接“转移”它们的资源,而不是复制一份,这样可以避免不必要的开销。

完美转发

完美转发的实现依赖于万能引用+引用折叠+std::forward,协同实现完美转发功能

万能引用(Universal Reference)

定义: 万能引用是指模板函数中形如 T&& 的参数,它既可以绑定到左值,也可以绑定到右值。其核心特性是能够根据传入参数的类型自动推导出 T 的类型,从而决定 T&& 是左值引用还是右值引用。

原理

  • 当传入左值时,T 被推导为 T&,根据引用折叠规则,T& && 折叠为 T&,即左值引用。
  • 当传入右值时,T 被推导为 T&&T&& && 折叠为 T&&,即右值引用。

示例

template<typename T>
void func(T&& t) {
// t 可以是左值引用或右值引用
}

int main() {
int x = 10;
func(x); // x 是左值,T 推导为 int&
func(10); // 10 是右值,T 推导为 int&&
}

引用折叠(Reference Collapsing)

定义: 引用折叠是C++中的一种规则,用于处理“引用的引用”情况。当模板参数推导或类型别名中产生引用的引用时,编译器会根据规则将其折叠为单一引用。

规则

  • T& &T&
  • T& &&T&
  • T&& &T&
  • T&& &&T&&

原理: 引用折叠是万能引用的基础。通过引用折叠,T&& 可以根据传入参数的类型推导出正确的引用类型。

示例

template<typename T>
void func(T&& t) {
// 根据传入参数类型,T&& 折叠为 T& 或 T&&
}

int main() {
int x = 10;
func(x); // T 推导为 int&,int& && 折叠为 int&
func(10); // T 推导为 int&&,int&& && 折叠为 int&&
}

std::forward

定义: 完美转发是指在函数模板中将参数以原始的左值或右值属性传递给另一个函数。其目的是在多层函数调用中保持参数的左值或右值特性。

原理: 完美转发依赖于万能引用和 std::forward 函数。std::forward 根据模板参数 T 的类型决定是否保留参数的左值或右值属性:

  • 如果 T 是左值引用,std::forward 返回左值引用。
  • 如果 T 是右值引用,std::forward 返回右值引用。

示例

template<typename T>
void wrapper(T&& t) {
// 将参数 t 完美转发给 func
func(std::forward<T>(t));
}

void func(int& x) { std::cout << "Lvalue\n"; }
void func(int&& x) { std::cout << "Rvalue\n"; }

int main() {
int x = 10;
wrapper(x); // 调用 func(int&)
wrapper(10); // 调用 func(int&&)
}

实现机制std::forward 的实现如下:

template<typename _Tp>
_GLIBCXX_NODISCARD
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

通过 static_caststd::forward 将参数 t 转换为正确的左值或右值引用类型

三者的关系

  • 万能引用:通过 T&& 和引用折叠规则,可以同时绑定左值和右值。
  • 引用折叠:是万能引用的基础,用于处理引用的引用情况。
  • 完美转发:依赖万能引用和 std::forward,确保参数在传递过程中保持其原始的左值或右值属性。

通过这三者的结合,C++11实现了高效的参数传递机制,避免了不必要的拷贝和移动操作

完美转发解决的问题

#include <iostream>
#include <utility>

void process(int& x) {
std::cout << "Lvalue: " << x << std::endl;
}

void process(int&& x) {
std::cout << "Rvalue: " << x << std::endl;
}

// 没有完美转发的包装函数
template<typename T>
void wrapper(T&& t) {
process(t); // 直接传递 t,丢失了右值属性
}

int main() {
int x = 10;
wrapper(x); // 传入左值,调用 process(int&)
wrapper(10); // 传入右值,调用 process(int&),而不是 process(int&&)
}

上述示例中,由于变量t在wrapper函数中是一个具名变量,因此尽管传入右值,类型是int&&,但是值类别是左值,函数重载会绑定到void process(int& x)。基于此,得出一个结论:函数重载的选择值类别匹配优先级高于类型匹配

使用完美转发可以解决上述问题:

#include <iostream>
#include <utility>

void process(int& x) {
std::cout << "Lvalue: " << x << std::endl;
}

void process(int&& x) {
std::cout << "Rvalue: " << x << std::endl;
}

template<typename T>
void wrapper(T&& t) {
process(std::forward<T>(t)); // 完美转发
}

int main() {
int x = 10;
wrapper(x); // 传入左值,调用 process(int&)
wrapper(10); // 传入右值,调用 process(int&&)
}

// 输出
// Lvalue: 10
// Rvalue: 10

移动构造函数

移动构造函数是 C++11 引入的一种特殊构造函数,用于将资源(如动态内存、文件句柄等)从一个对象“移动”到另一个对象,而不是复制。它通过右值引用&&)实现,通常用于优化性能。

示例

#include <iostream>
#include <cstring>

class MyString {
private:
char* data;
public:
// 普通构造函数
MyString(const char* str = "") {
data = new char[strlen(str) + 1];
strcpy(data, str);
}

// 移动构造函数
MyString(MyString&& other) noexcept {
data = other.data; // 接管资源
other.data = nullptr; // 将原对象的资源置为空
}

// 析构函数
~MyString() {
delete[] data;
}

void print() const {
std::cout << data << std::endl;
}
};

int main() {
MyString str1("Hello");
MyString str2 = std::move(str1); // 调用移动构造函数

str1.print(); // 输出空(资源已被移动)
str2.print(); // 输出 "Hello"

return 0;
}

使用std::move()调用移动构造函数

关键点

  1. 右值引用

    移动构造函数使用右值引用(&&)作为参数,表示可以“窃取”临时对象的资源。

  2. 资源移动

    移动构造函数将资源从源对象(other)移动到当前对象,避免不必要的复制。

  3. 性能优化

    移动构造函数通常用于管理动态内存、文件句柄等资源,避免深拷贝的开销。

  4. **noexcept**:

    移动构造函数通常标记为 noexcept,表示不会抛出异常,以便在标准库中优化性能。

std::move

template<typename _Tp>
_GLIBCXX_NODISCARD
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

参数接受一个万能引用,使用std::remove_reference去除引用,使得最终参数类型变成T &&类型的右值引用,从而保证可以被移动

移动过后的对象生命周期在什么时候结束?

资源被转移但是生命周期不发生改变,仍然在它原本的作用域结束时结束。

委托构造函数

委托构造函数是 C++11 引入的特性,允许一个构造函数调用同一个类中的另一个构造函数,从而避免代码重复。

示例

#include <iostream>

class MyClass {
private:
int a;
double b;
public:
// 主构造函数
MyClass(int x, double y) : a(x), b(y) {
std::cout << "Primary constructor" << std::endl;
}

// 委托构造函数
MyClass(int x) : MyClass(x, 0.0) { // 委托给主构造函数
std::cout << "Delegating constructor" << std::endl;
}

void print() const {
std::cout << "a = " << a << ", b = " << b << std::endl;
}
};

int main() {
MyClass obj1(10, 3.14);
MyClass obj2(20); // 调用委托构造函数

obj1.print(); // 输出 "a = 10, b = 3.14"
obj2.print(); // 输出 "a = 20, b = 0"

return 0;
}

关键点

  1. 代码重用

    委托构造函数可以调用同一个类中的其他构造函数,避免重复代码。

  2. 初始化顺序

    委托构造函数会先调用被委托的构造函数,然后再执行自己的函数体。

  3. 适用场景

    当一个类有多个构造函数,且某些构造函数的逻辑可以复用时,可以使用委托构造函数。

Lambda表达式(匿名函数对象)

在C++中,匿名函数通常指的是Lambda表达式,它是C++11标准中引入的一种功能,允许我们定义和使用没有具体名称的函数对象。Lambda表达式的主要用途是简化代码,特别是在需要使用简短函数但不想单独定义一个函数时。Lambda表达式也使得代码更加紧凑和可读,因为它允许我们直接在代码中定义一个函数的行为,而不是在别处。Lambda表达式的基本语法如下:

[capture](parameters) mutable -> return_type { body }

其中:

  • capture 是捕获列表,用于指定Lambda表达式可以访问的外部变量,以及是否通过值或引用来捕获它们。
  • parameters 是参数列表,与普通函数的参数列表类似。
  • mutable 关键字用于指定Lambda表达式可以修改通过值捕获的变量。
  • return_type 是返回类型,如果Lambda表达式的返回类型可以自动推断,则可以省略。
  • body 是Lambda表达式的函数体,包含了表达式的执行代码。

Lambda表达式只能捕获父作用域的局部变量或形参,不能捕获全局变量或静态变量。捕获全局变量时编译器提示:

'var' cannot be captured because it does not have automatic storage duration

捕获方式可以是值捕获(通过值传递),也可以是引用捕获(通过引用传递)。例如:

int x = 10;

auto lambda = [x]() mutable { return x + 1; }; // 值捕获

auto lambda_ref = [&x]() { return x + 1; }; // 引用捕获
  • 在上述例子中,第一个Lambda表达式通过值捕获变量x,而第二个通过引用捕获x。值得注意的是,如果Lambda表达式被声明为mutable,那么即使是通过值捕获,Lambda表达式也可以修改捕获的变量(但不会影响外部变量本身,引用捕获会影响)。
  • 这里的lambda只是Lambda表达式的别名,它依然是一个匿名类,由编译器进行转换

实现原理

编译器实现 lambda 表达式大致分为以下几个步骤:

  1. 创建 lambda匿名类,实现构造函数,使用 lambda 表达式的函数体重载 **operator()**(所以 lambda 表达式 也叫匿名函数对象)
  2. 创建 lambda 对象
  3. 通过对象调用 operator()