> 技术文档 > 指针与引用,const 关键字,栈内存与堆内存

指针与引用,const 关键字,栈内存与堆内存


1.1 指针与引用 (Pointers & References)

指针和引用是 C++ 中两种非常重要的机制,都用于间接访问变量。理解它们的区别与联系是 C++ 程序员的基本功。

核心概念

  • 指针 (Pointer): 指针是一个变量,它存储的是另一个变量的 内存地址。通过这个地址,我们可以间接访问或修改那个变量。指针本身有自己的内存空间。

  • 引用 (Reference): 引用可以看作是一个变量的 别名。它不是一个新变量,也不占用独立的内存空间(或者说,它与原变量共享内存空间)。一旦引用被初始化为一个变量的别名,它就不能再引用其他变量。

主要区别

特性

指针 (Pointer)

引用 (Reference)

本质

存储变量地址的变量

变量的别名

空值 (Null)

可以是 nullptr,表示不指向任何对象

必须引用一个已存在的对象,不能为 NULL

初始化

可以在任何时候初始化

必须 在声明时立即初始化

可变性

可以被重新赋值,使其指向另一个不同的对象

一旦初始化,就不能再引用其他对象

内存空间

自身占用内存空间 (通常为4或8字节)

不占用独立的内存空间(与原变量共享)

操作

需要使用 * (解引用) 来访问目标对象

直接使用,语法和操作原变量完全一样

数组

可以有指针数组 int* arr[]

不能建立引用数组

示例代码

#include void process_by_pointer(int* ptr) { if (ptr != nullptr) { // 指针使用前最好检查空值 *ptr = 20; // 使用 * 解引用来修改值 }}void process_by_reference(int& ref) { // 无需检查空值,因为引用必须有效 ref = 30; // 直接像操作普通变量一样修改}int main() { int a = 10; int b = 10; // --- 指针示例 --- int* p_a = &a; // p_a 存储 a 的地址 std::cout << \"Initial value of a: \" << a << std::endl; std::cout << \"Address stored in p_a: \" << p_a << std::endl; std::cout << \"Value pointed to by p_a: \" << *p_a << std::endl; *p_a = 15; // 通过指针修改 a 的值 std::cout << \"Value of a after modification via pointer: \" << a << std::endl; // 指针可以被重新赋值 p_a = &b; *p_a = 25; std::cout << \"Value of b after p_a points to it: \" << b << std::endl; // --- 引用示例 --- int& r_a = a; // r_a 是 a 的别名,必须在声明时初始化 std::cout << \"\\nInitial value of a: \" << a << std::endl; std::cout << \"Value via reference r_a: \" << r_a << std::endl; r_a = 35; // 通过引用修改 a 的值 std::cout << \"Value of a after modification via reference: \" << a << std::endl; // &r_a == &a,它们地址相同 std::cout << \"Address of a: \" << &a << std::endl; std::cout << \"Address of r_a: \" << &r_a << std::endl; // --- 函数传参 --- int x = 100; int y = 100; process_by_pointer(&x); process_by_reference(y); std::cout << \"\\nValue of x after process_by_pointer: \" << x << std::endl; // 变为 20 std::cout << \"Value of y after process_by_reference: \" << y << std::endl; // 变为 30 return 0;}

高频面试题

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

  • :这是最经典的问题。可以从上面的表格中的几个关键点来回答:

    • 本质:引用是别名,指针是地址变量。

    • 空值:指针可以为 nullptr,引用不能为空。

    • 初始化和可变性:引用必须在声明时初始化,且终生绑定;指针可以不初始化,也可以随时改变指向。

    • 语法:指针需要解引用 *,引用直接使用。

