结构化编程

程序=数据结构+算法

ADT 抽象数据类型

表达式

表达式的值由哪些因素决定?

最基本的:操作符、操作数

其他的,还由以下 4 个因素 共同决定:

  • 优先级 (Priority):决定了不同运算符结合的紧密程度(例如先乘除后加减)。
  • 结合性 (Associativity):决定了当优先级相同时,运算是从左向右还是从右向左进行(例如赋值运算 = 是右结合的)。
  • 类型转换 (Type conversion):在运算过程中发生的隐式或显式类型转换会改变操作数的值或类型,从而影响最终结果(例如 5/22,但 5.0/22.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.0inf - inf,或者 sqrt(-1)
      • 特性: 任何数和 NaN 运算结果都是 NaN。

image-20260104001159784

类型转换

  • static_cast<>()

    • 会在类型转换时出现截断和精度丢失 比如int转float可能会损失精度 int转short会高位截断
  • reinterpret_cast<>

    • 不对数据做任何处理,只是换个“眼镜”看内存 就是换一种方式去理解相同一块内存

      image-20260104003835457

    • 序列化就是这个原理

  • 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<>()

    image-20260104004815653

    • 编译器看到 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_castdynamic_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,数组也会变。这就实现了同一块数据的两种不同访问方式

image-20260104013430484

在没有继承和虚函数(C++ 多态)的情况下,用 struct + union 实现多态行为

其中FIGURE_TYPR用于标记类型 使用FIGURE时先通过这个判断类型 再进行对应操作 如下:

image-20260104013944722

但这种实现多态的方式不够便捷

在需要增加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,不行!” 然后抛出异常。这就是所谓的“类型安全”。

运行时多态 (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_ptrstd::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() 清空内容

image-20260104152807022

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 和内存图展示了数据到底存在哪。

image-20260104160139537

  • 小对象优化 (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)
    • 底层流程
      1. 调用 Handler:必须通过函数指针调用那个“管理函数”(Handler)。
      2. RTTI 检查:内部使用 typeid 对比存储的类型和请求的类型是否一致。
    • 性能代价
      • 包含一次函数调用(Function Call)开销(无法内联)。
      • 包含一次类型比较(Type Comparison)开销。
  • std::variant:开销极小
    • 访问方式:使用 std::get<T>(v)std::get<Index>(v)
    • 底层流程
      1. 编译期决议:请求的类型 T 或索引 Index 在编译时就是已知的。
      2. 直接寻址:内部直接转换为数组索引访问(调用 __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> 取第二个
    
    • 缺点01 没有名字直观。
  • 用法 B:解包 (std::tie)

    PPT 下面的红框展示了如何把 tuple 拆回单独的变量:

    int i; float f;
    std::tie(i, f) = r; // 把 tuple 里的值分别“绑”给 i 和 f
    cout << i << f;
    

std::tuplestd::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):

  1. 最安全/最推荐:value_or()

    // 如果有值就取出来,没值就返回括号里的默认值(比如 -1)
    int id = opt.value_or(-1); 
    
  2. 手动检查:has_value()

    if (opt.has_value()) {
        cout << opt.value(); // 确定有值了再取
    }
    
  3. 直接取值(有风险):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 个字节(取决于系统),指向别人的内存。
  • “退化”与“常量性” (核心考点):
    • 在表达式中,数组名 a 通常会退化 (Decay) 为指向数组首元素的指针,即 a 等价于 &a[0]
    • 但是! 这种退化后的指针是常量指针 (Constant Pointer)
      • PPT 明确写了:int * const。这意味着你不能改变 a 指向哪里。
      • 能做: int *p = a; p++; (指针 p 是变量,可以随便动)。
      • 不能做: a++;a = p; (数组名 a 是个“钉死”的地址标签,动不了)。PPT 用一个大大的 “X” 标记了 *(a++) = 0 这种写法是错误的。

如何用指针操作数组元素?

假设有:int a[12]; int *p = a;

A. 访问元素 (Value)

想要获取第 i 个元素的值,以下写法完全等价:

  1. 数组下标: a[i]
  2. 指针下标: p[i] (是的,指针也能用方括号)
  3. 数组名偏移解引用: *(a + i)
  4. 指针偏移解引用: *(p + i)

底层逻辑: 编译器看到 a[i],实际上就是在做 *(a + i)

B. 获取地址 (Address)

想要获取第 i 个元素的地址,以下写法等价:

  1. 取地址符: &a[i]
  2. 数组名偏移: a + i
  3. 指针偏移: p + i

C. 遍历数组的两种风格

  1. 指针算术 (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)会自动调用析构函数,顺带释放掉它管理的堆内存,彻底杜绝泄漏。

三大智能指针

  1. unique_ptr (独占型)
  • 核心理念RAII (资源获取即初始化)。
  • 行为独占资源的所有权。
  • 优势:利用栈对象的生命周期管理堆内存。当 unique_ptr 离开作用域(无论是正常结束、return 还是抛出异常 throw)时,其析构函数会自动调用 delete,从而完美杜绝内存泄漏。
  1. shared_ptr (共享型)
  • 核心理念引用计数 (Reference Counting)。
  • 行为:允许多个指针共享同一个对象。每复制一份 (shared_p_ref = shared_p),引用计数 (use_count) 加 1;每销毁一个,计数减 1。
  • 释放时机:只有当引用计数归 0 时,内存才会被真正释放。
  1. 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):存放二进制代码。

程序组织与作用域

作用域分成了四个级别,从内到外:

  1. 块级 (Block Scope): { ... } 里面的变量,出了大括号就死了。
  2. 函数级 (Function Scope): 在整个函数内有效(主要是 Label 标签)。
  3. 文件级 (File Scope): 在当前 .cpp 文件里有效,外面的文件看不见。
    1. 关键关键字 static 如果你在全局定义 static int k = 0;,这个 k 就只有当前文件能看见。这叫 内部链接 (Internal Linkage)
  4. 程序级 (Program Scope): 整个程序都能看见。
    1. 关键关键字 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种方式):

  1. 全名轰炸: L1::L2::f() —— 最安全但最麻烦。
  2. Using Declaration (推荐): using L::k; —— 只引入这一个名字,安全且方便。
  3. Using Directive (危险): using namespace std; —— 把整个库全倒进来。PPT 特别警告:不要在头文件中写这个! 否则包含了这个头文件的所有源文件都会被污染,容易引发灾难。

