银行账户管理系统
# 第二章:C语言 银行账户管理系统
本章是综合案例的第二关——从"学生通讯录"的单表链表,升级到"开户/存款/取款/转账"的事务性系统。核心挑战从"数据结构"转向**"数据一致性"**——转账时两个账户的余额必须同时成功或同时失败,不能出现"扣了A的钱、B没收到"的情况。
对比上一关的链表,本关改用结构体数组存储账户——让你亲身体验"数组 vs 链表"的取舍,并在结尾和 C++ 版对比类封装 vs 全局数组的巨大差异。
⚠️ 本关新增"造bug高峰":§06 转账环节故意不加事务保护 → 让你看到"钱凭空消失"的数据不一致现场。
# 渐进学习节奏
先读这段,再开始敲代码!本案例严格按照真实 C 语言工程师的开发节奏推进:
阶段 ① 菜单骨架 + 开户(§02-03)· 30 min
└ Step 1.1: 写 showMenu + switch 分发 → 编译 → 选 7 能退出
└ Step 1.2: Account 结构体 + 数组存储 → 开户 → 看到卡号递增
阶段 ② 核心业务(§04-06)· 60 min 【数组越界 + 事务一致性⭐】
└ Step 2.1: 存款/取款 → 边界检查 → findAccount 辅助函数自然涌现
└ Step 2.2: 查询余额 → 一行搞定(复用 findAccount)
└ Step 2.3: 转账 → ⚠️ 先看不加保护的bug → 再修复 → 理解事务
阶段 ③ 文件持久化(§07)· 20 min 【文本格式设计】
└ Step 3.1: 保存 fprintf → 退出再进 → 数据还在 ✅
└ Step 3.2: 加载 fscanf → 启动时自动恢复
2
3
4
5
6
7
8
9
10
11
12
🎯 每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就编译运行一次(看到 🧪 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ C语言新手最容易犯的四个错:
scanf后忘记getchar()消费换行符- 数组操作忘了检查索引边界 —
accounts[100]访问越界fscanf返回值不检查 — 格式错误静默失败- 全局变量
accountCount在多个函数中递增,忘记重置
🎯 本案例的"灵魂两问"(动手前先想清楚):
- §03 开户前:为什么用数组不用链表?银行系统里是"按卡号随机查"多还是"顺序遍历"多?
- §06 转账前:如果先扣A的钱再给B加钱,中间某一步失败了怎么办?
⚠️ 本案例的四处"陷阱预警":
- §03 数组越界:
accounts[accountCount]— accountCount 达到 MAX_ACCOUNTS 时就会越界- §04 重复卡号:开户时不检查卡号唯一性 — 两个用户用同一卡号
- §06 转账"丢钱":先扣后加中间无保护 — 钱凭空消失(本案例会现场演示)
- §07 fscanf 解析失败:手动改过文件格式后 fscanf 返回 < 3,静默跳过行
# 案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★☆☆☆ |
| 预估时长 | 2 小时 |
| 前置章节 | 结构体、数组、文件操作、指针 |
| 覆盖知识点 | typedef struct、数组遍历、线性查找、fopen/fprintf/fscanf、事务一致性 |
| 设计亮点 | 数组边界保护 + 转账一致性 + 文本文件格式设计 |
| ⚠ 已知局限 | 无加密、无并发、无数据库事务 |
# 目录快速导航
- 渐进学习节奏
- 案例元信息
- 01.系统需求
- 02.菜单骨架 【阶段①】
- 03.开户功能 【Account结构体】
- 04.存款与取款
- 05.查询余额
- 06.转账功能 【事务一致性⭐】
- 07.文件持久化 【阶段③】
- 08.总结与C++对比
# 01.系统需求
# 1.1 功能要求
1. 开户 —— 输入卡号、姓名、初始余额 → 存入数组
2. 存款 —— 输入卡号 + 金额 → 余额增加
3. 取款 —— 输入卡号 + 金额 → 余额减少(不能透支)
4. 查询余额 —— 输入卡号 → 显示余额
5. 转账 —— A转B → 双方余额同时变更 ⚠️ 不能"钱丢了"
6. 保存数据 → 文本文件 "bank_accounts.txt"
7. 启动时自动加载文件数据
0. 退出
2
3
4
5
6
7
8
# 1.2 C语言技术栈
| 技术 | 应用 |
|---|---|
typedef struct | Account 账户结构体 |
| 数组 | 定长 accounts[100] 存储账户 |
fopen/fprintf/fscanf | 文本文件读写 |
| 线性查找 | 根据卡号查账户索引 |
| 事务思维 | 转账的两个操作必须原子 |
# 02.菜单骨架
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_ACCOUNTS 100
#define NAME_LEN 50
#define FILENAME "bank_accounts.txt"
void showMenu() {
printf("\n========== 银行账户管理系统 ==========\n");
printf("1. 开户 2. 存款 3. 取款\n");
printf("4. 查询余额 5. 转账 6. 保存数据\n");
printf("7. 退出\n");
printf("=======================================\n");
printf("请选择: ");
}
int main() {
int choice;
while (1) {
showMenu();
scanf("%d", &choice);
getchar();
switch (choice) {
case 1: printf(">>> 开户\n"); break;
case 2: printf(">>> 存款\n"); break;
case 3: printf(">>> 取款\n"); break;
case 4: printf(">>> 查询\n"); break;
case 5: printf(">>> 转账\n"); break;
case 6: printf(">>> 保存\n"); break;
case 7: printf("再见!\n"); return 0;
default: printf("无效选择!\n"); 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
34
35
# 03.开户功能
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────────┐
│ 完成什么:定义 Account 结构体 + 数组存储 + 开户函数 │
│ 不做什么:不做存款/取款/转账/文件——那些是后续阶段的事 │
│ 验收标准:连续开 3 个账户 → 卡号 1/2/3 → 打印看到 3 条记录 │
│ 预计耗时:30 分钟 │
│ 关键思路:数组下标 = accountCount,每次开户 count++ │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
# 3.0 灵魂三问
🎯 在敲键盘之前,停 2 分钟问自己三个灵魂问题——回答清楚了再写代码,能少走 80% 的弯路。
❓ 问题一:为什么用结构体数组而不是链表?跟第一关反过来了?
来看第 01 关(学生通讯录)和第 02 关的对比:
| 维度 | 第 01 关·学生通讯录 | 第 02 关·银行账户(本关) |
|---|---|---|
| 主导操作 | 顺序遍历(打印全部学生) | 随机查找(按卡号查余额) |
| 数据增量 | 不可预知(学生可无限增多) | 可预估(银行账户不会无限增长) |
| 最佳容器 | 链表(O(1) 插入,不怕溢出) | 数组(O(1) 随机访问,按卡号=按索引) |
| 内存占用 | 每个节点多 8 字节 pNext 开销 | 紧凑存储,无指针开销 |
关键判断原则:
- "按编号查"多 → 用数组(O(1) 随机访问)
- "遍历全部"多 + 数据量不可预估 → 用链表(O(1) 插入)
🔑 两关看完,你就能独立判断"什么时候用数组、什么时候用链表"——这是 C 语言最重要的数据结构选择能力。
❓ 问题二:为什么用全局数组?不能用局部变量吗?
来看反例——新手最常见的错误:
// ❌ 反例:在 main 里定义数组,存款函数拿不到
int main() {
Account accounts[100];
int accountCount = 0;
// ... 但 deposit() 函数里怎么访问 accounts??
}
void deposit() {
// accounts[idx]... ← ❌ 编译报错:accounts 未声明!
}
2
3
4
5
6
7
8
9
10
问题:accounts 和 accountCount 在 main 的栈帧里,其他函数看不见。
来看"改良"反例——用参数传递:
// ❌ 反例:通过参数传递数组(语法正确,但极不优雅)
void deposit(Account *accounts, int *pCount) {
// 每个函数都要传这两个参数
}
void withdraw(Account *accounts, int *pCount) { ... }
void transfer(Account *accounts, int *pCount) { ... }
// 8 个函数 × 2 个参数 = 16 次参数传递 → 极不优雅
2
3
4
5
6
7
✅ 正确做法(本案例):全局数组 + 全局计数器——C 语言没有类成员变量,全局变量是"所有函数共享数据"的最直接方式。
Account accounts[MAX_ACCOUNTS]; // 全局数组
int accountCount = 0; // 全局计数器
// 所有函数直接访问 — 无需传参
2
3
⚠️ 全局变量的代价:虽然方便,但任何函数都能修改它——忘记重置
accountCount = 0就会引入 bug。C++ 用private成员变量解决这个问题。
❓ 问题三:为什么用自动递增卡号而不让用户手动输入?
来看反例——"让用户自己输卡号":
// ❌ 反例:手动输入卡号
printf("请输入卡号: ");
scanf("%d", &newAcc.accountNo);
2
3
问题:
- 重复卡号:用户 A 和用户 B 都输入 1001 → 两个账户共用一个卡号
- 卡号冲突:存款/取款时
findAccount(1001)只返回第一个匹配项 - 资金错位:B 的钱可能被存到 A 的账户
✅ 正确做法:accountNo = accountCount + 1 自动递增,永远不重复。
🔑 三问连起来的领悟:"全局数组 + 自动递增卡号"不是唯一方案,而是"C 语言无 OOP + 无自动去重"环境下的最佳权衡。C++ 版会用 class Bank + private vector 解决这些问题。
# 3.1 定义 Account 结构体
typedef struct {
int accountNo; // 卡号
char name[NAME_LEN]; // 姓名
double balance; // 余额
} Account;
Account accounts[MAX_ACCOUNTS];
int accountCount = 0; // 当前账户数
2
3
4
5
6
7
8
🔑 为什么用数组不用链表? 银行账户数量通常可预估(不会无限增长),数组的 O(1) 随机访问比链表的 O(n) 遍历更适合"按卡号查询"——这和第 01 关的"链表适合动态增长"形成对比。两个案例看完,你就能独立判断"什么时候用数组、什么时候用链表"。
# 3.2 实现开户
void openAccount() {
if (accountCount >= MAX_ACCOUNTS) {
printf("账户数量已达上限!\n");
return;
}
Account newAcc;
newAcc.accountNo = accountCount + 1; // 自动分配卡号
printf("请输入姓名: ");
scanf("%49s", newAcc.name);
printf("请输入初始余额: ");
scanf("%lf", &newAcc.balance);
if (newAcc.balance < 0) {
printf("余额不能为负!\n");
return;
}
accounts[accountCount++] = newAcc;
printf(">>> 开户成功!卡号: %d\n", newAcc.accountNo);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
🧪 测试:开户 → 卡号自动 1、2、3 递增 → 手动输入卡号 0 查询 → 提示"未找到"。
┌─ 📌 阶段 ① 小结 ────────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • struct 结构体定义 + 数组存储(数组 vs 链表的选择依据) │
│ • 全局变量作为"所有函数共享的数据" │
│ • 自动递增卡号避免重复 │
│ • 余额负数的第一道防护 │
│ │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 存款/取款/转账——需要 findAccount 辅助函数 │
│ • 文件持久化 │
│ │
│ 💡 本阶段最大领悟: │
│ "结构体数组 + 全局计数器 = C 语言版的'容器'" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
# 04.存款与取款
┌─ 🎯 阶段 ② 目标(存款取款) ───────────────────────────┐
│ 完成什么:存款/取款函数 → findAccount 辅助函数自然涌现 │
│ 不做什么:不做转账——那是 §06 的事 │
│ 验收标准:开户张三 → 存 500 → 取 200 → 查询余额 300 │
│ 预计耗时:20 分钟 │
│ 关键思路:写存款时发现"需要找账户" → 抽取 findAccount │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
🔑 教学要点:注意下面代码编写的真实顺序——不是先把 findAccount 设计好再写存款,而是写存款时发现"需要找账户",自然抽取出来的。这就是 C++ 版本中强调的:"辅助函数不是事先设计的,是写第二个功能时自然冒出来的需求"。
// 按卡号查找账户索引,找不到返回 -1
int findAccount(int accountNo) {
for (int i = 0; i < accountCount; i++) {
if (accounts[i].accountNo == accountNo) return i;
}
return -1;
}
void deposit() {
int accountNo;
double amount;
printf("请输入卡号: "); scanf("%d", &accountNo);
int idx = findAccount(accountNo);
if (idx == -1) { printf("未找到该账户!\n"); return; }
printf("请输入存款金额: "); scanf("%lf", &amount);
if (amount <= 0) { printf("金额必须 >0!\n"); return; }
accounts[idx].balance += amount;
printf(">>> 存款成功!余额: %.2f\n", accounts[idx].balance);
}
void withdraw() {
int accountNo;
double amount;
printf("请输入卡号: "); scanf("%d", &accountNo);
int idx = findAccount(accountNo);
if (idx == -1) { printf("未找到该账户!\n"); return; }
printf("请输入取款金额: "); scanf("%lf", &amount);
if (amount <= 0) { printf("金额必须 >0!\n"); return; }
if (amount > accounts[idx].balance) {
printf("余额不足!当前余额: %.2f\n", accounts[idx].balance);
return;
}
accounts[idx].balance -= amount;
printf(">>> 取款成功!余额: %.2f\n", accounts[idx].balance);
}
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
┌─ 📌 Step 2.1-2.2 小结 ───────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • findAccount 辅助函数的诞生——写存款时自然冒出来的需求 │
│ • 存款/取款的边界检查(金额>0、余额足够、账户存在) │
│ • 辅助函数复用——取款和查询都直接复用 findAccount │
│ │
│ 💡 最大领悟: │
│ "findAccount 不是设计出来的——是写 deposit 时 │
│ 发现需要'根据卡号找账户',自然抽取的" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 05.查询余额
void checkBalance() {
int accountNo;
printf("请输入卡号: "); scanf("%d", &accountNo);
int idx = findAccount(accountNo);
if (idx == -1) { printf("未找到!\n"); return; }
printf("卡号: %d | 姓名: %s | 余额: %.2f\n",
accounts[idx].accountNo,
accounts[idx].name,
accounts[idx].balance);
}
2
3
4
5
6
7
8
9
10
# 06.转账功能
┌─ 🎯 阶段目标 ────────────────────────────────────────┐
│ 完成什么:转账 → 亲手看见"钱凭空消失" → 修复为事务一致 │
│ 不做什么:不做并发保护、不做回滚——那是真实银行系统的事 │
│ 验收标准:A→B 转账成功两面都变 / 余额不足两面都不动 │
│ 预计耗时:25 分钟 │
│ 关键思路:先演示 BUG 版 → 让你亲眼看见"不该发生的事" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
┌─ ⚠️ 造BUG高峰 ────────────────────────────────────┐
│ 先写一个不带事务保护的转账 → 让你看到"钱凭空消失" │
│ 然后加事务修复 → 理解"一致性"的重要性 │
│ │
│ 🎓 教学价值:这是本关区别于"功能清单式教程"的核心—— │
│ 不是告诉你"要加检查",而是让你亲眼看见不加的后果 │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
# 6.1 错误版(先亲眼看出问题)
❓ 灵魂一问:如果转账是"先扣出去 + 再加进来"两步操作,中间某一步失败了会怎样?
来看反例——最自然的写法(也是错误的):
// ❌ 反例:不带事务保护的转账
void transfer_BUG() {
int fromNo, toNo;
double amount;
printf("转出卡号: "); scanf("%d", &fromNo);
printf("转入卡号: "); scanf("%d", &toNo);
if (fromNo == toNo) { printf("不能转给自己!\n"); return; }
int fromIdx = findAccount(fromNo);
int toIdx = findAccount(toNo);
if (fromIdx == -1 || toIdx == -1) {
printf("账户不存在!\n"); return;
}
printf("转账金额: "); scanf("%lf", &amount);
if (amount <= 0) { printf("金额必须 >0!\n"); return; }
// 第 1 步:扣 A 的钱 ← 这一步一定会执行
accounts[fromIdx].balance -= amount;
// ⚠️ 如果转账金额 2000 > 余额 1000,A 余额变成 -1000!
// 但程序已经"扣了钱",没法反悔——这才是真正的 bug
// 第 2 步:加 B 的钱 ← 这一步也会执行
accounts[toIdx].balance += amount;
// B 的余额从 500 变成了 2500
// 总结:系统凭空多出了 1500 元(A 的 -1000 + B 的 +2500)
printf(">>> 转账成功!\n");
}
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
🧪 复现资金丢失(亲手跑一遍!):
# 准备数据:开户 A(张三, 卡号1, 余额1000),开户 B(李四, 卡号2, 余额500)
# 运行程序,选 5 转账
转出卡号: 1
转入卡号: 2
转账金额: 2000 ← 转账 2000 元,但 A 只有 1000!
>>> 转账成功! ← ⚠️ 程序还高兴地说"成功"了
# 选 4 查询两个账户:
卡号: 1 | 姓名: 张三 | 余额: -1000.00 ← ❌ A 透支了!金额没检查
卡号: 2 | 姓名: 李四 | 余额: 2500.00 ← ❌ B 凭空多了 1500 元
2
3
4
5
6
7
8
9
10
11
12
钱哪来的? A 透支 -1000 + 系统多出 1500 = 凭空创造了 500 元。这就是没有事务保护的后果——转账的"扣钱"和"加钱"两步不是原子的。
┌─ 💡 停下来想一想 ──────────────────────────────────┐
│ │
│ 如果这是真实的银行系统: │
│ - 扣了用户的钱但对方没收到 → 用户投诉 │
│ - 两个账户余额对不上 → 年度审计发现 │
│ - 乘以 1000 次/天的转账量 → 资金缺口数万元 │
│ │
│ 这就是为什么所有银行系统必须保证"事务一致性" │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 6.2 修复版(事务保护)
❓ 灵魂二问:怎么修?C 语言没有 try-catch,怎么保证"两边同时成功或同时失败"?
答:全部校验放前面,修改操作放后面。把可能导致"扣了钱回不了头"的校验提到 balance -= amount 之前。
void transfer() {
int fromNo, toNo;
double amount;
printf("转出卡号: "); scanf("%d", &fromNo);
printf("转入卡号: "); scanf("%d", &toNo);
if (fromNo == toNo) { printf("不能转给自己!\n"); return; }
int fromIdx = findAccount(fromNo);
int toIdx = findAccount(toNo);
if (fromIdx == -1 || toIdx == -1) {
printf("账户不存在!\n"); return;
}
printf("转账金额: "); scanf("%lf", &amount);
if (amount <= 0) { printf("金额必须 >0!\n"); return; }
// ⭐ 关键修复:先验证余额 — 验证不通过就"核爆",什么都不动
if (amount > accounts[fromIdx].balance) {
printf("余额不足!当前余额: %.2f\n", accounts[fromIdx].balance);
return; // 验证不通过 → 双方都不动,资金原封不动
}
// 验证通过后,两行修改几乎同时执行(单线程下是原子的)
accounts[fromIdx].balance -= amount;
accounts[toIdx].balance += amount;
printf(">>> 转账成功!\n");
printf("A(%s)余额: %.2f → B(%s)余额: %.2f\n",
accounts[fromIdx].name, accounts[fromIdx].balance,
accounts[toIdx].name, accounts[toIdx].balance);
}
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
🔑 核心原则:先全部校验 → 再全部修改。这是事务的雏形——C 语言没有 try-catch 和数据库事务,全靠"前置校验"保证一致性。
原理图(为什么这样是安全的):
修复前(有 bug): | 修复后(事务安全):
|
1. accounts[from].balance -= amount ← 不可逆 | 1. if (amount > balance) return; ← 可以 abort
2. accounts[to].balance += amount | 2. 校验通过 → 进入"修改区"
| 3. accounts[from].balance -= amount;
如果步骤 2 失败,步骤 1 已经生效 | 4. accounts[to].balance += amount;
结果:数据不一致 | 修改区内的操作是连续的,中间没有"可能失败"的点
2
3
4
5
6
7
🧪 修复后测试:
转出卡号: 1
转入卡号: 2
转账金额: 2000
余额不足!当前余额: 1000.00
# 查询验证:双方余额不变 ← ✅
卡号: 1 | 姓名: 张三 | 余额: 1000.00
卡号: 2 | 姓名: 李四 | 余额: 500.00
2
3
4
5
6
7
8
⚠️ 真实银行系统的转账还要考虑:分布式事务(跨行清算)、网络抖动重试、防双重提交、审计日志……这些属于分布式系统设计范畴。本案例的目标是让你亲手看见 bug → 亲手修复,建立"一致性"的肌肉记忆。
┌─ 📌 阶段 ② 小结 ────────────────────────────────────────┐
│ ✅ 你刚刚完成了银行系统的核心业务层: │
│ • 存款/取款:findAccount 辅助函数诞生 + 边界检查 │
│ • 查询余额:一行搞定(复用 findAccount) │
│ • 转账:亲手看见 bug(钱凭空多出)→ 亲手修复(前置校验) │
│ │
│ 🔑 亲眼看见的关键现象: │
│ • 转账 2000 元超过余额 → bug 版:A:-1000 B:2500 │
│ • 钱凭空创造出 500 元——这就是"没有事务"的后果 │
│ • 修复后:前置校验余额不足 → 双方都不变 → 安全 │
│ │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 文件持久化——让数据在程序退出后还能活 │
│ │
│ 💡 本阶段最大领悟: │
│ "C 语言的事务 = 先全部校验再全部修改——前置 if-else 是 │
│ 你唯一的武器。C++ 有 try-catch,C 只有脑子" │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 07.文件持久化
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────────┐
│ 完成什么:保存/加载到文本文件 + 启动时自动恢复 │
│ 不做什么:不做二进制格式、不做加密、不做压缩 │
│ 验收标准:加账户 → 保存 → 退出 → 重新运行 → 数据还在 │
│ 预计耗时:20 分钟 │
│ 关键思路:fprintf 格式化写入 + fscanf 按格式回读 │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
🔑 为什么用文本格式而不是二进制?
| 格式 | 可读性 | git diff | 跨平台 | 数据修复 | 本案例选择 |
|---|---|---|---|---|---|
| 文本(fprintf) | ✅ 可读 | ✅ 可 diff | ✅ 通用 | ✅ 可手动编辑 | 本案例 |
| 二进制(fwrite) | ❌ 乱码 | ❌ 不可 diff | ⚠️ 端序问题 | ❌ 需 hex 编辑器 | 第 01 关 |
银行账户数据是人能读懂的数字和姓名——文本格式更实用。
# 7.1 文本格式保存
void saveToFile() {
FILE *file = fopen(FILENAME, "w");
if (!file) { printf("无法打开文件!\n"); return; }
for (int i = 0; i < accountCount; i++) {
fprintf(file, "%d %s %.2f\n",
accounts[i].accountNo,
accounts[i].name,
accounts[i].balance);
}
fclose(file);
printf(">>> 数据保存成功!\n");
}
2
3
4
5
6
7
8
9
10
11
12
# 7.2 启动加载
void loadFromFile() {
FILE *file = fopen(FILENAME, "r");
if (!file) {
printf("未找到数据文件,将创建新账户系统。\n");
return;
}
accountCount = 0; // 清空
while (accountCount < MAX_ACCOUNTS &&
fscanf(file, "%d %s %lf",
&accounts[accountCount].accountNo,
accounts[accountCount].name,
&accounts[accountCount].balance) == 3) {
accountCount++;
}
fclose(file);
printf(">>> 已加载 %d 个账户\n", accountCount);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
🧪 测试保存/加载(必须亲手跑):
# 第一次运行
$ ./bank_system
未找到数据文件,将创建新账户系统。 ← 启动加载:文件不存在
请选择: 1
请输入姓名: 张三
请输入初始余额: 1000
>>> 开户成功!卡号: 1
请选择: 2
请输入卡号: 1
请输入存款金额: 500
>>> 存款成功!余额: 1500.00
请选择: 6
>>> 数据保存成功!
请选择: 7
再见!
# 检查文件内容:
$ cat bank_accounts.txt
1 张三 1500.00 ← 文本格式,人类可读
# 第二次运行
$ ./bank_system
>>> 已加载 1 个账户 ← ✅ 启动时自动加载
请选择: 4
请输入卡号: 1
卡号: 1 | 姓名: 张三 | 余额: 1500.00 ← ✅ 数据恢复成功
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
✅ "关闭程序再打开数据还在"——这是你的第二个持久化程序(第一个是第 01 关的 stuinfo.dat)。
# 08.总结与C++对比
# 8.1 本关掌握的核心能力
| 能力 | C 语言体现 | 掌握标志 |
|---|---|---|
| 数据结构选择 | 数组 vs 链表判断 | 能独立决策"按卡号查用数组、动态增长用链表" |
| 事务一致性 | 转账的前置校验 | 能解释为什么"先全部校验 → 再全部修改" |
| 文本持久化 | fprintf/fscanf | 能读取自己保存的 bank_accounts.txt |
| 辅助函数抽取 | findAccount 自然涌现 | 能识别"这段逻辑后面会被复用"的时刻 |
| 边界保护 | 数组越界 / 余额负数检查 | 每个操作都有 if-else 防御 |
# 8.2 架构对比
| 维度 | C 语言实现 | C++ 实现 |
|---|---|---|
| 数据存储 | 全局数组 accounts[100] | std::vector<Account*> — 动态扩容 |
| 查找 | 线性遍历 O(n) | 可换 unordered_map O(1) |
| 扩容 | 固定 100,满了就拒绝 | push_back 自动扩容 |
| 余额检查 | 手动 if-else 写 8 次 | 成员函数封装,一次定义 8 处复用 |
| 转账一致性 | 前置校验(手动 ensure) | try-catch + RAII 回滚 |
| 账户类型 | 一种类型走天下 | 抽象基类 + 三个派生类(多态) |
| 文件 IO | fprintf/fscanf 文本 | ofstream/ifstream 类型安全 |
| 内存安全 | 数组越界 → segfault | vector::at() 抛异常 |
| 代码量 | ~150 行 | ~850 行(功能更多) |
# 8.3 代码对比:转账功能
C 语言版(本案例):
void transfer() {
// ... 输入卡号、金额 ...
int fromIdx = findAccount(fromNo); // 手动查索引
int toIdx = findAccount(toNo);
if (amount > accounts[fromIdx].balance) { // 手动校验
printf("余额不足!\n"); return;
}
accounts[fromIdx].balance -= amount;
accounts[toIdx].balance += amount;
printf(">>> 转账成功!\n");
}
2
3
4
5
6
7
8
9
10
11
C++ 等效版:
bool Bank::transfer(const string& fromId, const string& toId, double amount) {
Account* from = findAccount(fromId); // 封装在 Bank 类内部
Account* to = findAccount(toId);
if (!from || !to) return false;
if (!from->withdraw(amount)) return false; // 子类各自校验
to->deposit(amount); // VIP 有利息
return true;
}
2
3
4
5
6
7
8
C++ 版中:
from->withdraw(amount)这一行——普通账户拒绝透支、VIP 允许透支、储蓄未到期扣违约金——三种不同的校验逻辑在同一行代码中完成。这就是多态。
# 8.4 延伸挑战
完成主流程后,尝试把下列四项自己加上:
挑战 1(基础)· 加利息计算
遍历所有账户,余额 *= 1.03(年化 3%):
void calculateInterest() {
for (int i = 0; i < accountCount; i++) {
accounts[i].balance *= 1.03;
}
printf(">>> 已为 %d 个账户计息\n", accountCount);
}
2
3
4
5
6
在菜单加第 7 项"计息",并把原来的 7(退出)挪到 8。
挑战 2(进阶)· 加交易日志
每次存款/取款/转账写入日志文件带时间戳:
#include <time.h>
void writeLog(const char *operation, int accountNo, double amount) {
FILE *log = fopen("bank_log.txt", "a");
time_t now = time(NULL);
fprintf(log, "%s", ctime(&now)); // 时间戳
fprintf(log, "%s: 卡号%d 金额%.2f\n", operation, accountNo, amount);
fprintf(log, "---\n");
fclose(log);
}
// 在存款函数里加一行
void deposit() {
// ... 原有逻辑 ...
writeLog("存款", accountNo, amount);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
完成后退出程序,cat bank_log.txt 看到带时间的操作记录。
挑战 3(进阶)· 数组改链表 — 体验复杂度变化
把你的数组版改成链表版(双向链表)。重点对比:
| 操作 | 数组版 | 链表版 |
|---|---|---|
| 开户 | accounts[count++] = newAcc; O(1) | malloc + 尾插 O(1) |
| 按卡号查找 | accounts[i].accountNo O(n) | 遍历链表 O(n) |
| 转账 | 下标直接访问 O(1) | 两次遍历 O(n+n) |
结论:银行系统的"按卡号查"是高频操作,链表版的查找成本显著更高——这就是第 01 关用链表、本关用数组的根因。
挑战 4(终极)· 移植到 C++ — 丈量两种语言的差距
把你写的 C 版本逐模块改写成 C++,对比代码行数变化:
// C++ 版 Account(对比 C 版的 typedef struct)
class Account {
private:
int accountNo;
string name;
double balance;
public:
Account(int no, const string& n, double bal);
void deposit(double amount);
bool withdraw(double amount);
void showInfo() const;
// getter/setter...
};
// C++ 版 Bank(对比 C 版的全局数组)
class Bank {
private:
vector<Account> accounts; // 一行替代 accounts[100] + accountCount
public:
void openAccount();
void deposit();
// ...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
🎯 挑战 4 的价值:亲手走一遍 C → C++ 的迁移,你会精准理解:
vector<Account>一行替代了accounts[100]+accountCount+ 边界检查三件事private杜绝了"忘记重置 accountCount"的 bugstring替代char[50]不再担心溢出