C++ 指针与引用笔记:内存地址、数组与常见错误

📄 约 3,488 字 · ⏱ 大约 9 分钟

C++ 指针与引用笔记:内存地址、数组与常见错误

指针是 C++ 里最容易让初学者头疼、也最强大的特性之一。它直接操作内存地址,既能写出高效的代码,也最容易引发难以排查的 bug。理解了“内存”和“地址”这两个概念,指针就不神秘了。引用则可以理解为变量的“别名”,用起来比指针更直观。这一篇把两者放在一起讲清楚,并补充一些进阶但常用的知识点。

一、为什么需要指针

程序运行时的变量都放在内存里,每块内存有一个编号,叫地址(你可以把它想成内存的“门牌号”)。很多时候我们不想复制整份数据,而想“指向”那份数据去操作它——比如:

  • 函数里要修改外面的变量(而不是只改副本);
  • 处理一大块数组或字符串,不想整体拷贝导致性能下降;
  • 运行时才决定要多少内存(动态申请);
  • 实现链表、树、图这类需要“节点之间互相指向”的数据结构。

这些场景都绕不开指针。可以说,指针是 C++ 直通底层能力的钥匙。

二、内存与地址

int a = 10;
cout << &a;   // 打印 a 在内存里的地址,形如 0x...

& 是取地址运算符。变量 a 占了一块内存,里面存着 10,而 &a 就是那块内存的门牌号。不同变量、不同运行时,地址一般都不一样,所以不要假设地址固定。

三、指针的定义与使用

指针本身也是变量,只不过它存的是“别的变量的地址”:

int a = 10;
int* p = &a;     // p 指向 a(p 里存的是 a 的地址)
cout << *p;      // 解引用:取到 p 指向的东西,输出 10
*p = 20;         // 通过指针改 a 的值
cout << a;       // 输出 20
  • int* p 表示 p 是“指向 int 的指针”,* 在这里是“指针类型”的标志。
  • &a 取 a 的地址。
  • *p 叫解引用,访问它指向的内存里的数据。这个 * 和声明里的 * 含义不同,一个表类型一个表“取值”,初学者要慢慢区分。

四、指针类型必须匹配

int* 指针应该指向 int,double* 指向 double。类型决定了“从这个地址往后读多少字节、按什么方式解释”。如果把 int* 错误地指向 double 再解引用,会读错数据,结果是未定义的。所以指针的类型和它所指向的变量类型要一致。指向 char 的指针和指向 int 的指针,加 1 后跳过的字节数也不同(char 跳 1 字节,int 跳 4 字节),这也是类型重要的原因。

五、空指针 nullptr

指针不指向任何东西时,应赋值为 nullptr(C++11 起推荐,旧写法 NULL 也行):

int* p = nullptr;
if (p == nullptr) cout << "空指针";

永远不要对 nullptr 解引用,会直接崩溃。养成习惯:定义指针时立刻初始化,不知道指向哪就先给 nullptr。这样万一误用,至少行为是确定的,而不是去读一块随机地址。

六、指针的运算

指针加 1,不是加一个字节,而是“指向下一个同类型元素”:

int a[3] = {10, 20, 30};
int* p = a;        // 数组名 a 本身就等于 &a[0]
cout << *p;        // 10
cout << *(p + 1);  // 20
p++;               // 现在指向 a[1]

这种“按类型跨度移动”的特性,让指针遍历数组非常自然。两个指针相减得到的是它们之间的元素个数(而不是字节数)。

七、指针与数组的关系

数组名在大多数情况下会被当成指向首元素的指针,所以 a[i] 本质上就是 *(a + i)。遍历数组既可以用下标,也可以用指针:

for (int i = 0; i < 3; i++) cout << a[i] << " ";
for (int* p = a; p < a + 3; p++) cout << *p << " ";

两者完全等价,下标写法更易读,指针写法在某些底层场景更高效。但要注意:用指针遍历时,循环的结束条件 p < a + 3 不能写成 p != nullptr,因为数组末尾并没有一个空指针标记。

八、指针与函数:传值 vs 传地址

默认传值,函数拿到的是副本:

