结构化编程
程序=数据结构+算法
ADT 抽象数据类型
表达式
表达式的值由哪些因素决定?
最基本的:操作符、操作数
其他的,还由以下 4 个因素 共同决定:
- 优先级 (Priority):决定了不同运算符结合的紧密程度(例如先乘除后加减)。
- 结合性 (Associativity):决定了当优先级相同时,运算是从左向右还是从右向左进行(例如赋值运算
=是右结合的)。 - 类型转换 (Type conversion):在运算过程中发生的隐式或显式类型转换会改变操作数的值或类型,从而影响最终结果(例如
5/2是2,但5.0/2是2.5)。 - 求值顺序 (Evaluation order):虽然优先级和结合性规定了运算符的顺序,但 C++ 标准并没有完全规定操作数本身的计算顺序(例如
f(x) + g(y)中,先算f(x)还是先算g(y)是未定义的),这也会影响最终结果。
表达式会存在副作用 (Side Effect)
- 解释: 表达式在求值的过程中,除了产生一个计算结果(值)之外,还改变了系统的状态(例如修改了变量的值、进行了 I/O 输出等),这就叫副作用。
- 例子:
- 赋值表达式:
a = 1(不仅结果是 1,还把变量 a 的内存改了) - 复合赋值:
a += 1 - 输出操作:
cout << ...(修改了输出流的状态)
- 赋值表达式:
Integer
unsigned溢出会回绕wrap around 即max + 1 = 0
signed溢出会发生UB
- 可能出现
- 不可预测
- 程序崩溃
- 优化不可能的代码
- 解决
- 事前检查
- 编译期内置函数
- 更高精度的类型或库
Float
浮点数 = 符号位 (1bit) + 指数位 (8/11bit) + 尾数位 (23/52bit)
- 特殊状态(必须掌握):
- Infinity (无穷大):
- 产生:
1.0 / 0.0或者数值太大溢出。 - 特性: 能参与运算。这是一个合法的浮点数状态。
- 产生:
- NaN (Not a Number):
- 产生: 当数学逻辑崩塌时出现,比如
0.0 / 0.0,inf - inf,或者sqrt(-1)。 - 特性: 任何数和 NaN 运算结果都是 NaN。
- 产生: 当数学逻辑崩塌时出现,比如
- Infinity (无穷大):

类型转换
-
static_cast<>()
- 会在类型转换时出现截断和精度丢失 比如int转float可能会损失精度 int转short会高位截断
-
reinterpret_cast<>
-
不对数据做任何处理,只是换个“眼镜”看内存 就是换一种方式去理解相同一块内存

-
序列化就是这个原理
-
-
any_cast<>
-
为了灵活性,C++17 引入了
std::any。std::any a; a = 1; // 存入 int a = 1.5; // 存入 double,覆盖之前的 int a = string("hello"); // 存入 string,覆盖之前的 double它就像一个黑盒子,你可以把任何符合复制构造要求的类型扔进去。
-
取值的代价 (
any_cast):虽然存进去很自由,但取出来很麻烦。因为 C++ 编译器必须知道数据类型才能生成机器码,所以你不能直接用cout << a。 你必须显式地告诉编译器// 必须用 any_cast<原来的类型>(变量) cout << std::any_cast<int>(a) << endl; cout << std::any_cast<double>(a) << endl; -
安全性:如果你“猜错”了类型,程序会崩溃(抛出异常)
std::any a = 1.5; // 存的是 double // 下面这行会报错!抛出 std::bad_any_cast 异常 int i = std::any_cast<int>(a);std::any_cast
(any_obj)不匹配时抛出 std::bad_any_cast std::any_cast
(&any_obj)不匹配时返回 nullptr
-
-
-
const_cast<>()

