NJUのC++课:指针

-1

指针基础

指针本身只是一个变量,只不过它的值正是另一个变量的内存地址。 但是其需要指定一个类型:去维护它的内存大小

C语言中两个问题的解决

  1. 野指针的解决:

    何为野指针?就是指针未初始化时指向一个不确定的内存地址(没有实际意义)

    C++中引入了空指针去解决这个问题,表示这个指针不指向任何对象,和C不同的是,C中使用的是NULL,而他也可以被解释成0,C++使用的nullptr 是一个明确的指针类型,可以避免这个问题。

  2. 悬空指针的解决:

    何为悬空指针?其实是指向的内存已经被释放,但是指针本身没有被置空

    int *p = new int(10);
    delete p;
    ***p = 100;** // 严重的未定义行为
    

    解决方式:delete之后就应该立刻将指针置为空,即p = nullptr

与数组的基础结合

数组 = 一段连续空间,这是指针使用的绝佳方式。

  • 举例:

    int a[10];
    int *p = a; // 等价于 int *p = &a;
    

    核心等价关系: a[i] 完全等价于 *(a+i) 和 *(p+i)

  • 这里的a和p有区别:

    • 指针 p 是一个变量,可以进行 p++ 这样的自增操作。
    • 数组名 a 是一个常量,不能进行 a++ 这样的操作,这会导致编译错误。
    • sizeof(a) 返回整个数组的大小10个int,而 sizeof(p) 只返回指针自身的大小1个int。

多维数组和指针

C++中并不存在真正的多维数组,它本质上是“数组的数组”。一块连续的内存空间,被人为地划分为行和列。

  • 指针的两种玩法:
    1. *指针指向元素 (int p): 这是最直接的玩法。
      • int *p = &a[0][0]; 或 int *p = a[0];
        • 这里的a[0]是数组名,即指针常量
      • p 指向数组的第一个整数。p++ 会移动到下一个整数。
      • 访问 a[i][j] 就变成了 *(p + i * 列数 + j)。
    2. *指针指向数组 (int (q)[4]): 这种玩法更高级,也更容易混淆。
      • int (*q)[4] = a;
      • q 是一个指向“包含4个整数的数组”的指针。
      • q++ 会跳过4个整数,直接移动到下一行。
  • 交错数组 (Ragged Array): 这不是C++的标准二维数组。它是一个指针数组,每个指针再分别指向一个一维数组。
    • int* arr[3];
    • 这种数组的每一行可以有不同的长度,内存不一定是连续的,因此更加灵活,但管理也更复杂。

顺时针螺旋法则

如上面所展示的那样,在指针这里,声明很重要,这会决定一个指针变量的含义以及其步长,但是它很难读,这里展示一种很好的阅读方案。

  1. 从变量名开始:找到你要解析的变量名。
  2. 向右看:从变量名开始,向右看,遇到 [] (数组) 或 () (函数),就先把它读出来。
  3. 向左看:当右边遇到 ) 或者到头了,再回到变量名,向左看,遇到 * (指针),就把它读出来。
  4. 跳出括号:如果整个声明被括号 () 包围,那就跳出这层括号,重复第2和第3步。
  5. 最后读类型:当所有 *, [], () 都解析完后,最后读出最左边的类型名 (如 int, char, void 等)。
  • 实例解析 1:int* arr[3];

    1. 从变量名开始:找到变量名是 arr。

      int* arr[3];

    2. 向右看:从 arr 向右看,我们遇到了 [3]。

      • [3] 的意思是“一个包含3个元素的数组”。
      • 所以第一部分的解读是:“arr 是一个包含3个元素的数组...”

      int* arr[3];

      • ---->
    3. 向左看:右边到头了,回到 arr,向左看,我们遇到了 *。

      • 的意思是“...的指针”。
      • 所以第二部分的解读是:“...数组的每个元素都是一个指针...”

      int* arr[3];

      <--

    4. 跳出括号:这里没有括号包围,跳过。

    5. 最后读类型:解析完所有符号后,读最左边的类型 int。

      • int 的意思是“...指向 int 类型”。
      • 所以第三部分的解读是:“...这个指针指向的是 int 类型。”

    组合起来的完整含义:

    arr 是一个包含3个元素的数组,该数组的每一个元素都是一个指向 int 类型的指针。

维度的“降维打击”与“升维思考”

由于数组在内存中的连续性,我们可以巧妙地在函数传递改变它的维度

  • 维度退化 (Dimension Reduction):

    • 将一个多维数组传递给一个期望一维数组的函数是完全可行的,这是一种“降维打击”。
    int a[2][4];
    maximum(a[0], 2*4); // 将其视为一个长度为8的一维数组
    maximum(&a[0][0], 8); // 效果完全相同
    
    • 本质: 我们只是告诉函数:“这是一块连续内存的起始地址,以及它的总长度”,函数并不关心它原本是几行几列。
  • 维度提升 (Dimension Augmentation):

    • 反过来,我们也可以通过强制类型转换,让一个函数把一维数组当成多维数组来处理,这是“升维思考”。
    int b[12];
    show((int (*)[2])b, 6); // 将b看作一个6行2列的数组
    
    • “坑”: 这是一种非常危险但强大的操作。程序员必须自己保证逻辑上的维度和内存长度是匹配的,否则极易导致内存越界。

