C++ 指针与 const 的奇妙之旅:从基础到高级应用

引言:为什么指针和 const 这么重要?

在C++的学习路上,指针和 const 绝对是两个绕不开的“拦路虎”,但它们也是C++强大和灵活的基石。刚开始学的时候,我常常被它们搞得晕头转向,尤其是当它们俩“手拉手”一起出现的时候,简直是“劝退神器”!

不过别担心,经过一番摸索和老师的耐心讲解,我发现它们其实并没有那么神秘。指针就像是内存的“地址簿”,能让我们直接找到数据;而 const 就像是数据的“守护神”,能防止数据被意外修改。当它们结合起来,就能实现更精细、更安全、更高效的代码控制。

这篇笔记就是我学习过程中的一些心得体会,希望能用大白话和生动的例子,帮助大家一起攻克这两个难点!

1. 指针:内存的“地址簿”

想象一下,你的电脑内存就像一个巨大的图书馆,里面有很多书架(内存地址),每本书(数据)都放在一个特定的书架上。指针,就是记录这些书架位置的“地址簿”。它不存储书的内容,只存储书的“位置信息”。

  • 是什么? 指针是一个变量,它存储的是另一个变量的内存地址。
  • 怎么声明? 数据类型 *指针变量名;
    • int *p;:声明一个指向 int 类型数据的指针 p
  • 怎么获取地址? 使用 & (取地址运算符)。
    • int num = 10;
    • int *p = #:把 num 的地址存到 p 里。
  • 怎么访问数据? 使用 * (解引用运算符)。
    • *p = 20;:通过 p 找到 num 的位置,然后把 num 的值改成 20

小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main() {
int score = 100; // 定义一个整型变量 score
int *ptr_score = &score; // 声明一个指针 ptr_score,并存储 score 的地址

std::cout << "score 的值: " << score << std::endl; // 输出 100
std::cout << "score 的地址: " << &score << std::endl; // 输出 score 的内存地址
std::cout << "ptr_score 存储的地址: " << ptr_score << std::endl; // 输出 score 的内存地址
std::cout << "通过 ptr_score 访问 score 的值: " << *ptr_score << std::endl; // 输出 100

*ptr_score = 95; // 通过指针修改 score 的值
std::cout << "修改后 score 的值: " << score << std::endl; // 输出 95
return 0;
}

2. const:数据的“守护神”

const 关键字就像一个“封印”,一旦给某个变量加上 const,就意味着它的值不能再被修改了。这能让我们的程序更健壮,避免一些不小心造成的错误。

  • 基本含义: 常类型,其值不能被更新。
  • 主要作用:
    • 定义常量: const int MAX_VALUE = 100;
    • 类型检查: 编译器会帮你检查,如果你尝试修改 const 变量,它会报错。
    • 防止修改: 保护数据,增加程序稳定性。
    • 节省空间: const 常量在内存中通常只有一份拷贝,而宏定义可能会有多份。

小例子:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main() {
const int PI = 3; // 定义一个常量 PI
// PI = 3.14; // 错误!不能修改 const 变量

int radius = 5;
// void calculateArea(const int r) { r++; } // 错误!函数参数加 const 也能防止修改
return 0;
}

3. 指针与 const 的“组合拳”:谁是老大?

当指针和 const 结合在一起时,就变得有点复杂了。关键在于 const 到底修饰的是指针本身,还是指针指向的数据

核心秘诀:

  • const* 的左边: const 修饰的是指针指向的数据,数据不能改。
  • const* 的右边: const 修饰的是指针本身,指针不能改(不能指向别的地址)。

让我们看看这三种主要组合:

(1) 指向常量的指针 (Pointer to const)

  • 语法: const 类型 *指针变量名;类型 const *指针变量名;
  • 含义: 指针指向的数据是常量,你不能通过这个指针去修改它。但是,指针本身可以改变,它可以指向其他地方。
  • 比喻: 就像你有一个“只读的地址簿”,你可以查阅地址上的书,但不能修改书的内容。不过,你可以把这个地址簿指向另一本书。
  • 示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const int *ptr_to_const_data; // 指向 const int 的指针
    int val = 10;
    const int c_val = 20;

    ptr_to_const_data = &val; // OK,可以指向非 const 数据
    // *ptr_to_const_data = 15; // 错误!不能通过这个指针修改 val 的值

    ptr_to_const_data = &c_val; // OK,可以指向 const 数据
    // *ptr_to_const_data = 25; // 错误!不能通过这个指针修改 c_val 的值

    // 注意:虽然不能通过 ptr_to_const_data 修改 val,但可以通过 val 本身修改:
    // int *another_ptr = &val;
    // *another_ptr = 30; // OK,val 的值变成了 30
    // std::cout << *ptr_to_const_data << std::endl; // 输出 30

