Cpp的学习笔记

  • 2.2 结构 & 2.3 类:理解如何构建数据结构是底层设计的基础。
  • 2.4 构造 & 2.5 析构:掌握对象的生命周期管理,尤其是资源分配与释放。 《C++语言导学 第二版》

struct和class的深度对比

C++中,struct和class在语法上几乎一样,但是有一些区别,除此以外访问成员的方式完全相同。 struct默认成员是Public,但是class默认成员是private。

举个例子:

struct Point {
    int x;
    int y;
    void print() { std::cout << x << ", " << y << std::endl; }
};

int main() {
    Point p{1, 2};     // 这是一个对象
    p.print();         // 用 . 访问成员函数
    std::cout << p.x;  // 用 . 访问数据成员

    Point* ptr = &p;   // 这是一个指针
    ptr->print();      // 用 -> 访问成员函数
    std::cout << ptr->y; // 用 -> 访问数据成员
}

深入理解底层思维,可以把这个规则和 内存模型 联系起来:. 实际上是直接访问对象的内存,而 -> 是先解引用指针,再访问对象的内存。

在现代C++中他们的区别已经非常小了,但是在模板操作的时候,可以使用class或者typename但是无法使用struct。

!tip 对比

  • template <class T> void func(); // ✅ 正确
  • template <struct T> void func(); // ❌ 错误

在细节的业务开发的时候我们会根据意图来选择使用哪个,比如说POD,我们需要把一堆数据打包在一起没有复杂逻辑封装的时候,可以用struct。 DTO之类的。

而在需要有封装和抽象的时候,对象具有内部状态的时候,有复杂生命周期管理的时候,我们就最好用class。

在内存构造上,struct 和 class 完全没有区别。

如果你定义了一个 struct 和一个 class,且它们的成员、顺序和访问权限完全一致,那么它们在内存中的布局(Memory Layout)是 100% 相同的。

union-特殊的类类型

union被称为共同体,所有的程序都占据同一块内存地址,现代c++中使用的比较少,因为有了更安全的std::variant(c++ 17以后),传统的union不能支持POD类型,因为union不能轻易存放std::string或者std::vector,union不能自动调用它们的构造和析构函数。

这里提醒一句,std::string 和 std::vector 的构造与析构函数正是 C++ RAII(资源获取即初始化) 机制的灵魂所在。 ![[Pasted image 20260312011144.png]]

这里我们必须来说一下为什么有这个必要性,当你写 std::vector<int> v(100); 时,构造函数会去操作系统那里申请存放 100 个整数的内存空间,并把这些空间初始化,当 v 超出作用域(比如函数结束)时,它的析构函数会被自动调用,释放刚才申请的那块内存。

所以说,主要的原因是它们管理着堆内存,如果没有析构,那么会发生内存泄漏。

典型的vector组成

一个典型的 std::vector 在内存中通常由三个指针组成:

  1. T* _First: 指向堆内存中数据的开头。
  2. T* _Last: 指向最后一个有效元素的末尾。
  3. T* _End: 指向申请的总空间的末尾(Capacity)。

这里可以解释为什么我们之前说传统的 union 很难处理 std::string。

在里面编译器不知道当前存的是int还是std::string,并且union结束了生命周期的话,编译器不会调用析构。但是std::variant不一样,内部有一个类型标签,他会去检查和调用析构。

同样,在性能方面也有很大的差异,普通的struct或者in 在拷贝构造的时候,是简单的位拷贝,但是std::vector或者std::string会去申请一块同样大小的内存,并且把数据一个一个复制过去。两份的数据有各自的独立内存,修改一个不会影响另一个。

小字符串优化

有趣的是,std::string 的构造函数非常聪明。 如果你只存一个很短的字符串(比如 "Hi"),它不会去堆上申请内存,而是直接存在自己对象内部的固定数组里(通常是 15-22 个字节)。这就是 Small String Optimization (SSO),旨在减少小数据的内存申请开销。

enum枚举 不是容器!

当我们创建一个枚举的时候,不是在内存中创建了一个数组,在编译器的视角中,在编译后,Red、Green、Blue 这些名字大多都消失了,变成了常数(0, 1, 2。。。从0开始)。

内存视角:如果你定义了一个变量 Color myColor = Red;,在内存中只占用一个整数的空间(通常是 4 字节)。它并没有把 Red、Green、Blue 全都排在内存里。

相当于存的是页码不是内容,有效地减少内存消耗。

强类型的枚举

在现代c++中,我们优先使用enum class,这个和传统的enum不一样的地方在于,它不会进行隐式的转换,避免把Color::Red错误地加到一个int变量上。并且限制了作用域,必须使用Color::Red来访问。

C++的容器的思维

在 C 语言或基础阶段,我们用的是“裸数组”(Raw Array),那就像是一排简陋的货架,你自己得记着哪格放了东西、货架够不够大、搬家时得一个一个手动搬。

C++ 做了一件伟大的事情,将货架变成了不同功能的仓库。 现代C++中,容器是一个类模板,它的任务是管理内存和组织数据。 具体表现为可以在堆上申请空间,并且在析构的时候自动释放内存。 规定了数据的排布。