黑科技:

  • 别名 (Alias): 名字太长?namespace ATT = American_Telephone... 起个短名。
  • 匿名命名空间: namespace { int a; }。这里面的 a 只能在当前文件访问。PPT 提到它在现代 C++ 中被用来替代 static 全局变量。

预处理器

三大核心指令

  1. #include
    1. 本质就是把那个文件的内容复制粘贴过来。这就是为什么头文件要有“防卫式声明” (#ifndef ... #define ... #endif),防止重复粘贴。
  2. #define (宏):
    1. 常量替换: Symbolic constants
    2. 宏函数: 比如 FOR_EACH 宏。PPT 展示了一个用宏写的循环,代码非常丑陋且容易出错(需要用 do...while(0) 包裹来保证安全性)。
    3. 恐怖演示: PPT 举了个极端的例子,通过编译选项 -Dsqrt=rand,把程序里的开平方函数偷换成了随机数函数。结果 sqrt(2) 输出 6.93...e-310。这说明了宏的破坏力:它可以无视作用域,直接篡改语义
  3. 条件编译:
    1. #ifdef, #if, #elif 等。
    2. 用途: 版本控制(比如根据不同 OS 编译不同代码)、注释掉大段代码、防止头文件重复包含。

“去宏化”运动

宏 (#define) 的用途 C++ 推荐的替代品 理由
定义常量 (如 #define PI 3.14) const / enum 有类型检查,能调试
定义小函数 (如宏函数) inline 有语法检查,参数处理更安全
通用泛型 template 类型安全,支持特化
命名隔离 namespace 真正的逻辑隔离,而不是文本替换

const 关键字(考过)

  1. 修饰普通变量
    • 作用:
      • 替代宏 (#define): PPT 里特别强调,应该用 const 来替代 #define 定义常量。因为 const 有类型检查,便于调试,而宏只是盲目的文本替换。
      • 安全性: 告诉编译器这个值初始化后绝不能被修改。如果代码里试图 c = 129;,编译直接报错。
  2. 修饰指针
    1. 用法 A:指向常量的指针 const int *p (const 在 * 左边)
      • 作用: 你不能通过这个指针去修改它指向的数据。
    2. 用法 B:常量指针 int * const p (const 在 * 右边)
      • 作用: 指针本身是常量,指向不能变(不能指完 A 又指 B),但它指向的数据可以改。
  3. 修饰函数参数
    1. 作用: 保证函数内部不会(也不敢)修改调用者传进来的数据。
    2. 配合引用 (&) 使用时,既避免了大对象的拷贝开销,又起到了像“传值”一样的保护作用。
  4. 修饰类成员函数
    1. 用法: 在函数声明后面加 const
    2. 作用是承诺该函数承诺不修改对象的任何成员变量
  5. 修饰对象
    1. 作用: 这个对象也就是“只读”的了。你只能读取它的数据,或者调用它的 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)

对于第一个例子:

  • intlong 属于标准转换(注意:不是提升,提升特指比 int 小的类型变 int)。
  • intdouble 也属于标准转换

这两个转换在 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++混合编程应该注意的问题(考过)

  1. 名变换。要写上 extern "C"
  2. 用 C++写 main()
  3. new/delete 调用 C++的函数库,malloc/free 调用 C 的函数库,二者要匹配
  4. 数据结构要兼容,用 C 语言编译的数据结构的范围内。
  5. 混合编程时,不应当出现 class 等面向对象的关键字。