← C++ 教材目录(共 13 章)

第 4 章 指针、数组与内存模型

red wenzi · 2026-09-15 · 编程语言 · C++ · 📖 预计阅读 35 分钟 · 共 9 道练习
🎯 本章你会学到:指针与 const 的四组合、栈与堆、RAII 与智能指针。建议边读边敲代码,每节的练习先自己做,再展开答案对照。

本章目标是数据结构课的地基:把指针的 const 组合分清、搞懂数组退化、会用 new/delete、理解 RAII 和智能指针——这是写链表、树、图时不担心内存泄漏的关键。

4.1 指针与 const

心智模型:指针是“门牌号”,告诉你东西在哪。问题在于——这个门牌号本身能不能改?门牌号指向的东西能不能改?

const 的四种组合

const int* p:指向常量的指针,不能通过 p 改值,但能改指向;int* const p:常量指针,能改值,但不能改指向;const int* const p:两者都不能;int* p:都能。记不住就往回读:const 默认修饰它左边最近的东西,左边没东西就修饰右边的词。

示例代码
int a = 1, b = 2;
const int* p = &a;    // 不能 *p = 20(改值),但 p = &b 合法
int* const q = &a;    // *q = 30 合法,但 q = &b 编译错误
⚠️ 易错点 int* pa, pb; 的坑:pa 是 int*,pb 是 int!星号只跟着第一个名字。要两个指针就写 int *pa, *pb;

空指针用 nullptr

nullptr 是类型安全的空指针(C++11)。NULL 在 C++ 里通常是整数 0,在重载解析时会挑错函数。既然写 C++,就用 nullptr。

⚠️ 易错点 指针算术按元素大小步进:p + 1 前进 sizeof(*p) 字节。只能对同一数组内的元素做算术,越界计算指针本身就是未定义行为。

✍️ 本节练习

4.1.1必做指针版交换

写一个函数 void swapPtr(int* a, int* b) 交换两个 int(用指针解引用),main 里读两个数,传入地址调用,输出交换结果。

输入 一行两个整数 a b。

输出 交换后的 a b。

样例输入
3 5
样例输出
5 3

💡 提示 调用时传 &x、&y;函数里用 *a 解引用。先判空 if (!a || !b) return;

✅ 查看参考答案与解析
#include <iostream>
void swapPtr(int* a, int* b) {
    if (!a || !b) { return; }
    int t = *a;
    *a = *b;
    *b = t;
}
int main() {
    int x, y;
    std::cin >> x >> y;
    swapPtr(&x, &y);
    std::cout << x << ' ' << y << '\n';
    return 0;
}

解析 指针可能为空,使用前先判空;&x 取地址,*a 解引用。

4.1.2挑战const 指针实验

写程序验证:const int* p 不能通过 p 修改值但能改指向;int* const q 能改值但不能改指向。让程序在“非法操作”的地方用注释标出,只保留合法操作,最后输出 a 和 b 的值。

输入 无

输出 两行,a 的值、b 的值。

样例输入
(本题无输入)
样例输出
30
2

💡 提示 下面参考代码里被注释的行就是非法操作,手敲时试着打开注释看看编译器报什么错。

✅ 查看参考答案与解析
#include <iostream>
int main() {
    int a = 1, b = 2;
    const int* p = &a;      // 指向常量的指针:不能改 *p
    // *p = 20;             // 非法:编译错误
    p = &b;                 // 合法:可以改指向
    std::cout << *p << '\n';   // 2:读一下,避免“变量未使用”警告
    int* const q = &a;      // 常量指针:不能改指向
    *q = 30;                // 合法:可以改值
    // q = &b;              // 非法:编译错误
    std::cout << a << '\n';   // 30
    std::cout << b << '\n';   // 2
    return 0;
}

解析 const int* 修饰的是 *p(值);int* const 修饰的是 p 本身(指向)。

4.2 数组、字符串与 vector

数组是 C++ 里最容易“静悄悄出错”的东西。核心教训:能用 vector 就别用原生数组。

数组会退化为指针

数组不是指针,但传参、赋值给指针时退化为指向首元素的指针。最经典的陷阱:在定义数组的作用域里 sizeof(a) 是数组总大小;一旦退化成指针,sizeof 就变成指针大小(8 字节)。