2. 什么时候应该使用指针,什么时候应该使用引用?

    • 使用引用

      • 当你需要确保函数参数是一个有效的、非空的对象时(例如,函数内部不需要处理参数为空的情况)。

      • 当你想让函数参数的语法更简洁,像操作普通变量一样(例如,在重载运算符时)。

      • 当你希望保证一个别名在它的生命周期内始终指向同一个对象。

    • 使用指针

      • 当你需要表示“可选”或“不存在”的语义时,可以传递 nullptr

      • 当你需要在函数内部改变参数所指向的对象时(即改变指针的指向)。

      • 当你需要和 C 风格的库或需要进行底层内存操作的 API 交互时。

      • 当你需要构建复杂的数据结构,如链表、树等,其中节点需要能够指向 nullptr 或其他节点。

3. 函数返回一个局部变量的引用或指针会发生什么?

  • :这是一个非常危险的行为,会导致 未定义行为 (Undefined Behavior)

    • 函数执行完毕后,其栈帧会被销毁,所有的局部变量都会被释放。

    • 如果返回的是局部变量的引用或指针,那么这个引用或指针将指向一块已经被释放的、无效的内存区域。

    • 这种指针/引用被称为 悬挂指针 (Dangling Pointer)悬挂引用 (Dangling Reference)

    • 后续对它的任何访问都可能导致程序崩溃或得到无法预料的垃圾数据。

// 错误示范:返回局部变量的引用int& get_local_ref() { int local_var = 10; return local_var; // 危险!local_var 在函数返回后就被销毁}// 错误示范:返回局部变量的指针int* get_local_ptr() { int local_var = 20; return &local_var; // 危险!}int main() { int& r = get_local_ref(); // r 是一个悬挂引用 int* p = get_local_ptr(); // p 是一个悬挂指针 // std::cout << r << std::endl; // 未定义行为,可能崩溃}

1.2 const 关键字:不变性的承诺

const 是 C++ 中用于实现“不变性”的关键字。它告诉编译器和程序员,某个值不应该被修改。正确使用 const 可以增强程序的健壮性、可读性和安全性。

核心概念

const 是一个类型修饰符,用于声明一个实体是“只读”的。它可以用于变量、函数参数、函数返回值以及成员函数。

const 的主要用途

1. const 修饰普通变量

  • 声明一个常量,其值在初始化后不能被改变。

const int MAX_SIZE = 100;// MAX_SIZE = 200; // 编译错误!

2. const 修饰指针

  • 这是 const 用法中最容易混淆的部分,关键在于看 const 修饰的是什么。

  • 指向常量的指针 (Pointer to const): const* 左边。指针指向的内容是常量,不能通过该指针修改数据,但指针本身可以改变指向。

    int a = 10, b = 20;const int* p1 = &a;// *p1 = 15; // 编译错误!不能通过 p1 修改 a 的值p1 = &b; // 正确,p1 可以指向其他变量```int const* p1` 与 `const int* p1` 是等价的。
  • 常量指针 (const Pointer): const* 右边。指针本身是常量,它的指向不能被改变,但它指向的内容可以通过指针修改。

    int a = 10, b = 20;int* const p2 = &a;*p2 = 15; // 正确,可以修改 a 的值// p2 = &b; // 编译错误!p2 的指向不能改变
  • 指向常量的常量指针 (const Pointer to const): const* 两边。指针本身和它指向的内容都是常量,都不能被修改。

    int a = 10;const int* const p3 = &a;// *p3 = 15; // 编译错误!// p3 = &b; // 编译错误!

3. const 修饰函数参数

  • 这是 const 最常见的用途之一,特别是与引用结合。

  • const 引用传递 (const T&): 这是向函数传递复杂对象(如 string 或自定义 class)的首选方式。它有两个好处:

    1. 效率高:避免了创建对象的副本,节省了时间和内存。

    2. 安全性高:函数内部不能修改传入的原始对象,保护了数据。