层次漫游

层次漫游是C++ 面向对象编程的一个思维,指的是在继承层次结构中,通过基类指针或引用操作对象时,安全地向下转换到派生类,以便调用派生类特有的方法或访问其特有成员的过程。

基类指针,我们只能调用基类中声明的虚函数(如 draw()、rotate()),派生类定义了自己独有的成员函数(如 Smiley::wink()),通过基类指针无法直接调用。

解决方案是 将基类指针向下转换为派生类指针,才能访问派生类特有的功能。 dynamic_cast 是 C++ 提供的安全向下转换运算符,用于在继承层次中进行运行时类型识别(RTTI)。

Shape* ps = read_shape(cin);

// 尝试将 Shape* 转换为 Smiley*
if (Smiley* p = dynamic_cast<Smiley*>(ps)) {
    // 转换成功,ps 指向的是 Smiley 对象或其派生对象
    p->wink();  // 调用 Smiley 特有的函数
} else {
    // 转换失败,ps 不是 Smiley 类型
}

容器三大家族

C++提供了不同类型的容器,序列容器、关联容器和无序关联容器。

序列容器的含义就是按先后顺序排队,比如说:

  • std::vector:最常用的容器。 像一个可以自动变长的数组。内存是连续的,随机访问(比如看第 100 个)极快。
  • std::list:双向链表。内存不连续。你想在中间插队很容易,但想找第 100 个就得从头数。
  • std::deque:双向队列。头尾都能高效进出。

关联容器,可以按逻辑查找: 这些容器关注的是元素的值或关系,会自动帮你排好序。

  • std::set:集合。里面不能有重复元素,且永远是排好序的。
  • std::map:键值对(Key-Value)。就像字典,通过“单词”找“解释”。

底层通常是红黑树,查找速度非常稳(O(logn))。

无序关联容器通常使用哈希算法,std::unordered_map / set:

  • 它们不排序,而是通过“哈希算法”把东西乱序塞进桶里。
  • 优点:查找极其快(接近常数时间),适合处理海量数据。

除了容器以外还有其他几个概念很重要,这些概念在其他语言也会有体现。比如说是迭代器,它连接了算法和容器。

如果没有迭代器,你写一个排序算法得为 vector 写一套,为 list 写一套。有了迭代器,算法只需要知道“怎么从一个元素挪到下一个”,而不需要管容器底层是连续内存还是链表。

容器就是帮你管内存、定结构的工具。你不需要再用 malloc 去手动申请空间,也不用担心 free 漏了。

纯虚函数和抽象类

![[Pasted image 20260312011906.png]] 这个类就表示一个纯接口,这里的=0很奇怪,这说明这个函数是纯虚函数,Container的派生类必须去定义这个函数,你必须去重载这个纯虚函数,提供具体逻辑,这样类可以变成具体类,可以创建对象。

基类的析构

编译器确实会自动生成析构函数,但它生成的默认析构函数是非虚(Non-virtual)的。 如果基类析构函数不是 virtual,在多态使用场景下会导致内存泄漏或未定义行为。

如果通过一个基类指针去删除一个派生类对象,那么如果析构函数不是虚的,编译器会进行静态绑定这个行为,它看不到对象的实际类型(派生类)。

编译器检查ptr类型,发现是Base*,编译器查看Base的析构函数,发现不是虚函数,那么只调用了Base的析构,跳过了派生类的析构函数,派生类里的一些资源没有被销毁,内存发生泄漏。

既然 non-virtual 这么危险,为什么 C++ 不像 Java 或 C# 那样默认所有函数都是虚的?

原因还是为了性能(C++ 的执念):

  • 虚函数表指针 (vptr):我们在之前聊过,一旦有了虚函数,每个对象都要多占 8 字节(64位系统)来存虚表指针。
  • 性能开销:虚函数调用需要查表,比直接调用慢。
  • 零开销原则 (Zero-overhead Principle):如果你只是写一个简单的 struct 存数据,不需要多态,C++ 就不想让你为“你没用到的功能”付出内存和速度的代价。

只要你的类满足以下任何一个条件,就必须手动写一个 virtual 析构函数:

  1. 这个类里有任何一个其他的 virtual 函数。
  2. 这个类会被其他类继承(即它作为基类)。

基本操作

在类中,有几种常见的构造、析构和拷贝操作。 ![[Pasted image 20260312013300.png]]

![[Pasted image 20260312013412.png]]

拷贝消除

这是C++编译器的一种优化手段,他可以让拷贝不发生,在现代 C++(特别是 C++17 之后),这已经从一种“可选的优化”变成了“语言的强制要求”。

要理解它的实现,我们需要看一看函数调用的**栈帧(Stack Frame)是如何工作的。

传统做法中,调用者会给变量x预留空间,函数会在自己的栈帧构造一个临时对象,编译器会调用拷贝构造函数,把临时对象的数据复制给x,函数返回,销毁临时对象。