C风格的内存管理

在C++的new/delete和智能指针的出现之前,malloc和free是C语言操控堆内存的唯一手段。

  • malloc是什么:
    • malloc (memory allocation) 是一个函数,它向操作系统申请一块指定大小的原始、未初始化的堆内存。
    • 它返回一个 void* 类型的指针,需要我们手动强制转换为所需的类型。
    • int *p = (int*)malloc(sizeof(int));
  • malloc的原则: 为了效率,malloc的实现通常遵循以下原则,以避免频繁地、代价高昂地向操作系统申请内存(即系统调用 brk 或 mmap)。
    1. 减少系统调用: malloc会预先向操作系统申请一大块内存(内存池),然后自己在这块内存里进行管理和分配。
    2. 快速匹配: 内部通过不同的数据结构(如bins)来管理不同大小的空闲内存块,以便快速找到最合适的块进行分配。
    3. 减少锁竞争: 在多线程环境下,为每个线程维护独立的内存池(如ptmalloc, tcmalloc),避免线程之间因争夺全局内存锁而造成的性能瓶颈。

RAII & 智能指针

接上,C++中为了解决手动管理内存的种种问题,引入了RAII(资源获取即初始化)思想,智能指针就是其最典型的应用。

RAII —— 智能指针的灵魂

  • 旧时代的“痛” (old_use):
    • 在复杂的逻辑中,如果中间因为异常 (throw) 或者提前返回 (return),代码执行不到最后的 delete q;,那么这块内存就永远不会被释放,这就是内存泄漏 (Memory Leak)
    • 手动管理内存,既容易忘记释放,又难以保证异常安全。
  • RAII的“优雅” (newer_use):
    • auto p = int_ptr(new Blob(a)); // 1. 在构造函数中获取资源
    • 我们把裸指针 new Blob(a) 交给了一个栈上的对象 p 来管理。
    • 无论函数是正常结束,还是因为异常、return等原因提前退出,栈展开 (stack unwinding) 机制都会保证栈上的对象 p 被正确地销毁。
    • p 在其析构函数 (~int_ptr) 中自动释放资源 (delete ptr)。// 2. 在析构函数中释放资源
    • 这就是RAII的魔力:将资源释放的责任从“人”转移给了“编译器一定会执行的规则”,从而实现了资源的自动管理,做到了谁申请,谁释放异常安全

auto_ptr 98

auto_ptr是C++对RAII思想的第一次尝试,但它有一个致命的缺陷,现在已经被废弃,我们了解它主要是为了避免在旧代码中踩坑。

  • 反直觉的拷贝语义: 这是auto_ptr最大的“坑”。

    auto_ptr<int> p1(new int(10));
    auto_ptr<int> p2 = p1;
    
    • 这个“拷贝”操作,实际上执行的是所有权的转移。执行后,p2 获得了资源的所有权,而 p1 会自动变成一个空指针
    • 此时访问 *p1 将导致程序崩溃。(悬空指针!!)
  • 不适合STL容器: 这种诡异的拷贝行为,使得auto_ptr完全无法与STL容器(如std::vector)一起工作,因为容器在内部会进行大量的拷贝操作,会导致容器内的元素莫名其妙地“丢失”。

unique_ptr 11

  • 专属所有权 (Exclusive Ownership): unique_ptr 保证在任何时刻,只有一个指针能指向一个给定的资源。

    • 它从语法上禁止了拷贝
    unique_ptr<int> p1(new int(20));
    unique_ptr<int> p2 = p1; // 编译错误!这样就从根本上杜绝了auto_ptr的错误。
    
  • 明确的所有权转移: 如果你确实需要转移所有权,必须显式地使用 std::move。

    • unique_ptr<int> p2 = std::move(p1);
    • 这行代码清晰地表达了你的意图:“我要把p1的所有权转移给p2”,转移后p1变为空(nullptr)
  • 零开销抽象: unique_ptr 和裸指针的大小完全一样,性能开销也几乎为零,是C++“零开销抽象”理念的典范。

shared_ptr 11

