← 课程目录
第 25 讲 模块七:指针与内存

指针:从 C 到 C++

25 / 25
💡
本节课目标
先用 C 把指针讲透——地址、解引用、指针与数组、动态内存, 再回到 C++ 看同样的事情怎么写:引用、new / deletevector 与智能指针。学完你会明白:C++ 许多“方便的东西”, 都建立在地址、生命周期与所有权这些基础上。
开场

变量到底住在哪儿

📖 理论讲解

内存是一排编了号的格子

内存可以想成一长排格子,每个格子存 1 字节,每个格子有一个编号, 这个编号就叫地址。写 int x = 10; 的时候, 系统划出连续的若干格子放下 10(常见电脑上 int 占 4 字节), 并把 x 这个名字挂在这段空间的起始地址上。

平时我们只用名字,不关心编号。指针要做的事,就是把编号本身当成数据来用

📖 理论讲解

& :把地址拿出来看看

address.c C
#include <stdio.h>

int main(void) {
    int x = 10;

    printf("x 的值  :%d\n", x);
    printf("x 的地址:%p\n", (void*)&x);
    printf("x 占字节:%zu\n", sizeof(x));
    return 0;
}

&x 读作“x 的地址”,%p 是打印地址的格式。 跑出来大概是 000000000061FE1C 这样一串十六进制数。

每次运行地址都不一样,是正常的
现代操作系统会随机安排程序的内存位置(一种安全措施)。 所以不用记地址的具体数值;重要的是同一数组中元素的相对位置, 以及一个指针当前指向哪个对象。
⚡ 动手实操 约 10 分钟

先看一眼地址

address.c 敲进 CLion 跑一遍(新建 C 文件,或者直接放进 .cpp 里也能编译)。

  1. 连着运行三次,观察地址每次是不是都变了。
  2. 再声明 int y = 20;,把 &y 也打印出来, 看看两个地址差了多少
第一段

指针变量与解引用

📖 理论讲解

指针就是“专门存地址的变量”

pointer_basic.c C
#include <stdio.h>

int main(void) {
    int x = 10;
    int *p = &x;         // p 是指针,里面装的是 x 的地址

    printf("%d\n", *p);  // 10 —— 解引用:取出 p 指向的那个值
    *p = 99;             // 通过 p 去改 x
    printf("%d\n", x);   // 99 —— x 真的变了
    return 0;
}
写法含义
int *p;声明一个指针,它指向的东西是 int
&x取出 x 的地址
p地址本身
*p顺着地址找过去,拿到那个变量(可读可写)
⚠️
星号有两个身份,别混
int *p = &x; 里的 *声明:“p 是个指针”。
*p = 99; 里的 *操作:“顺着 p 找过去”。
看到星号先分清是在声明还是在
📖 理论讲解

类型必须对上

pointer_size.c C
int    x = 10;
double d = 3.14;
char   c = 'A';

int    *pi = &x;
double *pd = &d;
char   *pc = &c;

// 在常见的 64 位电脑上:sizeof(x)=4, sizeof(d)=8, sizeof(c)=1
// 而 sizeof(pi) == sizeof(pd) == sizeof(pc),通常都是 8
// 具体结果以你电脑上 sizeof 的输出为准

在常见桌面环境里,不同对象指针通常一样大,为什么还要区分 int *double *? 因为解引用时要知道读几个字节、怎么解释——从同一个地址开始, 按 int 与按 double 读取的字节数和解释方式不同,结果也完全不同。

Python

Python:变量名本来就是“指向对象的标签”

a = [1, 2]

b = a # b 和 a 指向同一个列表

b.append(3) # a 也变成 [1,2,3]

VS
C++

C / C++:默认是拷贝,想共享得自己写地址

int a = 1;

int b = a; // b 是独立的一份

int *p = &a; // 这才是“指向同一个”

💡
可以借 Python 的引用来理解
Python 里“两个名字指向同一个列表”的行为,可以近似理解成两个名字引用同一个对象。 Python 把地址细节藏起来了,而 C 允许你直接操作地址
第二段

指针的第一个用处:让函数改动外面的变量

📖 理论讲解

C 的函数传参,永远是拷贝

第 11 讲的结论在 C 里同样成立:函数拿到的是实参的复制品。 所以要让函数通过参数同时改动外面的两个变量,C 通常要传入它们的指针:

swap.c C
#include <stdio.h>

void swapWrong(int a, int b) {
    int t = a; a = b; b = t;    // 换的是两份拷贝
}

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