void swap(int x, int y) {
    int t = x; x = y; y = t;   // 只交换了副本
}
int main() {
    int a = 1, b = 2;
    swap(a, b);
    cout << a << b;   // 还是 1 2,没变
}

改成传指针才能真的交换:

void swap(int* x, int* y) {
    int t = *x; *x = *y; *y = t;
}
int main() {
    int a = 1, b = 2;
    swap(&a, &b);
    cout << a << b;   // 2 1,成功交换
}

九、引用

引用是“变量的别名”,和原变量是同一个东西:

int a = 10;
int& r = a;     // r 是 a 的别名
r = 20;
cout << a;      // 20

引用和指针的区别:

对比 指针 引用
可否为空 可以(nullptr) 不可以,必须初始化绑定
可否改指向 可以 不可以,绑定后永远是那个变量
使用方式 需要 * 解引用 直接当原变量用
本质 存地址的变量 变量的别名

引用常作函数参数,既避免复制大对象,又能修改原值,写法也更直观:

void swap(int& x, int& y) {
    int t = x; x = y; y = t;
}
swap(a, b);   // 比传指针好看多了

十、const 与指针/引用

  • const int* p:指向的内容不能改(指向常量的指针,p 自己还能改指向)。
  • int* const p:指针本身不能改指向(p 永远指向同一个地址)。
  • const int* const p:既不能改指向也不能改内容。
  • const int& r:常量引用,常用来作函数参数,防止修改且避免拷贝大对象(比如传 string 时用 const string& 是标配)。

十一、动态内存 new / delete

需要运行时才决定大小的内存,用 new 申请,delete 释放:

int* p = new int(5);     // 申请一个 int,初始化为 5
cout << *p;
delete p;                // 用完了必须释放

int* arr = new int[10];  // 申请数组
delete[] arr;            // 数组要用 delete[]

内存模型小知识:普通局部变量放在“栈”上,函数结束自动回收;new 出来的放在“堆”上,必须手动 delete,否则一直占着。这就是为什么忘记 delete 会内存泄漏。

十二、进阶:多级指针、指针数组、指针与字符串

  • 多级指针:int** pp 指向“指向 int 的指针”,常用于二维动态数组、函数参数需要修改指针本身时。
  • 指针数组:int* arr[3] 是“装了 3 个 int 指针的数组”,和“指向数组的指针” int (*p)[3] 写法相似但含义不同,注意括号位置。
  • 指针与字符串:C 风格字符串本质是 char* 指向字符数组,以 \0 结尾;C++ 里优先用 string,需要传底层字符时用 .c_str()。

十三、常见危险

  1. 野指针:指针未初始化就使用,指向随机地址,解引用后果难料。
  2. 悬空指针:指向的内存已经被 delete,指针却还在用。
  3. 内存泄漏:new 了却忘了 delete,程序跑久了内存越占越多,严重时会拖垮整个系统。
  4. 重复释放:同一块内存 delete 两次,行为未定义。
  5. 返回局部变量地址:函数里的局部变量在函数结束后销毁,返回它的指针会得到悬空指针。

好习惯:指针初始化为 nullptr,delete 之后顺手把指针也置 nullptr,这样即使误用也能早点暴露问题。

十四、练习

  1. 写一个函数,用指针作为参数,把传入的整数加 1。
  2. 用指针遍历数组,把每个元素翻倍。
  3. 用引用作参数实现一个三个数排序的函数。
  4. 用 new 动态申请一个长度为 n 的数组,读入 n 个数求平均值,最后 delete[]。
  5. 写一个函数 findMax(int* a, int n),返回数组最大值(只用指针,不用下标)。
  6. 思考:为什么 swap(int* x, int* y) 能交换外面的变量,而 swap(int x, int y) 不能?

十五、指针与字符串的深入

C++ 里字符串有两种形态。C 风格字符串是 char 数组,以 \0 结尾,常用 char* 指向它。这种字符串可以修改元素,适合需要就地操作的底层场景。但要注意,用字符串字面量直接初始化的 char*,指向的是只读常量区,不能修改,强行改会运行时崩溃。所以现代写法推荐用 string,既安全又方便,需要把 string 传给只认 char* 的老接口时,用 s.c_str() 拿到底层字符指针即可。理解这两者区别,能避开一大类“为什么改不了字符串”的困惑。读入一整行文本也优先用 getline(cin, s) 配合 string,比处理 char 数组省心。

