学生管理通讯录系统
# 第一章:C++ 学生管理通讯录系统
本章是综合案例的第一关——用卷一第 2-7 章(基础语法 / 结构体 / 函数 / 指针)完成一个菜单驱动的通讯录。代码故意保留 C 风格的 struct + 定长数组写法,是为了在第 02 案例"银行账户管理"里看到类化 + 多态后的同一问题,体会封装、继承、STL 容器带来的价值。
学习方式:本案例完全面向小白,按"功能拆分 → 写代码 → 跑日志 → 看原理 → 避陷阱"五步法循环。每个功能 30 分钟内可完成,全部 7 个功能 3 小时跑通。建议边读边敲,不要复制粘贴——肌肉记忆是新手最有效的学习方式。
# 渐进学习节奏
先读这段,再开始敲代码!本案例严格按照真实工程师的开发节奏推进,不会一上来甩你 300 行代码让你抄。我们的节奏是这样的:
阶段 ① 菜单骨架(02-03 节)
└ Step 1.1: 写 showMenu 空函数 → 编译 → 看到菜单
└ Step 1.2: 加 switch 分发 → 编译 → 选 0 能退出
阶段 ② 通讯录主功能(04-09 节)
└ Step 2.1: 设计 Person + AddressBooks 结构体
└ Step 2.2: 添加联系人 → 编译 → 加一个张三看 size=1
└ Step 2.3: 显示联系人 → 编译 → 看到张三
└ Step 2.4: 删除联系人 → 编译 → 删掉张三看 size=0
└ Step 2.5: 查找/修改/清空(依次类推)
2
3
4
5
6
7
8
9
10
🎯 每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就编译运行一次(看到 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ 新手最容易犯的错:抄完 300 行代码 → 编译报 50 个错 → 不知道从哪开始查 → 放弃。每加 30 行就编译一次——错了你就只需要查这 30 行,定位极快。
🎯 本案例的两处"灵魂思考"(动手前先想清楚):
- §04 添加联系人前(灵魂三问):每个联系人有哪些字段?通讯录怎么存这些 Person?为什么先 Person 再 AddressBooks?
- §06 删除联系人前(灵魂两问):能不能内联 isExist 不抽出来?isExist 返回 bool 还是 int?
⚠️ 本案例的三处"陷阱预警"(新手最容易踩,亲眼看一次记一辈子):
- §04 指针 vs 值传参:
AddressBooks *abs不是AddressBooks abs—— 否则函数返回数据全没了- §06 删除越界陷阱:
for (i=result; i<size; i++)中arr[i]=arr[i+1]—— 末尾会读到未写过的内存- §08 修改下标陷阱:
personArray[result]不是personArray[size]—— 写错了变成"追加"而不是"修改"
# 案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★☆☆☆☆(入门第一站) |
| 预估时长 | 3 小时(跟打 1.5h + 自测 1.5h) |
| 前置章节 | 卷一第 2 章 基础语法、第 3 章 数据类型、第 5 章 复合类型、第 6 章 流程语句、第 7 章 函数 |
| 覆盖知识点 | struct、定长数组、指针传参、switch/while、函数封装、cin/cout |
| 最终产物 | 单一可执行文件 address_book,控制台菜单驱动 |
| 代码规模 | 约 300 行 / 3 个文件(main.cpp、StudentManager.h、StudentManager.cpp) |
命名说明:本案例主线是"通讯录"(添加/显示/删除/查找/修改/清空联系人)。如果你更关心"学生成绩",可以把字段
phone/address换成subjectA/subjectB/subjectC,其他逻辑完全相同——这正是末尾"延伸挑战 B"。
# 目录快速导航
点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读。
- 渐进学习节奏 【🔑 必读】
- 案例元信息
- 01.系统需求
- 02.菜单功能 【阶段①骨架】
- 03.退出功能
- 04.添加联系人 【阶段②业务】
- 05.显示联系人
- 06.删除联系人 【isExist诞生⭐】
- 07.查找联系人 【复用isExist】
- 08.修改联系人 【下标陷阱】
- 09.清空联系人 【阶段②小结】
- 10.项目总结分析
- 11.项目技术思考
- 12.衔接与延伸
# 01.系统需求
# 1.1 需求介绍
通讯录是一个可以记录亲人、好友信息的工具。本教程主要利用C++来实现一个通讯录管理系统,支持录入、打印、保存、读取、统计、查找、修改、删除联系人信息等功能。
# 1.2 功能要求
代码的主要功能是管理一个通讯录,支持以下操作:
- 添加联系人:用户可以输入姓名、性别、年龄、电话和地址,将联系人信息添加到通讯录中。
- 显示联系人:显示通讯录中所有联系人的详细信息。
- 删除联系人:根据用户输入的姓名,删除指定的联系人。
- 查找联系人:根据用户输入的姓名,查找并显示指定联系人的详细信息。
- 修改联系人:根据用户输入的姓名,修改指定联系人的信息。
- 清空联系人:清空通讯录中的所有联系人。
- 退出程序:退出通讯录管理系统。
# 1.3 涉及知识点
- 结构体(
struct),用于定义学生信息复杂的数据结构。 - 用户输入输出:使用
scanf和printf实现与用户的交互。对用户输入进行简单处理,确保程序的健壮性。 - 循环和条件语句:使用
while循环实现菜单的持续显示和用户选择。使用switch-case语句根据用户选择调用相应的功能函数。 - 函数封装:将每个功能封装成独立的函数,如
addStudent、printStudents、saveStudents等。 - 格式化输出:使用
printf格式化输出学生信息,确保显示效果整齐美观。 - 文件操作,主要是通过
file文件保存信息和读取信息。 - 错误处理:在文件操作时检查文件是否成功打开,避免程序崩溃。在查找、修改和删除学生信息时,检查学号是否存在,提供友好的提示信息。
# 1.4 核心知识点
- 提供一个简单易用的联系人管理工具
- 演示C++基础编程概念的实际应用
- 展示结构体、指针、数组、循环、条件判断等核心语法的使用
# 02.菜单功能
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────────┐
│ 完成什么:让程序能显示一个 7 项菜单 + 选 0 能退出 │
│ 不做什么:不写任何业务逻辑(添加/显示/删除全是占位) │
│ 验收标准:跑起来能看到菜单 → 选 1~6 看到占位提示 → 选 0 退出 │
│ 预计耗时:20 分钟 │
│ 关键思路:先把"骨架"立起来,业务逻辑后面阶段再填 │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
🔑 为什么先写菜单?因为菜单是所有功能的入口——没有菜单就没法触发任何业务测试。先把这条 "main → showMenu → switch → 业务函数" 的管道立起来,后面填业务逻辑就有地方挂载了。
# 2.1 菜单显示空壳
🎯 Step 1.1:先写空壳函数,跳通主流程。
功能描述: 用户选择功能的界面
步骤: 1.封装函数显示该界面 如 void showMenu();2.在main函数中调用封装好的函数,注意在.h文件中添加声明。
#include "StudentManager.h"
#include "iostream"
using namespace std;
int main() {
showMenu();
//在 macOS 上,由于没有类似于 Windows 中的 system("pause") 函数来暂停控制台输出
//system("pause");
std::cout << "Press Enter to continue...";
std::cin.get(); // 等待用户输入字符
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
# 2.2 填充菜单内容
🎯 Step 1.2:填上真内容,编译看菜单。
//菜单界面
void showMenu() {
cout << "***************************" << endl;
cout << "***** 1、添加联系人 *****" << endl;
cout << "***** 2、显示联系人 *****" << endl;
cout << "***** 3、删除联系人 *****" << endl;
cout << "***** 4、查找联系人 *****" << endl;
cout << "***** 5、修改联系人 *****" << endl;
cout << "***** 6、清空联系人 *****" << endl;
cout << "***** 0、退出通讯录 *****" << endl;
cout << "***************************" << endl;
}
2
3
4
5
6
7
8
9
10
11
12
立刻编译运行(不要急着写下一步!)
g++ -std=c++17 main.cpp StudentManager.cpp -o address_book
./address_book
2
预期输出:
***************************
***** 1、添加联系人 *****
***** 2、显示联系人 *****
***** 3、删除联系人 *****
***** 4、查找联系人 *****
***** 5、修改联系人 *****
***** 6、清空联系人 *****
***** 0、退出通讯录 *****
***************************
Press Enter to continue...
2
3
4
5
6
7
8
9
10
✅ 看到这个菜单就 OK——验证了
main → showMenu这条管道是通的。 ❌ 如果报错undefined reference to showMenu:99% 是漏写StudentManager.cpp编译命令,或者.h里没声明。
# 2.3 主流程分发
🎯 Step 1.3:加 switch 分发,业务占位。
系统main函数,while循环在外,系统程序能够持续运行,switch判断语句进行功能选择与函数切换。
思路:根据用户不同的选择,进入不同的功能,可以选择switch分支结构,将整个架构进行搭建。
void showMainSelect() {
int select = 0;
while (true) {
showMenu();
// 从键盘输入
cin >> select;
switch (select) {
case 1: //添加联系人
cout << "***** 1、添加联系人 *****" << endl;
break;
case 2: //显示联系人
cout << "***** 2、显示联系人 *****" << endl;
break;
case 3: //删除联系人
cout << "***** 3、删除联系人 *****" << endl;
break;
case 4: //查找联系人
cout << "***** 4、查找联系人 *****" << endl;
break;
case 5: //修改联系人
cout << "***** 5、修改联系人 *****" << endl;
break;
case 6: //清空联系人
cout << "***** 6、清空联系人 *****" << endl;
break;
case 0: //退出通讯录
cout << "***** 0、退出通讯录 *****" << endl;
break;
default:
break;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 03.退出功能
# 3.1 设计退出功能
🎯 Step 1.4:选 0 退出、阶段①小结。
功能描述:退出通讯录系统!
当用户选择 0 时候,执行退出,选择其他先不做操作,也不会退出程序。代码如下所示:
case 0: //退出通讯录
cout << "欢迎下次使用" << endl;
//在 macOS 上,由于没有类似于 Windows 中的 system("pause") 函数来暂停控制台输出
//system("pause");
std::cout << "Press Enter to continue..." << endl;
std::cin.get(); // 等待用户输入字符
return;
break;
2
3
4
5
6
7
8
⚠️ 跨平台陷阱:system("pause") 和 system("cls") 都只在 Windows 控制台可用。Mac/Linux 上调用会直接报 sh: pause: command not found——这就是上面用 cin.get() 替代的原因。后面 clearScreen() 同理,用 ANSI Escape Code(\033[2J\033[H)跨平台清屏。
测试退出功能:把 showMainSelect() 嵌到 main 里跑一下,输入 0 看是否正常返回。
int main() {
showMainSelect(); // 进入菜单循环
return 0;
}
2
3
4
按 0 后预期打印:
***** 0、退出通讯录 *****
欢迎下次使用
Press Enter to continue...
2
3
return 让 showMainSelect 提前结束,控制流回到 main 后顺势退出——菜单循环的"出口"已经接通。
┌─ 📌 阶段 ① 小结 ────────────────────────────────────────┐
│ ✅ 你已经完成的: │
│ • 菜单骨架立起来了(看得到 7 个选项) │
│ • switch 分发链路通了(选 1~6 看到占位提示) │
│ • 退出功能 OK(选 0 能正常退出) │
│ │
│ ⏸ 还没做的: │
│ • Person/AddressBooks 数据结构(阶段 ② Step 2.1) │
│ • 添加/显示/删除/查找/修改/清空 6 大业务(阶段 ② Step 2.2~2.8)│
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage1: menu skeleton" │
│ │
│ 💡 此刻的领悟: │
│ "先把空架子立起来,比一上来写完整业务靠谱得多 —— │
│ 因为我能立刻看到效果,知道方向对不对" │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 04.添加联系人
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────────┐
│ 完成什么:实现 6 大通讯录业务(添加/显示/删除/查找/修改/清空)│
│ 不做什么:不做文件持久化(程序退出数据丢失,那是挑战 A) │
│ 验收标准:6 个 case 都能跑通,能加人 → 显示 → 删 → 查 → 改 → 清│
│ 预计耗时:120 分钟 │
│ 关键思路:每加一个功能就编译运行,看到效果再写下一个 │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
🔑 本阶段第一个动作 Step 2.1:设计数据结构(4.1 + 4.2 节)。所有业务都要操作 Person/AddressBooks,所以结构体必须先定义好——这是"最底层依赖"。
# 4.0 灵魂三问思考
🎯 Step 2.1:敲键盘前先想清楚要存什么。
在敲键盘之前,停 1 分钟问自己三个灵魂问题——回答清楚了再写代码,能少走 80% 的弯路。
❓ 问题一:每个联系人有哪些字段?
答:姓名、性别、年龄、电话、地址 → 5 个字段,需要一个结构体 Person 把它们打包。
来看反例 —— "用 5 个并列变量":
// ❌ 反例:5 个数组分别存
std::string names[100];
int sexes[100];
int ages[100];
std::string phones[100];
std::string addresses[100];
int size = 0;
2
3
4
5
6
7
问题:
- 删除时要同步操作 5 个数组——漏一个就数据错位(张三的姓名配李四的电话)
- 传参极其难看——
addPerson(names, sexes, ages, phones, addresses, &size),一个函数 6 个参数 - 加新字段要改一堆地方——加个"邮箱"要新增数组、改函数签名、改循环
✅ 正确做法:5 个字段属于"同一个实体",用 struct 打包成 Person——封装是对"逻辑相关性"的代码表达。
❓ 问题二:整个通讯录怎么存这些 Person?
答:用一个数组 Person[100] 装 + 一个 size 字段记录当前数量 → 再用一个结构体 AddressBooks 把这两者打包。
来看反例 —— "数组和 size 两个独立全局变量":
// ❌ 反例:两个并列的全局变量
Person g_persons[100]; // 全局通讯录
int g_size = 0; // 全局当前人数
2
3
问题:
- 谁修改 size 都不告知 —— 哪天有人
g_size = 999改飞了,遍历直接崩 - 不能同时存两个通讯录 —— 想搞"个人通讯录"+"工作通讯录"?做不到
- 测试很难 —— 单测一个函数还得清空全局状态
✅ 正确做法:容器 + 元素一起打包成 AddressBooks——这种 "容器 struct + 元素 struct" 的设计模式,会在所有后续案例反复出现(02 的 vector<Account*>、03 的 map<int, Computer>、04 的 vector<Document>)。
❓ 问题三:为什么是先 Person 再 AddressBooks,不是反过来?
来看反例:
// ❌ 反例:先写 AddressBooks
struct AddressBooks {
Person personArray[100]; // ⚠️ 这里编译就失败了:Person 还没定义
int size;
};
struct Person { /* ... */ }; // 太晚了
2
3
4
5
6
7
问题:C++ 编译器是"自上而下"扫的,用到一个类型时它必须已经被声明过。
✅ 正确做法:"被包含的类型"必须先定义,"包含别人的类型"必须后定义。这就是为什么所有头文件都遵循"基础类型在上、复合类型在下"的顺序。
🔑 三问连起来的领悟:写代码前的"想清楚 5 分钟",胜过敲坏键盘后的"调 bug 5 小时"。这不是教条——是真实工程师每天在做的事。
功能描述:实现添加联系人功能,联系人上限为1000人,联系人信息包括(姓名、性别、年龄、联系电话、家庭住址)
添加联系人实现步骤:
- 设计联系人结构体
- 设计通讯录结构体
- main函数中创建通讯录
- 封装添加联系人函数
- 测试添加联系人功能
# 4.1 联系人结构体
联系人信息包括:姓名、性别、年龄、联系电话、家庭住址
Person 结构体用于存储单个联系人的信息。设计如下:
#include <string> //string头文件
//联系人结构体
struct Person {
std::string name; // 姓名
int sex; // 性别
int age; // 年龄
std::string phone; // 电话
std::string address; // 地址
};
2
3
4
5
6
7
8
9
# 4.2 通讯录结构体
设计时候可以在通讯录结构体中,维护一个容量为100的存放联系人的数组,并记录当前通讯录中联系人数量
使用了一个结构体 AddressBooks 来存储通讯录数据,设计如下
#define MAX = 100; //最大人数
//通讯录结构体
struct AddressBooks {
Person personArray[100]; //通讯录中保存的联系人数组。
int size; //通讯录中人员人数
};
2
3
4
5
6
备注:这个数组必须要指定长度,否则报错。
# 4.3 创建通讯录变量
🎯 Step 2.2:在 main 创建通讯录。
添加联系人函数封装好后,在main函数中创建一个通讯录变量,这个就是我们需要一直维护的通讯录。
//创建通讯录
AddressBooks address;
//初始化通讯录人数
address.size = 0;
2
3
4
# 4.4 实现添加函数
🎯 Step 2.3:实现 addPerson 函数。
思路:添加联系人前先判断通讯录是否已满,如果满了就不再添加,未满情况将新联系人信息逐个加入到通讯录
//添加联系人信息
void addPerson(AddressBooks *abs) {
if (abs->size == 100) {
cout << "通讯录已经满了,无法添加" << endl;
return;
}
string name;
cout << "请输入姓名:" << endl;
cin >> name;
//直接赋值
abs->personArray[abs->size].name = name;
cout << "请输入性别:" << endl;
cout << "1 -- 男" << endl;
cout << "2 -- 女" << endl;
int sex = 0;
while (true) {
cin >> sex;
if (sex == 1 || sex ==2) {
abs->personArray[abs->size].sex = sex;
break;
}
cout << "输入性别有误,请重新输入" << endl;
}
int age;
cout << "请输入年龄:" << endl;
cin >> age;
abs->personArray[abs->size].age = age;
string phone;
cout << "请输入电话:" << endl;
cin >> phone;
abs->personArray[abs->size].phone = phone;
string address;
cout << "请输入家庭地址:" << endl;
cin >> address;
abs->personArray[abs->size].address = address;
//更新通讯录
abs->size ++;
cout << "添加用户:" << name << "成功。用户列表size是:" << abs->size << endl;
//替代 system("pause")
//system("pause");
std::cout << "点击enter键,暂停";
std::cin.get(); // 等待用户输入字符
//替代 system("cls")
//system("cls");
clearScreen(); // 清空屏幕
std::cout << "清空屏幕" << std::endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 4.5 测试添加联系人
选择界面中,如果玩家选择了 1,代表添加联系人,我们可以测试下该功能。
在 switch case 语句中,case 1 里添加:
case 1: //添加联系人
addPerson(&abs);
break;
2
3
测试日志如下所示:
***** 1、添加联系人 *****
请输入姓名:
yangchong
请输入性别:
1 -- 男
2 -- 女
1
请输入年龄:
33
请输入电话:
123
请输入家庭地址:
122
添加用户:yangchong成功。用户列表size是:1
2
3
4
5
6
7
8
9
10
11
12
13
14
size 从 0 变成 1、personArray[0] 拿到了刚刚输入的字段,说明"按值拷贝写入数组 + 计数器自增"这套裸 struct 写法跑通了。这也是后面所有功能(显示/删除/查找/修改)共用的底层数据存储。
🧠 为什么传
AddressBooks*而不是AddressBooks? 如果按值传参,函数内部的abs->size++修改的是拷贝,函数返回后通讯录依然是空的。指针(或引用)让外部address与函数内部abs指向同一份内存——这是卷一第 8 章"指针 vs 值传递"在真实场景里的第一次落地。
# 05.显示联系人
功能描述:显示通讯录中已有的联系人信息
🎯 本节目标(Step 2.4):让"添加 → 显示"这条最核心的回路跑通。这是后面所有功能(删/查/改/清)的验证手段——没有 showPerson,你根本没法看见 deletePerson/modifyPerson 改了啥。
# 5.1 显示联系人函数
思路:判断如果当前通讯录中没有人员,就提示记录为空,人数大于0,显示通讯录中信息
//显示所有联系人信息
void showPerson(AddressBooks * abs) {
if (abs->size == 0) {
cout << "通讯录为空,没有联系人" << endl;
return;
}
for (int i=0 ; i< abs->size ; i++) {
cout << "姓名:" << abs->personArray[i].name << "\t";
cout << "性别:" << (abs->personArray[i].sex == 1 ? "男" : "女") << "\t";
cout << "年龄:" << abs->personArray[i].age << "\t";
cout << "电话:" << abs->personArray[i].phone << "\t";
cout << "住址:" << abs->personArray[i].address << endl;
}
pauseAndCls();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 5.2 测试显示联系人
在 switch case 语句中,case 2 里添加:
case 2: //显示联系人
showPerson(&abs);
break;
2
3
打印结果:
***** 2、显示联系人 *****
姓名:yangchong 性别:男 年龄:33 电话:13241 住址:4231
通讯录为空,没有联系人 ← 把刚加的清掉再选 2,会走另一个分支
2
3
两条分支都被覆盖:有人时按 \t 对齐打印一行,没人时给出友好提示——这正是函数开头那个 if (abs->size == 0) 守卫的作用。
# 06.删除联系人
功能描述:按照姓名进行删除指定联系人
🎯 本节目标(Step 2.5):删除时遇到"我得先判断这个人在不在"的问题——这就是 isExist 辅助函数的诞生时刻。和 02 案例 Bank 类的 findAccount 一模一样:辅助函数不是事先设计的,是写第二个功能时自然冒出来的需求。后面 07 查找、08 修改还会复用 isExist。
# 6.0 灵魂两问:动手前思考
❓ 问题一:能不能不要 isExist,直接在 deletePerson 里写 for 循环找人?
来看反例 —— "看起来更直接"的做法:
// ❌ 反例:把找人逻辑内联到 deletePerson 里
void deletePerson(AddressBooks * abs) {
cout << "请输入您要删除的联系人" << endl;
string name; cin >> name;
int result = -1;
for (int i = 0; i < abs->size; i++) { // 找人逻辑
if (abs->personArray[i].name == name) {
result = i;
break;
}
}
if (result == -1) { cout << "查无此人" << endl; return; }
// ...删除逻辑
}
2
3
4
5
6
7
8
9
10
11
12
13
14
短期看起来代码更短了,但是:
- 马上写 findPerson 时,要原样复制这段 for 循环 —— 重复代码出现
- 再写 modifyPerson 时,又要复制一遍 —— 同一段逻辑出现 3 次
- 某天发现"按名字找"要改成"按名字 + 电话联合找" —— 要改 3 处,漏一处就出 bug
✅ 正确做法:写第一个功能(删除)时就察觉到"这段逻辑后面会被复用",立刻抽成 isExist。这是 DRY(Don't Repeat Yourself)原则的最朴素体现。
| 维度 | 内联做法 | 抽成 isExist |
|---|---|---|
| 第 1 次写 | 简单(1 处 for 循环) | 多写 1 个函数声明 |
| 第 2、3 次复用 | 复制粘贴 | 一行调用 |
| 修改逻辑(如改成模糊匹配) | 改 3 处 | 改 1 处 |
| 单测难度 | 必须连带测删除 | 单独测找人 |
❓ 问题二:isExist 该返回 bool 还是 int 索引?
来看反例 —— "看名字 isExist 自然返回 bool":
// ❌ 反例:返回 bool
bool isExist(AddressBooks * abs, string name);
void deletePerson(AddressBooks * abs) {
if (!isExist(abs, name)) { cout << "查无此人" << endl; return; }
// ⚠️ 问题来了:知道"在",但不知道"在哪"!
// 还得再写一遍 for 循环找下标 → 找了两遍
}
2
3
4
5
6
7
8
问题:bool 只回答了"在不在",但删除/修改还需要"在第几位"——又得重新遍历一次。找两遍 = 一次浪费。
✅ 正确做法:返回 int,约定 -1 表示不存在,其他值表示下标。这种约定在 STL 里也常见——std::string::find 用 npos 表"找不到",本质同一思路。
| 返回类型 | 调用方还要做什么 | 调用方代码 |
|---|---|---|
bool | 还得再遍历一次拿下标 | 两次 for 循环 |
int(-1 表不存在) | 直接拿下标用 | 一次调用搞定 |
Person*(nullptr 表不存在) | 拿到对象但不知道下标 | 删除时还要找位置 |
🔑 教学要点:函数的返回值设计 = "调用方下一步还要不要继续做"。回想一下 std::map::find 返回的 iterator——既能判断"在不在"(!= end()),又能直接拿到位置和值,一次调用解决三个需求。这就是好 API 的标准。
# 6.1 判断是否存在
🎯 Step 2.5 上:辅助函数 isExist 诞生。
设计思路:删除联系人前,我们需要先判断用户输入的联系人是否存在,如果存在删除,不存在提示用户没有要删除的联系人
因此我们可以把检测联系人是否存在封装成一个函数中,如果存在,返回联系人在通讯录中的位置,不存在返回-1
//检查联系人是否存在
int isExist(AddressBooks * abs, string name) {
for (int i=0 ; i<abs->size ; i++) {
//判断是否存在查询的人员,存在返回在数组中索引位置,不存在返回-1
if (abs->personArray[i].name == name) {
return i;
}
}
return -1;
}
2
3
4
5
6
7
8
9
10
# 6.2 实现删除函数
🎯 Step 2.5 下:复用 isExist 实现 deletePerson。
根据用户输入的联系人判断该通讯录中是否有此人。查找到进行删除,并提示删除成功。查不到提示查无此人。
//删除指定联系人信息
void deletePerson(AddressBooks * abs) {
cout << "请输入您要删除的联系人" << endl;
string name;
cin >> name;
int result = isExist(abs,name);
if (result == -1) {
cout << "查无此人" << endl;
return;
}
//比如reslut是10,总数量是100,那就将10之后的数据都往前挪动一位
for (int i=result ; i< abs->size ; i++) {
//查询制定联系人的索引是result
abs->personArray[i] = abs->personArray[i+1];
}
abs->size--;
cout << "删除成功" << endl;
pauseAndCls();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 6.3 测试删除联系人
在 switch case 语句中,case 3 里添加:
case 3: //删除联系人
deletePerson(&abs);
break;
2
3
先添加 2 个联系人 yangchong / lisi,再选 3 删除其中一个:
***** 3、删除联系人 *****
请输入您要删除的联系人
yangchong
删除成功
***** 2、显示联系人 *****
姓名:lisi 性别:男 年龄:22 电话:111 住址:beijing
2
3
4
5
6
⚠️ 越界陷阱:
for (int i=result; i<abs->size; i++)中personArray[i] = personArray[i+1]——当i = size - 1时会读到personArray[size],那是未写过的内存!这里碰巧没崩,是因为定长数组Person[100]那块空间确实存在。一旦换成vector或size == 100满员,就会立即触发越界。正确写法应是for (int i=result; i < abs->size - 1; i++),循环上界少减 1。这是后面 02 案例改成vector::erase的最大动机。
删除分支跑通:被删的下标 result 之后的元素整体前移、size--,再调一次 showPerson 就能直观验证。
# 07.查找联系人
功能描述:按照姓名查看指定联系人信息
🎯 本节目标(Step 2.6):复用 6.1 节的
isExist——你会真切感受到"代码复用"的快感。如果没有 isExist,你这里又得写一遍 for 循环找人;现在一行int result = isExist(...)就搞定。这正是辅助函数存在的意义。
# 7.1 查找联系人函数
实现思路:判断用户指定的联系人是否存在,如果存在显示信息,不存在则提示查无此人。
//查找指定联系人信息
void findPerson(AddressBooks * abs) {
cout << "请输入您要查找的联系人" << endl;
string name;
cin >> name;
int result = isExist(abs,name);
if (result == -1) {
cout << "查无此人" << endl;
return;
}
cout << "姓名:" << abs->personArray[result].name << "\t";
cout << "性别:" << (abs->personArray[result].sex == 1 ? "男" : "女") << "\t";
cout << "年龄:" << abs->personArray[result].age << "\t";
cout << "电话:" << abs->personArray[result].phone << "\t";
cout << "住址:" << abs->personArray[result].address << endl;
pauseAndCls();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.2 测试查找联系人
在 switch case 语句中,case 4 里添加:
case 4: //查找联系人
findPerson(&abs);
break;
2
3
通讯录里先准备一条 yangchong,分别测试命中 / 未命中两条路径:
***** 4、查找联系人 *****
请输入您要查找的联系人
yangchong
姓名:yangchong 性别:男 年龄:33 电话:13241 住址:4231
***** 4、查找联系人 *****
请输入您要查找的联系人
zhaoliu
查无此人
2
3
4
5
6
7
8
9
isExist 把"找到返回索引、找不到返回 -1"这段共性逻辑抽出来,findPerson 和后面的 deletePerson / modifyPerson 共用——典型的"小函数复用大函数"。
# 08.修改联系人
功能描述:按照姓名重新修改指定联系人
🎯 本节目标(Step 2.7):再次复用 isExist——这是 isExist 第三次登场。同时要特别注意下文里提醒的一个下标陷阱(使用
result而非size)。
# 8.1 修改联系人函数
实现思路:查找用户输入的联系人,如果查找成功进行修改操作,查找失败提示查无此人。
//修改指定联系人信息
void modifyPerson(AddressBooks * abs) {
cout << "请输入您要修改的联系人的姓名" << endl;
string name;
cin >> name;
int result = isExist(abs,name);
if (result == -1) {
cout << "查无此人" << endl;
return;
}
string name2;
cout << "请输入姓名:" << endl;
cin >> name2;
//直接赋值(注意:是写到 result 这个旧位置,不是 size)
abs->personArray[result].name = name2;
cout << "请输入性别:" << endl;
cout << "1 -- 男" << endl;
cout << "2 -- 女" << endl;
int sex = 0;
while (true) {
cin >> sex;
if (sex == 1 || sex ==2) {
abs->personArray[result].sex = sex;
break;
}
cout << "输入性别有误,请重新输入" << endl;
}
int age;
cout << "请输入年龄:" << endl;
cin >> age;
abs->personArray[result].age = age;
string phone;
cout << "请输入电话:" << endl;
cin >> phone;
abs->personArray[result].phone = phone;
string address;
cout << "请输入家庭地址:" << endl;
cin >> address;
abs->personArray[result].address = address;
cout << "修改成功" << endl;
pauseAndCls();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
⚠️ 新手最容易写反的下标:上面 6 处都是
personArray[result]——result是isExist找到的旧位置。如果误写成personArray[abs->size](笔者第一版就踩过),新值会落到"还没分配的下一个槽位",老联系人原封不动,看起来"修改无效"——而且当size == 100时直接数组越界,调试很难定位。把result当成"指针"看待,记牢:修改 = 覆盖原位置,不是追加新位置。
# 8.2 测试修改联系人
在 switch case 语句中,case 5 里添加:
case 5: //修改联系人
modifyPerson(&abs);
break;
2
3
先添加 yangchong,再选 5 把电话从 13241 改成 99999:
***** 5、修改联系人 *****
请输入您要修改的联系人的姓名
yangchong
请输入姓名:
yangchong
请输入性别:
1 -- 男
2 -- 女
1
请输入年龄:
33
请输入电话:
99999
请输入家庭地址:
shanghai
修改成功
***** 2、显示联系人 *****
姓名:yangchong 性别:男 年龄:33 电话:99999 住址:shanghai
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
回显显示电话和地址都已被覆盖、size 维持为 1——说明改的是原位置而不是新增了一条。这正是上面 callout 里强调"用 result 而非 size"的直接证据。
# 09.清空联系人
功能描述:清空通讯录中所有信息
🎯 本节目标(Step 2.8):最简单的一个功能——只改一个字段
size=0。但背后有个性能 trick值得记住:"逻辑清空"而非"物理清空"。
# 9.1 清空联系人函数
实现思路:将通讯录所有联系人信息清除掉,只要将通讯录记录的联系人数量置为0,做逻辑清空即可。
//清空所有联系人
void cleanPerson(AddressBooks * abs) {
abs->size = 0;
cout << "通讯录已清空" << endl;
pauseAndCls();
}
2
3
4
5
6
# 9.2 测试清空联系人
在 switch case 语句中,case 6 里添加:
case 6: //清空联系人
cleanPerson(&abs);
break;
2
3
先添加 2 条联系人,再选 6 清空、随后选 2 显示:
***** 6、清空联系人 *****
通讯录已清空
***** 2、显示联系人 *****
通讯录为空,没有联系人
2
3
4
5
💡 "逻辑清空" vs "物理清空":这里只把
size置 0,personArray[0..1]里的旧数据还在内存里——下次添加时会被新值覆盖,所以从用户视角看就是"被清掉了"。这种 trick 在性能敏感的容器(如内存池、循环队列)里很常见:避免一次性memset100 个槽位的开销。如果未来需要"安全擦除"(比如里面存的是密码),就必须显式逐个置零或用std::fill。
┌─ 📌 阶段 ② 小结 ────────────────────────────────────────┐
│ ✅ 你刚刚完成了真实工程师的 8 个迭代步骤: │
│ • Step 2.1 数据结构(Person + AddressBooks 嵌套) │
│ • Step 2.2 在 main 创建通讯录变量 │
│ • Step 2.3 addPerson —— 第一次操作 size++ 和数组下标 │
│ • Step 2.4 showPerson —— 验证手段,所有功能都靠它看效果 │
│ • Step 2.5 deletePerson —— **isExist 辅助函数自然涌现** ⭐ │
│ • Step 2.6 findPerson —— 第二次复用 isExist │
│ • Step 2.7 modifyPerson —— 第三次复用 isExist + 下标陷阱 │
│ • Step 2.8 cleanPerson —— 逻辑清空 trick │
│ │
│ 🔑 你应该亲眼看见的现象: │
│ • size 字段在加/删/清空时如何变化 │
│ • personArray[result] vs personArray[size] 的本质区别 │
│ • for 循环越界陷阱(删除时少减 1 会触发的问题) │
│ │
│ ⏸ 还没做的(留作挑战): │
│ • 持久化(挑战 A)—— 下一案例 02 会用 ofstream 实现 │
│ • 用 vector 替换定长数组(挑战 C)—— 02 直接用 vector │
│ • 改成 class 而非 struct —— 02 案例的核心升级 │
│ │
│ 📌 进入下一案例前务必: │
│ git add . && git commit -m "stage2: address book MVP" │
│ │
│ 💡 最大的领悟: │
│ "isExist 不是设计出来的,是写 deletePerson 时自然冒出来的 —— │
│ 每写一个功能,多想想'我能复用什么',而不是'我要新写什么'" │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 10.项目总结分析
# 10.1 代码整体结构
代码分为以下几个部分:
主函数 main:程序的入口,调用 showMainSelect 函数启动通讯录管理系统。
showMainSelect 函数:显示主菜单,并根据用户输入调用相应的功能函数。
功能函数:
addPerson:添加联系人。showPerson:显示所有联系人。deletePerson:删除联系人。findPerson:查找联系人。modifyPerson:修改联系人。cleanPerson:清空联系人。
辅助函数:
clearScreen:清空控制台屏幕。pauseAndCls:暂停程序并清空屏幕。isExist:检查联系人是否存在。
# 10.2 代码核心原理
- 菜单驱动: 通过
showMenu函数显示菜单,用户输入选项后,switch语句根据输入调用相应的功能函数。 - 联系人管理:
- 联系人信息存储在
AddressBooks结构体的personArray数组中,size变量记录当前联系人数量。 - 添加联系人时,将信息存入数组,并增加
size。 - 删除联系人时,将数组中的后续元素向前移动,并减少
size。 - 查找和修改联系人时,通过遍历数组找到指定联系人。
- 联系人信息存储在
- 屏幕控制: 使用
clearScreen函数清空控制台屏幕,使用pauseAndCls函数暂停程序并清空屏幕。
# 10.3 代码优缺点
优点
- 功能完整:实现了通讯录的基本功能,包括添加、显示、删除、查找、修改和清空。
- 结构清晰:代码模块化,功能函数分工明确,易于理解和维护。
- 用户友好:通过菜单驱动,用户可以通过简单的输入完成操作。
缺点
- 数据存储:联系人信息存储在内存中,程序退出后数据会丢失。可以考虑使用文件或数据库持久化存储。
- 输入验证不足:对用户输入的验证不够严格,例如电话号码和地址的格式未做检查。
- 扩展性有限:联系人数量固定为 100 个,无法动态扩展。可以使用动态数组(如
std::vector)改进。 - 跨平台问题:
clearScreen函数使用了 ANSI Escape Codes,可能在某些平台上不兼容。
# 10.4 一些改进建议
- 数据持久化: 将联系人信息保存到文件或数据库中,确保程序退出后数据不丢失。
- 输入验证: 对用户输入进行更严格的验证,例如检查电话号码是否为数字、地址是否合法等。
- 动态扩展: 使用
std::vector代替固定大小的数组,支持动态扩展联系人数量。 - 跨平台兼容: 使用跨平台的屏幕清空方法,例如通过条件编译实现不同平台的兼容。
- 代码优化: 将
modifyPerson函数中的重复代码提取为独立函数,提高代码复用性。
# 11.项目技术思考
# 11.1 为何要用结构体
这个核心数据AddressBooks和Person为何要用结构体,用结构体好处是什么。能否用其他代替?
1. 使用结构体的好处
- (1) 数据封装:结构体可以将多个相关的数据成员封装在一起,形成一个逻辑单元。例如,
Person结构体将姓名、性别、年龄、电话和地址封装在一起,表示一个联系人的完整信息。这种封装使得代码更易读、更易维护,因为相关的数据被组织在一起,而不是分散在多个变量中。 - (2) 代码简洁:使用结构体可以减少代码的冗余。例如,在
AddressBooks中,personArray和size被封装在一起,避免了需要单独管理数组和数组大小的麻烦。 - (3) 易于扩展:如果未来需要为
Person或AddressBooks添加新的字段(例如邮箱、备注等),只需在结构体中添加新的成员变量,而不需要修改大量的代码。 - (4) 与 C 语言兼容:结构体是 C 和 C++ 共有的特性,使用结构体可以保持代码的兼容性,尤其是在需要与 C 语言代码交互时。
- (5) 默认访问权限为
public:在 C++ 中,结构体的成员默认是public的,这意味着可以直接访问和修改结构体的成员变量。对于简单的数据封装(如Person和AddressBooks),这种设计是合适的。
2. 是否可以用其他方式替代
虽然结构体是一个合适的选择,但在某些情况下,可以使用其他方式来实现类似的功能。以下是几种替代方案:
(1) 类(class):类是 C++ 中更强大的封装工具,默认访问权限为 private,可以通过成员函数提供更复杂的逻辑。示例:
class Person {
private:
string name;
int sex;
int age;
string phone;
string address;
public:
// 构造函数、getter 和 setter 方法
};
2
3
4
5
6
7
8
9
10
(2) 标准库容器(如 std::vector)
std::vector 是 C++ 标准库中的动态数组,可以替代固定大小的数组(如 personArray)。
适用场景:如果需要动态管理联系人数量(而不是固定为 100 个),可以使用 std::vector。示例:
struct AddressBooks {
std::vector<Person> personArray; // 动态数组
};
2
3
(3) 元组(std::tuple):std::tuple 可以将多个不同类型的值组合在一起,类似于结构体。
适用场景:如果数据成员数量较少且不需要命名,可以使用 std::tuple。示例:
using Person = std::tuple<string, int, int, string, string>;
(4) 自定义类 + 标准库容器:结合类和标准库容器,可以实现更复杂的数据管理。
适用场景:如果需要更高级的功能(如数据持久化、搜索、排序等),可以设计一个自定义类,并使用 std::vector 存储联系人。示例:
class AddressBooks {
private:
std::vector<Person> personArray;
public:
void addPerson(const Person& person);
void deletePerson(const string& name);
// 其他方法
};
2
3
4
5
6
7
8
3. 为什么在这个代码中使用结构体是合适的
在这个代码中,Person 和 AddressBooks 的设计非常简单,主要用于存储和操作数据,而不涉及复杂的逻辑。因此,使用结构体是一个合适的选择,因为:
- 结构体的默认
public访问权限使得数据可以直接访问,符合简单数据封装的需求。 - 结构体的语法简洁,易于理解和使用。
- 不需要额外的功能(如数据隐藏、复杂的成员函数等),因此不需要使用类。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
结构体(struct) | 简单、易用、默认 public 访问权限 | 不适合需要严格封装的场景 | 简单的数据封装 |
类(class) | 支持严格封装、提供成员函数 | 语法稍复杂,适合更复杂的逻辑 | 需要封装和复杂逻辑的场景 |
std::vector | 动态管理数据,支持动态扩展 | 需要与其他数据结构结合使用 | 需要动态管理数据的场景 |
std::tuple | 轻量级,适合少量数据 | 数据成员没有命名,可读性较差 | 数据成员较少且不需要命名的场景 |
| 自定义类 + 容器 | 功能强大,支持复杂的数据管理和操作 | 实现复杂度较高 | 需要高级功能的场景 |
在这个代码中,使用结构体是最简单、最直接的选择。如果需要扩展功能(如动态管理联系人数量或更严格的封装),可以考虑使用类或标准库容器。
# 12.衔接与延伸
# 12.1 与下一案例的差异
下一案例 02.银行账户管理系统 (opens new window) 会做四件"升级",请留意对比:
| 维度 | 本案例 01 | 下一案例 02 |
|---|---|---|
| 数据载体 | struct Person + 定长 Person[100] | class Account + std::vector<Account*> |
| 成员访问 | 默认 public,abs->size++ 裸写 | private + getter/setter,封装 |
| 数据类型 | 单一 Person 类型 | 抽象基类 Account + 三个派生类(普通账户/VIP/储蓄)—— 多态首秀 |
| 容量 | 写死 100 | vector 动态扩容 |
| 持久化 | ❌ 程序退出即丢失 | ✅ CSV 文件 ofstream / ifstream,含"类型标签"反序列化 |
| 错误处理 | 打印 cout << "xxx" + return | 同上 + 异常 try/catch + 空指针检查 |
换句话说,02 会让你第一次亲身体会到"封装 + 多态 + 容器 + 文件"比"裸 struct + 数组"多出的价值。
# 12.2 三个延伸挑战
完成主流程后,尝试把下列三项自己加上:
挑战 A(基础)· 数据持久化 5 分钟落地版
在 cleanPerson 和程序退出前调用 savePerson,程序启动时调用 loadPerson,用最简单的每行一条记录的纯文本格式(不用 CSV),即:
void savePerson(const AddressBooks* abs) {
std::ofstream ofs("contacts.txt");
for (int i = 0; i < abs->size; ++i) {
const auto& p = abs->personArray[i];
ofs << p.name << ' ' << p.sex << ' ' << p.age
<< ' ' << p.phone << ' ' << p.address << '\n';
}
}
2
3
4
5
6
7
8
完成后再跑一次程序,观察 contacts.txt 长什么样——这就是卷一第 13 章 IO 与文件的最小实战。
挑战 B(进阶)· 改造成学生成绩表
把 Person 改成 Student:
struct Student {
std::string id; // 学号
std::string name; // 姓名
double chinese, math, english;
double average() const { return (chinese + math + english) / 3.0; } // 成员函数(C++ 可用)
};
2
3
4
5
6
并把"查找"功能扩展为"按学号查找"和"按平均分排序输出前 10 名"。需要用到卷一第 7 章的 sort(配合 Lambda):
std::sort(studentArray, studentArray + size,
[](const Student& a, const Student& b) { return a.average() > b.average(); });
2
挑战 C(现代化)· vector + 范围 for 重构
把 Person personArray[100]; int size; 两个字段合并为一个 std::vector<Person> persons;,删除所有"满了就拒绝"的分支,遍历改成范围 for:
for (const auto& p : abs->persons) {
std::cout << "姓名:" << p.name << "\t性别:" << (p.sex == 1 ? "男" : "女") << '\n';
}
2
3
这个改动会让你提前预习到卷一第 16 章 STL——也是下一案例 02 直接要用的写法。
小结:挑战 A 对应第 13 章、挑战 B 对应第 7 章 + 第 16 章、挑战 C 对应第 16 章 + 第 18 章。做完三道挑战,你就已经具备开始 02 案例的所有前置能力。
- ➡ 下一案例:02.银行账户管理系统 (opens new window) —— 从
struct升级到class+ 多态三态,从定长数组升级到vector,从内存临时数据升级到 CSV 文件持久化