主要基于 C++ 贺同学 。
一、 C++ 语言基础知识
1. 智能指针
在传统的 C++ 开发中,手动管理内存(new/delete)极易引发三个致命问题:
- 内存泄漏:申请了空间但忘记释放。
- 野指针/悬空指针:指针指向的内存已被释放,但指针还在被使用。
- 重复释放:对同一块内存调用了两次
delete,导致程序崩溃。
智能指针的核心思想是 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
- 封装成类:它将原始指针封装在一个类中。
- 自动析构:利用 C++ 的局部对象生命周期管理特性。当智能指针对象超出其作用域(Scope)时,编译器会自动调用它的析构函数。
- 释放资源:我们在析构函数内部编写了释放内存的逻辑,从而确保资源一定会被回收,无需程序员手动干预。
常用接口:
T* get():获取封装在内部的原生指针(裸指针)。operator*/operator->:重载了指针操作符,让智能指针用起来和普通指针一模一样。T* release():交出控制权。将内部指针置为nullptr,并返回原来的裸指针。注意:它不会释放裸指针指向的内存!(需要手动delete返回的指针)。void reset(T* ptr = nullptr):重置/替换。先释放当前管理的内存(如果有),然后接管传入的新指针ptr。
1.1. auto_ptr (C++98 引入,C++11 已被明确废弃)
- 机制:采用所有权剥夺模式。
- 致命缺点:存在隐式的内存崩溃风险。
auto_ptr<string> p1(new string("hello"));
auto_ptr<string> p2 = p1; // 编译通过,不报错。
// 此时 p1 的控制权被 p2 剥夺,p1 变成了 nullptr。
// 如果之后代码继续访问 p1->size(),程序会直接崩溃 (Crash)!
1.2. unique_ptr (C++11 引入,用来替代 auto_ptr)
- 机制:独占式拥有。保证同一时间内只有一个智能指针可以指向该对象。
- 安全性:直接在编译阶段扼杀隐患。
- 优点:零额外开销(和裸指针一样快),极其安全,避免资源泄漏的首选。
unique_ptr<string> p3(new string("auto"));
unique_ptr<string> p4 = p3; // ❌ 编译报错!禁止直接拷贝赋值。
// 如果真的想转移所有权,必须显式使用 std::move
unique_ptr<string> p5 = std::move(p3); // ✅ 成功,p3 置空,p5 接管
1.3. shared_ptr (共享型,强引用)
- 机制:共享式拥有。利用引用计数 (Reference Counting) 机制,允许多个指针指向同一个对象。
- 生命周期:每多一个
shared_ptr指向该资源,计数 +1;每销毁一个,计数 -1。当最后一个引用被销毁(计数为 0)时,自动释放资源。 - 关键函数:
use_count()可以查看当前有多少个指针共享该资源。
1.4. weak_ptr (弱引用)
- 机制:不控制对象生命周期的观察者。它依附于
shared_ptr存在。 - 特点:它的构造和析构不会引起引用计数的增加或减少。它只提供对对象的访问手段,没有重载
*和->。 - 用法:需要访问资源时,调用
lock()函数。如果资源还没被释放,lock()会返回一个临时的shared_ptr供你使用;如果资源已释放,返回空的shared_ptr。 - 核心作用:解决循环引用(死锁)问题。
- 循环引用场景:对象 A 内部有一个
shared_ptr指向对象 B;对象 B 内部也有一个shared_ptr指向对象 A。两者的引用计数永远降不到 0(最低是 1),导致内存永远泄漏。 - 解决方案:将 A 或 B 其中一方内部的智能指针换成
weak_ptr即可解决。
- 循环引用场景:对象 A 内部有一个
- 注意:
lock()是一个原子操作(Atomic Operation)。它把“检查对象是否存活”和“增加引用计数防止被销毁”这两个动作绑定在了一起。要么一起成功,要么直接返回空指针。因此,永远使用lock()去访问weak_ptr,而不是先查expired()。
2. 内存分配