示例代码
#include <iostream>
void f(int* arr) {                  // 写成 int arr[] 也等价
    std::cout << sizeof(arr);      // 8:指针大小,不是数组大小
}
int main() {
    int a[5]{1, 2, 3, 4, 5};
    std::cout << sizeof(a) / sizeof(a[0]);   // 5:元素个数
}
⚠️ 易错点 原生数组不检查越界。a[5] 越界读写不会报错,只是访问别人的内存,结果不可预测——这是和 Java 最危险的差别之一。

选择标准:能用容器就别用数组

长度在编译期就确定、且不需要在函数之间传递,才考虑原生数组;其他情况一律用 std::vector。数据结构课的实现里几乎只会用到 vector 和 std::array。

⚠️ 易错点 函数参数里的数组写法只是装饰:void f(int a[10]) 和 void f(int* a) 完全一样,那个 10 没有任何约束作用。

字符串:用 std::string,别用 char*

字符串字面量是 const char[N],存只读区,绝不能通过指针修改;写 char* p = "abc"; 在 C++11 起是编译错误。std::string 值语义、自动管理内存、可以拼接修改。传参用 const std::string&(Java 传引用副本没有这个开销,C++ 里 string 传值会拷贝)。

示例代码
std::string s = "hello";
s += ", world";
std::cout << s.substr(0, 5) << ' ' << s.find("world");  // hello 7
int n = std::stoi("42");          // 字符串转数字
std::string t = std::to_string(3.14);   // 数字转字符串
⚠️ 易错点 find 找不到返回 std::string::npos,别拿它和 -1 比较。

✍️ 本节练习

4.2.1必做vector 找最大值

读入 n 个整数存入 std::vector,输出其中的最大值。

输入 第一行 n;第二行 n 个整数。

输出 最大值。

样例输入
5
3 7 2 9 1
样例输出
9

💡 提示 遍历用 for (int x : v)。

✅ 查看参考答案与解析
#include <iostream>
#include <vector>
int main() {
    int n;
    std::cin >> n;
    std::vector<int> v(n);
    for (int i = 0; i < n; ++i) { std::cin >> v[i]; }
    int mx = v[0];
    for (int x : v) { if (x > mx) { mx = x; } }
    std::cout << mx << '\n';
    return 0;
}

解析 动态长度优先 vector;范围 for 只读遍历最简洁。

4.2.2挑战字符串反转

读入一行字符串(不含空格),输出反转后的字符串。要求先用 std::reverse 做一遍,再手写循环做一遍(两行输出)。

输入 一行字符串(不含空格,长度 ≤ 100)。

输出 两行,都是反转后的字符串。

样例输入
hello
样例输出
olleh
olleh

💡 提示 std::reverse(s.begin(), s.end()) 需要 <algorithm>。

✅ 查看参考答案与解析
#include <algorithm>
#include <iostream>
#include <string>
int main() {
    std::string s;
    std::cin >> s;
    std::string t = s;
    std::reverse(t.begin(), t.end());
    std::cout << t << '\n';
    for (int i = 0, j = (int)s.size() - 1; i < j; ++i, --j) {
        std::swap(s[i], s[j]);
    }
    std::cout << s << '\n';
    return 0;
}

解析 双指针首尾交换是手写反转的标准写法;std::swap 在 <utility>(algorithm 也会带进来)。

4.3 动态内存:new 与 delete

你在 C 里学过 malloc/free,在 Java 里完全不用管内存。C++ 在中间:用 new/delete 手动管理,但有一条铁律必须守住。

new / delete 的配对规则

new 分配单个对象、delete 释放;new[] 分配数组、delete[] 释放(必须配对)。new Node{1, nullptr} 分配结构体并列表初始化。两套内存函数绝对不能混用:new 出来的不能 free,malloc 出来的不能 delete,混用=未定义行为,程序经常在毫不相关的地方崩溃。

示例代码
int* p = new int(42);        delete p;
int* arr = new int[10]();     delete[] arr;
Node* head = new Node{1, nullptr};   delete head;
⚠️ 易错点 new (std::nothrow) 失败返回 nullptr 而不抛异常;普通 new 失败抛 std::bad_alloc。

内存分区:对象放在哪、什么时候销毁