-
编译器看到
const int c = 128;,它认为c是一个永远不会变的常量。替换优化:所以在后续代码中,凡是出现
c的地方(比如cout << c),编译器直接在编译阶段就把它替换成了字面量128。它根本没有去内存里读c的值,而是直接打印了常数 128 -
如果对原本是常量的对象进行修改,结果是未定义行为,一般的编译器会在直接引用常量时填入原来的值,而在对指针解引用时返回新值。
const_cast不是用来把const对象转换为非const对象的,而是用来去除指针的const修饰符,以便进行修改和调整 -
正确示范
int i = 42; const int *p_const = &i; // 指向常量的指针,不允许 p_const 修改 i int* p = const_cast<int*>(p_const); // 移除 const,允许 p 修改 i *p = 24; // 修改 i. 但假若 i 的类型为 const,会产生未定义行为
-
-
dynamic_cast<>
- 要用于类层次结构中,尤其是在有虚函数的多态情况下。它会在运行时进行类型检查,确保转换是安全的。它可以向上转换(将派生类对象转换为基类类型)和向下转换(将基类对象转换为派生类类型)。如果转换失败,指针类型会返回
nullptr,而引用类型会抛出std::bad_cast异常 const_cast和dynamic_cast类型TYPE应当是引用或指针
- 要用于类层次结构中,尤其是在有虚函数的多态情况下。它会在运行时进行类型检查,确保转换是安全的。它可以向上转换(将派生类对象转换为基类类型)和向下转换(将基类对象转换为派生类类型)。如果转换失败,指针类型会返回
| 特性 | static_cast | dynamic_cast |
|---|---|---|
| 发生时间 | 编译时 (Compile-time) | 运行时 (Run-time) |
| 安全性 | 不安全 (盲目信任程序员) | 安全 (失败返回 nullptr) |
| 性能 | 极快 (无运行时开销) | 较慢 (需要查表对比类型) |
| 对类要求 | 无特殊要求 | 父类必须有虚函数 (Polymorphic) |
| 失败后果 | 乱码或崩溃 (Undefined Behavior) | 返回 nullptr (指针) 或抛异常 (引用) |
| 使用场景 | 100% 确定类型时用来提升性能 | 不确定指针真实类型时用于安全判断 |
union
共享内存 里面的成员是叠着放的(共享起始地址) 联合体的大小等于最大那个成员的大小
union {
float f;
int i;
} u; // 定义一个联合体,里面既有 float 又有 int
u.f = f; // 第一步:把数据作为 float 存进去
// 第二步:作为 int 读出来进行位运算
// u.i 和 u.f 占用同一块内存,所以 u.i 里的比特位就是 f 的比特位
s += (u.i & (1 << i)) ? '1' : '0';
C++里面不允许直接对float进行位运算 这种方法可以绕过这一特性进行位运算
union Matrix {
// 视角 1:具名变量(用结构体包裹,保证内存布局顺序)
struct {
double _a11, _a12, _a13;
double _a21, _a22, _a23;
double _a31, _a32, _a33;
};
// 视角 2:二维数组
double _element[3][3];
};
你可以用循环 _element[i][j] 来初始化数据,然后立刻用 _a11 去读取它。你改了数组,_a11 也会变;你改了 _a11,数组也会变。这就实现了同一块数据的两种不同访问方式

在没有继承和虚函数(C++ 多态)的情况下,用 struct + union 实现多态行为
其中FIGURE_TYPR用于标记类型 使用FIGURE时先通过这个判断类型 再进行对应操作 如下:

但这种实现多态的方式不够便捷
在需要增加color width等新成员时,就需要在每个类型下面都写一遍增加,很麻烦
Variant
现有的union any存在问题
- 类型不安全
- 无法知道当前存储的类型
- 类型局限性
- any是运行时检查 too late
- 继承和多态的局限性
std::variant
std::variant<int, double, std::string> v;
-
这行代码定义了一个变量
v。 -
它只能存储尖括号里列出的这三种类型之一:
int或double或string。 -
赋值自由:你可以随意给它赋值这三种类型的数据,它会自动切换状态。
v = 42; // 现在是 int v = 3.14; // 现在变成了 double v = "hello"; // 现在变成了 string
variant 的两种强大的安全检查机制:
第一道防线:编译期检查 (Compile-time)
std::cout << std::get<float>(v); // ❌ 编译报错 (Compile Error)
- 原因:你的定义是
<int, double, string>,里面压根就没有float。 - 优势:编译器在编译阶段就能发现这种逻辑错误,直接阻止你生成烂代码。这是
union做不到的。
第二道防线:运行时检查 (Runtime)
// 假设当前 v 存的是 "hello" (string)
std::cout << std::get<int>(v); // ❌ 运行时报错 (Runtime Exception)
- 原因:虽然
int在允许列表中,但当前里面存的是string。你硬要把它当int取出来,类型不匹配。 - 结果:它不会像
union那样给你乱码(UB),而是抛出一个标准异常std::bad_variant_access。程序会跳到catch块里。
虽然抛异常比乱码好,但抛异常效率低且麻烦。PPT 底部展示了如何优雅地检查当前类型,从而避免异常:
-
方法一:查索引 (
v.index())// 0: int, 1: double, 2: string cout << v.index();你可以通过索引知道当前是第几个类型生效了。
-
方法二:查类型 (std::holds_alternative)
这是最推荐的做法,语义非常清晰:
if (std::holds_alternative<int>(v)) { // 安全:我知道它是 int,放心取 cout << std::get<int>(v); } else if (std::holds_alternative<double>(v)) { // ... }
variant 内部不仅存数据,还自动维护了一个 index,记录当前存的是第几个类型
访问者模式侵入性强 耦合度高 std::visit可以优化
这一页 PPT 讲解的是 C++17 中处理 std::variant 的核心武器:std::visit。
如果说上一页讲的 v.index() 和 std::get 只是手动挡的“开箱工具”,那么 std::visit 就是全自动的“分发器”。
它的核心作用是:自动判断 variant 当前里面存的是什么类型,并调用对应的处理函数。
以下是两种主要用法的详细解读:
用法一:传统的“函数对象” (Functor)
-
定义一个 Visitor 类:
你定义一个结构体 struct Visitor,并为 variant 可能包含的每一种类型都写一个重载的 operator() 函数。
struct Visitor { void operator()(int i) const { ... } // 如果是 int,跑这里 void operator()(double d) const { ... } // 如果是 double,跑这里 void operator()(const std::string& s) const { ... }// 如果是 string,跑这里 }; -
调用:
Visitor visitor; std::visit(visitor, v1); // 自动调用 operator()(int) std::visit(visitor, v2); // 自动调用 operator()(double)- 优点:逻辑清晰,每种类型的处理代码完全分离。
- 类比:这就像之前的
switch-case,但编译器会帮你检查是否覆盖了所有类型,更安全。
用法二:现代的“泛型 Lambda”
这种写法利用了 C++17 的 if constexpr,写起来更紧凑,不用专门去定义一个结构体。
-
代码解析:
std::visit([](const auto& arg) { // auto 让它可以接受任何类型 using T = std::decay_t<decltype(arg)>; // 获取 arg 的真实类型 T if constexpr (std::is_same_v<T, int>) { // 如果 T 是 int,编译这块代码 cout << "Got an int"; } else if constexpr (std::is_same_v<T, double>) { // 如果 T 是 double,编译这块代码 cout << "Got a double"; } // ... }, v); -
关键点
if constexpr:这是一个编译期的if。这意味着编译器会根据传入的具体类型,只生成对应的代码分支。这比运行时的if更快,而且允许你在分支里写该类型特有的操作(比如只有string才有.length(),如果不加constexpr,传入int时编译器会因为int没有.length()而报错)。
any
这一页 PPT 是对 C++17 std::any 特性的总结性概括(Title: std::any cont' 表示 continued,接上一页)。
它主要从安全性、灵活性和使用限制三个维度,定义了 std::any 到底是个什么东西。
以下是详细解读:
类型安全 (Type-safe) 可以安全封装任意类型的单个值 通过 std::any_cast 进行类型安全的取值
- 核心观点:虽然
std::any可以装任何东西,但它不是乱来的。 - 解释:
- 在 C 语言时代,如果你想存“任意类型”,你得用
void*。但void*很危险,你把它转回int还是double全靠猜,猜错就崩溃。 std::any内部记录了类型信息。当你用std::any_cast<T>取值时,它会检查:“你现在要取int,但我里面存的是string,不行!” 然后抛出异常。这就是所谓的“类型安全”。
- 在 C 语言时代,如果你想存“任意类型”,你得用
运行时多态 (Runtime Polymorphism)
- 核心观点:它让 C++ 这种静态语言有了动态语言(像 Python)的感觉。
- 解释: 值的类型在运行时确定,而不是编译期固定
- 编译期固定:像
int a;或std::variant<int, float> v;,变量能存什么类型在写代码时(编译期)就已经定死了。 - 运行时确定:
std::any的变量,直到程序跑起来那一刻,你赋给它什么,它就是什么。这赋予了极大的灵活性(当然也牺牲了性能)。
- 编译期固定:像
语义与限制 (Semantics & Limitations)
它的硬性条件:
-
支持空值状态:
std::any a; // 默认构造时,它里面是空的(has_value() 为 false)。
-
值必须可拷贝构造 (CopyConstructible):
- 这是最大的坑! 你不能把
std::unique_ptr放进std::any里。 - 为什么? 因为
std::any本身设计为支持复制(any a = b;)。如果b里装的是一个“独占、不可复制”的东西(如unique_ptr或std::mutex),a就没法复制它。为了防止这种逻辑矛盾,标准库直接规定:想进std::any的门,该类型必须能被复制。
- 这是最大的坑! 你不能把
std::any 的构造函数签名:
any(const any& other):拷贝构造函数(说明它支持拷贝)。any(any&& other):移动构造函数(支持高效转移)。template <class ValueType> any(ValueType&& value):这是那个“万能入口”,让你能把任何符合条件的数据塞进去。
a.type()
const std::type_info& t = a.type();
- 这是什么? 这是 C++ 的 RTTI (Runtime Type Information,运行时类型信息) 机制。
- 作用:
std::any内部不仅存了数据(比如1),还存了数据的类型信息(比如int的类型标签)。 - 对比:
std::variant用的是index()(第几个类型)。std::any用的是type()(类型对象type_info)。
has_value() 判断是否有值
reset() 清空内容

std::any 的底层实现原理。
手动维护虚函数表(V-table)机制 + 小对象优化(SOO)。
管理函数指针 (Handler _HandleFuncPtr)
Handler 指针是核心。
- 问题:当你把
std::any赋值为string时,它记住了数据,但如果以后要销毁这个any,它必须调用string的析构函数;如果要复制,必须调用string的拷贝构造。但any本身是通用的,它不知道string的存在。 - 解决方案:
- 在存入数据的那一刻(比如赋值
obj = 7),std::any会生成一个专属的静态管理函数(Manager Function)。 - 这个函数的地址被存在
_HandleFuncPtr里(图中红色框ac a5 6d ...就是这个函数指针的地址)。 - 这个函数接收一个
_Action枚举(如_Destroy,_Copy,_Get),然后针对内部存储的具体类型T执行相应的操作。 - 这相当于手动实现了一个“虚函数”,让
std::any记住了操作数据的方法。
- 在存入数据的那一刻(比如赋值
存储空间与小对象优化 (Storage & SOO)
Storage union 和内存图展示了数据到底存在哪。

- 小对象优化 (Small Object Optimization, SOO):
std::any内部预留了一块缓冲区(图中叫_buf,通常是 3 个指针大小,约 24 字节)。- 如果是小对象(比如
int,double,char*):直接存进这个_buf里,不分配堆内存。 - 图中案例:
any obj = 7;。int很小,所以直接存进去了。- 红框:存储的是
Handler函数指针。 - 蓝框:存储的是整数
7(十六进制07 00 00 00,小端序)。 - 这样做速度极快,没有
new/malloc的开销。
- 红框:存储的是
- 大对象 (Large Object):
- 如果存的是很大的结构体(超过了
_buf大小),std::any就会在堆 (Heap) 上申请内存,然后把堆内存的指针 (ptr) 存在这个位置。
- 如果存的是很大的结构体(超过了
std::any vs std::variant
std::any vs std::variant 核心对比笔记
1. 类型确定性 (Type Strategy)
std::any:极致的动态 (Runtime)- 编译期:完全未确定。编译器不知道里面存什么,也不关心。
- 运行时:所有类型检查都在运行时进行。它像一个黑盒,直到运行那一刻才揭晓谜底。
std::variant:受限的静态 (Compile-time)- 编译期:类型集合是确定的(必须在定义时列出,如
<int, double>)。编译器会进行静态检查。 - 运行时:仅做最轻量的检查——确认当前存储数据的“激活索引”(Index)是否和你请求的类型匹配。
- 编译期:类型集合是确定的(必须在定义时列出,如
2. 访问与取值机制 (Visit / Access Mechanism)
std::any:开销较大- 访问方式:使用
std::any_cast<T>(v)。 - 底层流程:
- 调用 Handler:必须通过函数指针调用那个“管理函数”(Handler)。
- RTTI 检查:内部使用
typeid对比存储的类型和请求的类型是否一致。
- 性能代价:高。
- 包含一次函数调用(Function Call)开销(无法内联)。
- 包含一次类型比较(Type Comparison)开销。
- 访问方式:使用
std::variant:开销极小- 访问方式:使用
std::get<T>(v)或std::get<Index>(v)。 - 底层流程:
- 编译期决议:请求的类型
T或索引Index在编译时就是已知的。 - 直接寻址:内部直接转换为数组索引访问(调用
__get_alt<I>)。
- 编译期决议:请求的类型
- 性能代价:低。
- 几乎等同于直接访问内存地址。
- 直接返回存储对象的引用,没有复杂的虚函数或 RTTI 检查机制。
- 访问方式:使用
如果你能列举出所有可能的类型,永远优先使用 std::variant,因为它更快且在编译期更安全;只有当你完全无法预知会收到什么类型的数据时,才使用 std::any 作为最后的手段。
tuple
std::tuple 就像是一个“匿名的结构体”,你不需要给它起名字,也不用定义结构体,直接用就行
-
定义:
tuple<int, float> foo() { return {1, 3.0f}; // 自动打包成 tuple } -
用法 A:直接访问 (通过索引)
auto r = foo(); cout << get<0>(r) << get<1>(r); // 用 get<0> 取第一个,get<1> 取第二个- 缺点:
0和1没有名字直观。
- 缺点:
-
用法 B:解包 (std::tie)
PPT 下面的红框展示了如何把 tuple 拆回单独的变量:
int i; float f; std::tie(i, f) = r; // 把 tuple 里的值分别“绑”给 i 和 f cout << i << f;
std::tuple 和 std::tie(C++11/14 技术),但现在的 C++17 已经有了更完美的写法叫 结构化绑定 (Structured Binding),完全取代了 PPT 里的 std::tie:
// C++17 写法:
auto [i, f] = foo(); // 直接创建变量 i, f 并赋值,一步到位
cout << i << f;
optional
它的主要目的是解决 “函数如何优雅地返回一个可能不存在的值” 这个问题。
The Old Ways
假设你要写一个函数 findUserID,通过用户名查找用户 ID。如果用户不存在,该返回什么?
- 方法一:魔术数字 (Magic Number)
- 做法:找不到就返回
-1。 - 红叉原因:歧义。万一
-1是一个合法的 ID 怎么办?或者你被迫占用了一个有效的整数作为错误码。
- 做法:找不到就返回
- 方法二:输出参数 (Additional Parameter)
- 做法:函数返回
bool表示成败,真正的 ID 通过引用参数int& outID带出来。 - 红叉原因:代码丑陋。调用起来很不自然,不能写
int id = find(...),必须先定义变量再传进去。
- 做法:函数返回
- 方法三:返回指针 (Return Pointer)
- 做法:返回
int*,找不到就返回nullptr。 - 红叉原因:内存泄漏风险 (Memory Leak Risk)。返回的指针指向哪里?是静态变量?还是堆内存?调用者需不需要
delete它?这会带来无穷的内存管理麻烦。
- 做法:返回
C++17 的优雅解法 (std::optional)
std::optional<T> 是一个包装器,它只有两种状态:要么有一个 T 类型的值,要么什么都没有 (empty)。
如何定义函数:
std::optional<int> findUserID(...) {
if (用户存在) {
return corresponding_id; // 自动包装成 optional
} else {
return std::nullopt; // 或者 return {}; 表示“空”
}
}
- 语义清晰:函数签名明确告诉调用者:“我可能会返回 int,也可能什么都不返回”。
如何使用结果 (Top Right):
-
最安全/最推荐:
value_or()// 如果有值就取出来,没值就返回括号里的默认值(比如 -1) int id = opt.value_or(-1); -
手动检查:
has_value()if (opt.has_value()) { cout << opt.value(); // 确定有值了再取 } -
直接取值(有风险):
value()try { int id = opt.value(); // 如果是空的,会抛出 std::bad_optional_access 异常 } catch (...) { ... }
数组和指针
两者的区别
- 本质不同:
- 数组 (Array): 是“容器”。
int a[12]在内存里实打实地占用了 12 个int的空间。PPT 强调数组是 “Same data type”(相同类型)且下标从 “0~n-1”。 - 指针 (Pointer): 是“路标”。
int *p只是一个存储地址的变量,它自己只占 4 或 8 个字节(取决于系统),指向别人的内存。
- 数组 (Array): 是“容器”。
- “退化”与“常量性” (核心考点):
- 在表达式中,数组名
a通常会退化 (Decay) 为指向数组首元素的指针,即a等价于&a[0]。 - 但是! 这种退化后的指针是常量指针 (Constant Pointer)。
- PPT 明确写了:
int * const。这意味着你不能改变a指向哪里。 - 能做:
int *p = a; p++;(指针p是变量,可以随便动)。 - 不能做:
a++;或a = p;(数组名a是个“钉死”的地址标签,动不了)。PPT 用一个大大的 “X” 标记了*(a++) = 0这种写法是错误的。
- PPT 明确写了:
- 在表达式中,数组名
如何用指针操作数组元素?
假设有:int a[12]; int *p = a;
A. 访问元素 (Value)
想要获取第 i 个元素的值,以下写法完全等价:
- 数组下标:
a[i] - 指针下标:
p[i](是的,指针也能用方括号) - 数组名偏移解引用:
*(a + i) - 指针偏移解引用:
*(p + i)
底层逻辑: 编译器看到 a[i],实际上就是在做 *(a + i)。
B. 获取地址 (Address)
想要获取第 i 个元素的地址,以下写法等价:
- 取地址符:
&a[i] - 数组名偏移:
a + i - 指针偏移:
p + i
C. 遍历数组的两种风格
- 指针算术 (Pointer Arithmetic) :
for (int i=0; i<12; i++)
*(p + i) = 0;
// 或者更纯粹的指针移动风格(PPT虽然没明写但在暗示 p++):
// *p++ = 0;
2.数组索引 (Array Indexing):
for (int i=0; i<12; i++)
a[i] = 0;
特别注意:作为函数参数时的“大坑”
void f(int a[], int n);
void f(char a[]); // 这是字符串数组,可以识别 \0 以知晓长度
在这里,int a[] 完全等价于 int *a。 当数组作为函数参数传递时,它会彻底丢失数组的长度信息,退化成一个纯粹的指针。所以你必须额外传一个 int n 来告诉函数数组有多大,否则函数内部做 sizeof(a) 得到的只是指针的大小(4或8字节),而不是整个数组的大小。
c++中规定#define NULL 0
void func(int); // 版本 1:接收整数
void func(char*); // 版本 2:接收指针
func(NULL); // 问:这会调用哪个函数?
- 灾难:因为 C++ 里
NULL就是0,编译器会把它当作整数。所以它会调用func(int)! - 这完全违背了程序员的直觉——我传的是“空指针”,结果你把它当“整数 0”处理了。
nullptr
为了解决这个问题,C++11 引入了新关键字 nullptr。
nullptr是一种特殊的类型 (std::nullptr_t),它不是整数,它只能被转换成指针。- 如果你写
func(nullptr),编译器非常清楚你想要的是指针,所以它会正确调用func(char\*)。
RAII Resource Acquisition Is Initialization 资源获取即初始化
- 时代的痛点 (“Old Use”) :
- 以前如果你用
new创建对象,必须记得手动delete。 - 隐患: 如果中间的代码抛出了异常 (
throw Bad()) 或者提前返回 (return),delete语句就永远执行不到了,导致内存泄漏 (Memory Leak)。
- 以前如果你用
- 新时代的解法 (“Newer Use”):
- 定义: RAII (Resource Acquisition Is Initialization)。
- 机制: 将资源的生命周期绑定到对象的生命周期上。
- 构造时获取资源(初始化)。
- 析构时自动释放资源(PPT 展示了一个
int_ptr的类,析构函数里写了delete ptr)
- 效果: 即使发生异常或提前返回,栈上的对象(如
unique_ptr)会自动调用析构函数,顺带释放掉它管理的堆内存,彻底杜绝泄漏。
三大智能指针
unique_ptr(独占型)
- 核心理念:RAII (资源获取即初始化)。
- 行为:独占资源的所有权。
- 优势:利用栈对象的生命周期管理堆内存。当
unique_ptr离开作用域(无论是正常结束、return还是抛出异常throw)时,其析构函数会自动调用delete,从而完美杜绝内存泄漏。
shared_ptr(共享型)
- 核心理念:引用计数 (Reference Counting)。
- 行为:允许多个指针共享同一个对象。每复制一份 (
shared_p_ref = shared_p),引用计数 (use_count) 加 1;每销毁一个,计数减 1。 - 释放时机:只有当引用计数归 0 时,内存才会被真正释放。
weak_ptr(观察型)
- 核心理念:弱引用 (不持有所有权)。
- 行为:它是
shared_ptr的“旁观者”。构造它不会增加引用计数。 - 用法:不能直接访问数据。必须先调用
.lock()尝试将其提升为shared_ptr。如果对象还存在(没被销毁),就能锁住并使用;如果对象已销毁(过期),则返回空,以此安全地解决悬空指针问题。
指针类型和引用类型
本质区别:
- 指针 (Pointer):是一个变量,存储的是另一个变量的地址。它本身有自己的内存空间。
- 引用 (Reference):是变量的别名 (Alias)。它本身不占额外的内存空间(在汇编层面通常由指针实现,但在 C++ 语法层面它是透明的)。
可空性:
- 指针可以是
nullptr(空指针)。 - 引用必须初始化,且不能为“空”(不存在空引用)。
可重新赋值:
- 指针可以改变指向,指向别的对象。
- 引用一旦初始化绑定到一个对象,就不能改变指向(从一而终)。
操作方式:
- 指针需要解引用(
*p)才能访问对象。 - 引用直接像原变量一样使用。
sizeof:
sizeof(指针)得到指针本身的大小(4 或 8 字节)。sizeof(引用)得到被引用对象的大小。
Function
C++ 的内存管理机制
C++ 程序的内存主要分为以下几个区域:
- 栈区 (Stack):
- 用途:存放函数的参数值、局部变量等。
- 管理:由编译器自动分配和释放。函数调用结束,内存自动回收。
- 特点:效率高,但空间有限(通常几 MB)。
- 堆区 (Heap):
- 用途:用于动态内存分配(通过
new/malloc分配)。 - 管理:由程序员手动控制(
delete/free),或者使用智能指针(RAII)自动管理。 - 特点:空间很大,但分配/释放开销大,容易产生内存碎片或泄漏。
- 用途:用于动态内存分配(通过
- 静态/全局存储区 (Static/Global):
- 用途:存放全局变量和静态变量 (
static)。 - 管理:程序启动时分配,程序结束时释放。
- 特点:生命周期贯穿整个程序运行期间。
- (注:通常细分为 Data Segment 存初始化的,BSS Segment 存未初始化的)
- 用途:存放全局变量和静态变量 (
- 常量存储区:存放常量字符串等,不允许修改。
- 代码区 (Text Segment):存放二进制代码。
程序组织与作用域
作用域分成了四个级别,从内到外:
- 块级 (Block Scope):
{ ... }里面的变量,出了大括号就死了。 - 函数级 (Function Scope): 在整个函数内有效(主要是 Label 标签)。
- 文件级 (File Scope): 在当前
.cpp文件里有效,外面的文件看不见。- 关键关键字
static: 如果你在全局定义static int k = 0;,这个k就只有当前文件能看见。这叫 内部链接 (Internal Linkage)。
- 关键关键字
- 程序级 (Program Scope): 整个程序都能看见。
- 关键关键字
extern: 如果你要用别的.cpp里定义的全局变量,必须在头文件里用extern float salary;声明一下。这叫 外部链接 (External Linkage)。
- 关键关键字
函数调用过程(Function Call Process) 中底层堆栈操作的总结。
1. 参数入栈 (Push arguments)
- 将函数需要的参数压入栈中(通常是从右往左压入)。
2. 保存上下文 (Save context)
- a) 保存返回地址 (Save return address):把“下一条指令的地址”压入栈,这样函数执行完知道该回哪去。
- b) 保存调用者的基址指针 (Save caller’s base pointer):把上一层函数(调用者)的栈底指针(EBP/RBP)压入栈保存,以便将来能恢复上一层的栈帧。
3. 执行函数 (Execute function)
- a) 设置新的基址指针 (Set new base pointer):将当前栈顶(ESP)变成新的栈底(EBP),开始构建当前函数的栈帧。
- b) 分配空间(可选) (Allocate space (optional)):移动栈指针,为局部变量腾出内存空间。
- c) 执行具体任务 (Carry out some tasks):运行函数内部的逻辑代码。
- d) 释放空间(若已分配) (Release space (if allocated)):回收局部变量占用的栈空间。
4. 恢复上下文 (Recover context)
- a) 加载/恢复调用者的基址指针 (Load caller’s base pointer):弹出栈里的旧基址指针,恢复到上一层函数的栈帧状态。
- b) 加载返回地址 (Load return address):从栈中弹出返回地址,准备跳转回去。
5. 继续执行调用者函数 (Continue to execute caller function)
- CPU 跳转回返回地址,继续执行调用该函数的后续代码。
命名空间
C++ 引入了 namespace 来隔离名字。
定义与嵌套:
- 你可以把代码包在
namespace L { ... }里。 - 支持套娃:
namespace L1 { namespace L2 { ... } }。
如何使用 (3种方式):
- 全名轰炸:
L1::L2::f()—— 最安全但最麻烦。 - Using Declaration (推荐):
using L::k;—— 只引入这一个名字,安全且方便。 - Using Directive (危险):
using namespace std;—— 把整个库全倒进来。PPT 特别警告:不要在头文件中写这个! 否则包含了这个头文件的所有源文件都会被污染,容易引发灾难。
黑科技:
- 别名 (Alias): 名字太长?
namespace ATT = American_Telephone...起个短名。 - 匿名命名空间:
namespace { int a; }。这里面的a只能在当前文件访问。PPT 提到它在现代 C++ 中被用来替代static全局变量。
预处理器
三大核心指令
#include:- 本质就是把那个文件的内容复制粘贴过来。这就是为什么头文件要有“防卫式声明” (
#ifndef ... #define ... #endif),防止重复粘贴。
- 本质就是把那个文件的内容复制粘贴过来。这就是为什么头文件要有“防卫式声明” (
#define(宏):- 常量替换:
Symbolic constants。 - 宏函数: 比如
FOR_EACH宏。PPT 展示了一个用宏写的循环,代码非常丑陋且容易出错(需要用do...while(0)包裹来保证安全性)。 - 恐怖演示: PPT 举了个极端的例子,通过编译选项
-Dsqrt=rand,把程序里的开平方函数偷换成了随机数函数。结果sqrt(2)输出6.93...e-310。这说明了宏的破坏力:它可以无视作用域,直接篡改语义。
- 常量替换:
- 条件编译:
#ifdef,#if,#elif等。- 用途: 版本控制(比如根据不同 OS 编译不同代码)、注释掉大段代码、防止头文件重复包含。
“去宏化”运动
| 宏 (#define) 的用途 | C++ 推荐的替代品 | 理由 |
|---|---|---|
| 定义常量 (如 #define PI 3.14) | const / enum | 有类型检查,能调试 |
| 定义小函数 (如宏函数) | inline | 有语法检查,参数处理更安全 |
| 通用泛型 | template | 类型安全,支持特化 |
| 命名隔离 | namespace | 真正的逻辑隔离,而不是文本替换 |
const 关键字(考过)
- 修饰普通变量
- 作用:
- 替代宏 (
#define): PPT 里特别强调,应该用const来替代#define定义常量。因为const有类型检查,便于调试,而宏只是盲目的文本替换。 - 安全性: 告诉编译器这个值初始化后绝不能被修改。如果代码里试图
c = 129;,编译直接报错。
- 替代宏 (
- 作用:
- 修饰指针
- 用法 A:指向常量的指针
const int *p(const 在*左边)- 作用: 你不能通过这个指针去修改它指向的数据。
- 用法 B:常量指针
int * const p(const 在*右边)- 作用: 指针本身是常量,指向不能变(不能指完 A 又指 B),但它指向的数据可以改。
- 用法 A:指向常量的指针
- 修饰函数参数
- 作用: 保证函数内部不会(也不敢)修改调用者传进来的数据。
- 配合引用 (&) 使用时,既避免了大对象的拷贝开销,又起到了像“传值”一样的保护作用。
- 修饰类成员函数
- 用法: 在函数声明后面加
const。 - 作用是承诺该函数承诺不修改对象的任何成员变量。
- 用法: 在函数声明后面加
- 修饰对象
- 作用: 这个对象也就是“只读”的了。你只能读取它的数据,或者调用它的
const成员函数,不能调用修改它状态的函数。
- 作用: 这个对象也就是“只读”的了。你只能读取它的数据,或者调用它的
内联函数 inline(考过)
编译系统将为 inline 函数创建一段代码,在调用点,以相应的代码替换。
- 限制是不能用于递归和函数指针。
- 适用于频率高、简单、小段的代码。
- 优点:提高可读性,提高效率。
- 缺点:
- 增大目标代码。如果一个函数被调用了 100 次,内联就会把它的代码复制 100 份。
- 病态换页。代码变大了,内存塞不下,操作系统就得频繁地把内存页换入换出
- 降低指令快取装置的命中率。代码体积变大后,缓存能装下的有效指令就变少了。
- 与宏的区别:宏是预处理阶段进行的简单文本替换(无类型检查、无作用域),而内联函数是编译阶段进行的带类型检查的代码嵌入,后者旨在替代宏以兼顾执行效率与安全性。
函数重载
考试里的两道真题:
void func( long a, long b) {
cout << "long";
}
void func(double a, double b) {
cout << "double";
}
int main(int argc, const char * argv[]) {
int a=1, b=1;
func(a, b);
return 0;
}
void foo(int x, int y = 2) {
std::cout << x + y << '\n';
}
void foo(int x, double y = 3.0) {
std::cout << x + y << '\n';
}
int main() {
foo(1);
return 0;
}
这两个都会编译器报错。提示对函数的调用是二义性的 (Ambiguous)。
对于第一个例子:
int到long属于标准转换(注意:不是提升,提升特指比 int 小的类型变 int)。int到double也属于标准转换。
这两个转换在 C++ 编译器的眼里是同一个等级的。编译器无法判断把 int 转成 long 更好,还是转成 double 更好。因为两者平级,编译器无法做出唯一的选择,所以报 Ambiguous(二义性) 错误。
对于第二个例子:
C++ 标准规定,如果存在多个可行的重载函数,编译器必须能找到一个“最佳匹配”。 但在本例中:
- 第一个参数
1(int) 对两个函数来说都是精确匹配 (Exact Match)。 - 第二个参数都使用了默认参数。
两个函数在匹配程度上平分秋色,难分高下。编译器无法决定你到底想用哪一个默认值,所以它为了避免猜错,只能抛出错误:call to 'foo' is ambiguous(对 foo 的调用有歧义)。
补充:C 与 C++ 的关系(考过)
(1)C++ 完全包含 C 语言成分,支持 C 所⽀持的全部编程技巧(C 的超集),C 是建立 C++的基础。
(2)任何 C 程序都能被 C++ 用基本相同的方法编写,并具备相同的运行效率和空间。
(3)C++ 还引入了重载、内联函数、异常处理等功能,对 C 中过程化控制及其功能进行了扩充;
(4)此外,C++ 还添加了 OOP 的完全支持。
补充:C 和 C++混合编程应该注意的问题(考过)
- 名变换。要写上
extern "C" - 用 C++写 main()
- new/delete 调用 C++的函数库,malloc/free 调用 C 的函数库,二者要匹配
- 数据结构要兼容,用 C 语言编译的数据结构的范围内。
- 混合编程时,不应当出现 class 等面向对象的关键字。