new / delete、
vector 与智能指针。学完你会明白:C++ 许多“方便的东西”,
都建立在地址、生命周期与所有权这些基础上。
new / delete、
vector 与智能指针。学完你会明白:C++ 许多“方便的东西”,
都建立在地址、生命周期与所有权这些基础上。
内存可以想成一长排格子,每个格子存 1 字节,每个格子有一个编号,
这个编号就叫地址。写 int x = 10; 的时候,
系统划出连续的若干格子放下 10(常见电脑上 int 占 4 字节),
并把 x 这个名字挂在这段空间的起始地址上。
平时我们只用名字,不关心编号。指针要做的事,就是把编号本身当成数据来用。
#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 这样一串十六进制数。
把 address.c 敲进 CLion 跑一遍(新建 C 文件,或者直接放进 .cpp 里也能编译)。
int y = 20;,把 &y 也打印出来,
看看两个地址差了多少。#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 找过去”。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:变量名本来就是“指向对象的标签”
a = [1, 2]
b = a # b 和 a 指向同一个列表
b.append(3) # a 也变成 [1,2,3]
C / C++:默认是拷贝,想共享得自己写地址
int a = 1;
int b = a; // b 是独立的一份
int *p = &a; // 这才是“指向同一个”
第 11 讲的结论在 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。
int *p = NULL; // 明确表示“暂时不指向任何东西”
if (p != NULL) { // 用之前先检查
printf("%d\n", *p);
}
// 如果 p 是 NULL 还直接写 *p,会触发未定义行为;常见结果是段错误
指针没有“默认安全值”。没初始化的指针里是一串垃圾数字,
解引用它比 NULL 更危险——所以声明指针时要么立刻指向某个东西,
要么先赋成 NULL。
swap.c 并运行,确认两行输出分别是 1 2 和 2 1。void addTen(int *p),把 p 指向的变量加 10,在 main 里验证。swap 里的 *a = *b 写成 a = b,
再运行一次。它可以通过编译,却交换失败:赋值改的是局部指针 a,不是它指向的整数。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),得到的是整个数组占用的字节数。
arr[i],编译器一直是当作 *(arr + i) 来做的。
数组下标从 0 开始,原因就在这里:首元素离起点的距离是 0。
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())。
#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。
'\0' 被覆盖掉,strlen 和 printf
就会一路往后读到不该读的内存。C 里大量的安全漏洞都出在这。
像 int arr[100]; 这样的固定数组,长度在编译时就确定了。
可如果要处理的数据量是用户运行时才输入的呢?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) | 还回去,否则这块内存到程序结束都占着 |
int *p = malloc(sizeof *p);
if (p == NULL) {
return 1;
}
free(p);
*p = 10; // 释放后继续使用:未定义行为
free(p); // 二次释放:也是未定义行为
// 还有一种不崩溃、但更阴险的:
// 申请了从来不 free —— 内存泄漏,程序跑得越久占得越多 麻烦在于:普通编译过程通常不会拒绝这些代码, 而且经常不是当场出问题,是过一会儿在别的地方莫名其妙地崩。 “谁申请、谁释放、什么时候释放”,全靠人脑记住;AddressSanitizer 等工具能在运行时帮助定位。
用纯 C 完成:
n,并确认输入成功且 n > 0。malloc 出 n 个 int,记得检查是不是 NULL。n 个成绩,算出总分和平均分并打印。free 掉,并把指针置成 NULL。做完留着这份代码——下半节课我们要把它一步步改写成 C++ 版本。
C 里 swap 必须用指针,是因为 C 只有这一种办法。
C++ 多了引用(第 20 讲学过),同一件事可以写得干净得多:
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 | 直接当变量用 |
| 能不能为空 | 能;允许为空时,解引用前要检查 | 语法上必须绑定对象,不能用空值初始化 |
| 能不能改指向 | 能,随时指向别处 | 不能,绑定后就是它了 |
结论:参数必须引用一个现有对象时,优先考虑引用。 引用本身不能改绑,但原对象过早销毁仍会产生悬空引用,所以生命周期依然重要。
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 写是这样的:
#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 讲起就一直在享受指针的好处、
却不用承担指针的风险。
#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 最重要的思想之一。
// 场景一:表示“这个参数可以没有”
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++ 推荐写法 |
|---|---|---|
| 让函数改动外面的变量 | void f(int *p) + f(&x) | void f(int& x) + f(x) |
| 只读地传大块数据 | const T* + 长度 | const T&,编译器强制 |
| 长度可变的数组 | malloc / free | std::vector |
| 字符串 | char[] + '\0' + strlen | std::string + .length() |
| 数组传给函数 | 指针 + 额外传长度 | const vector<T>&,自带 .size() |
| 单个动态对象 | malloc / free | std::unique_ptr |
| 空指针 | NULL | nullptr(类型安全) |
一句话:C 把内存管理全交给你,C++ 提供了一整套“替你管”的工具;
但这些工具底下跑的仍然是指针——所以理解指针,才能理解为什么 vector
传参要加 &、为什么函数返回局部变量的地址是个大错误。
拿第四段那份 malloc 版代码,依次改写三遍,每遍都跑通:
malloc / free → new[] / delete[],
printf / scanf → cout / cin。std::vector<int>,删掉所有释放代码。const std::vector<int>&。三份代码放在一起看,感受一下代码量和出错机会各减少了多少。
不运行程序,先写下你认为的输出,再跑一遍对答案:
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 分别改了什么。
一次 return 只能返回一个对象(也可以把多个结果打包进结构体)。这次练习用输出指针,
同时给出数组的最大值和它所在的下标:
void findMax(int *arr, int n, int *maxVal, int *maxIndex);
写完再用 C++ 的引用参数改写一遍,比较哪个版本调用时更不容易出错。
下面这段代码有三个毛病,指出来并改对:
int *p = malloc(10 * sizeof *p);
for (int i = 0; i <= 10; i++) p[i] = i;
printf("%d\n", p[0]);
提示:分别检查申请是否成功、循环边界,以及“有借有还”。
自己实现 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/delete、vector、unique_ptr |
vector / string 就别自己申请内存;
需要改动参数就用引用;实在需要单个动态对象就用
智能指针;裸指针主要用来表达可空或非拥有的访问,以及对接 C 接口。