程序运行起来,内存分成几个区:栈(局部变量,离开作用域自动销毁)、堆(new 出来的,手动管理)、静态区(全局/静态变量,程序结束时销毁)、代码区(机器码)、字面量区(只读)。理解它们的分工,才能回答“这个对象什么时候被销毁”。

⚠️ 易错点 和 JVM 对照:Java 的堆由 GC 管理;C++ 的堆要你负责。C++ 的“确定性”是最大优势:析构在确定时刻被调用,你可以把释放内存、关闭文件都写进析构函数,让它们必然发生——这就是下一节的 RAII。
栈(自动回收)堆(手动/智能指针管理)int a = 1int b = 2int* pnew int(42)忘记 delete 就泄漏unique_ptr 离开作用域自动释放p 里存的是地址(门牌号)
图 2:栈上的变量与堆上的对象,指针只是记录了地址

✍️ 本节练习

4.3.1必做动态数组求和

用 new 分配一个 5 个 int 的动态数组并初始化为 {1,2,3,4,5},求和输出,最后用正确方式释放。

输入 无

输出 15

样例输入
(本题无输入)
样例输出
15

💡 提示 new int[5]{1,2,3,4,5};释放用 delete[] arr;

✅ 查看参考答案与解析
#include <iostream>
int main() {
    int* arr = new int[5]{1, 2, 3, 4, 5};
    int sum = 0;
    for (int i = 0; i < 5; ++i) { sum += arr[i]; }
    std::cout << sum << '\n';
    delete[] arr;
    return 0;
}

解析 new[] 和 delete[] 必须配对;混用 free/delete 是未定义行为。

4.3.2挑战动态数组排序

读入 n,用 new 分配 n 个 int 的动态数组,读入 n 个数,排序后输出,最后释放。排序用 std::sort。

输入 第一行 n;第二行 n 个整数。

输出 升序排列,空格分隔。

样例输入
5
5 3 8 1 2
样例输出
1 2 3 5 8

💡 提示 std::sort(arr, arr + n)。

✅ 查看参考答案与解析
#include <algorithm>
#include <iostream>
int main() {
    int n;
    std::cin >> n;
    int* arr = new int[n];
    for (int i = 0; i < n; ++i) { std::cin >> arr[i]; }
    std::sort(arr, arr + n);
    for (int i = 0; i < n; ++i) {
        std::cout << arr[i] << (i + 1 == n ? '\n' : ' ');
    }
    delete[] arr;
    return 0;
}

解析 指针算术 arr + n 是合法的(同一数组内);删除后不要再碰 arr。

4.4 RAII 与智能指针

RAII 是 C++ 里最重要的编程思想,没有之一。心智模型:把资源的生命周期绑定到对象的生命周期上——构造时获取资源,析构时释放资源。因为栈对象的析构是编译器保证的,所以资源释放也就被保证了。

RAII:资源跟着对象走

不管你是正常返回、提前 return、break 跳出循环,还是抛了异常,栈对象的析构一定会被调用。把“释放内存”“关闭文件”“解锁互斥量”写进析构函数,它们就必然发生。标准库的容器、智能指针、lock_guard 全是 RAII。

⚠️ 易错点 看到裸指针就要问一句:谁负责释放?如果答案是“没人”,那就是内存泄漏。

unique_ptr:独占所有权,第一选择

std::unique_ptr 独占所有权:不能拷贝只能移动,没有额外开销。链表、树、图里的“拥有”关系用它最合适——head 析构时递归释放 next,整条链自动回收,不需要任何 delete。

示例代码
struct Node {
    int value = 0;
    std::unique_ptr<Node> next;
};
auto head = std::make_unique<Node>();
head->value = 1;
head->next = std::make_unique<Node>();
// 离开作用域时整条链自动释放
⚠️ 易错点 永远用 std::make_unique / std::make_shared 创建,不要用裸指针 new 后再包装。

shared_ptr / weak_ptr:共享与观察

shared_ptr 用引用计数,最后一个引用消失时释放;拷贝使计数 +1,use_count() 查看。只在确实需要共享所有权时才用(有原子操作开销)。weak_ptr 不增加计数,用来观察或打破循环引用。