(2) 常量指针 (const Pointer)

  • 语法: 类型 *const 指针变量名;
  • 含义: 指针本身是常量,一旦初始化,它就不能再指向其他地址了。但是,它所指向的数据是可以修改的。
  • 比喻: 就像你有一个“固定指向的地址簿”,它永远只能指向那本书,不能换别的书。但你可以随意修改那本书的内容。
  • 示例:
    1
    2
    3
    4
    5
    6
    int num = 0;
    int num1 = 1;
    int *const const_ptr = &num; // 常量指针,必须初始化!

    // const_ptr = &num1; // 错误!常量指针不能修改指向!
    *const_ptr = 100; // OK,可以通过指针修改 num 的值

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

  • 语法: const 类型 *const 指针变量名;
  • 含义: 指针本身是常量(不能修改指向),同时它所指向的数据也是常量(不能通过它修改数据)。
  • 比喻: 就像你有一个“固定指向的只读地址簿”,它永远只能指向那本书,而且你也不能修改那本书的内容。
  • 示例:
    1
    2
    3
    4
    5
    const int p = 3;
    const int *const const_ptr_to_const_data = &p; // 指向 const int 的常量指针

    // const_ptr_to_const_data = &some_other_var; // 错误!不能修改指向
    // *const_ptr_to_const_data = 5; // 错误!不能通过指针修改 p 的值

4. 函数参数传递的“艺术”:如何高效又安全地“送东西”?

在函数调用时,我们如何把数据“送”给函数呢?C++提供了几种方式,每种方式都有自己的特点和适用场景。

4.1 值传递:老实巴交的“复制粘贴”

  • 特点: 函数接收的是参数的副本
  • 工作原理: 当你把一个变量(尤其是类对象)通过值传递给函数时,编译器会为函数参数在内存中创建一个一模一样的副本。函数内部对这个副本的任何操作,都不会影响到原始变量。
  • 效率问题:
    • 对于 intchar 等基本类型,复制很快,开销可以忽略。
    • 但对于像 std::string 或自定义的 MyClass 这样的大对象,复制一个完整的对象需要调用复制构造函数,这可能涉及到大量的内存分配和数据拷贝。函数结束后,这个副本还会调用析构函数被销毁。这些操作会消耗大量时间和资源,导致效率低下。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

class MyClass {
public:
MyClass() { std::cout << "MyClass 构造函数" << std::endl; }
MyClass(const MyClass& other) { std::cout << "MyClass 复制构造函数" << std::endl; } // 复制构造函数
~MyClass() { std::cout << "MyClass 析构函数" << std::endl; }
};

void func_by_value(MyClass obj) { // obj 是 MyClass 的一个副本
std::cout << "进入 func_by_value" << std::endl;
// 对 obj 的操作不会影响 main 函数中的 original_obj
std::cout << "退出 func_by_value" << std::endl;
}

int main() {
MyClass original_obj; // 调用 MyClass 构造函数
std::cout << "--- 调用 func_by_value ---" << std::endl;
func_by_value(original_obj); // 这里会调用 MyClass 复制构造函数
std::cout << "--- func_by_value 调用结束 ---" << std::endl;
return 0;
} // original_obj 在这里析构

输出:

1
2
3
4
5
6
7
8
MyClass 构造函数
--- 调用 func_by_value ---
MyClass 复制构造函数
进入 func_by_value
退出 func_by_value
MyClass 析构函数
--- func_by_value 调用结束 ---
MyClass 析构函数

可以看到,func_by_value 调用前后,复制构造函数和析构函数都被调用了,这就是额外的开销。

4.2 引用传递:高效的“别名”魔法

  • 特点: 函数接收的是参数的别名
  • 工作原理: 当你把一个变量通过引用传递给函数时(例如 MyClass &obj),函数参数 obj 不会创建新的对象副本,它直接成为原始变量(例如 original_obj)的另一个名字。在底层,编译器通常会把引用实现为一个常量指针,存储着原始变量的地址。
  • 优点:
    • 避免复制: 不会调用复制构造函数和析构函数,大大提高了效率,尤其对于大对象。
    • 可修改原始数据: 函数内部对引用的操作,就是直接对原始变量的操作。
  • const 引用:效率与安全的完美结合
    • 如果你希望函数能高效地访问大对象,但又不希望函数修改原始对象,那么 const 引用 (const MyClass &obj) 是最佳选择。
    • 它既享受了引用传递的效率(不复制),又通过 const 保证了数据的安全性(不能修改)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

class MyClass {
public:
MyClass() { std::cout << "MyClass 构造函数" << std::endl; }
MyClass(const MyClass& other) { std::cout << "MyClass 复制构造函数" << std::endl; }
~MyClass() { std::cout << "MyClass 析构函数" << std::endl; }
};

void func_by_reference(MyClass &obj) { // obj 是 original_obj 的别名
std::cout << "进入 func_by_reference" << std::endl;
// obj.some_member = new_value; // 如果没有 const,这里可以修改 original_obj
std::cout << "退出 func_by_reference" << std::endl;
}