int main(void) {
    int m = 1, n = 2;

    swapWrong(m, n);
    printf("%d %d\n", m, n);    // 1 2 —— 没换成

    swap(&m, &n);               // 把地址交出去
    printf("%d %d\n", m, n);    // 2 1 —— 换成了
    return 0;
}
传地址,等于把“钥匙”给了函数
swap(&m, &n) 传进去的仍然是拷贝——但拷贝的是地址。 地址的副本照样能找到原来那个变量,所以 *a = *b 改的是 main 里的 m
📖 理论讲解

解引用之前先检查 NULL

null_check.c C
int *p = NULL;        // 明确表示“暂时不指向任何东西”

if (p != NULL) {      // 用之前先检查
    printf("%d\n", *p);
}

// 如果 p 是 NULL 还直接写 *p,会触发未定义行为;常见结果是段错误

指针没有“默认安全值”。没初始化的指针里是一串垃圾数字, 解引用它比 NULL 更危险——所以声明指针时要么立刻指向某个东西, 要么先赋成 NULL

⚡ 动手实操 约 15 分钟

亲手写一遍 swap

  1. 完整敲出 swap.c 并运行,确认两行输出分别是 1 22 1
  2. 再写一个函数 void addTen(int *p),把 p 指向的变量加 10,在 main 里验证。
  3. 故意把 swap 里的 *a = *b 写成 a = b, 再运行一次。它可以通过编译,却交换失败:赋值改的是局部指针 a,不是它指向的整数。
第三段

指针与数组紧密相关,但不是一回事

📖 理论讲解

多数表达式中,数组名会转换为首元素地址

array_pointer.c C
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;               // 这里 arr 会转换为首元素地址

printf("%d\n", *p);         // 10
printf("%d\n", *(p + 1));   // 20 —— +1 跳过一个 int,也就是 sizeof(int) 字节
printf("%d\n", p[2]);       // 30 —— p[i] 就是 *(p + i) 的简写

// 反过来也成立:arr[3] 的真实含义,就是 *(arr + 3)

p + 1 不是“地址加 1”,而是跳过一个元素—— 指针加法自动按类型大小换算。这也是为什么类型必须写对。

但数组本身不是指针:数组拥有整块连续空间,指针只是存着一个地址。 例如对真正的数组写 sizeof(arr),得到的是整个数组占用的字节数。

💡
下标其实是语法糖
你从第 14 讲开始写的 arr[i],编译器一直是当作 *(arr + i) 来做的。 数组下标从 0 开始,原因就在这里:首元素离起点的距离是 0
📖 理论讲解

数组传给函数,会“退化”成指针

array_decay.c C
void printAll(int arr[], int n) {   // 形参写成 [],实际仍会调整为指针
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
}

int main(void) {
    int nums[5] = {1, 2, 3, 4, 5};
    printAll(nums, 5);      // 长度必须自己传进去
    return 0;
}

函数里的 arr 只是个指针,sizeof(arr) 得到的是 指针的大小(常见 64 位环境中为 8 字节),不是数组的大小。所以 C 里把数组交给函数, 长度必须另外传一个参数——这正是 C++ 的 vector 能省掉的麻烦(它自己记得 .size())。

📖 理论讲解

C 的字符串:一串字符 + 一个结束标记

c_string.c C
#include <stdio.h>
#include <string.h>

int main(void) {
    char s[] = "hi";        // 内存里是 'h' 'i' '\0',一共三个字节

    printf("%zu\n", strlen(s));        // 2 —— 从头一个个数到 '\0' 为止
    printf("%c\n", *s);               // h
    printf("%c\n", s[1]);             // i
    return 0;
}

C 没有内建的 string 类型,标准库约定用 char 数组表示字符串, 结尾放一个 '\0' 当标记。strlen 求长度要 从头扫到结束标记,耗时随字符串长度增长;而 C++ std::string.length() 是常数时间操作。

strlen 返回专门表示大小的无符号类型 size_t, 用 printf 输出时对应 %zu

⚠️
C 字符串坑最多的地方
只要 '\0' 被覆盖掉,strlenprintf 就会一路往后读到不该读的内存。C 里大量的安全漏洞都出在这。
第四段

动态内存:程序跑起来才知道要多大

📖 理论讲解

malloc / free

int arr[100]; 这样的固定数组,长度在编译时就确定了。 可如果要处理的数据量是用户运行时才输入的呢?C 的答案是手动申请

