Skip to content

C++ 教学课件 - 第三次课

C++ 第三节课:深入理解内存与地址——指针与引用

大家好,欢迎来到我们的第三节 C++ 课程!在前两节课中,我们学习了 C++ 的基本数据类型、运算符等基础知识。今天,我们将深入探索 C++ 中一个非常重要且强大的概念:指针和引用。理解了它们,你才能真正掌握 C++ 的精髓,并为后续学习动态内存管理、数据结构等高级主题打下坚实的基础。

一、指针 (Pointer)

指针是 C++ 中最强大的特性之一,但也常常让新手感到困惑。 别担心,我们会一步步地理解它。

1. 指针的概念:内存地址的艺术

对于高手: 指针是驾驭内存的灵活缰绳,能够实现直接的内存操作,构建复杂的数据结构,并优化程序性能。

对于新手: 指针就像一把双刃剑,强大但稍有不慎就会割伤自己。理解指针需要时间和实践,但它是成为 C++ 高手的必经之路。错误使用指针可能导致程序崩溃或产生不可预测的行为,所以务必谨慎。

C# 的权衡: C# 作为 C++ 的“近亲”,为了提高开发效率和安全性,牺牲了部分底层控制权,限制了指针的使用。这降低了学习门槛,但也限制了某些高级操作的可能性。理解 C++ 的指针,能帮助你更好地理解其他语言的内存管理机制。

🤔 思考: 想象你的电脑内存是一栋栋排列整齐的房子,每个房子都有一个唯一的地址。变量就像住在这房子里的人,而指针就像记录着某个房子地址的纸条。

2. 指针的基本操作:寻址和取值

  • & (取址运算符 - Address-of Operator): 就像获取一个人的家庭住址。它返回变量在内存中的起始位置。

    • 例如:
      c++
      int a = 10;
      int *pa = &a; // pa 存储了变量 a 的内存地址
      &a 运算的结果就是变量 a 在内存中的地址。
  • 内存地址的表示: 内存地址通常以十六进制形式显示,例如 0x7ffee3a1b9dc。这只是一个编号,不必深究其具体数值。

  • * (声明指针变量 - Declaration): 在声明变量时,* 表明你声明的是一个指针变量,它用来存储内存地址。

    • 例如: int *pa; 声明了一个名为 pa 的指针变量,它可以存储一个 int 类型变量的内存地址。 注意: 这里的 * 仅仅是类型声明的一部分,表示 pa 是一个指向 int 的指针。
  • * (解引用运算符 - Dereference Operator): 就像拿着地址纸条找到对应的房子,然后看看里面住着谁。它访问指针所指向的内存地址中的值。

    • 例如:
      c++
      int a = 10;
      int *pa = &a;
      std::cout << *pa << std::endl; // 输出 10,即 pa 指向的内存地址中存储的值
      *pa 运算的结果就是指针 pa 所指向的内存地址中存储的值,也就是变量 a 的值。
  • 指针的大小: 指针变量本身也需要内存来存储地址。这个大小取决于你的计算机系统架构。

    • 32 位系统: 指针的大小通常是 4 个字节(足以表示 232 个不同的地址)。
    • 64 位系统: 指针的大小通常是 8 个字节(足以表示 264 个不同的地址)。
    • 你可以使用 sizeof(指针变量) 来查看指针的大小。

✍️ 练习: 写一段代码,声明一个 float 类型的变量 f 并赋值,然后声明一个指向 float 的指针 pf,将 f 的地址赋给 pf,并打印出 pf 的值(内存地址)和 *pf 的值(f 的值)。

3. 指针的初始化和空指针:避免野指针

  • 未初始化的风险: 就像拿到一个没有写任何地址的纸条,你不知道它指向哪里。未初始化的指针可能包含随机的内存地址,对其进行解引用操作会导致程序崩溃或未定义的行为,这种指针被称为野指针 (Wild Pointer)
  • nullptr 的意义: nullptr 是 C++11 引入的空指针常量,表示指针不指向任何有效的内存地址。将指针初始化为 nullptr 是一种良好的编程习惯。
    • 例如: int *pp = nullptr; 明确地表示指针 pp 当前没有指向任何有效的 int 型变量。
  • 检查空指针: 在使用指针之前,尤其是来自函数返回值或动态分配的内存时,最好检查指针是否为 nullptr,以避免访问无效内存。
    c++
    int *ptr = get_some_pointer(); // 假设 get_some_pointer 可能返回 nullptr
    if (ptr != nullptr) {
        // 指针有效,可以安全使用
        std::cout << *ptr << std::endl;
    } else {
        // 指针为空,处理错误情况
        std::cout << "指针为空!" << std::endl;
    }