void func_by_const_reference(const MyClass &obj) { // obj 是 original_obj 的只读别名
std::cout << "进入 func_by_const_reference" << std::endl;
// obj.some_member = new_value; // 错误!不能通过 const 引用修改
std::cout << "退出 func_by_const_reference" << std::endl;
}

int main() {
MyClass original_obj; // 调用 MyClass 构造函数
std::cout << "--- 调用 func_by_reference ---" << std::endl;
func_by_reference(original_obj); // 注意:这里 original_obj 不需要加 &
std::cout << "--- func_by_reference 调用结束 ---" << std::endl;

std::cout << "\n--- 调用 func_by_const_reference ---" << std::endl;
func_by_const_reference(original_obj); // 注意:这里 original_obj 也不需要加 &
std::cout << "--- func_by_const_reference 调用结束 ---" << std::endl;
return 0;
} // original_obj 在这里析构

输出:

1
2
3
4
5
6
7
8
9
10
11
MyClass 构造函数
--- 调用 func_by_reference ---
进入 func_by_reference
退出 func_by_reference
--- func_by_reference 调用结束 ---

--- 调用 func_by_const_reference ---
进入 func_by_const_reference
退出 func_by_const_reference
--- func_by_const_reference 调用结束 ---
MyClass 析构函数

可以看到,使用引用传递时,没有额外的复制构造函数和析构函数调用,效率更高。

4.3 按地址传递:传统的“地址簿”传递

  • 特点: 函数接收的是参数的地址(指针)。
  • 工作原理: 你把变量的内存地址(一个指针值)复制一份,传递给函数。函数内部通过这个地址来找到并操作原始变量。
  • 优点:
    • 避免复制: 和引用传递一样,只复制一个地址(通常4或8字节),避免了大对象的复制开销。
    • 可修改原始数据: 通过指针可以修改原始数据。
    • 可为空: 指针可以为 nullptr,表示不指向任何对象,这在某些场景下很有用(例如可选参数)。
  • 缺点:
    • 需要解引用: 在函数内部访问数据时,需要使用 *-> 运算符,语法相对繁琐。
    • 空指针风险: 必须小心处理 nullptr,否则解引用空指针会导致程序崩溃。

与引用传递的对比:

特性 引用传递 (MyClass &obj) 按地址传递 (MyClass *obj_ptr)
语法 更简洁,直接使用变量名,无需 *& 需要 * 解引用或 -> 访问成员,调用时需要 & 取地址符
空值 引用不能为 nullptr,必须引用一个有效对象 指针可以为 nullptr,需要空指针检查
重新绑定 引用一旦初始化,不能再引用其他对象(是别名) 指针可以重新指向其他对象
安全性 相对更安全,因为不能为 nullptr,且不能重新绑定 相对不安全,有空指针风险,且可以重新指向,需要更多注意
底层实现 通常被编译器实现为常量指针 就是指针

选择建议:

  • 优先使用 const 引用传递 (const MyClass &obj): 当函数只需要读取大对象的数据,而不需要修改它时,这是最推荐的方式,兼顾了效率和安全性。
  • 使用引用传递 (MyClass &obj): 当函数需要修改大对象的数据,且确保参数不会为空时。
  • 使用按地址传递 (MyClass *obj_ptr): 当函数需要修改数据,且参数可能为空,或者需要处理动态数据结构(如链表、树)时。

5. const 在函数和类中的“身影”

const 不仅能修饰变量和指针,在函数和类中也扮演着重要角色。

  • const 修饰函数参数:

    • void func(const int var);:对于基本类型,值传递加 const 意义不大。
    • void func(const char *str);:指针指向的内容是常量,函数内不能修改 str 指向的数据。
    • void func(char *const str);:指针本身是常量,函数内不能改变 str 的指向。
    • void func(const MyClass &obj);最常用! 引用传递大对象,且保证函数内不修改对象。
  • const 修饰成员函数(常成员函数):

    • 语法: 在成员函数声明的末尾加上 const,例如:void print() const;
    • 作用: 保证该成员函数不会修改类的任何非静态数据成员。
    • 限制: 常成员函数只能调用其他常成员函数,不能调用非常成员函数。
    • 常对象: const 声明的对象(例如 const MyClass obj;)只能调用其常成员函数。
  • const 成员变量:

    • 类中的 const 成员变量必须通过初始化列表进行初始化,因为它们在对象构造后就不能再被修改了。
      1
      2
      3
      4
      5
      class Apple {
      public:
      const int apple_number; // const 成员变量
      Apple(int i) : apple_number(i) {} // 必须通过初始化列表初始化
      };

我的学习总结

  • 指针是地址,**const** 是限制。
  • const* 左边,数据不能改;const* 右边,指针不能改。
  • 值传递:复制,开销大,不改原值。
  • 引用传递:别名,高效,可改原值(加 const 则不可改)。
  • 按地址传递:传地址,高效,可改原值,但有空指针风险。
  • 大对象参数:首选 const 引用,其次引用,最后考虑指针。
  • const 成员函数:不改对象状态,const 对象只能调它。