dynamic_array.c C
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int n;
    printf("要几个数:");
    if (scanf("%d", &n) != 1 || n <= 0) {
        printf("请输入正整数\n");
        return 1;
    }

    int *arr = malloc((size_t)n * sizeof *arr); // C 会自动把 void* 转成目标指针类型
    if (arr == NULL) {                          // 申请仍然可能失败,必须检查
        printf("内存不够\n");
        return 1;
    }

    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }

    free(arr);      // 用完必须还回去
    arr = NULL;     // 顺手置空,防止后面误用
    return 0;
}
部分作用
malloc(n * sizeof *arr)按元素数量申请足够的字节,返回起始地址
不写 (int*)在 C 中 void* 会自动转换为对象指针;强转反而可能掩盖漏写头文件的问题
== NULL申请失败会返回 NULL,不检查就用等于赌运气
free(arr)还回去,否则这块内存到程序结束都占着
📖 理论讲解

三种经典事故

memory_bugs.c C
int *p = malloc(sizeof *p);
if (p == NULL) {
    return 1;
}

free(p);
*p = 10;      // 释放后继续使用:未定义行为
free(p);      // 二次释放:也是未定义行为

// 还有一种不崩溃、但更阴险的:
// 申请了从来不 free —— 内存泄漏,程序跑得越久占得越多

麻烦在于:普通编译过程通常不会拒绝这些代码, 而且经常不是当场出问题,是过一会儿在别的地方莫名其妙地崩。 “谁申请、谁释放、什么时候释放”,全靠人脑记住;AddressSanitizer 等工具能在运行时帮助定位。

⚡ 动手实操 约 20 分钟

写一个长度由用户决定的成绩统计

用纯 C 完成:

  1. 先问用户“有几个学生”,读进 n,并确认输入成功且 n > 0
  2. mallocnint,记得检查是不是 NULL
  3. 循环读入 n 个成绩,算出总分和平均分并打印。
  4. 最后 free 掉,并把指针置成 NULL

做完留着这份代码——下半节课我们要把它一步步改写成 C++ 版本。

第五段

迁移到 C++:同样的事,更安全的写法

📖 理论讲解

一、引用取代“为了改动而传的指针”

C 里 swap 必须用指针,是因为 C 只有这一种办法。 C++ 多了引用(第 20 讲学过),同一件事可以写得干净得多:

swap.cpp CPP
void swap(int& a, int& b) {    // 引用:不用星号
    int t = a;
    a = b;
    b = t;
}

int main() {
    int m = 1, n = 2;
    swap(m, n);                // 也不用取地址,直接传变量
    std::cout << m << " " << n << std::endl;   // 2 1
    return 0;
}
指针 int*引用 int&
调用swap(&m, &n)swap(m, n)
使用要写 *a直接当变量用
能不能为空能;允许为空时,解引用前要检查语法上必须绑定对象,不能用空值初始化
能不能改指向能,随时指向别处不能,绑定后就是它了

结论:参数必须引用一个现有对象时,优先考虑引用。 引用本身不能改绑,但原对象过早销毁仍会产生悬空引用,所以生命周期依然重要。

📖 理论讲解

二、C++ 的直接动态分配:new / delete

new_delete.cpp CPP
int n;
if (!(std::cin >> n) || n <= 0) {
    return 1;
}

int* arr = new int[n];     // 不用 sizeof,不用强制类型转换

for (int i = 0; i < n; i++) {
    arr[i] = i * i;
}

delete[] arr;              // new[] 配 delete[],new 配 delete
arr = nullptr;

malloc 好在三点:不用算字节数不用强制类型转换会调用构造函数 (申请 std::string 这类对象时,malloc 只给内存、不会把对象初始化好)。

🚫
但它没解决根本问题
delete 照样可能忘记写、可能走不到(中途 return 了、抛异常了)。 new 只是比 malloc 好用一点,责任还在你身上
📖 理论讲解

三、首选方案:根本不要自己管内存

上面那段动态数组,用 vector 写是这样的:

vector_version.cpp CPP
#include <iostream>
#include <vector>

int main() {
    int n;
    if (!(std::cin >> n) || n <= 0) {
        return 1;
    }

    std::vector<int> arr(n);       // 要多大给多大

    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }
    // 没有 free,没有 delete —— 离开作用域自动释放
    return 0;
}

