编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

      • README
      • 学生管理通讯录系统
      • 银行账户管理系统
        • 渐进学习节奏
        • 案例元信息
        • 目录快速导航
        • 01.系统需求
          • 1.1 功能要求
          • 1.2 C语言技术栈
        • 02.菜单骨架
        • 03.开户功能
          • 3.0 灵魂三问
          • 3.1 定义 Account 结构体
          • 3.2 实现开户
        • 04.存款与取款
        • 05.查询余额
        • 06.转账功能
          • 6.1 错误版(先亲眼看出问题)
          • 6.2 修复版(事务保护)
        • 07.文件持久化
          • 7.1 文本格式保存
          • 7.2 启动加载
        • 08.总结与C++对比
          • 8.1 本关掌握的核心能力
          • 8.2 架构对比
          • 8.3 代码对比:转账功能
          • 8.4 延伸挑战
      • 校园身份预约系统
      • Json与内存数据库
      • 订单票务购买系统
      • 迷你KV存储引擎器
      • 迷你编译器解释器
    • 专栏博客

    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 综合案例
杨充
2026-06-08
目录

银行账户管理系统

# 第二章: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 → 启动时自动恢复
1
2
3
4
5
6
7
8
9
10
11
12

🎯 每个 Step 必须做的三件事:

  1. 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
  2. 写一小段代码就编译运行一次(看到 🧪 标志立刻动手)
  3. 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)

⚠️ C语言新手最容易犯的四个错:

  1. scanf 后忘记 getchar() 消费换行符
  2. 数组操作忘了检查索引边界 — accounts[100] 访问越界
  3. fscanf 返回值不检查 — 格式错误静默失败
  4. 全局变量 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. 退出
1
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;
        }
    }
}
1
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++           │
└──────────────────────────────────────────────────────────┘
1
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 未声明!
}
1
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 次参数传递 → 极不优雅
1
2
3
4
5
6
7

✅ 正确做法(本案例):全局数组 + 全局计数器——C 语言没有类成员变量,全局变量是"所有函数共享数据"的最直接方式。

Account accounts[MAX_ACCOUNTS];  // 全局数组
int     accountCount = 0;         // 全局计数器
// 所有函数直接访问 — 无需传参
1
2
3

⚠️ 全局变量的代价:虽然方便,但任何函数都能修改它——忘记重置 accountCount = 0 就会引入 bug。C++ 用 private 成员变量解决这个问题。

❓ 问题三:为什么用自动递增卡号而不让用户手动输入?

来看反例——"让用户自己输卡号":

// ❌ 反例:手动输入卡号
printf("请输入卡号: ");
scanf("%d", &newAcc.accountNo);
1
2
3

问题:

  1. 重复卡号:用户 A 和用户 B 都输入 1001 → 两个账户共用一个卡号
  2. 卡号冲突:存款/取款时 findAccount(1001) 只返回第一个匹配项
  3. 资金错位: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;   // 当前账户数
1
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);
}
1
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 语言版的'容器'"                 │
└──────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 04.存款与取款

┌─ 🎯 阶段 ② 目标(存款取款) ───────────────────────────┐
│ 完成什么:存款/取款函数 → findAccount 辅助函数自然涌现      │
│ 不做什么:不做转账——那是 §06 的事                          │
│ 验收标准:开户张三 → 存 500 → 取 200 → 查询余额 300       │
│ 预计耗时:20 分钟                                           │
│ 关键思路:写存款时发现"需要找账户" → 抽取 findAccount      │
└─────────────────────────────────────────────────────────┘
1
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);
}
1
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 时                 │
│    发现需要'根据卡号找账户',自然抽取的"                      │
└──────────────────────────────────────────────────────┘
1
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);
}
1
2
3
4
5
6
7
8
9
10

# 06.转账功能

┌─ 🎯 阶段目标 ────────────────────────────────────────┐
│ 完成什么:转账 → 亲手看见"钱凭空消失" → 修复为事务一致     │
│ 不做什么:不做并发保护、不做回滚——那是真实银行系统的事     │
│ 验收标准:A→B 转账成功两面都变 / 余额不足两面都不动        │
│ 预计耗时:25 分钟                                          │
│ 关键思路:先演示 BUG 版 → 让你亲眼看见"不该发生的事"     │
└──────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
┌─ ⚠️ 造BUG高峰 ────────────────────────────────────┐
│ 先写一个不带事务保护的转账 → 让你看到"钱凭空消失"       │
│ 然后加事务修复 → 理解"一致性"的重要性                  │
│                                                     │
│ 🎓 教学价值:这是本关区别于"功能清单式教程"的核心——     │
│ 不是告诉你"要加检查",而是让你亲眼看见不加的后果       │
└──────────────────────────────────────────────────────┘
1
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");
}
1
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 元
1
2
3
4
5
6
7
8
9
10
11
12

钱哪来的? A 透支 -1000 + 系统多出 1500 = 凭空创造了 500 元。这就是没有事务保护的后果——转账的"扣钱"和"加钱"两步不是原子的。

┌─ 💡 停下来想一想 ──────────────────────────────────┐
│                                                      │
│  如果这是真实的银行系统:                              │
│  - 扣了用户的钱但对方没收到 → 用户投诉                 │
│  - 两个账户余额对不上 → 年度审计发现  			│
│  - 乘以 1000 次/天的转账量 → 资金缺口数万元           │
│                                                      │
│  这就是为什么所有银行系统必须保证"事务一致性"           │
└──────────────────────────────────────────────────────┘
1
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);
}
1
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;
结果:数据不一致			    |  修改区内的操作是连续的,中间没有"可能失败"的点
1
2
3
4
5
6
7

🧪 修复后测试:

转出卡号: 1
转入卡号: 2
转账金额: 2000
余额不足!当前余额: 1000.00

# 查询验证:双方余额不变 ← ✅
卡号: 1 | 姓名: 张三 | 余额: 1000.00
卡号: 2 | 姓名: 李四 | 余额: 500.00
1
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 只有脑子"                │
└──────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 07.文件持久化

┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────────┐
│ 完成什么:保存/加载到文本文件 + 启动时自动恢复               │
│ 不做什么:不做二进制格式、不做加密、不做压缩                  │
│ 验收标准:加账户 → 保存 → 退出 → 重新运行 → 数据还在       │
│ 预计耗时:20 分钟                                           │
│ 关键思路:fprintf 格式化写入 + fscanf 按格式回读           │
└──────────────────────────────────────────────────────────┘
1
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");
}
1
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);
}
1
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                     ← ✅ 数据恢复成功
1
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");
}
1
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;
}
1
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);
}
1
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);
}
1
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();
    // ...
};
1
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"的 bug
  • string 替代 char[50] 不再担心溢出
上次更新: 2026/06/10, 11:13:41
学生管理通讯录系统
校园身份预约系统

← 学生管理通讯录系统 校园身份预约系统→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式