拷贝消除的原理就是,编译器通过修改调用约定来优化,当我们去调用一个返回对象的函数时候,编译器会给函数传递一个指针,指向调用者中存放结果的地址(x变量的地址)。

这样函数内部就不会创建局部临时对象,而是在指针指向的内存位置上执行构造函数,这样构造的对象就在最终的位置。

因此准确来说,它取消了拷贝构造操作。

与“移动语义”(Move)的关系

很多开发者会混淆这两者。其实它们是互补关系:

  1. 第一优先级:拷贝消除。如果能直接在目的地构造,就完全不调用构造函数。性能开销:0。
  2. 第二优先级:移动语义。如果不能消除(比如对象必须先在别处存在),就调用“移动构造函数”,把资源所有权偷过来。性能开销:极低(通常只是几个指针赋值)。
  3. 第三优先级:拷贝语义。如果以上都不行,才老老实实调用拷贝构造函数,进行昂贵的深拷贝。

拷贝和移动

![[Pasted image 20260312014533.png]]

拷贝容器

![[Pasted image 20260312014700.png]]

这里说明了浅拷贝可能导致资源竞争和程序崩溃。 这里v1内部有一个指针指向堆内存里的数组,当我们执行Vector v2 =v1的时候,编译器会 逐成员拷贝 。

  • 编译器的逻辑:把 v1 里的指针地址(比如 0x0012)原封不动地复制给 v2。
  • 结果:v1 和 v2 变成了两个独立的变量,但它们内部的指针却指向了同一块内存地址。

当我们修改v10的时候,v20也跟着变了。修复的方法也很简单,需要手动定义拷贝行为,而不是复制地址。

  • 申请一块新的、同样大小的内存。
  • 把原内存里的内容复制过去。

![[Pasted image 20260312015115.png]]

之后v2 = v1结果就是图下:

![[Pasted image 20260312015212.png]]

除去拷贝构造函数,还需要一个拷贝赋值操作: ![[Pasted image 20260312015302.png]]

移动容器

移动是C++ 11 之后性能飞跃的分水岭,经常用来处理大型容器。

在没有“移动”概念的老派 C++ 中,如果你从函数返回一个大型 Vector:

  1. 拷贝构造:系统会申请一块同样大的新内存。
  2. 数据搬运:把旧内存里的 10,000 个数据一个个复制到新内存。
  3. 销毁旧物:把函数里的局部对象删掉,释放原来的内存。

可以看上面的拷贝消除版块。 “移动”的逻辑非常流氓但极其高效:它不复制数据,而是直接偷走指针。

![[Pasted image 20260312015621.png]] 这里是一个vector加法的例子,如果是在传统c++中,计算r = x+y+z的时候,x+y 中,operator+ 返回的时候,res的内容会被拷贝到一个临时对象,然后res被销毁。 +z的时候又会拷贝到一个临时对象,然后又一次将结果复制给r。 这里就是不合理的地方。

res会在堆上申请空间,存好数据。既然res会被销毁,那么我们可以直接将内存指针转移给调用者。因此我们会使用&& 右值引用。

  • Vector(Vector&& a):移动构造函数。
  • Vector& operator=(Vector&& a):移动赋值运算符。

当编译器看到 r = x + y + z 时,它知道 x + y 产生的是一个临时的、即将销毁的对象(右值)。

  1. 它不再调用“拷贝”函数。
  2. 它调用“移动”函数:直接把临时对象里的指针偷过来,赋值给 r。
  3. 把临时对象里的指针设为 nullptr。

![[Pasted image 20260312020356.png]]

被移动的对象的引用不会变,但是对象内部的资源通常会变成nullptr或者初始状态。

资源管理

![[Pasted image 20260312020647.png]]

在C++中,资源是指任何必须获取并在使用后(显式或隐式)释放的东西

就比如内存,new分配的内存,释放是通过delete,线程也必须释放,通过Join()或者析构。 文件句柄也一样,但是释放的方式是close()或者析构。锁用unlock(),网络连接用close(),数据库是disconnect()。

资源句柄是一个对象,它在构造函数中获取资源,在析构函数中释放资源,这就是C++中的资源管理的核心模式——RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

资源安全的三级

弱资源安全:资源最终会被释放(但可能延迟)
强资源安全:资源在不再需要时立即释放
无泄漏:所有资源都被正确管理

C++标准库提供了多种资源句柄:

资源句柄管理的资源特性
std::vector动态数组可拷贝(深拷贝)或移动
std::string字符串可拷贝或移动
std::thread线程只能移动,不能拷贝
std::unique_ptr单个对象/数组独占所有权,只能移动
std::shared_ptr单个对象共享所有权,可拷贝
std::fstream文件可移动
std::lock_guard互斥锁自动解锁

GC模式和RAII模式的优劣

垃圾回收(GC):

  • 优点:自动管理内存,减少程序员负担
  • 缺点:只管理内存,不管理其他资源;全局性,破坏局部性;不确定的释放时机

RAII/资源句柄:

  • 优点:管理所有资源;确定性释放;局部性更好;零开销抽象
  • 缺点:需要程序员理解生命周期