2.1. 栈区 (Stack)
- 特性:由编译器自动分配和释放,操作方式类似于数据结构中的栈(先进后出 LIFO)。分配效率极高。
- 存储内容:局部变量、函数的参数值、函数返回地址等。
- 生长方向:通常是向下生长(从高地址向低地址扩展)。
- 致命风险(Stack Overflow):栈的空间非常有限(在 Linux 系统下默认通常是 8MB)。如果你开了一个超大的局部数组(比如
int arr[1000000];),或者递归调用层数太深(没有退出条件),就会把栈撑爆,导致栈溢出崩溃。
2.2. 堆区 (Heap)
- 特性:动态内存分配区域,必须由程序员手动申请(
new/malloc)和释放(delete/free)。空间非常大,几乎受限于机器的物理内存。 - 生长方向:通常是向上生长(从低地址向高地址扩展)。
- 致命风险:
- 内存泄漏 (Memory Leak):
new了之后忘记delete。 - 内存碎片 (Memory Fragmentation):频繁地分配和释放不同大小的小块内存,会导致内存空间变得七零八碎,虽然总空间够,但申请不出一整块连续的内存。
- 内存泄漏 (Memory Leak):
2.3. 全局/静态存储区 (Global/Static Segment)
这里存放的是生命周期贯穿整个程序运行期的变量(全局变量和 static 静态变量)。在底层,它被细分为两个相邻的区域:
.data区(已初始化数据区):存放已经明确初始化且不为 0 的全局变量和静态变量。.bss区(未初始化数据区):存放未初始化或初始化为 0 的全局变量和静态变量。操作系统在加载程序时,会自动把这块区域的数据清零。
2.4. 常量存储区 (Constant / Read-Only Segment)
- 特性:也被称为
.rodata(Read-Only Data) 区。这里的内存受到操作系统的严格保护,绝对只读。 - 存储内容:字符串字面量(比如
char* p = "Hello World";里的"Hello World")以及部分const修饰的变量。 - 致命风险:如果你尝试强行修改这块区域的数据(比如
p[0] = 'h';),操作系统会立刻抛出 段错误 (Segmentation Fault) 并终止程序。
2.5. 代码区 (Code / Text Segment)
- 特性:存放程序的机器指令(也就是你写的代码编译后的二进制文件)。
- 特点:
- 只读:防止程序在运行中意外修改自己的指令。
- 共享:如果你同时打开了 5 个记事本程序,内存中其实只有一份记事本的代码区,它们共享这部分内存以节约资源。
3. 指针参数传递和引用参数传递
3.1. 指针传递:本质是“值传递”(传的是地址的副本)
当你把一个指针传给函数时,其实是发生了一次“拷贝”。
- 栈区开辟:编译器会在被调用函数的栈帧里,开辟一块新的内存,专门用来存放传进来的地址值。这个新指针(形参)和外面的旧指针(实参)是两个独立的变量,只是它们碰巧装了同一个地址。
- 改指针的指向(形参变,实参不变):如果你在函数里写
p = nullptr;,这只是把形参那个临时变量清空了,外面的实参指针完全不受影响。 - 改指针指向的内容(形参变,实参也变):如果你写
*p = 10;,因为形参和实参拿着同一把钥匙(地址),这会切实地改变外部数据。
- 改指针的指向(形参变,实参不变):如果你在函数里写
3.2. 引用传递:底层是“间接寻址”(安全的高级马甲)
引用传递在底层实现上,其实也占用了栈空间的内存(通常被编译器实现为一个常量指针 Type* const)。
- 操作透明化:最大的区别在于,编译器负责了所有的“解引用(
*)”工作。当你在函数里操作引用变量时,编译器自动将其翻译为间接寻址,直接去操作主调函数里的那个本体。 - 修改本体:你在函数内对引用做的任何赋值,都会 100% 作用于外部的实参。
3.3. 为什么想改外部指针的指向,必须用 ** 或 *&?
既然指针传递是拷贝了指针本身,如果想在函数里改变外部指针自身的指向(比如让外部指针指向一块新 new 出来的内存):
- 传“指针的指针”(二级指针
int** p)。 - 或者传“指针的引用”(
int* &p,推荐写法,更直观)。
3.4. 编译器视角:符号表 (The Symbol Table)
- **符号表(Symbol Table)**是编译器在编译期间维护的一张表,记录了变量名和对应的内存地址。
- 指针的符号表:记录的是【指针变量名 —> 指针自己的内存地址】。指针自己的地址是固定的,但指针里面存的值(指向哪里)随时可以改。
- 引用的符号表:记录的是【引用变量名 —> 本体对象的内存地址】。
- 符号表一旦生成,变量名和地址的映射关系就彻底定死了。所以指针可以随意改变其保存的地址值,而引用一旦绑定了某个对象,终生不能换绑。
4. static 和 const 关键字
4.1. static 关键字
static 的核心逻辑在于改变变量的生命周期(使其贯穿整个程序)或限制标识符的链接属性(使其仅在特定范围内可见)。
4.1.1. 修饰局部变量(静态局部变量)
- 存储位置:由栈区改为静态数据区。
- 生命周期:从程序运行到结束,不会随函数退出而销毁。
- 作用域:保持不变,仍限制在定义它的语句块内。
- 特性:只在第一次执行时初始化一次,后续调用保留上次运行的值。
4.1.2. 修饰全局变量(静态全局变量)
- 可见性变化:由“整个工程可见”变为**“仅本源文件可见”**(内部链接)。
- 用途:有效防止多个文件中出现同名变量导致的链接冲突。
4.1.3. 修饰函数(静态函数)
- 作用:与修饰全局变量类似,限制函数仅在声明它的**模块(文件)**内可见,隐藏了函数接口。
4.1.4. 修饰类成员(静态成员变量/函数)
静态成员变量:
- 归属:属于类而非特定对象,所有对象共享同一个副本。
- 初始化:必须在类外进行定义和初始化(因为它们独立于对象存在)。
静态成员函数:
- 无
this指针:因此不能访问非静态成员,只能访问静态成员。 - 调用方式:可以通过类名直接调用
Class::Func(),也可以通过对象调用。 - 禁止 virtual:虚函数依赖
vptr指针,而vptr存放在对象内存中并通过this访问,static函数无this,故不能为虚函数。虚函数的调⽤关系:this -> vptr -> ctable -> virtual function。
4.2. const 关键字
const 的核心逻辑是只读保护,防止数据被意外修改。
4.2.1. 修饰基本数据类型
const int a = 10;与int const a = 10;等价。- 变量值被锁定,尝试修改会导致编译错误。
4.2.2. 修饰指针与引用(关键区分)
- 常量指针(Pointer to Constant):
const int* p或int const* p。const在*左侧,修饰指向的内容。内容不可变,指针指向可变。 - 指针常量(Constant Pointer):
int* const p。const在*右侧,修饰指针本身。内容可变,指针指向不可变。 - 双重限定:
const int* const p。指向的内容和指针本身都不可变。
4.2.3. 函数中的 const 应用
- 做参数:保护输入数据不被函数内部修改,常用于
const T&(引用传递)以提高效率并确保安全。 - 做返回值:防止返回值被作为左值修改(如返回
const ref)。
4.2.4. 类中的 const 用法
- const 成员变量:
- 初始化:只能在构造函数的初始化列表中进行。
- 特性:在对象生命周期内是常量,不同对象的该常量值可以不同。
- const 成员函数:
- 目的:承诺不修改对象的任何非静态成员。
- 互斥性:不能与
static同时修饰同一个成员函数(因为static无this指针,而const修饰的是隐式的this)。 - 例外:被
mutable修饰的变量可以在const函数中被修改。
- 常量对象:
- 限制:一旦定义为
const ClassObj,该对象只能调用 const 成员函数。
- 限制:一旦定义为
4.2.5. 常量成员函数深度解析
成员函数在调用时会隐式传递 this 指针:
- 普通成员函数:隐式形参为
T* const this(指针地址不可变,指向内容可变)。 - 常量成员函数:隐式形参变为
const T* const this(指向的内容也不可变)。
| 对象类型 | 可调用函数类型 | 说明 |
| 非常量对象 | const 和 非const 成员函数 | 权限可以缩小(从可读写到只读) |
| 常量对象 | 仅 const 成员函数 | 权限不能放大(必须保证只读) |
5. C++ 和 C 核心区别
5.1. 语法与关键字层面的扩展
虽然 C 和 C++ 在基本控制语句上没有太大差异,但 C++ 在语法和关键字上进行了大量扩充,以支持更高级的编程范式和更高的安全性:
- 头文件与命名空间:
- C++ 引入了全新的标准头文件体系。
- 命名空间(Namespace):C++ 允许开发者自定义命名空间(
namespace)以有效避免命名冲突,而 C 语言不支持此特性。
- 动态内存管理:
- 除了兼容 C 语言的
malloc和free,C++ 引入了new和delete关键字,能够更好地结合面向对象的构造和析构机制。
- 除了兼容 C 语言的
- 指针与引用:
- C++ 在传统指针的基础上,增加了**引用(Reference)**的概念,使参数传递和变量别名操作更加安全和直观。
- 新增关键字与安全性:
auto:用于自动类型推导(现代 C++ 中极具价值)。explicit:用于修饰构造函数,严格控制显式和隐式类型转换。dynamic_cast:增加了运行时的类型安全检查机制。
5.2. 函数机制的差异(重载与多态)
C++ 在函数层面提供了远超 C 语言的灵活性,核心体现在重载和虚函数:
- 函数重载(Overload):
- 现象:C++ 支持定义同名但参数列表不同的函数,而 C 不支持。
- 底层原因(名字修饰 Name Mangling):编译时,C 语言的函数名修饰仅包含函数名本身(如
_func);而 C++ 的名字修饰会将参数类型追加到函数名后(例如int sum(int, int)会被修饰为_Z3sumii)。因此,编译器能够准确区分同名的不同函数。
- 虚函数(Virtual Functions):
- C++ 引入了
virtual关键字定义虚函数,这是实现动态多态的核心机制。
- C++ 引入了
5.3. 数据封装:struct 与 class 的区别
这是 C 和 C++ 在数据结构定义上的重要分水岭:
- C 语言的 struct:仅能包含成员变量,纯粹用于组合数据。
- C++ 的 struct:
- 能力升级:不仅可以包含成员变量,还可以包含成员函数。
- 权限控制:引入了访问权限概念。
struct的默认成员访问权限和默认继承权限均为public(公有)。
- C++ 的 class:
- C++ 专属关键字,表示“类”。
- 与 C++
struct的唯一核心区别:class的默认成员访问权限和默认继承权限均为private(私有)。
5.4. 代码复用:模板与标准库
- 模板(Templates):C++ 增加了模板机制(泛型编程),极大提升了代码的重用性,允许编写与数据类型无关的代码。
- STL 标准库:依托于模板,C++ 提供了强大且丰富的 STL(Standard Template Library,标准模板库),包含各种高效的容器和算法。
5.5. 核心编程思想:结构化 vs 面向对象
这是两者最本质的思维模式差异:
- C 语言(面向过程 / 结构化):
- 重点:算法 + 数据结构。
- 设计思路:关注“过程”。首先考虑如何编写一段代码或一个过程,对输入的流进行运算处理,最终得到输出。
- 定位:C 语言的
struct更适合被视为一个数据结构的实现体。
- C++ 语言(面向对象):
- 重点:对象模型。
- 设计思路:关注“实体”。首先考虑如何构造一个契合特定问题领域的对象模型。通过对象之间的交互,以及获取对象本身的状态信息来得到所需的输出。
- 定位:C++ 的
class更适合被视为一个对象的实现体。
6. C++ 和 Java 核心区别
6.1. 指针与内存安全
- Java 屏蔽了显式指针:Java 不允许程序员直接使用指针来访问内存,这从根本上杜绝了 C++ 中常见的指针越界、野指针等内存操作失误。
- JVM 内部隐式使用:Java 并非完全没有指针,其底层虚拟机(JVM)内部依然使用指针来管理引用,从而在保证效率的同时确保了程序的绝对安全。
6.2. 面向对象与类设计
- 纯粹的面向对象:Java 是一门完全面向对象的语言。除了基本数据类型,其余所有元素都是类对象。所有的函数和变量都必须被封装在类中,作为类的一部分。
- 废除特殊结构:Java 彻底取消了 C++ 中的
struct(结构体)和union(联合体),强制使用类(Class)来统一封装数据和方法。 - 继承机制差异:C++ 支持多重继承(一个子类有多个父类),但这容易引发复杂的菱形继承问题。Java 不支持类的多重继承,而是通过**“一个类继承多个接口(Interface)”**的方式来实现类似功能,既满足了多态需求,又避免了多重继承的设计缺陷。
6.3. 内存管理与垃圾回收(Garbage Collection)
- 对象创建:Java 程序中所有的对象都是通过
new操作符在内存堆(Heap)上动态创建的。 - Java 的自动回收(GC):Java 具备自动内存管理机制。当一个对象不再被引用时,垃圾回收器会给它打上标签。回收程序会作为后台线程运行,利用系统的空闲时间自动清理这些无用内存。
- C++ 的手动释放:C++ 没有自带的垃圾回收器,必须由程序员手动管理内存(使用
delete),这极大地增加了内存泄漏的风险和程序设计者的负担。
6.4. 语法特性与关键字裁剪
- 操作符重载:C++ 允许操作符重载(这被视为其突出特性之一),而 Java 不支持操作符重载,以保持语法的简单直接。
- 预处理功能:C++ 在编译前有预编译阶段(处理宏定义、
#include等)。Java 摒弃了预处理器,但提供了import关键字来实现类似的代码模块引入功能。 - goto 语句:Java 虽然将
goto保留为关键字,但在语法层面全面禁止了它的使用,以此强制开发者编写结构更清晰、更易读的代码。
6.5. 类型转换与字符串机制
- 类型转换:C++ 允许较多隐式的数据类型转换,而 Java 对类型安全要求更严,通常需要程序员进行显式的强制类型转换。
- 字符串实现:C++ 的传统字符串是以 Null(
\0)作为终止符的字符数组;而 Java 的字符串是原生通过类对象(如String和StringBuffer)来实现和管理的,提供了更丰富的内置方法。
6.6. 异常处理
- 强制异常捕获:Java 拥有一套非常严格且完善的异常处理机制(Exception Handling),用于捕获和处理运行时的意外事件,确保程序的健壮性。
7. 常量
7.1. C++ 中如何定义常量
const关键字:最常用的定义方式,带有类型检查(如const int a = 10;)。constexpr关键字(C++11 引入):用于定义编译期常量,能够在编译阶段求值,进一步提升运行效率。#define宏定义:C 语言遗留的预处理指令(如#define PI 3.14),仅作简单的文本替换,不带类型检查,C++ 中一般推荐使用const替代。enum枚举:用于定义一组相关的整型常量。
7.2. 常量在内存中的存放位置
不同类型的常量在内存中的生命周期和存放区域是不同的。这取决于常量是如何声明的以及它的作用域。
7.2.1. 局部常量 (Local Constants)
- 存放位置:栈区(Stack)
- 详细解释:在函数体内部定义的常量(如
void func() { const int a = 5; })。它们和普通的局部变量一样,在函数被调用时在栈上分配内存,在函数执行结束(退出作用域)时自动销毁。 - 特性:虽然存放在可读写的栈区,但编译器会在语法层面上阻止你修改它的值。
7.2.2. 全局常量 (Global Constants)
- 存放位置:符号表(Symbol Table)(通常情况)
- 详细解释:在所有函数外部定义的常量。为了提高访问效率,编译器在编译期通常不会为其分配实际的物理内存空间,而是将其名称和对应的值记录在编译器的符号表中。
- 特性:在程序运行过程中,当用到这个常量时,编译器会直接使用符号表中的值进行替换(常量折叠优化),从而加快了执行速度并节省了内存。
7.2.3. 字面值常量 (Literal Constants)
- 存放位置:常量区(Constant Area /
.rodata段) - 详细解释:代码中直接写出的固定值,例如字符串字面量
"Hello World"、数字100等。 - 特性:常量区是只读的。操作系统在加载程序时,会为这块内存设置只读保护。如果试图通过强制类型转换的指针去修改这块内存里的内容,通常会导致程序崩溃(Segmentation Fault / 段错误)。
8. 重载(Overload)、重写(Override)和重定义 / 隐藏(Redefine / Hide)
8.1. 重载(Overload)
- 定义:在同一个可访问区内(即同一个作用域,比如同一个类中),声明了几个具有不同参数列表的同名函数。
- 底层机制:依赖于 C++ 的**名字修饰(Name Mangling)**机制。编译器在编译时,会将函数的参数类型、个数等信息附加到函数名后面,从而在底层生成不同的函数名。
- 核心规则:
- 必须不同:参数的类型、个数或顺序必须至少有一个不同。
- 无关项:重载不关心函数的返回类型。仅返回类型不同,参数列表相同的函数不能构成重载,会导致编译报错。
8.2. 重写(Override)
- 定义:在派生类(子类)中,重新定义基类(父类)中除了函数体之外,其他全部完全相同的虚函数。这是实现 C++ 动态多态的核心。
- 核心规则:
- 作用域不同:被重写和重写的函数必须分别位于基类和派生类中。
- 基类要求:被重写的基类函数必须是
virtual(虚函数),且绝对不能是static(静态函数)。 - 签名一致:函数名、参数列表、返回类型(协变返回类型除外)必须完全相同。
- 权限灵活:重写函数的访问修饰符可以不同。例如,基类中是
private的虚函数,派生类在重写时可以将其改为public。
8.3. 重定义 / 隐藏(Redefine / Hide)
- 定义:派生类(子类)重新定义了基类(父类)中具有相同名字的函数,导致基类的同名函数在派生类中被“隐藏”。
- 触发条件(满足其一即可):
- 非虚函数同名:基类函数不是
virtual函数,只要派生类中有同名函数(无论参数列表和返回类型是否相同),基类函数就会被隐藏。 - 虚函数同名但参数不同:基类函数是
virtual函数,但派生类中的同名函数参数列表与基类不同。此时不构成重写(因为参数不同),而是发生隐藏。
- 非虚函数同名:基类函数不是
- 核心特征:参数列表和返回类型都可以不同,只要名字相同且未构成严格的重写,就会触发重定义(隐藏)。
8.4. 核心对比
| 特性 | 重载 (Overload) | 重写 (Override) | 重定义/隐藏 (Hide) |
| 发生范围 (作用域) | 同一个类/作用域中 | 不同类(基类与派生类) | 不同类(基类与派生类) |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 | 可同可不同 |
| 返回值类型 | 无关(可同可不同) | 必须相同 | 可同可不同 |
基类是否有 virtual | 无关 | 必须有 virtual | 无 virtual,或有 virtual 但参数不同 |
8.5. tricky point
因为提到的是**“重定义(隐藏)” **,这意味着该函数没有构成多态(不是虚函数,或者参数不同)。在这种情况下,C++ 采用的是静态绑定(早绑定),即在编译阶段,编译器会根据变量的声明类型**来决定调用哪个函数,而不是看它实际运行时的对象类型。
情况一:通过子类 B 的对象直接调用
结果:调用子类 b 里定义的函数。
原因:这就是“隐藏(Hide)”的字面表现。当你在 b 的作用域里调用该函数时,编译器首先在子类 B 中查找。一旦找到了同名函数,就会直接调用它,父类 A 中的同名函数被“隐藏”了。
B objB;
objB.func(); // 明确调用 B 里面重定义的 func()
情况二:通过父类 A 的指针(或引用)指向子类 B 的对象来调用(tricky point)
结果:调用父类 a 里定义的函数。
原因:这是重定义(隐藏)和重写(多态)最大的区别!因为函数不是 virtual(或者参数不匹配没构成重写),编译器只看指针的声明类型。既然指针声明为 A*,编译器就直接绑定父类 A 里的版本,根本不管它实际指向的是个 B 对象。
A* ptr = new B();
ptr->func(); // 即使指向 B,但因为是 A 类型的指针,且没有多态,所以调用 A 里面的 func()
情况三:在子类 B 的对象中,强行想调用父类 A 被隐藏的函数
结果:调用父类 a 里定义的函数。
原因:虽然父类的函数被隐藏了,但它并没有消失。你可以通过作用域解析运算符 :: 来明确告诉编译器“我要越过子类的作用域,直接去父类里找”。
B objB;
objB.A::func(); // 明确调用父类 A 里面被隐藏的 func()
9. 构造函数与赋值运算符
构造函数的核心作用
当类的对象被创建时,编译系统会为该对象分配内存空间,并自动调用构造函数。
- 唯一目的:完成对象数据成员的初始化工作。
- 特点:函数名与类名相同,无返回值。
9.1. 无参构造函数(默认构造函数)
- 定义:没有任何参数的构造函数。
- 生成机制:如果你没有在类中显式写出任何构造函数,编译器会自动生成一个无参构造函数(函数体为空,什么也不做)。
- 注意事项:一旦你定义了其他带参数的构造函数,编译器就不再自动生成无参构造函数。如果此时你仍需要无参创建对象,必须手动显式写出一个。
9.2. 一般构造函数(重载构造函数)
- 定义:带有参数的构造函数。
- 多态体现:一个类可以拥有多个一般构造函数,只要它们的参数个数或参数类型不同(即构造函数重载)。
- 工作方式:在创建对象时,编译器会根据传入的具体参数,自动匹配并调用对应的构造函数。
9.3. 拷贝构造函数 (Copy Constructor)
- 定义:参数为本类对象引用(通常为
const ClassName&)的构造函数。 - 作用:用一个已经存在的对象,去初始化(复制)出一个全新的同类对象。
- 深浅拷贝陷阱:
- 编译器会默认提供一个拷贝构造函数,但它执行的是浅拷贝(只复制值和指针地址)。
- 核心原则:如果类中有指针成员(涉及动态内存分配),必须自定义拷贝构造函数并实现深拷贝(为新对象的指针重新分配内存),否则会导致两个对象的指针指向同一块内存,析构时发生“重复释放(Double Free)”错误。
9.4. 类型转换构造函数 (Conversion Constructor)
- 定义:通常是只有一个参数的构造函数,用于根据一个指定类型的对象创建出一个本类的对象。
- 隐式转换风险:编译器可能会利用它进行意想不到的隐式类型转换。
- 防范方案:如果业务逻辑不允许这种默认转换,必须在构造函数声明前加上
explicit关键字,强制要求显式调用,阻断隐式转换的发⽣。
9.5. 赋值运算符重载(operator=)
- 本质:它不属于构造函数,而是一个普通的成员函数重载。
- 作用:将
=右边对象的值,复制给=左边的对象。 - 前提条件:
=左右两边的对象必须都已经存在(已完成创建和初始化)。 - 默认行为:如果没有显式编写,系统也会生成一个默认的赋值运算符,同样只做基本的浅拷贝操作(遇到指针成员也需要手动重载以实现深赋值)。
9.6. 代码辨析:拷贝构造 vs 赋值运算符
哪怕代码里都有 = 号,底层的调用逻辑也完全不同。牢记**“对象是否已经存在”**这个判定标准:
A a1; // 调用无参构造函数,创建 a1
A a2; // 调用无参构造函数,创建 a2
// 场景一:赋值运算符重载
a1 = a2;
// 解释:此时 a1 和 a2 都已经是存在(初始化完毕)的对象了。
// 这里的 '=' 是将 a2 的值赋给现成的 a1,调用赋值运算符。
// 场景二:拷贝构造函数
A a3 = a1;
// 解释:这行代码的本质是在“创建” a3 这个新对象。
// 因为 a3 之前并不存在,这是在做初始化工作,所以哪怕用了 '=',调用的也是拷贝构造函数。
// 等价于 A a3(a1);
10. 四种强制转换
10.1. static_cast(静态转换)
- 核心定位:最常用的普通类型转换,用于明确指出类型转换意图,建议将代码中所有的隐式转换都替换为
static_cast。 - 安全性机制(无动态检查):
- 上行转换(派生类 -> 基类):安全。子类转换为父类是天然合法的。
- 下行转换(基类 -> 派生类):不安全。因为它没有在运行时进行动态类型检查,如果基类指针实际上指向的并不是该派生类对象,转换虽然能成功,但后续访问会极其危险。
- 主要用途:主要用于执行非多态的转换操作(例如基本数据类型之间的转换
int转float,或者明确知道安全的类层次转换)。
10.2. dynamic_cast(动态转换)
- 核心定位:专门用于具有多态性(包含虚函数)的类层次(派生类与基类)之间的转换。
- 语法要求:目标类型 (
type-id) 必须是类指针、类引用或void*。 - 安全性机制(运行时检查):
- 下行转换安全卫士:与
static_cast不同,dynamic_cast会在运行时检查转换是否真的安全。 - 失败处理:当类型不一致(即基类指针实际指向的不是目标派生类)时,如果转换的是指针,它会返回一个 空指针(
nullptr);如果转换的是引用,则会抛出bad_cast异常。 - 对比优势:
static_cast在类型不匹配时会强行转换出一个错误意义的危险指针(可能导致非法内存访问奔溃),而dynamic_cast通过返回空指针完美避开了这个雷区。
- 下行转换安全卫士:与
10.3. const_cast(常量转换)
- 核心定位:专门用于修改变量的
const(常量)属性 或volatile属性。 - 唯一性:这是 C++ 四个强制转换符中,唯一一个有权操作(去除或增加)变量
const性质的转换符。 - 主要用途:常用于将
const指针或引用转换为非const指针或引用,以便在特定场景下(如调用只接受非const参数的旧版 API)修改数据。
10.4. reinterpret_cast(重新解释转换)
- 核心定位:高危操作!不到万不得已,绝对不要使用。它本质上是底层位模式的重新解释。
- 主要特点与风险:
- 无视类型,强制重解:它直接从底层内存的二进制数据对数据进行重新解释,完全不关心原类型和目标类型是否兼容。
- 跨度极大:可以将整型强制转换为指针,也可以把指针转换为数组,甚至在指针和引用之间进行“肆无忌惮”的强制转换。
- 移植性极差:转换结果严重依赖于具体的硬件平台和编译器实现,跨平台时极易出错。
11. 指针与引用
11.1. 核心概念:实体 vs 别名
- 指针(Pointer):是一个客观存在的实体。它有自己独立的内存空间,这块空间里存放的是另一个变量的内存地址。
- 引用(Reference):仅仅是一个别名(Alias)。它依附于一个已经存在的变量,和该变量共享同一块内存空间,没有任何属于自己的独立内存。
11.2. 底层编译机制(符号表差异)
它们在编译期的“符号表(Symbol Table)”处理方式完全不同,这也是导致它们后续所有行为差异的根本原因:
- 指针的符号表:记录的是
[指针变量名 -> 指针变量自己的地址]。因为指针自己有地址,所以它内部存的具体内容(即指向哪块内存)是可以随意改变的。 - 引用的符号表:记录的是
[引用变量名 -> 目标对象的地址]。符号表的映射关系一经创建就无法更改。这就决定了引用一旦绑定了一个变量,就“从一而终”,绝对不能再换绑给其他变量。
11.3. 使用特性的全面对比
11.3.1. 初始化与重绑定(可变性)
- 指针:非常自由。可以在任何时候被初始化,也可以随时被重新赋值,改变指向的对象。
- 引用:非常严格。必须在定义的同时进行初始化,且一旦绑定,后续绝对不能更改。
11.3.2. 空值(Null)状态
- 指针:可以为空(
nullptr或NULL),表示它当前不指向任何有效的内存。这是极其容易引发“段错误(Segmentation Fault)”的地方。 - 引用:绝对不能为空。它必须依附于一个合法存在的实体对象。
11.3.3. 内存大小(sizeof 运算)
sizeof(指针):得到的是指针变量本身的大小。在 32 位系统中通常是 4 字节,在 64 位系统中通常是 8 字节(与它指向什么类型无关)。sizeof(引用):得到的是引用所绑定的那个目标对象的大小。比如引用了一个double,sizeof得到的就是 8 字节。
11.3.4. 参数传递与操作方式
- 作为函数参数传递:
- 传指针:本质上仍然是**“传值(Pass by Value)”**,只不过传递的这个“值”是一个内存地址。函数内部会拷贝一份指针,修改这个副本指针本身的指向不会影响外部,但通过指针解引用可以修改外部内存。
- 传引用:本质上是**“传地址/传引用(Pass by Reference)”**。函数直接接收变量的别名,没有复制过程,对引用的任何修改都会直接作用于原对象,效率更高且代码更简洁。
- 操作方式:指针需要使用解引用操作符
*才能访问或修改目标对象;而引用不需要任何前缀,像操作普通变量一样直接操作即可。
11.4. tricky point
- 引用本身不能是
const:因为引用从诞生起就不能换绑(天生具有不可变性),所以写成int& const r = a;这种语法是没有意义且不合法的,材料想表达的应该是这层意思。 - 但可以有“对常量的引用(Reference to const)”:例如
const int& r = a;。这在 C++ 中极其常见,表示你不能通过这个引用(别名)去修改原对象的值,这在函数传参中是保护数据最常用的手段。而指针既可以是“指针本身是常量(int* const p)”,也可以是“指向的内容是常量(const int* p)”。
11.5. 对比表
| 对比维度 | 指针 (Pointer) | 引用 (Reference) |
| 本质 | 独立存在的实体变量 | 目标变量的别名 |
| 内存空间 | 有自己的独立内存空间 | 无独立空间,与原对象共享内存 |
| 初始化 | 可以不立即初始化 | 必须在定义时初始化 |
| 是否可变 | 可以随时改变指向 | 不可改变(从一而终) |
| 是否可为空 | 可以为 nullptr | 绝对不能为空 |
sizeof 结果 | 指针类型的大小(4或8字节) | 被绑定对象的大小 |
| 操作方式 | 需要解引用 (*p) | 直接操作 (r) |
12. 野指针(Wild Pointer)与悬空指针(Dangling Pointer)
12.1. 野指针 (Wild Pointer)
- 定义:从未被初始化过的指针。
- 产生原因:在定义一个指针变量后,没有给它赋初值。此时,指针内部的值是内存中残留的随机垃圾值,这意味着它随机指向了内存中的某个未知区域。
- 编译器提示:在开启编译器警告(如使用
gcc -Wall)时,编译器通常能精准捕获并报出used uninitialized(使用了未初始化的变量)警告。
12.2. 悬空指针 (Dangling Pointer)
- 定义:指向的内存已经被释放,但指针本身依然存在的指针。
- 产生原因:当指针指向一块动态分配的内存(如
new或malloc),随后这块内存被delete或free释放还给了操作系统。此时,内存已经失效,但指针变量本身的值(那个地址)并没有被清空,它依然“悬挂”在那里,指着那块已经被回收的坟墓。 - 隐蔽性:编译器通常无法检查出悬空指针,这使得它比野指针更难排查。
12.3. 共同的危害:未定义行为 (Undefined Behavior, UB)
无论是野指针还是悬空指针,它们都指向了**“不安全、不可控”的无效内存区域**。
- 后果:一旦试图访问(解引用
*p)这些无效内存,就会引发未定义行为 (UB)。 - 具体表现:
- 直接崩溃:如果指向了受保护的操作系统内存,程序会立即引发段错误(Segmentation Fault)并崩溃。
- 数据损坏(最可怕):如果指向了程序自身的其他合法数据区,它可能会默默地篡改其他变量的值,导致程序出现极其诡异且难以复现的逻辑错误。
12.4. 如何彻底避免?(核心防范准则)
在平时的编码中,养成良好的防御性编程习惯是关键:
- 定义即初始化:
- 永远不要只声明一个裸指针。在定义指针的同时,要么给它分配有效的内存地址,要么直接置为空指针。
- 规范写法:
int* p = nullptr;(C++11 推荐使用nullptr替代NULL)。
- 释放后立即置空:
- 在使用
delete或free释放内存后,必须紧接着将该指针赋值为nullptr。 - 规范写法:
delete p; p = nullptr;。这样即使后续不小心再次访问p,程序会稳定地抛出空指针异常,而不是产生诡异的悬空指针 UB。
- 在使用
- 拥抱现代 C++:使用智能指针 (Smart Pointers):
- 这是解决内存问题的终极方案。使用
<memory>库中的std::unique_ptr或std::shared_ptr。它们利用 RAII(资源获取即初始化)机制,在对象生命周期结束时自动释放内存,并在底层完美切断了悬空指针和野指针产生的可能。
- 这是解决内存问题的终极方案。使用
13. const 修饰指针的不同情况
const 和指针结合时,要区分 const 修饰的是:
- 指针指向的内容
- 还是 指针本身
核心就是分清:
- 值不能改
- 还是 指向不能改
13.1. 指向常量的指针
const int *p1;
也可写成:
int const *p1;
含义:
p1是指针p1的指向可以改- 但不能通过
p1修改它指向的值
例如:
int a = 10, b = 20;
const int *p1 = &a;
p1 = &b; // 可以
//*p1 = 30; // 不可以
即:
指针可变,指向的值不可变
13.2. 常量指针
int * const p2 = &a;
含义:
p2本身不能改,不能再指向别处- 但
p2指向的值可以改
例如:
int a = 10, b = 20;
int * const p2 = &a;
*p2 = 30; // 可以
//p2 = &b; // 不可以
即:
指针不可变,指向的值可变
13.3. 指向常量的常量指针
const int * const p3 = &a;
含义:
p3本身不能改p3指向的值也不能改
例如:
int a = 10, b = 20;
const int * const p3 = &a;
//*p3 = 30; // 不可以
//p3 = &b; // 不可以
即:
指针不可变,指向的值也不可变
13.4. 判断技巧
对于指针,常用判断规则为:
const在*左边:指向的内容不能改const在*右边:指针本身不能改
13.5. 补充说明
const int *p 表示不能通过 p 修改它指向的值,但并不代表该对象本身一定是常量。
例如:
int a = 10;
const int *p = &a;
a = 20; // 仍然可以
说明 const 限制的是:
不能通过该指针去修改对象
13.6. 总结
| 写法 | 含义 | 能否改指向 | 能否改指向的值 |
|---|---|---|---|
const int *p | 指向常量的指针 | 可以 | 不可以 |
int * const p | 常量指针 | 不可以 | 可以 |
const int * const p | 指向常量的常量指针 | 不可以 | 不可以 |
14. 函数指针
函数指针,本质上是一个指向函数的指针变量。普通指针可以指向普通变量,而函数指针指向的是函数的入口地址。
函数在编译后都会有一个入口地址,函数指针保存的就是这个地址,因此可以通过函数指针间接调用函数。
函数指针的核心可以从定义和用途两个方面理解:
- 定义:函数指针是指向函数的指针变量。
- 用途:可以调用函数,也可以作为函数参数,典型应用就是回调函数。
14.1. 函数指针的定义
函数指针首先是一个指针变量,只不过它指向的不是普通数据,而是函数。
例如:
char* fun(char* p); // 函数
char* (*pf)(char* p); // 函数指针
这里,fun 是函数,pf 是函数指针。pf 可以指向返回值类型为 char*、参数类型为 char* 的函数。
需要注意,函数指针的类型必须和所指向函数的返回值类型、参数列表保持一致。
14.2. 函数指针的使用
函数指针可以指向某个具体函数:
pf = fun;
也可以通过函数指针调用函数:
pf(p);
也可以写成:
(*pf)(p);
这两种写法都正确,通常直接写 pf(p) 更常见。
14.3. 函数指针声明的理解
函数指针最容易混淆的是写法。判断时可以抓住一点:先看变量名,再看它是不是被 * 修饰,最后看外层的参数列表和返回值。
例如:
char* (*pf)(char* p);
含义是:
pf是一个指针- 它指向一个函数
- 这个函数参数是
char* - 返回值是
char*
注意这里 (*pf) 一定要加括号。因为括号决定 pf 是“指针”还是“函数”。
如果写成:
char** pf(char* p);
或者:
char* pf(char p);
那含义就变了,表示 pf 是函数,而不是函数指针。
14.4. 函数指针的主要用途
函数指针常见有两个用途:
14.4.1. 通过指针调用函数
这样可以把“调用哪一个函数”这件事变得更灵活,而不是把函数写死。
也就是说,程序运行时可以根据需要,让函数指针指向不同函数,从而执行不同逻辑。
例如:
void 加密A(char* data) { ... }
void 加密B(char* data) { ... }int main() {
void (*加密方法)(char*); // 声明一个函数指针 加密方法 = 加密A; // 指向加密A
加密方法(data); // 实际调用加密A 加密方法 = 加密B; // 改为指向加密B
加密方法(data); // 实际调用加密B
}
这种方式的好处是:
- 调用逻辑更灵活
- 便于切换不同实现
- 有利于降低代码耦合
本质上可以理解为:
同一个“调用入口”,可以在运行时绑定不同函数。
14.4.2. 作为函数参数,实现回调函数
这是函数指针最常见的用途。
所谓回调,可以理解为:
把一个函数先传给别人,等条件满足时,再由别人来调用这个函数。
也就是把“处理方法”交给另一个函数,在将来某个时机由它回过头来调用,这就是回调。
例如:
// 你告诉系统:“收到消息时,调用我的处理函数”
void 收到消息时回调(void (*callback)(char*)) {
char* 消息 = 监听网络();
callback(消息); // 收到消息后,调用你传进来的函数
}// 你的处理函数
void 我的处理(char* 消息) { ... }int main() {
收到消息时回调(我的处理); // 注册回调函数
}
这个例子里:
收到消息时回调是一个“框架函数”或“系统函数”callback是传进来的函数指针我的处理是用户自己定义的处理函数
执行流程是:
- 在
main中,把我的处理传给收到消息时回调 收到消息时回调内部监听消息- 一旦收到消息,就通过
callback(消息)调用你提供的处理函数
这就是典型的回调机制。
它的核心思想是:
- 调用时机由对方决定
- 具体处理逻辑由你提供
所以回调特别适合这些场景:
- 事件处理
- 消息通知
- 排序规则自定义
- 遍历时自定义操作
- 异步任务完成后的处理
14.4.3. 用途理解
这两种用途虽然表现不同,但本质上是一样的:
都是先把函数地址保存起来,再在需要的时候通过这个地址调用函数。
区别在于:
第一种:调用不同函数
重点在于“函数可以切换”,强调的是运行时选择不同实现。
第二种:回调函数
重点在于“函数作为参数传递给别人”,强调的是把处理逻辑交给别人,在特定时机触发。
所以可以这样概括:
- 直接切换调用对象:体现灵活性
- 作为参数传递:体现扩展性和解耦能力
14.5. 面试常问点
1、函数指针和普通指针的区别
普通指针指向变量或对象,函数指针指向函数入口地址。
2、函数名和函数指针的关系
函数名在大多数情况下可以看作函数地址,因此既可以写 pf = fun,也可以写 pf = &fun,两种写法都可以。
3、函数指针类型必须匹配
函数指针指向函数时,返回值类型和参数列表必须一致。
14.6. 总结
函数指针就是指向函数的指针变量,保存的是函数的入口地址。
它的主要作用:
1、通过函数指针调用函数;
2、将函数指针作为参数,实现回调机制。
15. 堆和栈区别
堆和栈都是程序运行时用于管理内存的区域,但两者在管理方式、分配回收方式、空间特性、效率、用途等方面都有明显区别。
一般可以从以下几个方面来区分:
- 管理方式不同
- 分配和回收方式不同
- 空间组织方式不同
- 效率不同
- 碎片问题不同
- 使用场景不同
15.1. 栈
栈内存一般由编译器自动管理,在函数执行时自动分配,在函数结束时自动回收。
通常用于存放:
- 局部变量
- 函数参数
- 返回地址
- 一些编译器自动生成的临时数据
栈的特点是:
- 自动分配,自动回收
- 内存通常是连续的
- 分配和释放速度快
- 空间相对较小
- 一般不会产生内存碎片
函数调用时,会为该函数分配一个栈帧。
栈帧中通常保存函数参数、局部变量、返回地址等信息。
函数调用结束后,对应的栈帧自动销毁。
所以栈可以理解为:
函数调用过程中临时使用的内存空间。
15.2. 堆
堆内存一般由程序员手动管理。
在 C++ 中通常通过 new/delete,在 C 中通常通过 malloc/free 进行申请和释放。
堆主要用于存放:
- 生命周期较长的数据
- 动态申请的对象或数组
- 大块内存数据
堆的特点是:
- 手动申请,手动释放
- 空间通常比栈大
- 分配方式更灵活
- 分配和释放速度相对较慢
- 如果释放不当,容易产生内存泄漏
- 多次申请和释放后,容易产生内存碎片
所以堆可以理解为:
程序运行时按需申请、按需释放的动态内存区域。
15.3. 管理方式的区别
栈由编译器自动管理。
什么时候分配,什么时候释放,程序员一般不需要手动控制。
例如:
void func() {
int a = 10; // a 一般存放在栈上
}
当 func 执行结束后,a 会自动销毁。
堆由程序员管理。
申请后如果不主动释放,对应内存不会自动回收。
例如:
int* p = new int(10);
// ...
delete p;
如果忘记 delete,就可能造成内存泄漏。
15.4. 分配和回收方式的区别
栈:
- 函数调用时自动分配
- 函数结束时自动回收
- 生命周期通常和作用域一致
堆:
- 由程序员主动申请
- 由程序员主动释放
- 生命周期由程序员控制
这也是两者最本质的区别之一。
15.5. 空间组织和效率区别
栈一般是连续内存空间,分配和回收只需要移动栈顶指针,因此效率很高。
因为栈遵循后进先出,所以管理代价很小。
堆的分配通常更复杂。
系统需要从可用内存中找到合适的空间分配给程序,所以效率一般低于栈。
而且多次申请和释放后,堆中可能出现很多不连续的小块空间。
因此:
- 栈速度快,但灵活性较差
- 堆速度较慢,但灵活性更强
15.6. 碎片问题
栈一般不会产生内存碎片。
因为栈上的分配和释放是连续进行的,后进先出,整体比较规整。
堆容易产生内存碎片。
因为堆上内存是动态申请和释放的,多次使用后可能出现很多零散的小块空闲区,导致虽然总空闲空间足够,但无法满足大块连续内存申请。
所以:
- 栈基本无碎片问题
- 堆容易有碎片问题
15.7. 空间大小和生长方向
通常情况下:
- 栈空间较小
- 堆空间较大
很多系统中,栈和堆的增长方向通常相反:
- 栈一般向低地址方向扩展
- 堆一般向高地址方向扩展
但这里要注意:
这是很多平台上的常见实现方式,不是语言层面必须保证的标准。
15.8. 面试常问点补充
1、为什么栈快?
因为栈的分配和回收通常只需要移动栈顶指针,开销很小。2、为什么堆容易泄漏?
因为堆内存需要手动释放,如果申请后忘记释放,就会造成内存泄漏。3、什么时候用栈,什么时候用堆?
一般来说:
- 生命周期短、大小明确的数据,适合放栈上
- 生命周期长、大小不固定、需要动态管理的数据,适合放堆上
4、数组一定在栈上吗?
不一定。
局部数组通常在栈上,动态申请的数组在堆上。例如:
int a[10]; // 一般在栈上
int* p = new int[10]; // 在堆上
16. 函数传递参数的几种方式
函数传参常见有三种方式:
- 值传递
- 指针传递
- 引用传递
它们的核心区别在于:
- 函数内部操作的到底是不是实参本身
- 能不能通过形参修改外部实参
16.1. 值传递
值传递就是把实参的值复制一份给形参,形参只是实参的一个副本。
因此,在函数内部对形参的修改,不会影响外部实参。
例如:
void func(int a) {
a = 100;
}int main() {
int x = 10;
func(x);
// x 仍然是 10
}
这里调用 func(x) 时,本质上是把 x 的值拷贝给了形参 a。
所以函数里改的是 a,不是外面的 x。
特点:
- 传递的是实参的副本
- 形参和实参是两个不同对象
- 函数内部修改形参不会影响实参
16.2. 指针传递
指针传递本质上仍然属于值传递。
因为传进去的其实是“地址的副本”,也就是把实参地址复制给形参指针。
但是由于这个地址指向的是外部实参,所以可以通过指针间接修改实参。
例如:
void func(int* p) {
*p = 100;
}int main() {
int x = 10;
func(&x);
// x 变成 100
}
这里传进去的是 x 的地址。
虽然形参 p 本身也是一个副本,但 p 指向 x,所以通过 *p 可以修改 x。
需要注意:
- 如果在函数内部修改的是
p自己的指向,通常不会影响外部指针变量 - 如果修改的是
*p,才是修改实参对应的对象
例如:
void func(int* p) {
p = nullptr; // 只改了形参 p,本身不影响外部
}
所以更准确地说:
指针传递是地址值的传递,通过解引用可以间接操作实参。
特点:
- 本质上还是值传递
- 传递的是地址
- 可以通过指针间接修改实参
- 需要显式使用
*和取地址符&
16.3. 引用传递
引用传递就是给实参起了一个别名,函数内部对引用形参的操作,直接作用于实参。
例如:
void func(int& a) {
a = 100;
}int main() {
int x = 10;
func(x);
// x 变成 100
}
这里 a 是 x 的引用,也可以理解成 x 的别名。
所以对 a 的修改,本质上就是对 x 的修改。
原笔记中提到“把引用对象的地址放在栈空间中”,这种说法更偏底层实现,不适合作为定义。
因为引用在语言层面更准确的理解应该是:
引用是对象的别名。
至于编译器底层是否通过地址实现,那是实现细节,面试中一般不作为定义去说。
特点:
- 引用是实参的别名
- 对引用形参的操作就是对实参本身的操作
- 语法上比指针更直接,不需要解引用
16.4. 三种方式的区别
- 值传递
传递的是实参的副本,函数内部修改形参不会影响实参。 - 指针传递
传递的是地址值,通过指针可以间接修改实参。 - 引用传递
传递的是实参的别名,函数内部可以直接修改实参。
16.5. 需要注意的点
1. 这里要区分:
- 改变指针指向:不一定影响实参
- 通过指针解引用修改内容:才会影响实参
例如:
void func(int* p) {
p = nullptr; // 不影响外部实参
}
但如果写成:
void func(int* p) {
*p = 100; // 会影响外部实参
}
所以更准确的说法应该是:
2. 引用和指针传参怎么选
一般来说,如果参数不能为空,而且语义上就是“这个对象本身”,常用引用。
如果参数可能为空,或者需要表达“可选对象”,常用指针。
3. 值传递适合什么场景
适合传递小对象,或者函数内部只需要使用参数、不需要修改实参的情况。
17. new / delete 和 malloc / free 区别
17.1. 共同点
new/delete 和 malloc/free 都可以用来在堆上申请和释放内存。
也就是说,它们的共同作用都是:
- 在堆区分配空间
- 在不需要时回收空间
但它们的设计层级和使用语义并不一样。
17.2. 本质区别
new/delete 是 C++ 的操作符,malloc/free 是 C 语言标准库中的函数。
这一点是两者最基本的区别。
也就是说:
new/delete属于语言层面的机制malloc/free属于库函数调用
17.3. new 和 delete 做了什么
new 在申请对象时,通常会做两件事:
- 分配内存空间
- 调用对象的构造函数,对对象进行初始化
例如:
MyClass* p = new MyClass;
这行代码不只是“申请一块内存”,还会自动调用 MyClass 的构造函数。
delete 在释放对象时,也通常会做两件事:
- 调用对象的析构函数
- 释放内存空间
例如:
delete p;
这行代码会先析构对象,再释放对应内存。
所以可以概括为:
new= 分配内存 + 构造对象delete= 析构对象 + 释放内存
17.4. malloc 和 free 做了什么
malloc 只负责按字节申请一块原始内存,不会调用构造函数。
例如:
MyClass* p = (MyClass*)malloc(sizeof(MyClass));
这里虽然申请到了足够大的空间,但这块空间只是“裸内存”,对象并没有真正构造完成。
free 也只负责释放内存,不会调用析构函数。
例如:
free(p);
它只是把这块内存交还给系统,不会执行对象清理逻辑。
所以可以概括为:
malloc= 只分配原始内存free= 只释放原始内存
17.5. 初始化和析构上的区别
这是面试里最核心的点。
对于内置类型,例如 int、char,两者区别还不算特别明显。
但对于类对象,区别就非常大。
new 会调用构造函数,delete 会调用析构函数;
而 malloc/free 都不会自动调用构造和析构。
因此:
new/delete适合对象malloc/free更偏向原始内存管理
这也是为什么 C++ 中一般更推荐使用 new/delete 来管理对象。
17.6. 返回值和类型安全区别
new 返回的是对应类型的指针,一般不需要强制类型转换。
例如:
int* p = new int;
而 malloc 返回的是 void*,在 C++ 中通常需要强制转换。
例如:
int* p = (int*)malloc(sizeof(int));
所以从类型安全和可读性上看,new 更自然一些。
17.7. 失败时的处理方式区别
new 分配失败时,默认会抛出异常,通常是 std::bad_alloc。
例如:
int* p = new int;
如果分配失败,会进入异常处理流程。
而 malloc 分配失败时,不会抛异常,而是返回 nullptr。
例如:
int* p = (int*)malloc(sizeof(int));
if (p == nullptr) {
// 分配失败
}
所以两者在错误处理机制上也不同:
new主要靠异常malloc主要靠返回空指针判断
17.8. 原笔记中需要纠正的点
第一,new 得到的是经过初始化的空间,malloc 得到的是未初始化的空间,这个说法不够严谨。
更准确地说:
new的重点不是“空间已初始化”,而是“对象被构造了”malloc的重点不是“未初始化”,而是“只分配了原始内存,没有构造对象”
因为 new 是否发生初始化,还和具体写法有关。
例如:
int* p1 = new int; // 对内置类型默认初始化行为要具体看形式
int* p2 = new int(); // 值初始化
new会按照类型语义构造对象,而malloc只是分配裸内存。
第二,delete 一个类型,free 一个字节长度的空间,这个说法不准确。
更准确的说法应该是:
delete针对的是由new创建的对象free针对的是由malloc申请的内存块
它们不是“释放类型”和“释放字节长度”的区别,而是“对象语义”和“原始内存语义”的区别。
17.9. 为什么有了 malloc/free 还需要 new/delete
原因是:
malloc/free 只能完成原始内存的申请和释放,
但 C++ 是面向对象语言,很多时候我们分配的不只是内存,而是“对象”。
对象在创建时通常需要:
- 调用构造函数完成初始化
对象在销毁时通常需要:
- 调用析构函数完成资源清理
而 malloc/free 不能自动完成这些事情。
因此,C++ 才提供了 new/delete,用来支持“对象级别”的动态创建和销毁。
所以可以简单概括为:
malloc/free面向内存,new/delete面向对象。
17.10. 不能混用
new 和 delete 必须配对使用,malloc 和 free 也必须配对使用。
不能混用,例如:
int* p = new int;
free(p); // 错误
或者:
int* p = (int*)malloc(sizeof(int));
delete p; // 错误
原因在于两套机制背后的管理方式不同,混用会导致未定义行为。
17.11. 数组的情况
如果是数组,也要注意对应关系:
int* p1 = new int[10];
delete[] p1;
int* p2 = (int*)malloc(sizeof(int) * 10);
free(p2);
这里 new[] 必须配 delete[],不能写成普通 delete。
因为数组对象可能涉及多个元素的析构,必须使用正确的释放形式。
17.12. 总结
new/delete 和 malloc/free 都可以在堆上申请和释放空间,但区别很大。
new/delete 是 C++ 操作符,面向对象,除了分配和释放内存,还会负责构造和析构对象。malloc/free 是库函数,面向原始内存,只负责按字节申请和释放空间,不会调用构造和析构。
核心区别可以总结为:
new/delete是操作符,malloc/free是库函数new调构造,delete调析构malloc/free只处理原始内存new返回对应类型指针,malloc返回void*new失败抛异常,malloc失败返回nullptr- 两者不能混用
18. volatile 和 extern 关键字
这两个关键字都很常见,但作用完全不同。
volatile 主要解决的是编译器优化问题,告诉编译器这个变量的值可能在程序控制之外被改变。extern 主要解决的是声明和链接问题,表示这个变量或函数在别处定义,这里只是声明引用。
18.1. volatile 的作用
volatile 的核心作用是:
告诉编译器,这个变量的值可能随时发生变化,每次使用时都要重新去内存读取,而不能只依赖寄存器中的缓存值。
它通常用于这些场景:
- 硬件寄存器
- 中断服务程序中会修改的变量
- 多线程中被其他执行流修改的标志位(但这里只能保证可见性相关的部分语义,不能替代同步机制)
18.2. volatile 的三个常见特性
18.2.1. 易变性
volatile 变量的值可能在程序看不见的地方被修改,因此编译器不能假设它的值一直不变。
所以每次读取 volatile 变量时,通常都要重新从内存中取值,而不能直接使用之前保存在寄存器里的旧值。
例如:
volatile int sensor_value;
// 假设 sensor_value 是硬件传感器的实时值
int a = sensor_value; // 从内存读取
int b = sensor_value; // 再次从内存读取,而不是复用寄存器中的 a 的值
这里如果 flag 不是 volatile,编译器可能把它优化成只读一次;
但加了 volatile 后,会更倾向于每次循环都重新读取 flag。
18.2.2. 不可随意优化
volatile 告诉编译器,这个变量的读写操作是有意义的,不要把这些访问轻易优化掉。
例如对普通变量,如果编译器判断某些读写结果对程序无影响,可能会删除或合并;
但对 volatile 变量,一般不能这样做。
volatile bool flag = false;
while (!flag) { /* 等待外部事件修改 flag */ }
// 编译器不会将 while 循环优化为 if (!flag) { while(1); }
不过这里要注意,更准确的说法是:
volatile禁止的是对该变量访问的某些优化,不是“所有优化都不能做”。
所以不能简单说成“加了 volatile 编译器就完全不优化”。
18.2.3. 访问顺序上的约束
编译器通常不能随意改变多个 volatile 访问之间的相对顺序。
也就是说,对 volatile 变量的读写,编译器要尽量按照代码中的顺序生成访问动作。
但这里一定要注意:
volatile不能等价理解为真正的内存屏障,也不能保证线程同步语义。
它只能约束编译器对 volatile 访问的某些重排,不能完全解决多线程中的原子性、同步和有序性问题。
18.3. volatile 需要注意的点
第一,volatile 不能保证原子性。
例如 count++ 即使 count 是 volatile,这个操作也仍然可能不是原子的。
第二,volatile 不能替代锁,也不能替代 std::atomic。
在 C++ 多线程中,如果要正确处理共享变量,更规范的做法通常是使用:
std::atomic- 互斥锁
- 条件变量等同步机制
第三,volatile 的“顺序性”不能说得太绝对。
它只对 volatile 相关访问提供一定编译器层面的约束,不等于 CPU 层面的完整内存顺序保证。
更稳妥的说法是:
volatile主要用于禁止编译器把对该变量的访问优化掉,适合处理硬件寄存器、中断共享变量等场景;在多线程同步中不能替代原子类型和锁。
18.4. extern 的作用
extern 用在变量或函数声明前,表示:
这个变量或函数不是在这里定义的,而是在别的地方定义的,这里只是声明,要拿来用。
例如:
extern int g_value;
extern void func();
这表示:
g_value在别的源文件中已经定义func在别的地方已经实现- 这里仅仅是声明,告诉编译器它们存在
18.5. extern 和定义的区别
这是 extern 最核心的点。
例如:
int a;
这通常是一个定义。
而:
extern int a;
这是一个声明,表示 a 在别处定义。
也就是说:
- 定义:真正分配存储空间
- 声明:只告诉编译器这个名字存在
对函数来说,普通函数声明本身默认就带有 extern 含义,例如:
void func();
这已经是在声明函数了,一般不一定非要显式写 extern。
18.6. extern 的作用域问题
extern声明本身也遵循普通作用域规则。写在函数内部,就是局部可见;写在函数外部,就是文件作用域可见。
例如:
void func1() {
extern int g_value;
g_value = 10;
}
这里 g_value 的声明只在 func1 这个作用域里可见。
并不是 extern 特殊限制了它,而是因为它写在函数内部。
18.7. 为什么不是只用 include
#include 的本质是文本替换,它通常用于包含头文件,而不是直接包含 .c 或 .cpp 源文件。
正确做法通常是:
- 在头文件中写声明
- 在源文件中写定义
- 需要使用时包含头文件
- 编译链接阶段再把各个源文件连起来
在头文件中,如果是跨文件使用的全局变量,往往会这样写:
extern int g_value;
然后在某一个源文件中真正定义:
int g_value = 0;
所以 extern 的主要作用不是“加速编译”,而是:
用来声明外部符号,配合分文件编译和链接使用。
“加速编译”不是它的核心用途。
18.8. extern “C” 的作用
这是 C++ 面试里非常常见的点。
在 C++ 中,extern "C" 的作用是:
告诉编译器按 C 语言的方式处理函数名,避免 C++ 名字修饰,方便和 C 代码进行链接。
例如:
extern "C" void c_func();
原因是:
- C++ 支持函数重载,所以编译后函数名通常会被修饰
- C 语言不支持函数重载,函数名规则比较直接
- 如果 C++ 直接去链接 C 函数,名字可能对不上
extern "C"就是告诉编译器:按 C 的命名方式来处理
所以它本质上是为了解决:
- C++ 调用 C 函数
- C 和 C++ 混合编程时的名字匹配问题
19. define 和 const 区别
#define 和 const 都可以用来表示“常量”,但它们本质上不是一回事。#define 是预处理指令,本质是做文本替换;const 是语言层面的常量限定符,本质是定义一个只读对象或只读属性。也正因为这个出发点不同,它们在处理阶段、类型、安全性、作用域和调试方式上都会有差别。
19.1. 处理阶段和本质不同
#define 在预处理阶段展开,编译器真正开始语法分析之前,宏名就已经被替换掉了。
例如:
#define MAX 100
int a = MAX;
预处理后实际更接近:
int a = 100;
所以 #define 做的不是“定义一个常量对象”,而是“告诉预处理器:以后看到这个名字,就直接换成后面的内容”。
而 const 是编译阶段按 C/C++ 语义处理的,它有明确的类型、作用域和语法含义。
例如:
const int MAX = 100;
int a = MAX;
这里的 MAX 不是简单替换文本,而是一个有类型的常量,编译器会正常分析它。
所以最根本的区别就是:
#define是预处理替换const是语言常量
19.2. 类型、安全性和边界问题不同
#define 没有类型,也没有类型检查,因为它只是机械替换。
这也是它容易出问题的地方,尤其是带参数宏,很容易因为展开方式不当产生边界错误。
例如:
#define SQUARE(x) x * x
int a = SQUARE(2 + 3);
展开后其实是:
int a = 2 + 3 * 2 + 3;
结果不是预期的 25,而是 11。原因就在于宏不会理解“这是一个整体表达式”,它只会照字面展开。
如果一定要写宏,往往要写成:
#define SQUARE(x) ((x) * (x))
才相对安全。
而 const 不存在这种问题。它是正常语法的一部分,有明确类型,也会参与编译器的类型检查,所以语义更稳定、出错概率更低。
因此从安全性上说,const 明显强于 #define。
也正因为如此,在 C++ 里定义常量时,一般更推荐用 const,而不是用宏去代替常量。
19.3. 内存、符号和调试上的区别
#define 在预处理阶段就被替换掉了,所以宏名本身通常不会作为一个真正的语言符号进入后续语义分析。
它本身不是对象,也没有“独立存储”这个概念。你可以理解为:代码里最终出现的是替换后的内容,而不是一个叫 MAX 的只读对象。
而 const 是正常的语言符号,编译器知道它的名字、类型和作用域。
至于它是否一定单独占一块内存,不能说得太绝对,要看具体情况和编译器优化。
例如:
const int MAX = 100;
有时候编译器可能直接把它当成立即数使用,不一定真的单独分配存储;
但如果你对它取地址,或者它以某种方式需要具备可寻址性,就可能会有实际存储。
所以这里更准确的理解是:
#define本质上不是对象,没有对象级存储语义const是语言中的常量对象或常量限定,是否实际分配独立内存与用法和优化有关
从调试角度看,const 也更友好。因为它作为正常符号存在,调试器更容易识别;而宏在展开后,很多时候你看到的已经不是宏名本身了,可读性和可调试性都会差一些。
19.4. 作用域和使用场景不同
#define 不严格受 C++ 作用域系统管理,它更像是从定义位置开始生效的一条替换规则,通常一直持续到 #undef 或文件结束。
而 const 遵循正常的语言作用域规则,可以有块作用域、文件作用域、类作用域等。
例如:
void func() {
const int MAX = 100;
}
这里 MAX 只在 func 内有效;但如果是宏,往往没有这么自然的作用域控制。
所以在使用场景上,两者也不一样。#define 更适合做预处理层面的事情,比如条件编译、头文件保护、编译开关这类场景;const 更适合表达真正意义上的常量。
例如:
#define DEBUG
#ifdef DEBUG
// 调试代码
#endif
这种就很适合用宏,因为这是预处理条件判断。
但如果只是想定义一个只读常量值,例如最大长度、缓冲区大小、状态码等,通常就更适合写成:
const int MaxSize = 100;
在现代 C++ 里,如果还要求编译期常量语义,很多时候会进一步使用 constexpr,而不是继续依赖 #define。
20. 计算类的大小
sizeof(class) 的计算规则,核心要抓住三点:
第一,sizeof 统计的是对象实例中真正占用的内存。
第二,静态成员不属于对象本身,所以不会计入对象大小。
第三,类的大小还会受到对齐影响,不能只看成员字节数简单相加。
20.1. 几个基本规则
空类即使没有任何成员,sizeof 也不是 0,而是 1。
因为 C++ 规定类对象实例必须有独立地址,空类实例也要占一个字节,用来保证不同对象能够区分。
普通成员变量要计入对象大小,静态成员变量不计入。
因为静态成员属于整个类,不属于某个具体对象。
如果类中有虚函数,通常会多出一个虚函数表指针,也就是 vptr。
在 32 位环境下通常占 4 字节,在 64 位环境下通常占 8 字节。
不过类的最终大小仍然要结合对齐来看,所以这类题默认环境下常写成 4(32位) / 8(64位)。
20.2. 分析几个类
class A {};
这个类是空类,没有任何成员。
但空类对象也必须有独立地址,所以:
sizeof(A) = 1
这里的关键点不是“它什么都没有”,而是“它必须能实例化,并且不同对象地址不能完全一样”,所以最少占 1 字节。
class A { virtual void Fun() {} };
这个类有虚函数。
一旦类中有虚函数,编译器通常会为对象放一个虚函数表指针 vptr,用于运行时找到对应的虚函数表。
因此这个类的大小通常就是一个指针的大小:
sizeof(A) = 4 // 32位环境
sizeof(A) = 8 // 64位环境
所以这种题的标准回答一般是:
sizeof(A) = 4(32bit) / 8(64bit)
准确说应该是“通常如此”,因为 vptr 属于编译器实现细节。
class A { static int a; };
这个类里只有一个静态成员变量。
静态成员不属于某个对象本身,而是属于整个类,所以不计入对象大小。
因此这个类本身仍然相当于空类:
sizeof(A) = 1
这一点很容易和普通成员混淆,要特别注意。
class A { int a; };
这个类里有一个普通的 int 成员变量。
对象中真正存着这个 int,所以类大小至少要能容纳一个 int。
通常:
sizeof(int) = 4
因此:
sizeof(A) = 4
class A { static int a; int b; };
这里虽然有两个成员,但其中 a 是静态成员,不计入对象大小;
真正属于对象的只有一个普通成员 b。
所以这个类的大小仍然只是一个 int 的大小:
sizeof(A) = 4
20.3. 这一题真正想考什么
这道题表面上是在问几个类的大小,实际上主要考三件事:
第一,空类大小不是 0,而是 1。
第二,静态成员不算进对象大小。
第三,虚函数会带来虚表指针,所以对象会额外占一个指针大小。
所以这几道题可以直接整理成:
class A {}; sizeof(A) = 1
class A { virtual void Fun() {} }; sizeof(A) = 4(32bit) / 8(64bit)
class A { static int a; }; sizeof(A) = 1
class A { int a; }; sizeof(A) = 4
class A { static int a; int b; }; sizeof(A) = 4
21. 面向对象的三大特性,并举例说明
C++ 面向对象的三大特性是:封装、继承、多态。
这三者分别解决的是不同问题:
- 封装:把数据和操作数据的方法组织在一起,并控制外部访问权限
- 继承:让新类在已有类基础上复用和扩展功能
- 多态:同样的接口,在不同对象上表现出不同的行为
21.1. 封装
封装的核心思想,就是把对象的数据和操作这些数据的方法放在一起,同时对外隐藏实现细节,只暴露必要接口。
也就是说,类不仅仅是数据的集合,还是“数据 + 行为”的统一体。
外部不应该随意直接改对象内部状态,而应该通过类提供的接口去访问。
在 C++ 中,封装主要通过访问控制来体现:
public:对外公开private:类外不能直接访问protected:类外不能直接访问,但派生类可以访问
例如:
class BankAccount {
private:
double balance;public:
BankAccount(double b) : balance(b) {} void deposit(double money) {
if (money > 0) balance += money;
} double getBalance() const {
return balance;
}
};
这里 balance 是私有的,外部不能直接随意修改,只能通过 deposit 和 getBalance 这样的接口操作。
这就是封装:隐藏内部实现,控制访问方式。
所以封装的意义主要在于:
- 保护数据,防止被外部随意破坏
- 降低耦合,外部只关心接口,不关心内部实现
- 提高可维护性,内部实现变了,只要接口不变,外部代码通常不用改
21.2. 继承
继承的核心思想是:在已有类的基础上扩展出新类,复用已有代码和行为。
被继承的类叫基类(父类),新产生的类叫派生类(子类)。
派生类可以继承基类已有的成员,也可以增加自己的新成员,或者重写基类中的虚函数。
例如:
class Animal {
public:
void eat() {
cout << "动物会吃东西" << endl;
}
};class Dog : public Animal {
public:
void bark() {
cout << "狗会叫" << endl;
}
};
这里 Dog 继承了 Animal,所以 Dog 对象既可以调用 eat(),也可以调用自己新增的 bark()。
Dog d;
d.eat();
d.bark();
这就体现了继承的作用:代码复用和功能扩展。
- 普通继承:直接继承基类已有成员和行为
- 纯虚函数:强调接口约束,让子类必须自己实现
另外,继承和组合不是一回事。
继承表示“is-a”的关系,比如“狗是一种动物”;
组合表示“has-a”的关系,比如“汽车有一个发动机”。
两者都能实现代码复用,但语义不同,不能混着说成“通过组合实现继承”。
所以继承最核心的理解就是:
子类复用父类已有功能,并在此基础上继续扩展。
21.3. 多态
多态的核心思想是:同样的接口,作用于不同对象时,会产生不同的行为。
例如都调用 speak(),猫和狗的实现不一样,这就是多态。
在 C++ 中,面试里说“多态”时,通常主要指运行时多态,它一般需要满足三个条件:
- 有继承关系
- 基类中有虚函数
- 通过基类指针或引用调用虚函数
例如:
class Animal {
public:
virtual void speak() {
cout << "动物发出声音" << endl;
}
};class Dog : public Animal {
public:
void speak() override {
cout << "汪汪" << endl;
}
};class Cat : public Animal {
public:
void speak() override {
cout << "喵喵" << endl;
}
};
通过基类指针调用:
Animal* p1 = new Dog();
Animal* p2 = new Cat();p1->speak(); // 汪汪
p2->speak(); // 喵喵
这里虽然调用形式都是 speak(),但因为实际对象不同,执行结果不同,这就是多态。
多态的意义在于:
- 调用代码更统一
- 扩展性更好
- 面向接口编程,而不是面向具体实现编程
比如以后再增加 Bird 类,只要也重写 speak(),原来使用 Animal* 的代码通常不用改。
21.4. 多态中“早绑定”和“晚绑定”的理解
原笔记里提到“多态和非多态的本质区别就是函数地址早绑定还是晚绑定”,这个方向是对的,但表达可以更准确一点。
在 C++ 中:
- 非虚函数调用通常在编译期就能确定调用哪个函数,这叫静态绑定或早绑定
- 虚函数调用往往要到运行时根据对象的实际类型决定调用哪个函数,这叫动态绑定或晚绑定
例如:
class Base {
public:
virtual void func() { cout << "Base" << endl; }
};class Derived : public Base {
public:
void func() override { cout << "Derived" << endl; }
};
当写:
Base* p = new Derived();
p->func();
编译时只知道 p 是 Base*,但运行时才知道它实际指向 Derived 对象,所以最后调用的是 Derived::func()。
这就是运行时多态,也是晚绑定。
不过也要注意,C++ 里“多态”广义上还可以包括函数重载、运算符重载这类编译期多态。
但在“三大特性”这个题里,一般默认回答的重点还是基于虚函数的运行时多态。
21.5. 这三大特性的关系
这三者不是孤立的,经常是配合使用的。
封装负责把对象组织好,并保护内部状态;
继承负责在已有类基础上扩展;
多态负责让统一接口在不同派生类对象上表现不同。
可以简单理解为:
- 封装解决“怎么把对象设计好”
- 继承解决“怎么复用和扩展”
- 多态解决“怎么统一调用和灵活扩展”
所以它们共同构成了面向对象设计的核心思想。
22. 多态的实现
多态一般说的就是面向对象里的多态,通常指继承 + 虚函数实现的运行时多态。
也就是说,同样是通过基类接口去调用函数,实际执行的却可能是不同子类中的实现。
不过从更宽一点的角度看,多态也可以分成静态多态和动态多态。
只是平时如果不特别区分,面试里说“多态”,大多数情况下默认指的还是动态多态。
22.1. 静态多态和动态多态
静态多态是在编译期就能确定到底调用哪个函数。
最典型的是函数重载,另外模板在更广义上也常被看作静态多态的一种。
例如函数重载时,编译器会根据函数名、参数个数、参数类型等信息,在编译阶段就决定该调用哪一个函数,所以它不依赖运行时对象类型。
动态多态是在运行期才决定调用哪个函数。
它通常通过基类定义虚函数,子类重写虚函数,再通过基类指针或基类引用调用来实现。
所以可以这样理解:
- 静态多态:编译期决定调用哪个函数
- 动态多态:运行期决定调用哪个函数
不过在面向对象语境里,真正最核心、最常考的还是动态多态。
22.2. 动态多态成立的条件
C++ 中动态多态一般要满足三个条件:
第一,必须有继承关系。
也就是要有基类和派生类。
第二,基类中要有虚函数。
只有把函数声明为 virtual,编译器才会为它建立动态绑定机制。
第三,要通过基类指针或基类引用调用这个虚函数。
如果直接用对象本身调用,很多情况下不会体现出运行时多态的效果。
所以多态最典型的形式就是:
- 父类定义虚函数
- 子类重写这个虚函数
- 用父类指针或引用指向子类对象
- 通过父类接口调用虚函数
这样在运行时才会根据对象真实类型决定执行哪个版本。
22.3. 动态多态为什么能实现
动态多态的底层实现,一般和虚函数表以及虚函数表指针有关。
通常情况下,只要类中有虚函数,编译器就会为这个类维护一张虚函数表。
对象内部通常会有一个隐藏的指针,叫虚函数表指针,也就是常说的 vptr,它指向该类对应的虚函数表。
当通过基类指针去调用虚函数时,程序不会在编译期把函数地址完全写死,而是会在运行时通过对象里的 vptr 找到虚函数表,再从表里找到真正要调用的函数地址。
因此,如果基类指针实际指向的是不同子类对象,那么虽然调用形式相同,最终找到的函数地址却可能不同,这就实现了多态。
所以动态多态的本质可以理解为:
- 非虚函数:编译期确定调用地址,属于早绑定
- 虚函数:运行期根据对象实际类型确定调用地址,属于晚绑定
22.4. 重载算不算多态
如果按广义理解,重载可以算静态多态,因为它确实表现为“同一个函数名对应多种形式”,并且编译器在编译阶段决定调用哪一个版本。
但如果按狭义、面向对象语境理解,平时说“多态”一般不是指重载,而是指基于继承和虚函数的动态多态。
所以面试时更稳妥的回答方式是:
重载通常可以看作静态多态,但面向对象三大特性里提到的多态,一般默认指的是动态多态,也就是继承加虚函数实现的运行时多态。
22.5. 子类是否一定要重写父类虚函数
如果父类中的是普通虚函数,子类可以重写,也可以不重写。
如果子类不重写,那么调用时就继承父类版本。
如果父类中的是纯虚函数,那么子类通常必须实现它;否则子类也会成为抽象类,不能实例化。
所以更准确地说:
- 普通虚函数:子类可以选择重写
- 纯虚函数:如果子类不重写,那么子类仍然是抽象类,不能创建对象
纯虚函数的作用,本质上就是定义一个统一接口,强制派生类去实现具体行为。
这也是面向对象里“接口规范”的一种体现。
22.6. 纯虚函数和抽象类
纯虚函数一般写成:
virtual void func() = 0;
只要一个类中存在纯虚函数,这个类通常就是抽象类,不能直接实例化。
它的意义不在于自己完成功能,而在于规定一套接口标准,让派生类必须按照这套规则来实现。
所以纯虚函数常用来表达:
- 这个类只负责定义接口
- 具体实现交给子类完成
这也是为什么很多设计里会用抽象基类作为统一父类。
23. 虚函数相关(虚函数表、虚函数指针),虚函数的实现原理
虚函数这一节,核心就是理解一件事:
为什么同样是通过基类指针或引用调用函数,最后却能在运行时执行到派生类版本。
表面上看,这是 C++ 多态;底层上看,通常依赖的是虚函数表和虚函数表指针。
23.1. 虚函数的基本现象
在基类中把成员函数声明为 virtual,然后在派生类中重写它,之后如果通过基类指针或基类引用去调用这个函数,就会在运行时根据对象的实际类型决定调用哪个版本。
例如:
class Base {
public:
virtual void func() {
cout << "Base::func" << endl;
}
};class Derived : public Base {
public:
void func() override {
cout << "Derived::func" << endl;
}
};
如果写:
Base* p = new Derived();
p->func();
那么虽然 p 的静态类型是 Base*,但它实际指向的是 Derived 对象,所以运行时会调用 Derived::func()。
这就是虚函数实现多态的表象。
23.2. 虚函数表和虚函数指针
通常情况下,只要一个类中有虚函数,编译器就会为这个类生成一张虚函数表。
虚函数表本质上可以理解为一个表,里面存放的是该类虚函数对应的函数地址。
同时,编译器通常还会在对象内部放一个隐藏成员,也就是虚函数表指针,常记作 vptr。
这个指针指向当前对象所属类型对应的虚函数表。
所以一般可以这样理解:
- 虚函数表:存放虚函数地址的表
- 虚函数指针:对象内部隐藏的指针,指向所属类的虚函数表
要注意,这是一种典型实现方式,也是面试中默认讲法。
标准并没有强制规定必须叫 vtable、vptr,也没有强制要求必须按这种形式实现,但主流编译器基本都是这种思路。
23.3. 派生类和虚函数表的关系
如果基类中有虚函数,那么派生类也会成为支持多态的类型。
不管派生类是否重写这个虚函数,编译器通常都会为派生类维护自己对应的虚函数表。
如果派生类重写了某个虚函数,那么派生类虚函数表中对应位置通常会换成派生类自己的函数地址。
如果派生类没有重写,那么那个位置通常仍然保留基类版本的地址。
所以更准确地说,不是“派生类中自然一定有自己定义的虚函数”,而是:
只要继承体系中有虚函数,派生类对象通常也会带有虚函数机制,并对应有自己的虚函数表布局。
23.4. 虚函数调用过程
虚函数调用之所以能在运行时决定具体调用哪个函数,关键就在于调用时不是直接写死函数地址,而是要先通过对象找到它的虚函数表。
大致过程可以理解为:
第一步,先通过对象内部的 vptr 找到该对象实际类型对应的虚函数表。
第二步,在虚函数表中找到这个虚函数对应的入口地址。
第三步,跳转到这个地址去执行函数。
所以当基类指针指向不同类型对象时,即使写的都是:
p->func();
最终也可能走到不同的函数地址。
例如:
Base* p1 = new Base();
Base* p2 = new Derived();
p1->func(); // 调用 Base::func
p2->func(); // 调用 Derived::func
调用形式相同,但因为 p1 和 p2 所指对象的实际类型不同,它们对象内部的 vptr 指向的虚函数表也不同,所以最终找到的函数地址不同。
这就是动态多态的底层原理。
23.5. vptr 什么时候初始化
通常可以说,vptr 的设置发生在对象构造过程中,由编译器插入相关代码完成。
很多资料会简单说“在构造函数中完成初始化”,这个说法方向是对的,但更严谨一点应说:
vptr通常是在构造阶段由编译器生成的代码完成设置的。
因为这并不一定是你手写构造函数代码本身在做,而是编译器在对象构造流程里帮你安排好的。
同理,在析构过程中,对象的虚函数机制状态也会随着析构层次变化而调整。
23.6. 为什么必须通过基类指针或引用才能体现多态
这是虚函数题里很常见的点。
多态真正生效,一般要求通过基类指针或基类引用去调用虚函数。
因为这样调用时,编译器只知道“接口类型是基类”,真正对象类型要到运行时才能确定,所以才需要动态绑定。
如果直接用对象调用,例如:
Derived d;
d.func();
那么对象类型在编译期就是确定的,通常不涉及这种通过基类接口分发的多态效果。
另外还要注意区分对象切片和指针/引用多态。
例如:
Derived d;
Base b = d;
b.func();
这里是把 Derived 对象赋值给 Base 对象,发生的是对象切片,派生类那部分被切掉了。
此时 b 就是一个独立的 Base 对象,不再体现派生类行为。
而如果写成:
Derived d;
Base* p = &d;
p->func();
这才是基类指针指向派生类对象,才是多态场景。
23.7. 如果没有 virtual,会发生什么
如果基类中的函数没有声明成 virtual,那么即使派生类中写了一个同名函数,通过基类指针调用时,也不会产生运行时多态。
例如:
class Base {
public:
void func() {
cout << "Base::func" << endl;
}
};class Derived : public Base {
public:
void func() {
cout << "Derived::func" << endl;
}
};
再写:
Derived d;
Base* p = &d;
p->func();
这里调用的仍然是 Base::func()。
原因不是“截取了基类部分内存”这种说法本身,而是:
非虚函数调用采用静态绑定,编译器在编译期就根据指针或引用的静态类型决定了要调用哪个函数。
这里 p 的静态类型是 Base*,所以编译器直接把 p->func() 绑定为 Base::func(),不再给运行时动态分发机会。
也就是说:
- 有
virtual:运行时通过虚函数机制决定调用版本 - 没有
virtual:编译期按静态类型直接绑定
这就是早绑定和晚绑定的区别。
23.8. 虚函数实现多态的条件
把这一节收拢起来,其实虚函数实现多态通常要满足下面几个条件:
第一,要有继承关系。
第二,基类中要有虚函数。
第三,派生类对虚函数进行重写。
第四,要通过基类指针或基类引用调用这个虚函数。
其中第三条有时可以稍微放宽:
如果派生类不重写,那么调用时仍可能落到基类虚函数版本;只是这时没有体现出“不同对象不同实现”的多态效果。
所以面试里最稳妥的说法是:
动态多态通常依赖继承、虚函数以及基类指针或引用调用;底层一般通过对象中的虚函数表指针找到对应类的虚函数表,再查表调用真正函数地址来实现。
24. 编译器处理虚函数表应该如何处理
这一节的核心,不是死记“拷贝、替换、追加”这几个词,而是要真正理解:编译器在派生类中要重新组织虚函数表,让“基类接口”在运行时能正确落到派生类实现上。
可以先把结论抓住:
对于派生类,编译器通常会按照基类已有的虚函数布局,构造出派生类自己的虚函数表;如果派生类重写了基类虚函数,就把对应表项替换成派生类版本;如果派生类又引入了新的虚函数,就把这些新函数加入到派生类相关的虚表中。
如果是多继承,并且多个基类都有虚函数,那么派生类对象里通常不止一个 vptr,而是每个多态基类子对象各自带一个 vptr,因此也通常会对应不止一张虚表。

24.1. 单继承时,编译器大致怎么做
如果先不考虑多继承,只看最简单的单继承,那么编译器处理派生类虚函数表,通常可以直观理解成三步:
第一步,以基类虚函数表的布局为基础。
也就是说,基类里原来有哪些虚函数、顺序怎样,派生类通常要先继承这套“槽位结构”。
第二步,检查派生类有没有重写基类虚函数。
如果重写了,就把对应槽位里的函数地址改成派生类的版本。这样以后通过基类指针调用虚函数时,查到的就会是派生类实现。
第三步,检查派生类有没有新增虚函数。
如果有,通常会把这些新的虚函数加入派生类自己的虚表中。
所以“拷贝、替换、追加”这个说法,放在单继承语境里可以作为一个很好记的直观模型。
只是更严谨一点说,不一定真的是机械地“把整张表原封不动拷贝一份”,而是编译器按照基类虚函数布局为派生类构造对应的虚表结构。
24.2. 多继承时,为什么会有多个 vptr
这张图里最关键的点,其实就是多继承场景。
例如:
struct B {
long b;
virtual void foo() {}
virtual void bar() {}
};
struct C {
long c;
virtual void quz() {}
virtual void baz() {}
};
struct D : public B, public C {
long d;
virtual void bar() {} // override B::bar
virtual void quz() {} // override C::quz
virtual void qux() {}
};
这里 B 和 C 都是带虚函数的类,所以它们本身都是多态类。
当 D 同时继承 B 和 C 时,D 对象内部通常会包含两个“基类子对象”:
- 一个
B子对象 - 一个
C子对象
而由于这两个基类子对象都需要支持各自那套虚函数调用机制,所以在典型实现里,D 对象内部通常也会有两个 vptr:
- 一个属于
B这部分 - 一个属于
C这部分
所以多继承下不能再简单理解成“一个对象只有一个虚函数表指针”。
更准确的理解应该是:
一个对象里,每个带虚函数的基类子对象,通常都要有能力找到自己那部分对应的虚表。
这也是为什么图里 D 会出现 vptr1 和 vptr2。
再看表项变化:
对于 B 这条继承链,D 重写了 B::bar,但没有重写 B::foo。 所以和 B 相关的虚表里,foo 仍然指向 B::foo,而 bar 会被替换成 D::bar。 另外,D自己新增了 qux(),它通常会被放进派生类相关的主虚表中,所以图里第一张表里出现了 D::qux。
对于 C 这条继承链,D 重写了 C::quz,但没有重写 C::baz。
所以和 C 相关的那张虚表里,quz 会替换成 D::quz,而 baz 仍然保留 C::baz。
这正是图中表达的核心含义。
不过这里有一个点要说严谨一点:
图里的内存布局和虚表形式,是典型实现示意图,有助于理解,但不是 C++ 标准强制规定的唯一布局。不同编译器、不同 ABI 下,具体虚表内容里还可能有 RTTI 信息、偏移量、析构函数表项,甚至 thunk 跳板函数,所以面试里不要把图里的排布说成“标准唯一实现”。
24.3. 主基类、次虚表,以及 pb、pc、pd 为什么地址不同
在很多实现里,编译器会选择一个基类作为主基类,派生类新增的虚函数通常优先放到这条主虚表里。图中就是把 B 这一支看成主要那一支,所以 D::qux 出现在第一张虚表中。
而另一个有虚函数的基类,比如这里的 C,则通常会对应自己的那张“次虚表”或次级虚表。
再看这个转换:
D* pd = new D();
B* pb = pd;
C* pc = pd;
这里真正重要的是:pb、pc、pd 三个指针值通常不完全一样。
pd 指向整个 D 对象的起始位置。pb 指向 D 对象中的 B 子对象。如果 B 恰好是主基类并且位于对象起始处,那么很多实现里 pb 和 pd 的地址会一样。
但 pc 指向的是 D 对象中的 C 子对象,它通常位于对象内部的另一个偏移位置,所以 pc 往往和 pd 不同。
也就是说:
pd:指向完整D对象pb:指向其中的B子对象pc:指向其中的C子对象
所以从 D* 转成 C* 时,编译器往往需要做一次指针调整,把地址挪到 C 子对象的起始位置。
这也解释了为什么多继承下的多态调用比单继承更复杂。
例如你写:
pb->bar();
pc->quz();
pb->bar() 会通过 B 那部分的 vptr 去找第一张虚表,最后定位到 D::bar()。pc->quz() 会通过 C 那部分的 vptr 去找第二张虚表,最后定位到 D::quz()。
而且这里还可能涉及一个更细的底层问题:
当通过 C* 去调用最终落到 D::quz() 时,this 指针一开始是指向 C 子对象的,不一定正好就是完整 D 对象的起始地址。为了让 D::quz() 内部拿到正确的 this,编译器有时会借助 thunk 去做一次 this 指针调整。这个点如果面试官追问到多继承底层,可以顺带提一下,会显得理解更深入。
24.4. 这一节最适合怎么记
所以“编译器如何处理派生类虚函数表”这一节,最适合这样理解:
对于单继承,派生类虚表通常是在基类虚表布局基础上构造出来的:重写的虚函数替换原表项,新增的虚函数再追加进去。
对于多继承,如果多个基类都有虚函数,那么派生类对象里通常会有多个 vptr,分别对应不同基类子对象的虚函数表。通过不同基类指针调用虚函数时,会先定位到对应子对象的 vptr,再查对应虚表,从而在运行时找到正确函数。
而 D* 转成不同基类指针时,指针值本身也可能不同,因为它们分别指向不同的基类子对象。
25. 析构函数一般写成虚函数的原因
析构函数一般写成虚函数,最核心的原因是:
为了保证通过基类指针删除派生类对象时,能够按正确顺序调用完整的析构函数链。
如果一个类会被当作基类使用,也就是可能出现“基类指针指向派生类对象”这种情况,那么它的析构函数通常就应该声明成虚函数。
25.1. 为什么必须是虚函数
先看最典型的场景:
class Base {
public:
~Base() {
cout << "Base destructor" << endl;
}
};class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor" << endl;
}
};
如果写:
Base* p = new Derived();
delete p;
这里 p 的静态类型是 Base*,但它实际指向的是一个 Derived 对象。
如果 Base 的析构函数不是虚函数,那么 delete p 时,编译器不会通过动态绑定去找“这个对象真正的析构函数链”,而是按 Base* 这个静态类型去处理。这样通常只会调用 Base 的析构函数,而不会正确调用 Derived 的析构函数。
结果就是:
- 派生类自己的资源得不到释放
- 对象析构不完整
- 程序行为属于未定义行为
通过非虚基类析构函数删除派生类对象是未定义行为。
内存泄漏只是其中一种常见后果,不是唯一后果。
25.2. 虚析构函数是如何解决这个问题的
如果把基类析构函数写成虚函数:
class Base {
public:
virtual ~Base() {
cout << "Base destructor" << endl;
}
};class Derived : public Base {
public:
~Derived() {
cout << "Derived destructor" << endl;
}
};
再写:
Base* p = new Derived();
delete p;
这时 delete p 会先根据对象的实际类型找到正确的析构函数入口,也就是先调用 Derived 的析构函数,再自动调用 Base 的析构函数。
析构顺序是:
- 先析构派生类部分
- 再析构基类部分
这才符合对象构造和析构的规律。因为对象构造时是先构造基类,再构造派生类;析构时正好反过来,先析构派生类,再析构基类。
所以虚析构函数的作用,本质上就是:
让析构也能参与运行时多态,保证 delete 基类指针时能执行到实际对象对应的完整析构流程。
25.3. 为什么说“多态基类”通常必须有虚析构函数
这里要抓住一个关键词:多态基类。
只要一个类打算被当作基类使用,并且可能通过基类指针或基类引用操作派生类对象,那么它通常就应该提供虚析构函数。
因为一旦出现下面这种写法:
Base* p = new Derived();
delete p;
就必须依赖虚析构函数,才能安全地销毁对象。
所以这条经验可以直接记成:
只要一个类有可能作为多态基类使用,它的析构函数就应该是虚函数。
很多时候,甚至只要类里已经有别的虚函数了,析构函数通常也应该顺手写成虚函数,这几乎是 C++ 面向对象设计里的基本习惯。
25.4. 不写虚析构函数会出现什么问题
不写虚析构函数,最直接的问题就是派生类析构不执行。
例如:
class Base {
public:
~Base() {}
};
class Derived : public Base {
private:
int* data;
public:
Derived() {
data = new int[100];
}
~Derived() {
delete[] data;
}
};
如果这样删除:
Base* p = new Derived();
delete p;
那么 Derived::~Derived() 可能根本不会被调用,data 对应的内存就无法正确释放。
这就是最典型的“派生类资源泄漏”问题。
这种写法是未定义行为,资源泄漏只是常见表现之一。
25.5. 什么时候可以不写成虚函数
也不是所有类的析构函数都必须写成虚函数。
如果一个类根本不是拿来做基类的,或者虽然是基类,但你明确保证绝不会通过基类指针删除派生类对象,那么析构函数可以不是虚函数。
例如一个只作为普通值类型使用的类,根本没有多态删除场景,就没必要为析构函数引入虚机制。
所以更准确的说法不是:
“析构函数一般都要写成虚函数”
而是:
作为多态基类的类,析构函数一般应写成虚函数。
这样更严谨。
25.6. 这一节怎么回答最合适
析构函数写成虚函数,是为了保证通过基类指针删除派生类对象时,能够根据对象实际类型先调用派生类析构,再调用基类析构,从而完成正确的析构链。如果基类析构函数不是虚函数,那么这种删除行为就是未定义行为,常见后果是派生类资源没有释放完全。因此,只要一个类可能作为多态基类使用,它的析构函数通常就应该声明为虚函数。
26. 构造函数为什么一般不定义为虚函数
更准确地说,不是“构造函数一般不定义为虚函数”,而是:
构造函数不能是虚函数。
原因可以从语言语义和底层实现两方面理解。
26.1. 从语义上看,构造对象时必须先知道对象的确切类型
虚函数的作用,是在已经有一个对象之后,通过基类指针或引用,在运行时根据对象的实际类型决定调用哪个函数。
也就是说,虚函数解决的是“对象已经存在了,我现在该调哪个版本”的问题。
但构造函数不一样。构造函数本身的任务就是“把对象创建出来”。
在创建对象之前,编译器和程序必须先明确:到底要创建的是哪一种对象,是 Base,还是 Derived。只有对象类型先确定了,编译器才知道:
- 需要分配多大的内存
- 应该调用哪一层构造函数
- 对象布局是什么样
- 成员该如何初始化
所以构造函数调用的前提,就是对象类型已经确定。
而虚函数依赖的是“先有对象,再根据对象实际类型做动态分发”。这两者在逻辑顺序上是冲突的。
也就是说:
构造函数是用来确定并创建对象的,虚函数是建立在对象已经存在的基础上做动态绑定的,所以构造函数不能设计成虚函数。
26.2. 从实现上看,虚函数机制依赖 vptr,而构造阶段对象本身还在建立中
C++ 里虚函数通常依赖虚函数表和虚函数表指针 vptr 来实现。
对象在构造完成之前,这套“完整对象对应的虚函数分发状态”其实还没有最终稳定下来。
更准确地说,对象是在构造过程中一步步建立起来的:
- 先构造基类部分
- 再构造成员部分
- 最后构造派生类自己的部分
在这个过程中,编译器会逐步设置和调整对象里的 vptr。
因此,虚函数机制本身是依赖“对象已经进入某个确定构造阶段”的,而构造函数本身正是这个建立过程的一部分,所以它不可能再反过来依赖一套已经完整建立好的虚调用机制去决定“该调哪个构造函数”。
所以从底层实现看,也可以理解为:
构造函数正在负责把对象搭起来,而虚函数调用需要借助对象内部已经建立好的虚函数机制;对象都还没构造完成,就不能指望用这套机制来决定构造函数本身。
26.3. 还可以顺带记一个常见点
虽然构造函数不能是虚函数,但构造函数内部可以写虚函数调用。
只是这时候不会表现出正常的运行时多态。
例如在基类构造函数里调用虚函数,调用到的通常是基类自己的版本,而不是派生类重写后的版本。
因为此时派生类部分还没有构造完成,当前对象还不能当作一个完整的派生类对象来看待。
这个点和“构造函数不能是虚函数”本质上是一致的:
构造阶段,对象的动态类型分发能力是受限制的。
26.4. 这一节最适合怎么记
构造函数不能是虚函数。
因为虚函数调用建立在“对象已经存在,并且可以根据实际类型做动态绑定”的基础上,而构造函数本身的作用正是创建对象。创建对象时必须先确定对象的确切类型,不能再靠虚函数机制去决定该调用哪个构造函数。并且从实现上看,虚函数依赖对象中的 vptr 和虚函数表,而这些机制本身也是在对象构造过程中逐步建立起来的。
27. 构造函数或析构函数中调用虚函数会怎样
一般不建议在构造函数或析构函数中调用虚函数。
因为这时候即使写的是虚函数调用,也不会表现出平时那种运行时多态效果。调用到的,通常是当前构造或析构阶段所属类自己的版本,而不是更下层派生类重写后的版本。
这背后的核心原因是:对象在构造和析构过程中,并不是始终处于“完整派生类对象”状态。
27.1. 在构造函数中调用虚函数会怎样
构造对象时,顺序是:
- 先构造基类部分
- 再构造成员
- 最后构造派生类部分
也就是说,当执行基类构造函数时,派生类那一部分其实还没有构造完成。
这时如果在基类构造函数里调用虚函数,编译器不会把它分发到派生类重写版本,而是会调用基类自己的版本。
例如:
class Base {
public:
Base() {
func();
}
virtual void func() {
cout << "Base::func" << endl;
}
};
class Derived : public Base {
public:
void func() override {
cout << "Derived::func" << endl;
}
};
如果写:
Derived d;
执行 Base 构造函数时,里面调用的 func() 不是 Derived::func(),而是 Base::func()。
原因不是“虚函数失效了”,而是:
在基类构造阶段,当前对象就被当作一个基类对象来看待,派生类部分还没有准备好,因此不会向下分发到派生类版本。
所以构造函数里调用虚函数,得到的通常不是“真实类型对应的版本”,而是“当前构造层次自己的版本”。
27.2. 在析构函数中调用虚函数会怎样
析构过程和构造相反,顺序是:
- 先析构派生类部分
- 再析构基类部分
也就是说,当执行基类析构函数时,派生类那部分其实已经析构完了。
这时如果在基类析构函数里调用虚函数,也不会再分发到派生类重写版本,而是调用基类自己的版本。
例如:
class Base {
public:
virtual ~Base() {
func();
}
virtual void func() {
cout << "Base::func" << endl;
}
};
class Derived : public Base {
public:
~Derived() {}
void func() override {
cout << "Derived::func" << endl;
}
};
当销毁 Derived 对象时,等执行到 Base 析构函数里的 func() 时,调用的通常也是 Base::func(),而不是 Derived::func()。
原因是:
到了基类析构阶段,派生类部分已经被销毁,当前对象不再被当作一个完整的派生类对象来看待,因此不会再向下分发到派生类版本。
27.3. 为什么会这样
虚函数的运行时分发,本质上依赖对象当前所处的构造/析构阶段。
在构造和析构过程中,编译器会让对象的虚函数行为对应当前正在构造或析构的那一层类,而不是始终对应最终派生类。
所以可以这样理解:
- 在基类构造函数中,对象被视为“基类对象”
- 在派生类构造函数中,对象才开始具备“派生类对象”语义
- 在派生类析构函数结束后,对象又退回到“基类部分还活着”的状态
- 到基类析构函数执行时,对象再次只按“基类对象”处理
因此,构造和析构阶段的虚函数调用,都不会得到我们平时理解的“按最终动态类型分发”的效果。
27.4. 为什么不建议这样写
不建议在构造函数或析构函数中调用虚函数,主要原因是:
第一,容易让人误以为会触发正常多态。
实际上并不会调用到更下层派生类版本,结果和直觉往往不一致。
第二,如果派生类版本依赖派生类成员状态,那么在构造阶段这些成员可能还没初始化,在析构阶段又可能已经被销毁。
所以即使语言允许这种调用,也不适合作为设计方式。
第三,如果调用涉及纯虚函数,问题会更严重。
因为在构造或析构阶段,如果最终落到当前类中的纯虚函数,而它没有可用定义,程序行为会出问题,很多实现下会直接异常终止。
所以这一节最核心的结论就是:
构造函数和析构函数中调用虚函数时,不会表现出通常意义上的运行时多态,而是调用当前构造或析构阶段所属类自己的版本。因此一般不建议这样写。
28. 析构函数的作用,如何起作用?
析构函数的作用,核心就是在对象生命周期结束时,负责做“收尾工作”。
如果构造函数主要负责对象创建时的初始化,那么析构函数就主要负责对象销毁前的清理。
它最常见的用途包括:
- 释放对象自己申请的资源
- 做必要的结束处理
- 保证对象离开作用域或被删除时,资源能够正确回收
这里的“资源”不只是内存,还可能包括:
- 堆内存
- 文件句柄
- 网络连接
- 锁
- 数据库连接等
所以析构函数本质上不是单纯“销毁变量”,而是:
在对象死亡前,自动执行清理逻辑。
28.1. 析构函数的基本特点
析构函数和类名相同,但前面要加 ~。
例如类名是 A,析构函数就是:
~A()
析构函数没有返回值,也没有参数。
一个类只能有一个析构函数,因此析构函数不能重载。
如果程序员没有自己写析构函数,编译器通常会自动生成一个默认析构函数。
所以析构函数的几个基本点可以概括为:
- 名字是在类名前加
~ - 没有参数
- 没有返回值
- 不能重载
- 一个类只能有一个析构函数
28.2. 析构函数什么时候会被调用
析构函数是在对象生命周期结束时自动调用的。
常见有几种情况。
第一种,局部对象离开作用域时。
例如函数里的局部对象,在函数结束时会自动析构。
第二种,delete 一个动态创建的对象时。
如果对象是通过 new 创建的,那么在 delete 时会调用析构函数。
第三种,程序结束时,全局对象和静态对象也会在适当时机析构。
也就是说,一般不需要程序员手动显式去“调用析构函数”,系统会在对象该销毁的时候自动调用它。
所以原笔记里“撤销对象时,编译器会自动调用析构函数”这个方向是对的,更准确可以说成:
当对象生命周期结束时,析构函数会被自动调用。
28.3. 析构函数到底清理什么
析构函数最重要的作用,是清理对象在生命周期内占用的资源。
例如:
class A {
private:
int* p;
public:
A() {
p = new int[10];
}
~A() {
delete[] p;
}
};
这里构造函数中申请了一块堆内存,析构函数中就负责把它释放掉。
如果不写析构函数,那么对象销毁时,p 这个指针变量本身虽然会消失,但它指向的那块堆内存不会自动释放,就可能造成资源泄漏。
所以析构函数真正清理的,不是“对象名字”本身,而是对象内部持有的那些外部资源。
这也是为什么析构函数特别重要:
对象生命周期结束时,必须把它占着的资源交回去。
28.4. 析构函数是如何起作用的
析构函数的起作用方式,可以理解成一句话:
对象一旦要销毁,系统就会自动执行析构函数中的代码。
例如局部对象:
void func() {
A obj;
}
当 func() 结束时,obj 离开作用域,就会自动调用 obj 的析构函数。
再例如动态对象:
A* p = new A;
delete p;
这里 delete p 做的事情并不只是“回收内存”,而是通常先调用对象的析构函数,再释放对象所占的内存空间。
所以析构函数真正“起作用”的方式,就是在对象被销毁的那个时刻,由系统自动执行它,从而完成清理。
28.5. 构造函数和析构函数的关系
构造函数和析构函数通常是成对理解的。
构造函数负责:
- 对象创建时初始化成员
- 申请资源
- 建立对象状态
析构函数负责:
- 对象销毁时清理成员
- 释放资源
- 撤销对象状态
所以可以简单理解为:
- 构造函数:负责“建立”
- 析构函数:负责“善后”
如果一个类在构造阶段申请了资源,那么通常就应该在析构阶段释放这些资源。
这也是面向对象里一个很重要的思想:
谁申请,谁释放;谁负责建立,谁负责清理。
28.6. 编译器自动生成的析构函数有什么作用
如果程序员不自己写析构函数,编译器一般会生成默认析构函数。
默认析构函数本身也会参与对象销毁过程。
但要注意,默认析构函数通常只会做成员对象的析构等默认行为。
如果类里有指针,并且这个指针指向的是程序员自己 new 出来的堆内存,那么默认析构函数并不会自动帮你 delete 那块堆内存。
例如:
class A {
public:
int* p;
A() {
p = new int[10];
}
};
这里如果没有自定义析构函数,那么对象销毁时,p 这个指针成员会跟着对象一起消失,但它指向的堆内存不会自动释放。
所以是否需要自己写析构函数,关键看类有没有自己管理资源。
如果类只是普通值成员,往往默认析构就够了;
如果类自己申请了外部资源,通常就需要自定义析构函数。
28.7. 析构函数一般定义成什么访问权限
析构函数一般定义为 public,这样对象在正常使用和销毁时都可以访问到它。
这也是最常见的写法。
当然,在一些特殊设计里,析构函数也可能不是 public,但面试题里通常回答“一般定义为公有成员”就可以了。
28.8. 这一节最合适怎么理解
析构函数的作用,就是在对象生命周期结束时自动执行清理工作,主要用于释放对象占用的资源。
它和构造函数作用相反:构造函数负责初始化和建立对象,析构函数负责销毁前的收尾和资源回收。析构函数没有参数、没有返回值、不能重载,一个类只能有一个。对象离开作用域或被 delete 时,析构函数会自动调用,从而保证对象管理的资源能够被正确释放。
29. 构造函数的执行顺序?析构函数的执行顺序?
这一题的核心就是一句话:
构造是由内到外、由先到后;析构正好反过来。
也就是说,创建一个派生类对象时,要先把它依赖的部分构造好,再构造它自己;
销毁一个派生类对象时,要先销毁它自己,再逐层销毁它依赖的部分。
29.1. 构造函数的执行顺序
构造一个派生类对象时,顺序通常是:
第一,构造基类部分。
如果有多个基类,那么构造顺序按照继承列表中声明的顺序决定,而不是按构造函数初始化列表里的书写顺序决定。
第二,构造成员对象。
如果类中有多个成员对象,那么它们的构造顺序按照在类中声明的顺序决定,而不是按初始化列表中的顺序决定。
第三,执行派生类自己的构造函数体。
也就是等基类和成员都准备好之后,最后才轮到派生类自己完成构造。
所以整体顺序可以概括为:
基类 → 成员对象 → 派生类自身
例如:
class Base1 {};
class Base2 {};
class Member1 {};
class Member2 {};
class Derived : public Base1, public Base2 {
private:
Member1 m1;
Member2 m2;
public:
Derived() {}
};
那么构造顺序就是:
Base1Base2m1m2Derived
这里即使你在初始化列表里把顺序写乱了,真正执行时也不会按你初始化列表的顺序来,而是仍然按:
- 基类:继承声明顺序
- 成员:类内声明顺序
这是面试里很常考的点。
29.2. 为什么不是按初始化列表顺序
很多人容易误以为,初始化列表里谁写在前面就先构造谁。
其实不是。
例如:
class A {};
class B {};class Test {
private:
A a;
B b;
public:
Test() : b(), a() {}
};
虽然初始化列表里写的是 b(), a(),
但真正构造时还是先构造 a,再构造 b,因为它们在类中的声明顺序是 a 在前,b 在后。
这样设计是为了让对象构造顺序稳定、可预测,不会因为初始化列表写法变化而改变对象实际构造逻辑。
29.3. 析构函数的执行顺序
析构顺序和构造顺序正好相反。
销毁一个派生类对象时,顺序通常是:
第一,执行派生类自己的析构函数体。
第二,析构成员对象。
如果有多个成员对象,析构顺序是它们声明顺序的逆序。
第三,析构基类部分。
如果有多个基类,析构顺序是它们继承声明顺序的逆序。
所以整体顺序可以概括为:
派生类自身 → 成员对象 → 基类
这和你原笔记里的大方向是一致的,只是更完整一点要补上“逆序”这个点。
例如前面的类:
class Derived : public Base1, public Base2 {
private:
Member1 m1;
Member2 m2;
public:
~Derived() {}
};
析构顺序就是:
Derivedm2m1Base2Base1
可以看到,确实是把构造顺序完全反过来了。
29.4. 为什么析构顺序要反过来
这也很好理解。
构造时,派生类依赖于基类和成员对象先存在,所以必须先构造基类和成员,再构造自己。
析构时则相反,要先把最外层的派生类自己清理掉,再销毁它依赖的成员和基类。
本质上就是:
谁后构造,谁先析构。
这和栈的后进先出思路是很像的。
29.5. 这一节最适合怎么记
构造函数执行顺序是:先基类,再成员对象,最后派生类自身。
其中多个基类按继承表中的声明顺序构造,多个成员对象按类中声明顺序构造,而不是按初始化列表顺序构造。
析构函数执行顺序正好相反:先派生类自身,再成员对象,最后基类。
多个成员对象按声明顺序逆序析构,多个基类按继承表顺序逆序析构。
30. 纯虚函数(应用于接口继承和实现继承)
纯虚函数的核心作用,是把“这个函数必须存在”这件事先规定下来,但把“这个函数具体怎么实现”交给派生类决定。
它通常写成:
virtual 返回类型 函数名(参数列表) = 0;
只要一个类中包含纯虚函数,这个类通常就是抽象类,不能实例化。
它存在的意义,更多不是为了直接创建对象,而是为了给派生类提供一套统一接口。
30.1. 纯虚函数为什么会出现
普通虚函数的意思是:基类先给出一个默认实现,派生类可以选择重写,也可以不重写。
而纯虚函数更进一步,它表达的是:
这个函数在这个类层面只定义“规范”,不提供完整可直接使用的默认行为,派生类应当自己实现。
所以纯虚函数特别适合描述“接口”。
例如“动物都会叫”“图形都能求面积”这种场景,基类可以规定必须有 speak()、area() 这样的接口,但基类本身并不一定知道具体该怎么实现,因为不同派生类的实现完全不同。
例如:
class Shape {
public:
virtual double area() = 0;
};
这里 Shape 只是在规定:所有图形都应该能求面积。
至于圆形怎么算、矩形怎么算,要由具体派生类完成。
30.2. 接口继承和实现继承怎么理解
如果一个函数是纯虚函数,那么派生类继承到的主要是“接口要求”,也就是“你必须有这个函数”。
这种更偏向接口继承。
如果一个函数是普通虚函数,基类已经给出实现,那么派生类既继承了接口,也继承了默认实现;如果需要,还可以重写。
这种更偏向实现继承。
所以可以这样理解:
- 纯虚函数:更强调“继承接口”
- 普通虚函数:更强调“继承接口 + 继承默认实现”
例如:
class Base {
public:
virtual void f1() = 0; // 纯虚函数
virtual void f2() { // 普通虚函数
cout << "Base::f2" << endl;
}
};
这里:
f1()主要是在规定接口,派生类通常要自己实现f2()则是基类已经给出一个默认版本,派生类可以直接继承,也可以重写
30.3. 派生类必须重写纯虚函数吗
一般来说,如果派生类想成为一个可实例化的普通类,那么它就必须把继承下来的纯虚函数实现掉。
否则,这个派生类本身也仍然是抽象类,还是不能实例化。
例如:
class Base {
public:
virtual void func() = 0;
};
class Derived : public Base {
};
这里 Derived 没有实现 func(),所以 Derived 仍然是抽象类,不能创建对象。
如果改成:
class Derived : public Base {
public:
void func() override {
cout << "Derived::func" << endl;
}
};
这时 Derived 才变成可实例化类。
所以更准确地说:
不是“派生类一定必须重写纯虚函数”,而是“如果派生类不重写,那么它自己也会继续保持抽象类身份”。
30.4. 纯虚函数可以有实现吗
可以。
这是一个很容易被忽略的点。
虽然纯虚函数写成 = 0,表示它是纯虚函数,但这并不代表它绝对不能有函数定义。
在类外,仍然可以给它提供实现代码。
例如:
class Base {
public:
virtual void func() = 0;
};
void Base::func() {
cout << "Base::func" << endl;
}
这在语法上是合法的。
不过要注意,Base 依然是抽象类,依然不能实例化。
给纯虚函数提供定义,并不会让它失去“纯虚”这个属性。
那这个实现有什么意义?
它主要是给派生类提供一个“可以复用的公共基础实现”,派生类在自己的重写函数中,可以显式调用它。
例如:
class Derived : public Base {
public:
void func() override {
Base::func();
cout << "Derived::func" << endl;
}
};
所以更准确地说:
纯虚函数可以有实现,但它仍然是纯虚函数;这个实现通常不是给抽象类对象直接用的,而是给派生类在重写时复用的。
通常需要在派生类重写函数中,显式写
Base::func()去调用基类给纯虚函数提供的实现。
30.5. 纯虚函数的实际意义
纯虚函数最大的意义,是让基类能够只负责“定规则”,而不负责“定具体行为”。
这样做有几个好处:
第一,统一接口。
所有派生类都必须遵守同一套函数接口。
第二,强制规范。
继承这个基类的人必须去实现这些函数,否则类就不能实例化。
第三,便于多态。
通过基类指针或引用,可以统一调用这些函数,而具体行为由不同派生类决定。
所以纯虚函数本质上是面向对象里“接口抽象”的重要工具。
30.6. 这一节最适合怎么记
纯虚函数写成 virtual func() = 0;,它的作用是定义接口规范,让派生类必须提供实现。
只要类中有纯虚函数,这个类通常就是抽象类,不能实例化。纯虚函数更偏向接口继承,而普通虚函数更偏向接口和实现一起继承。
纯虚函数也可以有函数定义,但这不会改变类是抽象类的事实;这个定义通常是供派生类在重写时显式调用的。
31. 静态绑定和动态绑定的介绍
理解静态绑定和动态绑定,先要分清静态类型和动态类型。
静态类型,是变量在程序里声明时的类型,在编译期就确定了。
动态类型,是指指针或引用当前实际指向的对象类型,只有在运行时才能最终确定。
例如:
class Base {};
class Derived : public Base {};
Base* p = new Derived();
这里:
p的静态类型是Base*p所指对象的动态类型是Derived
静态绑定和动态绑定,本质上就是:函数调用到底是根据静态类型决定,还是根据动态类型决定。
31.1. 静态绑定和动态绑定分别是什么
静态绑定,也叫早绑定,是指在编译阶段就已经确定要调用哪个函数。
它绑定的是静态类型,所以调用结果主要取决于变量声明时的类型。
动态绑定,也叫晚绑定,是指在运行阶段才确定要调用哪个函数。
它绑定的是动态类型,所以调用结果取决于对象实际类型。
在 C++ 里,最典型的规则就是:
- 非虚函数是静态绑定
- 虚函数在满足多态条件时是动态绑定
例如:
class Base {
public:
void f() {
cout << "Base::f" << endl;
}
virtual void g() {
cout << "Base::g" << endl;
}
};
class Derived : public Base {
public:
void f() {
cout << "Derived::f" << endl;
}
void g() override {
cout << "Derived::g" << endl;
}
};
再看:
Base* p = new Derived();
p->f();
p->g();
这里:
p->f()调用的是Base::f(),因为f不是虚函数,属于静态绑定p->g()调用的是Derived::g(),因为g是虚函数,属于动态绑定
所以这一节最核心的区别就是:
静态绑定看声明类型,动态绑定看实际对象类型。
31.2. 动态绑定成立的条件
动态绑定不是“只要写了 virtual 就一定自动发生”,它通常要满足下面几个条件:
第一,要有继承关系。
第二,基类函数必须是虚函数。
第三,要通过基类指针或基类引用调用这个虚函数。
例如:
Derived d;
Base* p = &d;
Base& r = d;
这时候通过 p 或 r 去调用虚函数,才会体现运行时多态。
如果直接写:
Derived d;
d.g();
虽然 g 是虚函数,但这里对象类型在编译期就是明确的,调用并不体现“通过基类接口在运行时分发”的典型多态场景。
另外,这里平时主要讨论的是函数调用的绑定。
普通数据成员不存在这种虚调用机制,所以一般不说“成员变量的动态绑定”。
31.3. 缺省参数为什么是静态绑定
这是这道题里特别容易考的一个点。
虚函数本身可以动态绑定,但缺省参数值是静态绑定的。
也就是说:
- 调用哪个虚函数实现,看动态类型
- 缺省参数取哪个值,看静态类型
例如:
class Base {
public:
virtual void func(int x = 10) {
cout << "Base::func, x = " << x << endl;
}
};
class Derived : public Base {
public:
void func(int x = 20) override {
cout << "Derived::func, x = " << x << endl;
}
};
再写:
Base* p = new Derived();
p->func();
很多人直觉会以为输出的是:
Derived::func, x = 20
但实际上结果通常是:
Derived::func, x = 10
原因就是:
func是虚函数,所以最终调用到的是Derived::func- 但默认参数值是在编译期根据
p的静态类型Base*决定的,所以取的是基类里的默认值10
这就会形成一种现象:
派生类的函数实现,配上基类的默认参数值。
所以面试里通常会强调一个结论:
不要在重写虚函数时重新定义默认参数。
因为即使你在派生类里重新写了默认参数,很多情况下也不会得到你直觉上想要的效果,反而容易造成混淆。
31.4. 这一节最适合怎么记
静态绑定就是编译期绑定,看静态类型;动态绑定就是运行期绑定,看动态类型。
在 C++ 中,非虚函数通常是静态绑定,虚函数在通过基类指针或引用调用时通常是动态绑定。
缺省参数值虽然常和虚函数写在一起,但它本身仍然是静态绑定,所以不要在重写虚函数时重新定义默认参数。
32. 深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)
深拷贝和浅拷贝,核心区别在于:复制对象时,复制的是“资源本身”,还是只复制“资源地址”。
如果一个类的成员都是普通值类型,比如 int、double、普通对象等,那么默认拷贝一般没有问题,因为这些成员直接按值复制即可。
但如果类中有指针,并且这个指针指向一块动态申请的资源,那么默认拷贝往往就不安全了。
这里还要先纠正一个容易混淆的点:
对象发生拷贝时,不只是“等号赋值”会涉及拷贝。
常见有两种情况:
- 用一个对象初始化另一个对象,调用拷贝构造函数
- 已有对象之间赋值,调用拷贝赋值运算符
如果程序员没有自己定义,编译器通常会生成默认版本,它们做的都是逐成员复制,也就是常说的浅拷贝效果。
32.1. 什么是浅拷贝
浅拷贝就是把对象中的成员按值直接复制一份。
如果成员里有指针,那么复制过去的只是指针里的地址值,而不是指针指向的那块数据本身。
例如:
class A {
public:
int* p;
A(int x) {
p = new int(x);
}
~A() {
delete p;
}
};
如果直接这样拷贝:
A a1(10);
A a2 = a1;
默认浅拷贝后,a1.p 和 a2.p 会保存同一个地址。
也就是说,这两个对象表面上是两个对象,但它们内部其实共用同一块堆内存。
这样就会带来问题:
- 改
a1指向的数据,a2看到的也会跟着变 - 当两个对象析构时,会对同一块内存
delete两次
这类问题更准确地说是:
- 重复释放
- 悬空指针
- 进而导致未定义行为
原笔记里说“导致野指针”,方向接近,但更准确地说,通常是先 double free,再留下悬空指针。
所以浅拷贝的问题本质不是“复制错了对象”,而是:
两个对象错误地共享了本应独立管理的资源。
32.2. 什么是深拷贝
深拷贝就是在复制对象时,不只是复制指针的值,而是另外申请新的资源,再把原资源内容复制过去。
这样复制之后,两个对象各自拥有独立资源,互不影响。
还是上面的类,如果要实现深拷贝,就需要自己写拷贝构造函数和拷贝赋值运算符。
例如:
class A {
public:
int* p;
A(int x) {
p = new int(x);
}
A(const A& other) {
p = new int(*other.p);
}
A& operator=(const A& other) {
if (this != &other) {
delete p;
p = new int(*other.p);
}
return *this;
}
~A() {
delete p;
}
};
现在再写:
A a1(10);
A a2 = a1;
这时 a2.p 不再和 a1.p 指向同一块内存,而是指向一块新申请的、内容相同的内存。
也就是说:
a1和a2的值相同- 但它们管理的是两份独立资源
这就是深拷贝。
32.3. 为什么说深拷贝更安全
深拷贝更安全,主要是因为它避免了多个对象错误共享同一份动态资源。
例如:
A a1(10);
A a2 = a1;
*a2.p = 20;
如果是浅拷贝,那么 a1.p 和 a2.p 指向同一地址,改 a2 时,a1 也会被影响。
如果是深拷贝,那么两者资源独立,改 a2 不会影响 a1。
更重要的是析构阶段。
浅拷贝时:
a1析构,释放一次内存a2析构,又释放同一块内存- 出现重复释放,程序行为未定义
深拷贝时:
a1析构,释放自己的资源a2析构,释放自己的资源- 两者互不影响
所以“深拷贝更安全”最核心的原因就是:
每个对象都独立拥有并管理自己的资源,不会因为共享同一地址而产生重复释放和悬空指针问题。
32.4. 什么时候浅拷贝可以,什么时候必须深拷贝
如果类的成员都是普通值类型,没有自己管理动态资源,那么默认的成员逐个复制通常就够了,这种情况下浅拷贝是可行的。
例如:
class A {
public:
int x;
double y;
};
这种类即使默认拷贝,也不会出资源管理问题。
但如果类中有指针,并且这个指针表示“对象独占拥有一块资源”,那么通常就必须考虑深拷贝。
否则默认浅拷贝很容易出错。
当然,现代 C++ 里更推荐直接用现成的资源管理类型,比如:
std::stringstd::vectorstd::unique_ptrstd::shared_ptr
这样很多时候就不用自己手写深拷贝逻辑了。
32.5. 这一节最适合怎么记
浅拷贝是对象成员的一一复制,如果成员中有指针,复制的只是地址,不会复制指针所指向的资源本身。这样多个对象可能共享同一块内存,容易在析构时产生重复释放和悬空指针问题。
深拷贝是在复制对象时,重新申请独立资源,并把原数据内容复制过去。这样每个对象都拥有各自独立的资源,互不影响,因此更安全。
所以一般来说:
- 没有动态资源时,默认拷贝通常没问题
- 有指针并管理堆资源时,深拷贝通常更安全
33. 什么情况下会调用拷贝构造函数(三种情况)
拷贝构造函数的作用,是用一个已经存在的同类型对象,去初始化一个新对象。
它的典型形式是:
ClassName(const ClassName& other);
这一节最核心的一句话就是:
只要是在“定义新对象,并且初值来自另一个同类型对象”时,就可能调用拷贝构造函数。
经典上常说有三种情况。
33.1. 用一个对象初始化另一个对象
这是最直接、最典型的情况。
例如:
A a1;
A a2 = a1;
或者:
A a2(a1);
这里 a2 是一个新对象,它是用 a1 来初始化的,所以会调用拷贝构造函数。
要注意,这里是“初始化”,不是“赋值”。
如果写成:
A a1, a2;
a2 = a1;
这时候 a2 已经存在了,不是创建新对象,而是给已有对象赋值,所以调用的通常是拷贝赋值运算符,不是拷贝构造函数。
所以这一类最本质的特征是:
- 新对象正在创建
- 初值来自另一个同类型对象
33.2. 对象以值传递的方式传入函数
如果函数参数是按值传递,那么调用函数时,形参本身也是一个新对象。
这个新对象要用实参来初始化,因此会涉及拷贝构造。
例如:
void func(A a) {
}
A a1;
func(a1);
这里形参 a 是按值接收 a1 的。
从语义上看,进入 func 时需要用 a1 去构造一个新的形参对象 a,因此会调用拷贝构造函数。
所以值传参本质上就是:
用实参去初始化函数内部的形参对象。
不过这里要补充一个现代 C++ 里的细节:
实际运行时,编译器可能会做优化,或者优先使用移动构造函数,因此你未必一定能观察到一次真实的拷贝构造调用。
但从语言语义上说,这属于拷贝构造函数会参与的典型场景。
33.3. 对象以值返回的方式从函数返回
如果函数返回类型是对象,并且按值返回,那么返回值也会涉及“用一个对象去初始化另一个对象”的过程,因此经典上也归为拷贝构造函数的调用场景。
例如:
A func() {
A a;
return a;
}
这里经典理解是:返回时要用局部对象 a 去构造返回值对象,所以会调用拷贝构造函数。
不过这一点在现代 C++ 里一定要说严谨一些。
因为返回值优化(RVO/NRVO)和 C++17 之后的一些规则,会使很多这类拷贝/移动在实际中被省略掉,甚至直接构造到目标位置。
所以更准确的说法应该是:
按值返回对象是拷贝构造函数可能参与的经典场景,但实际是否发生可见的拷贝构造,可能会受到拷贝消除和移动语义影响。
面试里如果对方问的是基础版,回答“值返回会调用拷贝构造”通常够用;
如果想答得更稳妥,可以顺带补一句“现代编译器常会做拷贝消除”。
33.4. 这一节最容易混淆的点
最容易混淆的是“初始化”和“赋值”。
例如:
A a1;
A a2 = a1;
这里虽然写了 =,但这是定义时初始化,本质上调用的是拷贝构造函数。
而:
A a1;
A a2;
a2 = a1;
这里 a2 已经存在了,后面的 = 是赋值操作,所以调用的是拷贝赋值运算符。
所以不能单纯看有没有等号,要看:
- 是不是在创建新对象
- 还是在给已有对象重新赋值
33.5. 这一节最适合怎么记
拷贝构造函数在“用已有对象初始化新对象”时调用。
经典三种情况是:
第一,用一个对象初始化另一个对象。
第二,对象按值传递给函数。
第三,对象按值从函数返回。
但要注意,在现代 C++ 中,值传参和值返回场景下,编译器可能会进行拷贝消除,或者改为调用移动构造函数,所以不一定总能观察到真实的拷贝构造调用。
34. 为什么拷贝构造函数必须是引用传递,不能是值传递?
拷贝构造函数之所以必须把参数写成引用,最根本的原因就是:
如果写成值传递,会发生无限递归调用。
拷贝构造函数的作用,是用一个已有对象来初始化一个新对象。
它的标准形式一般是:
ClassName(const ClassName& other);
这里参数必须是同类型对象的引用,通常还是 const 引用。
34.1. 如果写成值传递,会发生什么
假设错误地写成这样:
class A {
public:
A(A other) {
}
};
表面上看,好像只是把参数按值传进来。
但问题在于:按值传参本身就需要先拷贝一份对象。
也就是说,当你试图调用这个拷贝构造函数时,为了把实参传给形参 other,编译器首先就得先构造出一个 other 对象。
而构造这个 other 对象,又需要调用拷贝构造函数。
但这个拷贝构造函数本身的参数又是按值传递,于是又要再拷贝一次。
这样就会不断重复下去,形成无限递归。
本质过程可以理解成:
- 想调用拷贝构造函数
- 先得把参数按值传进去
- 按值传进去又要调用拷贝构造函数
- 而这个拷贝构造函数又要按值接收参数
- 于是再次调用自己
- 永远停不下来
所以值传递在这里是行不通的。
34.2. 为什么引用传递就可以
如果参数写成引用:
class A {
public:
A(const A& other) {
}
};
这里传入的不是一个新的对象副本,而只是给已有对象起一个别名。
引用本身不需要再创建一个同类型临时对象,因此不会再次触发拷贝构造。
也就是说:
- 值传递:需要先拷贝出一个参数对象
- 引用传递:不需要拷贝对象本身,只是引用已有对象
所以只有引用传递才能避免这种递归问题。
34.3. 为什么通常还要加 const
拷贝构造函数一般写成:
A(const A& other);
而不是:
A(A& other);
主要是因为加上 const 后,既可以接受普通对象,也可以接受常对象、临时对象,适用范围更广。
例如:
const A a1;
A a2(a1);
如果参数不是 const A&,这种情况就不一定能正常匹配。
所以标准写法通常都是:
拷贝构造函数参数应写成
const T&
34.4. 这一节最适合怎么记
拷贝构造函数必须使用引用传递,不能使用值传递。
因为如果参数按值传递,那么为了给这个参数构造副本,又要再次调用拷贝构造函数,从而导致无限递归。
而引用传递不会创建新的对象副本,只是给已有对象起别名,因此可以正常工作。
所以拷贝构造函数通常写成:
ClassName(const ClassName& other);
35. 结构体内存对齐方式和为什么要进行内存对齐
结构体内存对齐,核心是在问两个问题:
第一,结构体成员在内存里是怎么排布的。
第二,为什么编译器不把成员紧紧挨着排,而是要插入一些“空字节”。
这道题一般既要会说对齐规则,也要会说对齐原因。
35.1. 结构体内存对齐的基本规则
先说成员对齐。
结构体中第一个成员通常放在偏移量为 0 的位置。
从第二个成员开始,每个成员都要放到某个合适的对齐位置上。这个位置通常要满足:
- 是该成员对齐要求的整数倍
- 如果设置了
#pragma pack(n),还要再受n限制
常见的说法可以写成:
成员的实际对齐值 =
min(编译器指定的对齐上限, 该成员自身的对齐要求)
在很多基础题里,如果不专门考虑更复杂的 ABI 细节,常把“成员自身的对齐要求”近似理解为“该类型大小”,例如:
char按 1 对齐short按 2 对齐int按 4 对齐double常见按 8 对齐
所以后续每个成员的起始偏移,通常都要是它实际对齐值的整数倍。
再说结构体整体对齐。
当所有成员都排完后,结构体本身的总大小也要是某个对齐值的整数倍。这个对齐值通常是:
结构体中所有成员实际对齐值里的最大值
如果设置了 #pragma pack(n),那整体对齐通常也会受它限制。
所以常见表达是:
结构体总大小要向上补齐到“最大有效对齐值”的整数倍
这也是为什么成员看起来加起来明明只有几个字节,sizeof 却更大。
35.2. 用一个例子说明
例如:
struct A {
char c;
int i;
};
这里:
c放在偏移0i通常要求按4对齐,所以不能直接接在偏移1后面- 编译器会在
c后面补 3 个字节 i放到偏移4
所以整个结构体布局通常是:
c:1 字节- 填充:3 字节
i:4 字节
总大小是 8,不是 5。
再例如:
struct B {
char c1;
char c2;
int i;
};
这里:
c1在偏移0c2在偏移1i仍然要按4对齐,所以会从偏移4开始- 中间补 2 个字节
总大小通常仍然是 8。
所以做这类题时,要按“偏移量 + 对齐要求”一步一步推,不能只把成员大小直接相加。
35.3. 为什么要进行内存对齐
内存对齐最主要的原因是提高访问效率。
CPU 访问内存时,并不一定是任意字节随便取都同样高效。很多体系结构更适合按一定边界去读数据。
例如一个 int 通常是 4 字节,如果它刚好放在 4 的整数倍地址上,那么 CPU 往往可以更自然地一次取出这 4 个字节。
如果一个 4 字节数据没有按 4 字节边界对齐,而是跨越了两个读取边界,那么 CPU 可能需要:
- 读两次内存
- 做额外拼接
- 再把结果送入寄存器
这样就会增加额外开销,降低性能。
所以内存对齐本质上是在拿少量空间换时间,让 CPU 更高效地访问数据。
35.4. 为什么还和平台兼容性有关
除了效率,内存对齐还和硬件平台有关。
并不是所有硬件都能像 x86 那样对未对齐访问比较宽容。
有些平台对未对齐访问支持很差,甚至会直接触发硬件异常。
也就是说,如果数据没有按平台要求对齐:
- 有的平台只是访问变慢
- 有的平台可能直接出错
所以内存对齐不仅是性能问题,也是硬件兼容性和可移植性问题。
这也是为什么编译器默认会遵循目标平台的对齐规则,而不是简单地把结构体成员一个挨一个紧贴排布。
35.5. #pragma pack 是做什么的
`#pragma pack(n)“ 可以改变编译器采用的对齐上限。
它并不是说“所有成员都按 n 对齐”,而是说成员对齐通常要取:
min(n, 成员本身需要的对齐值)
例如如果写:
#pragma pack(1)
struct A {
char c;
int i;
};
那么 i 就不再按 4 字节边界对齐,而可以直接紧跟在 c 后面。
此时结构体大小可能就变成 5。
这样做可以节省空间,但通常会牺牲访问效率,某些平台上甚至会带来未对齐访问问题。
所以 `pack(1)“ 这类写法一般只在特殊场景下使用,比如网络协议、文件格式、硬件寄存器映射等需要严格控制布局的时候。
36. 内存泄漏的定义,如何检测与避免?
内存泄漏,简单说就是:
程序申请了一块内存,在后续已经不再需要使用它时,却没有把它释放掉。
更准确一点说,内存泄漏通常指的是:
程序动态申请的内存失去了有效管理,之后既无法再使用,也无法再释放,于是这块内存一直被占着,直到进程结束才可能由操作系统统一回收。
所以它的本质不是“内存用过了”,而是:
申请到的资源没有被正确归还。
在 C/C++ 里,最典型的情况就是:
new了对象,没有deletemalloc了内存,没有free- 原来指向堆内存的指针被覆盖了,导致这块内存再也找不到
- 异常路径或提前 return 时,清理代码没有执行
如果泄漏不断累积,程序运行时间越长,占用内存可能就越多,严重时会导致性能下降、可用内存被耗尽,甚至程序异常终止。
36.1. 什么情况算内存泄漏
例如:
int* p = new int(10);
p = nullptr;
这里原来 new 出来的那块内存已经没有任何指针再指向它了,也没有机会 delete,这就是典型的内存泄漏。
再例如:
void func() {
int* p = new int[100];
}
函数结束时,局部变量 p 消失了,但 p 指向的堆内存并不会自动释放,这也属于内存泄漏。
所以可以这样理解:
- 局部变量自己会随作用域结束而销毁
- 但局部变量指向的堆内存,不会因为指针变量消失而自动释放
36.2. 内存泄漏有哪些常见表现
内存泄漏最常见的表现是程序内存占用持续增长,而且增长后不会回落。
如果程序是长期运行的服务进程,这种现象会尤其明显。
但要注意,不是“内存占用变大”就一定是泄漏。
有些程序是在做缓存、内存池、延迟释放,这些不一定是泄漏。
所以判断内存泄漏,不能只看“占用高不高”,而要看:
- 这块内存是不是本该释放却没释放
- 占用是否持续增长且无法回收
- 是否存在明确的分配路径,但没有对应释放路径
36.3. 如何检测内存泄漏
内存泄漏检测一般可以分成三层:现象观察、工具检测、代码审查。
先说现象观察。
可以通过任务管理器、top、htop、ps 等工具观察进程内存是否持续上涨。
这只能帮助你怀疑“可能有泄漏”,但不能精确定位是哪一行代码泄漏了。
原笔记里提到看 swap、vmstat、netstat 这类命令,方向上属于“观察系统资源变化”,但更准确地说,这些工具只能帮助发现“内存行为异常”,并不能直接证明就是内存泄漏,更不能精确定位泄漏点。
真正定位泄漏,还是要靠专门工具。
在 Linux/C++ 开发里,常见的工具有:
第一,Valgrind。
它是经典的内存调试工具,可以检查:
- 内存泄漏
- 越界访问
- 重复释放
- 使用未初始化内存等问题
例如常见用法是:
valgrind --leak-check=full ./a.out
它会告诉你:
- 哪些内存没有释放
- 是在哪条调用路径上分配的
- 泄漏了多少字节
第二,AddressSanitizer / LeakSanitizer。
这是现在非常常用的一类工具,编译时加 sanitizer 选项,就能在运行时检查问题。
例如:
g++ -fsanitize=address -g test.cpp
很多环境下它也能同时检查泄漏问题,或者配合 LeakSanitizer 使用。
这类工具一般比 Valgrind 更轻量,日常开发中很常用。
第三,IDE 或平台自带工具。
例如 Visual Studio 也有 CRT 内存泄漏检测能力,一些大型工程还会接入专门的分析器。
除了工具,代码审查也很重要。
例如看到下面这些写法时,就要提高警惕:
new/malloc后没有明确对应释放- 指针被重新赋值前,旧资源没释放
- 一个函数里有多个 return 分支,但不是所有分支都释放资源
- 异常抛出后清理代码被跳过
- 手写拷贝构造、赋值、析构不完整,导致资源管理出问题
36.4. 如何避免内存泄漏
避免内存泄漏,最核心的原则不是“记得手动 delete”,而是:
尽量不要把资源管理建立在“人不能忘”的前提上。
在现代 C++ 里,最有效的方法是 RAII。
也就是让资源的申请和对象生命周期绑定:对象创建时拿到资源,对象销毁时自动释放资源。
最典型做法就是优先使用标准库容器和智能指针,而不是手写裸指针管理资源。
例如:
- 动态数组优先用
std::vector - 字符串优先用
std::string - 独占资源优先用
std::unique_ptr - 共享资源再考虑
std::shared_ptr
例如:
std::unique_ptr<int> p = std::make_unique<int>(10);
这样对象离开作用域时会自动释放资源,不需要手动 delete。
如果确实要手动管理资源,那么至少要做到:
new对应deletenew[]对应delete[]malloc对应free- 保证每条执行路径都能释放资源
- 注意异常安全,避免中途抛异常导致释放逻辑跳过
另外,如果类自己管理堆资源,就要特别注意拷贝控制。
也就是要正确处理:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
否则很容易因为浅拷贝、重复释放、遗漏释放等问题引发资源泄漏或其他未定义行为。
这也是前面深拷贝那一节会和这里关联起来的原因。
36.5. 这一节最适合怎么记
内存泄漏是指程序动态申请的内存,在不再需要时没有被释放,导致这块内存一直被占用。
典型情况包括:new 后不 delete、malloc 后不 free、指针被覆盖导致原内存失去引用、异常或提前返回导致清理代码未执行。
检测内存泄漏可以先通过进程内存持续增长来怀疑,再通过专门工具定位,例如 Valgrind、AddressSanitizer、LeakSanitizer 等。
避免内存泄漏最重要的方法是使用 RAII,尽量用 vector、string、智能指针等自动管理资源,而不要依赖手动 new/delete。
37. 平衡二叉树、高度平衡二叉树(AVL)
这一节最好按“二叉树 → 二叉搜索树 → 平衡二叉树 → AVL”的顺序去理解。
因为 AVL 本质上不是一种普通二叉树,而是二叉搜索树上的一种严格平衡实现。
37.1. 二叉树和二叉搜索树
二叉树是指每个节点最多只有两个孩子节点,分别叫左子节点和右子节点。
它是一个很基础的树结构,本身并不要求左右孩子之间有什么大小关系。
二叉搜索树是在二叉树基础上再加了一条有序规则:
- 左子树上所有节点的键值都小于当前节点
- 右子树上所有节点的键值都大于当前节点
所以二叉搜索树的特点是:查找、插入、删除时,都可以像“二分”那样逐层缩小范围。
这里原笔记里有一句方向写反了,正确应该是:
- 遇到比当前节点小的值,往左走
- 遇到比当前节点大的值,往右走
不是“大的往左,小的往右”。
例如插入一个值时,就是从根开始比较:
- 小于当前节点,进入左子树
- 大于当前节点,进入右子树
- 一直走到空位置,再把新节点插进去
删除时常见分三种情况:
- 叶子节点:直接删除
- 只有一个孩子:让孩子接到父节点上
- 有两个孩子:通常用右子树中最小节点,或左子树中最大节点,来替代当前节点
所以二叉搜索树的核心是:它既是树,又保持了有序性。
37.2. 什么是平衡二叉树
普通二叉搜索树虽然查找、插入、删除理想情况下可以达到 O(log n),
但如果数据插入顺序很特殊,比如一直递增插入,那么树就可能退化成一条链表,这样操作复杂度就会退化成 O(n)。
所以才引入“平衡”这个思想。
平衡二叉树并不是指某一种固定结构,而是一个大的概念。
它的大意是:
让树尽量不要一边太深、一边太浅,避免退化成链状结构。
也就是说,“平衡”本质上是在限制树的形状,让树高度尽量保持在对数级别,这样查找、插入、删除效率才稳定。
很多树结构都属于平衡二叉树的实现方式,例如:
- AVL 树
- 红黑树
- AA 树等
所以“平衡二叉树”是一个大类,而 AVL 是其中一种。
37.3. 什么是高度平衡二叉树(AVL)
AVL 树是最经典的一种平衡二叉搜索树,也常被称为高度平衡二叉树。
它的平衡条件非常严格:
对任意一个节点,它的左子树和右子树的高度差最多为 1。
也就是说,每个节点都必须满足:
- 左右子树高度差只能是
-1、0、1
只要某个节点左右子树高度差超过 1,这棵树就失衡了。
所以 AVL 的“平衡”是非常严格的,这也是它被称为“高度平衡”的原因。
37.4. AVL 为什么效率高
因为 AVL 对高度控制得很严格,所以它的树高始终比较低。
这样查找时,从根走到目标节点的路径不会太长,因此查找效率很稳定。
所以 AVL 的查找、插入、删除,时间复杂度通常都能保持在 O(log n)。
它的优点是:
- 查找效率高
- 树高度控制严格
- 不容易退化
但代价是:
- 插入和删除后维护平衡更复杂
- 调整操作比普通二叉搜索树多
所以 AVL 可以理解为:
用更严格的平衡条件,换更稳定的查找效率。
37.5. AVL 如何保持平衡
当插入新节点或删除节点后,某些祖先节点的左右子树高度差可能会超过 1,这时 AVL 就需要进行调整。
调整时,一般是:
- 从当前变化节点开始,向上回溯
- 找到第一个失衡节点
- 对它进行旋转恢复平衡
AVL 的调整方式本质上就是两类:
- 单旋转
- 双旋转
常见四种失衡情况一般记成:
- LL
- RR
- LR
- RL
其中:
- LL 和 RR 用单旋转解决
- LR 和 RL 用双旋转解决
你原笔记里说“从下往上找到第一个不平衡点,需要进行单旋转或者双旋转调整”,这个说法是对的,这也是 AVL 调整的核心过程。
37.6. 单旋转和双旋转怎么理解
如果失衡是“同一方向连续偏重”,一般用单旋转。
例如 LL 型失衡:
某节点左子树过高,并且它的左孩子的左子树又更高,这时通常做一次右旋。
RR 型失衡则相反,通常做一次左旋。
如果失衡是“拐弯型”的,也就是先往左再往右,或者先往右再往左,那单旋转不够,就需要双旋转。
例如:
- LR:先对左孩子左旋,再对当前节点右旋
- RL:先对右孩子右旋,再对当前节点左旋
所以可以简单记成:
- 一条直线型失衡:单旋
- 拐弯型失衡:双旋
37.7. 这一节最适合怎么记
二叉树是每个节点最多有两个孩子的树。
二叉搜索树是在二叉树基础上增加了有序规则:左子树都比根小,右子树都比根大,因此查找、插入、删除可以按大小关系逐层进行。
平衡二叉树是指通过某种规则让树尽量保持平衡,避免退化成链表。
AVL 树是一种严格的平衡二叉搜索树,要求任意节点左右子树高度差不超过 1,所以也叫高度平衡二叉树。
当插入或删除导致失衡时,AVL 会从下往上找到第一个失衡节点,并通过单旋转或双旋转恢复平衡。
因此 AVL 能把查找、插入、删除的时间复杂度稳定在 O(log n)。
38. 说一下红黑树(RB-tree)
红黑树本质上是一种自平衡二叉搜索树。
它在普通二叉搜索树的基础上,额外给每个节点增加了“颜色”信息,并通过一些约束条件来控制树的高度,从而保证查找、插入、删除的效率都能稳定在 O(log n)。
所以理解红黑树,关键不是只背那五条性质,而是要知道:
红黑树用“颜色约束”来间接维持平衡,但它不像 AVL 那样追求严格高度平衡,而是追求一种“足够平衡”。
38.1. 红黑树的五条性质
红黑树的定义通常有下面五条。
第一,每个节点要么是红色,要么是黑色。
第二,根节点是黑色。
第三,每个叶子节点(这里的叶子一般指空节点 NIL)是黑色。
这个 NIL 不是我们平时画图时看到的普通数据节点,而是逻辑上补出来的空孩子节点。红黑树在定义时,通常把所有空指针位置都看成黑色 NIL 节点。
第四,如果一个节点是红色,那么它的两个孩子一定都是黑色。
也就是说,红色节点不能连续出现,不会有“红红相连”。
第五,从任意一个节点到它所有后代 NIL 节点的路径上,黑色节点数量都相同。
这个“相同的黑色节点个数”通常叫做黑高。
这五条性质共同保证了红黑树不会严重失衡。
38.2. 红黑树为什么能保持平衡
红黑树并不是要求左右子树高度差最多为 1,那是 AVL 的思路。
红黑树的平衡更“宽松”,它靠的是两条核心限制:
- 红节点不能连续出现
- 任意路径上的黑节点数量必须相同
这两条约束叠加起来,就限制了树不可能退化得太厉害。
因为如果一条路径特别长,那么它不可能全靠红节点无限拉长;
而黑节点数量又必须和其他路径保持一致,所以整棵树的高度就被控制住了。
因此红黑树虽然不是“严格平衡”,但它一定是“近似平衡”的,这就足够保证基本操作复杂度维持在 O(log n)。
38.3. 红黑树和二叉搜索树的关系
红黑树首先仍然是二叉搜索树,所以它也满足二叉搜索树的基本有序规则:
- 左子树所有节点值小于根节点
- 右子树所有节点值大于根节点
因此它也保留了二叉搜索树按大小查找的特点。
只不过普通二叉搜索树可能退化成链表,而红黑树通过颜色规则和调整操作,避免了这种严重退化。
所以可以说:
红黑树 = 带颜色约束的二叉搜索树。
38.4. 插入和删除为什么还需要调整
因为插入或删除一个节点后,很可能会破坏红黑树原有的五条性质。
例如:
- 插入红节点后,可能出现红红相连
- 删除黑节点后,可能破坏黑高平衡
所以红黑树在插入和删除之后,通常都要做重新着色和旋转来恢复性质。
这里的调整手段主要就两类:
- 变色
- 左旋、右旋
也就是说,红黑树并不是插进去就完了,而是插入或删除后还要“修树”。
38.5. 红黑树和 AVL 的区别
这一点很常考。
AVL 也是平衡二叉搜索树,但它要求任意节点左右子树高度差不超过 1,平衡条件更严格。
红黑树的平衡要求没有那么严格,它只要求满足那五条颜色性质。
所以两者的区别可以概括成:
- AVL 更严格平衡,查找通常更快一些
- 红黑树平衡稍弱,但插入和删除调整通常更少,实现上更适合频繁修改的场景
也就是说:
- 如果更强调查找效率,AVL 往往更优
- 如果插入删除很多,红黑树往往更常用
这也是为什么很多标准库和底层容器喜欢用红黑树,而不是 AVL。
38.6. 红黑树的实际意义
红黑树的意义在于,它在“查询效率”和“维护成本”之间做了一个很好的折中。
普通二叉搜索树实现简单,但最坏情况可能退化成 O(n)。
AVL 平衡更严格,但插入删除时旋转维护更频繁。
红黑树没有 AVL 那么严格,但平衡已经足够好,同时维护成本相对更低,所以工程上应用非常广。
例如很多语言或库中的有序映射、有序集合,底层常常会用红黑树来实现。
38.7. 这一节最适合怎么记
红黑树是一种自平衡二叉搜索树。
它通过给节点增加红黑颜色,并满足五条性质,来保证整棵树不会严重失衡,从而使查找、插入、删除的时间复杂度保持在 O(log n)。
五条性质里最关键的两点是:
- 红节点不能连续出现
- 任意节点到各个叶子 NIL 的路径上,黑节点数量相同
红黑树不是严格平衡,而是近似平衡。
相比 AVL,它查找性能略弱一些,但插入和删除时调整通常更少,因此在实际工程中应用非常广。
39. 说一下 define、const、typedef、inline 使用方法
这一节最好不要把四个东西混在一起背,而是分清它们分别属于哪一层:
#define 是预处理指令,本质是文本替换。const 是语言里的常量限定符,本质是只读语义。typedef 是类型别名机制,本质是给类型起新名字。inline 是内联说明符,本质上是对函数展开和 ODR 使用规则的支持,不是简单的“宏替代品”。
所以这一题通常从几组对比来讲最清楚。
39.1. const 和 #define 的区别
这两个都常被拿来定义“常量”,但本质完全不同。
#define 发生在预处理阶段,它只是简单的文本替换。
例如:
#define MAX 100
后面代码里凡是看到 MAX,预处理器就把它替换成 100。
它本身没有类型,也不参与正常的语法和类型检查。
而 const 是编译阶段按 C++ 语义处理的:
const int MAX = 100;
这里 MAX 是一个有类型的只读对象或只读表达式语义,编译器知道它的类型、作用域,也会对它做类型检查。
所以两者最核心的区别是:
#define:预处理替换,没有类型const:编译期常量语义,有类型
这就带来几个直接差别。
第一,安全性不同。#define 没有类型检查,容易因为宏展开产生意外结果;const 有类型,更安全。
第二,作用域不同。const 遵循正常的 C++ 作用域规则;#define 更像一条从定义处开始生效的替换规则,一直到 #undef 或文件结束。
第三,调试和可读性不同。const 是正常语言符号,调试更友好;#define 在预处理后名字往往就消失了。
原笔记里“define 预处理后占用代码段空间,const 占用数据段空间”这个说法太绝对,不建议这么背。
更准确地说是:
#define本身不是对象,没有独立存储语义const是否实际分配存储空间,要看具体用法和编译器优化,不能一概而论
所以面试里更稳妥的结论是:
定义普通常量时,C++ 里通常优先用
const或constexpr,而不是#define。
另外,#define 还有一些 const 做不了的事,比如:
- 条件编译
- 头文件保护
- 宏开关
例如:
#define DEBUG#ifdef DEBUG
// 调试代码
#endif
这类场景就属于 #define 更合适。
39.2. #define 和 typedef 的区别
typedef 的作用不是定义常量,而是给类型取别名。
例如:
typedef unsigned long ulong;
这里 ulong 只是 unsigned long 的别名。
而 #define 也可以写出类似效果:
#define ULONG unsigned long
但这两者本质不同。
typedef 是编译器理解的类型别名,它参与类型系统,也有类型检查;#define 只是预处理文本替换,编译器并不知道“你是在定义类型别名”。
所以更准确地说:
typedef:给类型起别名#define:只是把一段文本替换成另一段文本
这在复杂类型上尤其明显。
例如:
typedef char* PCHAR;
#define PCHAR2 char*
如果写:
PCHAR a, b;
PCHAR2 c, d;
那么:
a和b都是char*c是char*,但d实际上只是char
因为 #define 展开后相当于:
char* c, d;
这里只是简单替换文本,不会把 char* 作为一个整体类型去理解。
这正说明:
typedef是类型层面的别名,#define不是。
另外,作用范围上也不同。typedef 遵循正常 C++ 作用域规则;#define 是预处理规则,不是正常意义上的词法作用域对象。
所以这部分最适合记成:
typedef 用来定义类型别名,安全、清晰、参与类型系统;#define 更通用,但本质只是文本替换,不能真正替代 typedef。
顺带补一句,现代 C++ 中很多时候会更推荐用:
using ulong = unsigned long;
它比 typedef 更直观,尤其在模板别名里更常用。
39.3. #define 和 inline 的区别
这两个经常放在一起比较,是因为它们都可能看起来像“避免函数调用开销”的手段。
但它们本质上也完全不是一回事。
先纠正一个常见说法:#define 不是关键字,它是预处理指令;inline 也不是“函数”,它是一个函数/变量说明符。
#define 写函数式宏时,本质仍然是文本替换。例如:
#define SQUARE(x) ((x) * (x))
而 inline 是正常函数:
inline int square(int x) {
return x * x;
}
两者区别主要有三点。
第一,处理阶段不同。
宏在预处理阶段展开;inline 函数在编译阶段按正常函数处理。
第二,安全性不同。
宏没有类型检查,也不遵循真正函数语义,参数可能被重复求值。inline 是正常函数,有类型检查,也只按正常函数规则求值。
例如宏:
#define SQUARE(x) ((x) * (x))
int a = SQUARE(i++);
这里 i++ 可能被执行两次。
而 inline 函数不会有这种问题:
inline int square(int x) {
return x * x;
}
第三,语义不同。
宏只是替换代码;inline 仍然是函数,它只是允许编译器在合适时机进行内联展开,同时也影响多重定义规则。
这里还要注意一个面试里很容易说错的点:
inline不等于“编译器一定会展开”。
它更准确的含义是一个“建议 + 语言规则支持”,编译器可以选择是否真正内联。
所以不能把 inline 直接理解成“肯定没有函数调用开销”。
因此,如果只是想写一个安全的小函数,通常优先用 inline,而不是函数式宏。
39.4. 这一节最适合怎么记
#define 是预处理指令,做文本替换,可用于宏常量、条件编译、头文件保护等。const 是语言中的常量限定符,用来定义有类型的只读量,安全性更高。typedef 用来给类型起别名,本质是类型层面的重命名,不是文本替换。inline 是内联说明符,作用于函数时仍然是正常函数,比函数式宏更安全。
所以平时使用时可以这样理解:
- 定义普通常量:优先
const/constexpr - 定义类型别名:用
typedef或using - 写小函数:优先
inline - 条件编译、头文件保护、宏开关:用
#define
40. 预处理、编译、汇编、链接程序的区别
一段高级语言代码变成最终可执行文件,通常会经过这四个阶段:
预处理 → 编译 → 汇编 → 链接
这四步最容易混的是“编译”和“汇编”。
其实可以这样抓主线:
- 预处理:先处理各种
#开头的预处理指令 - 编译:把高级语言翻译成汇编代码
- 汇编:把汇编代码翻译成机器指令,生成目标文件
- 链接:把多个目标文件和库文件拼起来,生成最终可执行文件
40.1. 预处理
预处理阶段主要处理源文件中以 # 开头的预处理指令,例如:
#include#define#if、#ifdef、#endif
例如:
#include <stdio.h>
#define MAX 100
预处理器会做的事情主要有:
- 展开头文件
- 宏替换
- 条件编译
- 删除注释等
这一阶段的输出,通常还是文本文件,只是内容已经不是你原来写的那个源文件了,而是把各种预处理结果展开后的代码。
常见中间文件后缀是 .i。
所以预处理的本质是:
先把源代码中依赖预处理器处理的内容展开好,交给后面的编译器。
40.2. 编译
编译阶段才真正开始把高级语言翻译成低级语言表示。
这里的“低级语言”通常还不是机器码,而是汇编代码。
也就是说:
- 输入:预处理后的源码文本,例如
hello.i - 输出:汇编代码文本,例如
hello.s
所以编译阶段做的事是:
把 C/C++ 这样的高级语言,翻译成汇编语言。
这一阶段还会做很多语法、语义和优化相关工作,例如:
- 词法分析
- 语法分析
- 语义分析
- 中间代码生成
- 优化
- 目标代码生成
不同高级语言最终都可以翻译到某种目标平台对应的汇编语言或机器码,但具体生成什么样的汇编,和语言、编译器、平台、优化选项都有关。
所以不能简单说“不同高级语言翻译出的汇编语言相同”,只能说它们最终都可以落到同一硬件平台可执行的低级表示上。
40.3. 汇编
汇编阶段是把汇编代码进一步翻译成机器指令。
也就是说:
- 输入:汇编文件
.s - 输出:目标文件
.o
这个 .o 文件已经不是文本了,而是二进制目标文件。
里面包含机器指令、符号表、重定位信息等内容。
所以汇编阶段做的事是:
把人还能看懂的汇编代码,变成机器能执行的二进制目标代码。
这里生成的 .o 还不能直接运行,因为它通常只是可重定位目标文件,里面可能还存在:
- 外部符号还没解析
- 多个目标文件还没合并
- 库函数地址还没确定
所以它只是“半成品”。
40.4. 链接
链接阶段的作用,就是把前面生成的一个或多个目标文件,以及它们依赖的库文件,最终组合成一个完整可执行文件。
例如程序里调用了 printf,但 printf 的实现代码不在你自己的源文件里,而是在标准库中。
链接器就负责把你自己的目标文件和这些库里的目标代码正确连接起来。
它主要做的事情包括:
- 合并各个目标文件
- 解析外部符号引用
- 确定函数和全局变量的最终地址
- 进行重定位
- 生成最终可执行文件
所以链接阶段的本质是:
把分散的代码和数据拼装起来,解决“谁调用谁、谁引用谁、最终地址在哪里”这些问题。
这一阶段结束后,才会得到真正能运行的可执行文件。
40.5. 这四个阶段最容易混淆的点
最容易混的是“编译”和“汇编”。
很多人会把“编译”笼统地说成“把源代码变成可执行文件”,这在广义上没错,但如果面试官是在问编译过程细分,那么这里的“编译”是狭义概念,特指:
- 编译:高级语言 → 汇编语言
- 汇编:汇编语言 → 机器指令目标文件
所以这两个阶段一定要分开说。
另外,链接也很重要,因为即使一个 .cpp 文件已经被编译、汇编成 .o,它仍然可能不能单独运行,必须等链接器把外部依赖都补齐。
40.6. 这一节最适合怎么记
预处理阶段处理 #include、#define、条件编译等预处理指令,输出仍然是文本代码。
编译阶段把高级语言翻译成汇编代码,输出通常是 .s 文件。
汇编阶段把汇编代码翻译成机器指令,生成二进制目标文件 .o。
链接阶段把多个目标文件和库文件合并起来,解析符号引用,最终生成可执行文件。
所以整体流程就是:
源代码 → 预处理后的代码 → 汇编代码 → 目标文件 → 可执行文件
41. 说一下 fork、wait、exec 函数
这三个函数通常放在一起讲,因为它们正好对应 Unix/Linux 进程控制里最经典的一套流程:
fork:创建子进程exec:让当前进程去执行一个新程序wait/waitpid:父进程回收子进程,避免僵尸进程
最常见的使用方式就是:
父进程先 fork 出一个子进程,子进程再 exec 去执行另一个程序,父进程用 wait 等子进程结束并回收资源。
41.1. fork 是做什么的
fork 用来创建子进程。
调用一次 fork 之后,会得到两个几乎相同的执行流:
- 父进程继续往下执行
- 子进程也从
fork返回处继续往下执行
所以 fork 的特点是:
一次调用,返回两次。
返回值用来区分父子进程:
- 在父进程中,
fork返回子进程的 PID - 在子进程中,
fork返回0 - 失败时返回
-1
你原笔记里“fork 拷贝出一个父进程副本”这个方向是对的,但更准确地说,不是简单地把整个进程内存立刻完整复制一遍。现代系统里通常采用的是写时拷贝,也就是 COW,Copy On Write。
也就是说,fork 之后父子进程一开始会共享同一份物理内存页,只有当某一方真的去写内存时,系统才会为它分配新的物理页。
所以可以理解为:
fork逻辑上复制了一个子进程,但物理内存通常不是一开始就全量复制,而是借助写时拷贝延迟复制。
41.2. exec 是做什么的
exec 的作用不是“创建新进程”,而是:
用一个新程序替换当前进程的代码和数据。
exec 不会额外创建一个新进程,它只是把“当前这个进程”变成另一个程序。
例如,通常是子进程在 fork 之后调用 exec:
- 先
fork出子进程 - 子进程调用
exec - 子进程开始执行新的程序
所以更准确的说法应该是:
exec会用新的可执行程序映像替换当前进程,而不是“替换父进程”。
调用 exec 成功后,原来进程中的用户代码、数据、堆、栈等都会被新程序替换,进程 PID 一般保持不变,因为还是同一个进程,只是“跑的程序”变了。
exec 成功时不会返回,因为原来的程序执行流已经被新程序替换掉了。
只有执行失败时,才会返回 -1。
41.3. wait 是做什么的
wait 的作用主要有两个:
第一,让父进程等待子进程状态变化,最常见就是等待子进程结束。
第二,回收已经结束的子进程资源,避免产生僵尸进程。
如果父进程不去 wait,子进程虽然结束了,但内核里还会保留它的一些退出信息,变成僵尸进程。
所以 wait 的一个重要意义就是“收尸”。
wait成功时,返回结束或状态变化的子进程 PID- 失败时返回
-1
所以 wait 不是成功返回 0,而是成功返回某个子进程号。
另外,wait 调用后,如果当前没有子进程结束,父进程通常会阻塞,直到某个子进程状态发生变化。
41.4. 三者通常怎么配合
最经典的流程就是:
第一,父进程调用 fork 创建子进程。
第二,子进程调用 exec 执行新程序。
第三,父进程调用 wait 等待子进程结束并回收。
这个模型非常常见,比如 shell 执行外部命令,本质上就经常是这么做的。
可以这样理解:
fork:先把人“生出来”exec:再让这个进程去“换一个程序身份”wait:父进程最后负责等待并回收
41.5. 这一节最适合怎么记
fork 用来创建子进程,调用一次会返回两次:父进程返回子进程 PID,子进程返回 0,失败返回 -1。现代系统中 fork 通常配合写时拷贝机制,不会一开始就完整复制所有内存。
exec 用来用新程序替换当前进程映像,它不会创建新进程。通常是子进程在 fork 之后调用 exec 去执行另一个程序。exec 成功后不返回,失败返回 -1。
wait 用来让父进程等待子进程状态变化,并回收子进程资源,避免僵尸进程。wait 成功返回发生状态变化的子进程 PID,失败返回 -1。