十六、函数指针简介

函数指针就是“指向函数的指针”,可以把函数当参数传给另一个函数,实现“回调函数”。比如排序时想自定义比较规则,就可以传一个比较函数进去。概念听起来玄,其实和变量指针一样:先定义指针类型,再让它指向某个函数,最后通过指针去调用。竞赛和项目里常用在事件处理、排序定制等场景,初学了解概念即可,不必深究实现细节,但要知道“函数也可以被当作数据传来传去”这件事。

十七、内存布局一图流

程序运行时,内存大致分几块:代码区放指令,全局区放全局和静态变量,栈区放局部变量(自动分配自动回收、空间小但快),堆区放 new 出来的东西(手动管理、空间大但要记得释放)。理解“局部变量在栈上、函数结束就失效”这一点,就能解释为什么不能返回局部变量的地址——那个地址指向的栈空间已经被回收了。递归写得太深也会撑爆栈,导致栈溢出;而堆上的大数组只要记得释放就能长期存在。堆和栈的区别,决定了为什么大数组要放全局或堆上。

十八、几个容易混淆的写法

*p++:先取 *p 的值,然后指针 p 自增指向下一个(后缀 ++ 优先级更高)。(*p)++:p 指向的那个值加 1,指针本身不动。*(p++) 和 *p++ 等价。面试和考试常考这些,记住核心:后缀 ++ 作用在指针上,加括号能改变作用对象。多级指针 `int** 指向“指向 int 的指针”,二维动态数组、或需要函数修改指针本身时会用到,理解了一级指针,多级只是套娃,不必害怕。遇到int p[3]和int (p)[3]` 也要分清:前者是指针数组,后者是指向数组的指针。

十九、练习拓展

  1. 写一个函数 int* find(int* a, int n, int x),在数组中查找 x 并返回它的地址(找不到返回 nullptr)。
  2. 用 new 申请一个二维数组(先 new 一维指针数组,再为每个指针 new 一行),用完记得按相反顺序 delete。
  3. 思考:为什么 swap(int* x, int* y) 能交换外面的变量,而 swap(int x, int y) 不能?用自己的话写一遍理由。

二十、指针学习路线图与避坑

学指针最容易卡在“地址”和“解引用”这两个全新概念上。建议一开始就在纸上画内存格子,标出每个变量和它的地址,再手动模拟 *p、p++、*p++ 的结果,然后写小程序验证你的手算对不对。卡住时回到最原点的一句话:“指针里存的是地址,解引用才拿到数据”,大部分困惑都能迎刃而解。

另一个高频坑是野指针和悬空指针。养成两个习惯能避开绝大多数事故:第一,定义指针立刻初始化,不知道指哪就给 nullptr;第二,delete 之后顺手把指针也置 nullptr,这样即使后面误用,程序也会在确定性的地方崩溃,而不是去读一块随机内存制造玄学 bug。掌握了指针,链表、树、图这些需要“节点互相指向”的数据结构才真正向你敞开大门,C++ 的威力也才真正显现出来。

二十一、一句话总结怎么选

日常写代码,能用引用就别用指针:引用更安全、写法更干净,也不容易出野指针。只有在必须表示“可能没有”(用 nullptr 表达空值)、需要重新指向别处、或者做底层数据结构(比如链表节点指针、树的孩子指针)时,才用指针。这个简单的取舍,能让你大部分时间远离指针带来的麻烦,只在真正需要时才动用它的威力。把引用当默认、把指针当特例,是写好 C++ 的一条省力心得。

小结

指针难,是因为它把“变量”和“变量的地址”分开了,一不小心就会指向不该指的地方。初学时记住三条保命原则:定义即初始化、不用的置 nullptr、new 和 delete 成对出现。引用则是更安全好用的“别名”,能代替指针的地方尽量用引用。把这两样吃透,C++ 的大门就真正打开了,后面学数据结构、学算法都将得心应手。

📰 每日速递 更多 →