vector 会替你管理动态存储, 并在自己被销毁时自动释放资源std::string 对 C 字符串也是同样的关系——你从第 4 讲起就一直在享受指针的好处、 却不用承担指针的风险

📖 理论讲解

四、非要一个指针时:智能指针

unique_ptr.cpp CPP
#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p = std::make_unique<int>(42);

    std::cout << *p << std::endl;   // 42 —— 用起来和普通指针一样

    // main 结束,p 自动把那块内存 delete 掉,想忘也忘不了
    return 0;
}

unique_ptr 是“会自己 delete 的指针”:用起来和普通指针一样 (*p 取值),但生命周期结束时自动释放。 这个“资源交给对象管,对象一死就释放”的套路叫 RAII, 是 C++ 区别于 C 最重要的思想之一。

💡
裸指针仍然有用,只是不用来管内存
raw_pointer.cpp CPP
// 场景一:表示“这个参数可以没有”
void log(const std::string& msg, const int* code = nullptr) {
    std::cout << msg;
    if (code != nullptr) {         // 传了就打印,没传就算了
        std::cout << "(错误码 " << *code << ")";
    }
    std::cout << std::endl;
}

// 场景二:对接要求指针参数的 C 接口,或观察由别处管理的对象
第六段

C 与 C++ 对照表

📖 理论讲解

同一件事的两种写法

要做的事CC++ 推荐写法
让函数改动外面的变量void f(int *p) + f(&x)void f(int& x) + f(x)
只读地传大块数据const T* + 长度const T&,编译器强制
长度可变的数组malloc / freestd::vector
字符串char[] + '\0' + strlenstd::string + .length()
数组传给函数指针 + 额外传长度const vector<T>&,自带 .size()
单个动态对象malloc / freestd::unique_ptr
空指针NULLnullptr(类型安全)

一句话:C 把内存管理全交给你,C++ 提供了一整套“替你管”的工具; 但这些工具底下跑的仍然是指针——所以理解指针,才能理解为什么 vector 传参要加 &、为什么函数返回局部变量的地址是个大错误。

⚡ 动手实操 约 25 分钟

把 C 版成绩统计改成 C++ 三连

拿第四段那份 malloc 版代码,依次改写三遍,每遍都跑通:

  1. 版本一malloc / freenew[] / delete[]printf / scanfcout / cin
  2. 版本二:改用 std::vector<int>,删掉所有释放代码。
  3. 版本三:把“求总分和平均分”抽成函数,参数用 const std::vector<int>&

三份代码放在一起看,感受一下代码量和出错机会各减少了多少。

练习

课后练习

练习 25-1

预测输出

不运行程序,先写下你认为的输出,再跑一遍对答案:

int a = 5, b = 8;
int *p = &a;
*p = *p + b;
p = &b;
*p = 100;
printf("%d %d\n", a, b);

重点:说清楚 p = &b*p = 100 分别改了什么。

练习 25-2

一次返回两个结果

一次 return 只能返回一个对象(也可以把多个结果打包进结构体)。这次练习用输出指针, 同时给出数组的最大值和它所在的下标

void findMax(int *arr, int n, int *maxVal, int *maxIndex);

写完再用 C++ 的引用参数改写一遍,比较哪个版本调用时更不容易出错。

练习 25-3

找出内存问题

下面这段代码有三个毛病,指出来并改对:

int *p = malloc(10 * sizeof *p);
for (int i = 0; i <= 10; i++) p[i] = i;
printf("%d\n", p[0]);

提示:分别检查申请是否成功、循环边界,以及“有借有还”。

练习 25-4

挑战:手写 strlen

自己实现 size_t myStrlen(const char *s):从 s 开始一个个往后走, 遇到 '\0' 停下,返回走过的字符数。不许用 strlen

提示:可以用下标 s[i],也可以试试移动指针本身 (while (*s != '\0') { count++; s++; }),后一种写法更接近 C 的味道。

收尾

小结

📖 理论讲解

这节课你掌握了

概念关键点
地址与 &每个变量都住在某个编号的位置上
指针与 *存地址的变量;解引用后可读可写
指针传参C 里让函数改动外部变量的唯一办法
指针与数组arr[i] 就是 *(arr + i);传参会退化
malloc / free运行时申请内存,用完必须还
C++ 的替代品引用、new/deletevectorunique_ptr
记住这条优先级
能用 vector / string 就别自己申请内存; 需要改动参数就用引用;实在需要单个动态对象就用 智能指针;裸指针主要用来表达可空或非拥有的访问,以及对接 C 接口。