4. 多级指针:指向指针的指针

  • 概念: 指针本身也是变量,它也有自己的内存地址。因此,我们可以用另一个指针来存储一个指针变量的地址,这就是二级指针。同理,可以有三级、四级甚至更多级的指针。
  • 二级指针: 指向指针的指针。
    • 例如:
      c++
      int a = 10;
      int *pp = &a;  // pp 指向 a 的地址
      int **pp2 = &pp; // pp2 指向 pp 的地址
      pp2 存储的是指针 pp 的内存地址。
  • 三级指针: 指向二级指针的指针。
    • 例如: int ***pp3 = &pp2;
  • 应用场景: 多级指针主要用于更复杂的数据结构(如图、树等)和一些高级编程技巧中,对于初学者来说,理解二级指针就足够了。

5. void 指针:通用的指针

  • 可以指向任何类型: void * 是一种特殊的指针类型,它可以指向任何类型的变量的内存地址。
    • 例如:
      c++
      int n = 10;
      float f = 3.14;
      void *vp;
      vp = &n;  // void 指针可以指向 int
      vp = &f;  // void 指针也可以指向 float
  • 不能直接解引用: 由于 void 指针不知道它指向的数据类型,因此不能直接使用 * 解引用。需要先将其强制转换为具体的指针类型才能访问其指向的值。
    • 例如:
      c++
      int n = 10;
      void *vp = &n;
      int *ip = static_cast<int*>(vp); // 强制转换为 int*
      std::cout << *ip << std::endl;    // 现在可以解引用了
  • void 作为函数返回类型: 表示函数不返回任何值。这与 void 指针是不同的概念。

二、引用 (Reference)

引用是 C++ 中另一个重要的概念,它提供了一种更安全、更方便的方式来间接操作变量。

1. 引用的概念:变量的别名

  • 别名: 引用就像是给变量取了一个“绰号”或者“外号”。它并非新的变量,而是现有变量的另一个名字,它们指向相同的内存地址。
  • 声明引用: 使用 & 符号声明引用类型。
    • 例如:
      c++
      int a = 10;
      int &ra = a; // ra 是 a 的引用
      ra 现在是变量 a 的一个别名,对 ra 的任何操作都会直接影响到 a
  • 引用的权限: 引用不能扩大被引用变量的访问权限。 例如,你不能创建一个指向 const int 变量的非常量引用。

2. 引用的特点:一旦绑定,终生不变

  • 必须初始化: 引用在声明时必须立即被初始化,并且一旦初始化,就永远绑定到最初引用的变量,不能再指向其他变量。这保证了引用的有效性。
  • 不可重新赋值指向: 引用一旦初始化,其指向的内存地址就不可改变。赋值操作会改变引用所指向变量的值,而不是改变引用的指向。
    c++
    int a = 10;
    int b = 20;
    int &ra = a; // ra 引用 a
    ra = b;      // 相当于 a = b;  ra 仍然引用 a,只是 a 的值变成了 20
  • C 语言没有引用: 引用是 C++ 特有的特性,C 语言中只有指针。

3. 指针和引用的区别:选择合适的工具

特性指针引用
定义是一种变量,存储的是内存地址。拥有自己的内存空间。是一个已存在变量的别名,不占用额外的内存空间。
初始化可以不初始化,可以在任何时候指向不同的变量(修改存储的地址)。必须在声明时初始化,并且一旦初始化,就不能再指向其他变量。
空值可以为空指针 (nullptr),表示不指向任何有效的内存地址。不存在空引用。引用在声明时必须绑定到一个有效的对象。
语法使用 * 解引用来访问指向的值,使用 & 取地址。直接使用引用名,就像使用原变量一样。
效率间接访问,需要通过地址来找到值,效率略低于直接访问。本质上是被引用变量的另一个名字,编译器通常会将其优化为直接访问,效率更高。
应用场景动态内存分配、函数参数传递(需要修改实参)、实现链表等数据结构。函数参数传递(避免拷贝,直接操作实参)、提高代码可读性、作为函数返回值。
本质存储内存地址的变量。变量的别名,是对内存地址的封装。

💡 记忆技巧: 可以把引用想象成一个人的“外号”,一旦有了这个外号,你就不能再把它给其他人了。而指针就像一个房子的地址,你可以修改地址指向的房子。

三、变量与常量 (Variables and Constants)

在讨论指针和引用时,理解变量和常量的概念至关重要。

1. 变量 (Variable)

  • 可变性: 变量的值在程序运行过程中可以被修改和读取。
  • 声明: 例如 int age = 20;

2. 常量 (Constant)

  • 不可变性: 常量的值在声明后不能被修改。
  • 声明: 使用关键字 const 声明常量。
    • 例如: const int MAX_VALUE = 100;
  • 作用: 常量用于表示程序中不应该被改变的值,提高代码的可读性和安全性。

四、常量指针和指针常量 (Const Pointers and Pointer Constants)

这是初学者常常混淆的一个概念,让我们仔细区分。