当一个资源需要被多个所有者共同管理和拥有时,shared_ptr就派上了用场。

  • 共享所有权 (Shared Ownership): shared_ptr通过**引用计数(use_count())**来工作。
    • std::shared_ptr<int> p1{new int(10)}; // 此时引用计数为 1
    • std::shared_ptr<int> p2 = p1; // 拷贝p1,引用计数变为 2
    • std::shared_ptr<int> p3 = p2; // 拷贝p2,引用计数变为 3
  • 自动释放: 每当一个shared_ptr被销毁(例如离开作用域),引用计数就会减一。当引用计数降为0时,代表没有任何人再需要这个资源了,资源会被自动释放。
  • 实现原理: 一个shared_ptr对象内部通常包含两个指针:一个指向资源本身,另一个指向一个控制块。这个控制块中存储了引用计数等管理信息。所有指向同一资源的shared_ptr都共享同一个控制块。

weak_ptr 11

shared_ptr虽然强大,但它有一个致命的问题——循环引用 (Circular Reference)

  • 循环引用的“死锁”:
    • 双向链表: 如果Node A的next指针(shared_ptr)指向Node B,而Node B的prev指针(shared_ptr)又指回Node A。即使没有任何外部指针指向这两个节点,它们各自的引用计数也永远是1,导致两个节点都无法被释放,造成内存泄漏。
    • 观察者模式: 如果Teacher(发布者)持有Student(订阅者)的shared_ptr,而Student为了能回调老师,也持有了Teacher的shared_ptr,同样会形成循环引用。
  • weak_ptr的解决方案: weak_ptr是一种不控制资源生命周期的智能指针。它像一个“观察者”,可以指向由shared_ptr管理的资源(仅仅观察),但是不会增加引用计数
    • 解决方式: 在上述场景中,将导致循环的那条引用关系改为weak_ptr。例如,双向链表中的prev指针,或者Student指回Teacher的指针。
  • 安全访问: weak_ptr不能直接访问资源,因为它不确定资源是否还存在。必须通过调用lock()方法。
    • lock()会检查资源是否存在。如果存在,它会返回一个指向该资源的有效的shared_ptr(使得在访问期间资源不会被释放);如果资源已被销毁,它会返回一个空的shared_ptr。这保证了访问的绝对安全。

shared_ptr 和 weak_ptr 的内存反映

  • shared_ptr 对象自身 (例如 sp1, sp2): 它的大小通常是裸指针的两倍。因为它内部包含了两个指针:Data AddrCtrl Addr

    • 如图:

  • Ctrl Addr指向的内容又分为两块内容

    • 上面是指向虚函数表vtable
    • 下面则是很重要的部分
      1. 强引用计数 (use_count): 记录当前有多少个 shared_ptr 共同拥有这个数据对象。
      2. 弱引用计数 (weak_count):weak_count = (当前 weak_ptr 的数量) + (当前 shared_ptr 是否存在?)
  • 我们着重强调下面一部分的实现,举PPT中的例子说明一下:

    auto sp1 = make_shared<MyObject>(0xFF); // 01 00 00 00 | 01 00 00 00
    auto sp2 = sp1; // 02 00 00 00 | 01 00 00 00
    weak_ptr<MyObject> wp = sp1; // 02 00 00 00 | 02 00 00 00
    auto sp_locked = wp.lock(); // 03 00 00 00 | 02 00 00 00
    sp_locked.reset(); // 02 00 00 00 | 02 00 00 00
    
    • 行一:
      • sp1 被创建。它的 Data Addr 指向 MyObject,Ctrl Addr 指向控制块。
      • 控制块初始化:
        • use_count (强引用计数) 被设为 1 (因为 sp1 这一个 shared_ptr 正在拥有它)。
        • weak_count (弱引用计数) 也被设为 1。这是因为即使只有一个 shared_ptr 存在,控制块也必须存在,所以弱引用计数从1开始,代表强引用自身对控制块的“引用”。
    • 行二:
      • sp2 的 Data Addr 和 Ctrl Addr 被设置为与 sp1 完全相同的值。现在 sp1 和 sp2 指向同一个数据和同一个控制块。
      • 控制块更新:
        • 由于多了一个所有者 (sp2),use_count 加 1,从 01 变为 02
        • weak_count 保持不变。
    • 行三:
      • wp 也指向了那个控制块,但它是一个“非拥有”的、弱的引用。
      • 控制块更新:
        • use_count 不变,因为它不增加所有权。
        • weak_count 加 1,从 01 变为 02,因为多了一个观察者。
    • 行四:
      • 一个新的 shared_ptr sp_locked 被创建。
      • 控制块更新:
        • use_count 加 1,从 02 变为 03。这是为了保证在 sp_locked 的生命周期内,所指向的数据不会被释放。
        • weak_count 保持不变。
    • 行五:
      • 控制块更新:
        • use_count 减 1,从 03 变回 02
        • weak_count 保持不变。

指针&函数

这一部分C++并没有什么创新点,所以我们不多做阐述

  • 用于参数:
    • 提高传输效率
    • 使用函数的副作用去修改外部变量的值
    • 常量指针
  • 函数指针:
    • 比如说计算一元函数在某区间上的定积分

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值