示例代码
std::shared_ptr<Node> p = std::make_shared<Node>();
std::shared_ptr<Node> q = p;          // 计数 2
std::cout << p.use_count();           // 2
std::weak_ptr<Node> w = p;            // 不增加计数
⚠️ 易错点 循环引用会泄漏:a->peer = b; b->peer = a; 计数永远不为 0,把一条边改成 weak_ptr。不要用裸指针 new 之后交给两个 shared_ptr(同一块内存释放两次)。

✍️ 本节练习

4.4.1必做unique_ptr 小链表

用 std::unique_ptr 建两个链表节点(Node 含 int value 和 std::unique_ptr<Node> next),输出两个节点的值。整个程序不允许出现 delete。

输入 无

输出 1 2

样例输入
(本题无输入)
样例输出
1 2

💡 提示 std::make_unique<Node>();head->next = std::make_unique<Node>();

✅ 查看参考答案与解析
#include <iostream>
#include <memory>
struct Node {
    int value = 0;
    std::unique_ptr<Node> next;
};
int main() {
    auto head = std::make_unique<Node>();
    head->value = 1;
    head->next = std::make_unique<Node>();
    head->next->value = 2;
    std::cout << head->value << ' ' << head->next->value << '\n';
    return 0;   // 离开作用域自动释放整条链
}

解析 unique_ptr 析构时递归释放 next,整条链自动回收,这就是 RAII。

4.4.2挑战shared_ptr 计数实验

用 shared_ptr 创建对象,拷贝两次后输出 use_count(),再让一个引用离开作用域,再输出一次 use_count(),观察引用计数变化。

输入 无

输出 两行:3、2。

样例输入
(本题无输入)
样例输出
3
2

💡 提示 std::make_shared<Node>();拷贝使计数 +1。

✅ 查看参考答案与解析
#include <iostream>
#include <memory>
struct Node { int value = 0; };
int main() {
    std::shared_ptr<Node> a = std::make_shared<Node>();
    std::shared_ptr<Node> b = a;   // 计数 2
    {
        std::shared_ptr<Node> c = a;   // 计数 3
        std::cout << a.use_count() << '\n';   // 3
    }                                // c 离开作用域,计数 2
    std::cout << a.use_count() << '\n';   // 2
    return 0;
}

解析 shared_ptr 引用计数归零才释放;要共享所有权就从同一个指针拷贝,绝不用裸指针 new 两次。

本章小结

🧩 本章综合练习

这几道题把本章多个知识点串起来,建议合上资料独立完成,再展开答案对照。

4.A综合智能指针 + 指针区间 + 就地反转

用 std::unique_ptr<int[]> 管理动态数组(全程不写 delete);写 void reverse(int* first, int* last) 按左闭右开区间就地反转;再写 long long sum(const int* first, const int* last) 求和。输出反转后的数组与总和。

输入 第一行整数 n;第二行 n 个整数。

输出 第一行反转后的数组(空格分隔);第二行 sum=总和

样例输入
5
1 2 3 4 5
样例输出
5 4 3 2 1
sum=15

💡 提示 区间用左闭右开 [first,last):last 指向最后一个元素的下一位;unique_ptr<int[]> 用 get() 取裸指针传给函数。

✅ 查看参考答案与解析
#include <algorithm>
#include <iostream>
#include <memory>

void reverse(int* first, int* last) {   // [first, last)
    if (first == last) { return; }
    --last;                             // 变成闭区间再交换
    while (first < last) {
        std::swap(*first, *last);
        ++first;
        --last;
    }
}

long long sum(const int* first, const int* last) {
    long long s = 0;
    for (const int* p = first; p != last; ++p) { s += *p; }
    return s;
}

int main() {
    int n{};
    std::cin >> n;
    std::unique_ptr<int[]> a(new int[n]);
    for (int i = 0; i < n; ++i) { std::cin >> a[i]; }
    reverse(a.get(), a.get() + n);
    for (int i = 0; i < n; ++i) { std::cout << a[i] << (i + 1 == n ? '\n' : ' '); }
    std::cout << "sum=" << sum(a.get(), a.get() + n) << '\n';
    return 0;
}

解析 裸指针只在函数内部流转,所有权始终在 unique_ptr 手里;这样即使中间抛异常也不会泄漏。

📚 本文概念都在知识大全:

指针 · const 与 const 正确性 · 栈与堆 · RAII · 智能指针