1. 常量指针 (Pointer to const)

  • 定义: 指针指向的内存地址中的值是常量,不能通过该指针修改指向的值,但指针本身可以指向其他地址。
  • 声明: const 数据类型 * 指针变量名;
    • 例如:
      c++
      int a = 10;
      int b = 20;
      const int *pa1 = &a; // pa1 是一个指向常量整数的指针
      // *pa1 = 30;  // 错误!不能通过 pa1 修改 a 的值
      pa1 = &b;      // 正确!pa1 可以指向其他地址
  • 理解: const 修饰的是 *pa1,即指针指向的内容。

2. 指针常量 (Const Pointer)

  • 定义: 指针本身是一个常量,一旦初始化后,它所指向的地址不能改变,但可以通过该指针修改指向的内存地址处的值。
  • 声明: 数据类型 * const 指针变量名;
    • 例如:
      c++
      int a = 10;
      int b = 20;
      int *const pa2 = &a; // pa2 是一个指向整数的常量指针
      *pa2 = 30;      // 正确!可以通过 pa2 修改 a 的值
      // pa2 = &b;      // 错误!pa2 不能指向其他地址
  • 理解: const 修饰的是 pa2,即指针本身。

3. 指向常量的指针常量 (Const Pointer to const)

  • 定义: 指针本身是常量,并且它指向的内存地址中的值也是常量,两者都不能被修改。
  • 声明: `const 数据类型 * const 指const int * const pa3 = &a; // pa3 是一个指向常量整数的常量指针 // *pa3 = 30; // 错误!不能通过 pa3 修改 a 的值 // pa3 = &b; // 错误!pa3 不能指向其他地址 ```
  • 理解: 两个 const 各司其职,第一个 const 修饰 *pa3,表示不能通过 pa3 修改指向的值;第二个 const 修饰 pa3,表示 pa3 的指向不能修改。

🤔 记忆技巧: 从右向左读有助于理解。

  • const int * pa: pa 是一个指针,指向 const int (常量整数)。
  • int * const pa: pa 是一个 const 指针,指向 int (整数)。
  • const int * const pa: pa 是一个 const 指针,指向 const int (常量整数)。

五、auto 自动类型推断 (Automatic Type Deduction)

auto 关键字是 C++11 引入的非常方便的特性,可以简化代码并提高可读性。

  • 编译器自动推断: auto 允许编译器根据变量的初始化值自动推断变量的类型,而无需显式指定。
    • 例如:
      c++
      auto x = 10;       // x 被推断为 int
      auto y = 3.14;     // y 被推断为 double
      auto z = "hello";   // z 被推断为 const char*
      std::vector<int> myVector = {1, 2, 3};
      auto it = myVector.begin(); // it 被推断为 std::vector<int>::iterator
  • 适用场景:
    • 类型名过长或复杂: 例如在使用标准库容器时,迭代器的类型通常很长,使用 auto 可以简化声明。
    • 类型不明确: 某些情况下,表达式的返回类型可能比较复杂或不直观,使用 auto 可以让代码更简洁。
  • 提高代码可读性: 在某些情况下,auto 可以让代码更专注于变量的含义,而不是具体的类型。
  • 注意事项:
    • auto 必须初始化:编译器需要根据初始值来推断类型。
    • auto 的推断结果是明确的,不是简单的“万能类型”。
    • 过度使用 auto 可能会降低代码的可读性,尤其是在不清楚变量类型的情况下。应该在提高代码可读性和维护性之间找到平衡。

六、常用转义字符 (Escape Sequences)

转义字符用于在字符串中表示一些特殊的字符,这些字符不能直接输入或具有特殊的含义。

  • \n: 换行符 (Newline) - 将光标移动到下一行开头。
  • \t: 水平制表符 (Horizontal Tab) - 将光标移动到下一个制表位。
  • \': 单引号 (Single Quote) - 用于在单引号包围的字符常量中表示单引号自身,例如 '\''
  • \": 双引号 (Double Quote) - 用于在双引号包围的字符串字面量中表示双引号自身,例如 "abc\"def"
  • \\: 反斜杠 (Backslash) - 用于表示反斜杠自身,例如 "C:\\Windows"
  • \?: 问号 (Question Mark) - 用于避免某些编译器将 ?? 解释为三字符序列。
  • \0: 空字符 (Null Character) - 表示字符串的结尾,用于 C 风格的字符串。
  • : 空格 - 虽然不是转义字符,但空格在字符串中也是常用的字符。

示例:

c++
#include <iostream>

int main() {
  std::cout << "第一行\n第二行" << std::endl;
  std::cout << "Name:\tJohn\tAge:\t30" << std::endl;
  std::cout << "单引号:\',双引号:\",反斜杠:\\" << std::endl;
  return 0;
}

作业是写一个文本文件,将之前你理解的关键的概念加入总结:

  1. 指针是什么?和引用是什么?指针和引用之间的区别是什么?