void print_info(const std::string& info) { std::cout << info << std::endl; // info = \"new info\"; // 编译错误!不能修改}

4. const 修饰成员函数

  • 在成员函数声明的末尾加上 const,表示这个函数是一个 “只读”函数

  • 它承诺不会修改调用该函数的对象的任何成员变量(除了 mutable 成员)。

  • const 对象只能调用 const 成员函数。

class User {private: std::string name;public: User(std::string n) : name(n) {} // const 成员函数,承诺不修改成员变量 std::string get_name() const { // name = \"test\"; // 编译错误! return name; } // 非 const 成员函数 void set_name(const std::string& new_name) { name = new_name; }};int main() { const User admin(\"admin\"); // const 对象 std::cout << admin.get_name() << std::endl; // 正确,可以调用 const 成员函数 // admin.set_name(\"root\"); // 编译错误!const 对象不能调用非 const 成员函数}

高频面试题

1. const int* pint* const p 有什么区别?

  • :这是 const 和指针结合的经典问题。

    • const int* p 是一个 指向常量的指针。你不能通过 p 来修改它所指向的值 (*p = ... 是非法的),但你可以让 p 指向另一个地址 (p = ... 是合法的)。

    • int* const p 是一个 常量指针。你必须在声明时初始化它,之后就不能再改变它的指向 (p = ... 是非法的),但你可以通过 p 来修改它所指向的值 (*p = ... 是合法的)。

    • 可以这样记:const 靠近谁,谁就不能变。const int*const 靠近 int,所以 int 值不能变;int* constconst 靠近 * (指针),所以指针指向不能变。

2. 什么是 const 成员函数?它有什么作用?

  • const 成员函数是在函数声明末尾带有 const 关键字的类成员函数。

    • 作用:它向编译器和调用者承诺,该函数不会修改对象的任何数据成员。

    • 重要性

      1. 实现 const 正确性:它允许 const 对象(被 const 修饰的类的实例)调用成员函数。如果没有 const 版本的函数,const 对象将无法调用它,因为编译器无法保证非 const 函数不会修改对象。

      2. 接口清晰:它清楚地表明了哪些函数是“只读”操作,哪些是“写入”操作,提高了代码的可读性和可维护性。

3. 为什么函数参数倾向于使用 const 引用?

  • :主要有两个原因:

    1. 避免拷贝,提高性能:对于大的对象(如 std::vector, std::string 或自定义类),传值会产生一个完整的副本,开销很大。通过引用传递可以避免这个拷贝。

    2. 防止意外修改,保证安全:加上 const 关键字可以确保函数内部不会修改传入的原始对象,这使得函数行为更可预测,调用方也更放心。

    • 总结:const 引用集成了指针的高效和引用的安全、简洁。

1.3 栈内存与堆内存 (Stack vs. Heap)

理解 C++ 的内存模型,特别是栈和堆的区别,对于编写高效、无内存泄漏的程序至关重要。

核心概念

C++ 程序中的内存主要分为几个区域,其中最重要的是栈和堆。

  • 栈 (Stack):

    • 管理者:由 编译器 自动管理。

    • 分配方式:函数调用时,为其局部变量、参数等分配内存;函数返回时,自动释放。这是一个后进先出 (LIFO) 的过程。

    • 优点:分配和释放速度极快,因为只需要移动栈顶指针。

    • 缺点:内存空间有限(通常是几 MB),如果分配过多(如深度递归或巨大的局部数组),会导致 栈溢出 (Stack Overflow)

    • 生命周期:与作用域绑定,变量在离开其作用域时(如函数结束、代码块结束)立即被销毁。

  • 堆 (Heap):

    • 管理者:由 程序员 手动管理。

    • 分配方式:通过 new (C++) 或 malloc (C) 关键字进行动态分配。必须通过 delete (C++) 或 free (C) 手动释放。

    • 优点:内存空间巨大,可以灵活地在运行时按需分配大块内存。

    • 缺点:分配和释放速度相对较慢(涉及更复杂的算法);管理不当容易导致 内存泄漏 (Memory Leak)悬挂指针 (Dangling Pointer)

    • 生命周期:从 new 开始,直到 delete 结束。其生命周期与作用域无关。

示例代码

#include #include void stack_example() { // 以下变量都在栈上分配 int x = 10; // 局部变量 char buffer[1024]; // 局部数组 std::cout << \"stack_example function is running.\" << std::endl;} // 函数结束时,x 和 buffer 的内存被自动释放void heap_example() { // 使用 new 在堆上分配内存 int* p_int = new int(20); // 分配一个 int int* p_arr = new int[50]; // 分配一个包含 50 个 int 的数组 std::cout << \"Value from heap: \" << *p_int << std::endl; // 必须手动释放内存,否则会造成内存泄漏 delete p_int; delete[] p_arr; // 释放数组必须使用 delete[] p_int = nullptr; // 良好的习惯:释放后将指针置空 p_arr = nullptr;}int main() { stack_example(); heap_example(); // 在现代 C++ 中,推荐使用智能指针来管理堆内存,以避免手动 delete // std::unique_ptr 会在离开作用域时自动释放内存 std::unique_ptr smart_ptr = std::make_unique(30); std::cout << \"Value from smart pointer: \" << *smart_ptr << std::endl; return 0;} // smart_ptr 在这里离开作用域,它所管理的堆内存被自动释放

高频面试题

1. 栈和堆有什么区别?

  • :可以从以下几个方面回答:

    • 管理方式:栈由编译器自动管理,堆由程序员手动管理(new/delete)。

    • 分配速度:栈分配速度快,是简单的指针移动;堆分配速度慢,可能涉及复杂的内存查找和记录。

    • 内存大小和碎片:栈空间小且连续;堆空间大但不一定连续,反复分配和释放容易产生内存碎片。

    • 生命周期:栈上变量的生命周期与作用域绑定;堆上对象的生命周期由程序员决定,从 newdelete

    • 风险:栈可能发生溢出 (Stack Overflow);堆可能发生内存泄漏 (Memory Leak) 或产生悬挂指针。

2. 什么是内存泄漏 (Memory Leak)?如何避免?

  • :内存泄漏是指程序在堆上分配了内存,但在使用完毕后忘记释放(或无法释放),导致这块内存一直被占用,程序无法再使用它。随着时间推移,不断累积的内存泄漏会耗尽系统资源,导致程序变慢甚至崩溃。

    • 原因:主要是忘记调用 deletedelete[]

    • 如何避免

      1. RAII (Resource Acquisition Is Initialization):这是 C++ 中管理资源的核心思想。确保资源的生命周期与对象的生命周期绑定,当对象被销-毁时,其析构函数会自动释放资源。

      2. 智能指针 (Smart Pointers):这是 RAII 的最佳实践。使用 std::unique_ptrstd::shared_ptrstd::weak_ptr 来自动管理堆内存。当智能指针离开作用域时,它会自动调用 delete,从而从根本上杜绝内存泄漏。在现代 C++ 中,应优先使用智能指针,而不是裸指针 (new/delete)。

3. deletedelete[] 的区别是什么?混用会怎么样?

    • delete 用于释放由 new 分配的 单个对象 的内存。

    • delete[] 用于释放由 new[] 分配的 对象数组 的内存。

    • 混用后果

      • delete 释放 new[] 分配的数组:对于内置类型(如 int, double),可能不会立即出错,但行为是 未定义的,且很可能只释放了第一个元素的内存,导致其余元素内存泄漏。对于有自定义析构函数的类类型,只会调用第一个对象的析构函数,导致其他对象的资源(如文件句柄、网络连接)无法被正确释放,造成资源泄漏。

      • delete[] 释放 new 分配的单个对象:行为同样是 未定义的,可能会导致程序崩溃或内存损坏。编译器可能会尝试读取不存在的数组大小信息,从而访问无效内存。

    • 结论newdeletenew[]delete[] 必须配对使用,绝不能混用。