C++模拟ATM取款机:从类设计到文件持久化的完整实现
简介面向C初学者与软件工程课程实践这份ATM模拟取款机源码包以单个docx文档呈现容量仅23KB。文档内含完整源代码与需求分析说明围绕ATM与consumer两个类搭建核心框架consumer封装姓名、卡号、余额和私有密码ATM通过友元关系访问用户数据并实现欢迎界面、卡号密码双重验证、连续输错三次锁机、密码修改、取款金额合法性校验与余额扣减、余额查询、退出系统等功能菜单清晰展示了类封装、友元类、字符数组处理和基本业务流程控制。对于希望理解面向对象设计中类协作与简单金融系统逻辑的读者这份紧凑的文档提供了可运行参考模型也能作为课程设计或自学C类设计的入门素材。代码模块划分明确适合进一步扩展每日取款限额、交易记录等功能。目前已有272人学习浏览。1. C模拟ATM取款为什么说它比看起来难一倍很多人都觉得 C模拟ATM取款源代码 这类项目就是打印一个菜单、再用 cin 读几个选项的小玩具。直到自己动手才发现真正的复杂度全在看不到的地方密码输错三次之后程序该往哪走余额不足时是先提示还是直接中止文件保存到一半程序崩了下次启动还能不能读回来这些状态细节一旦没管住代码就会变成一堆 if 嵌套在硬撑。这个标题指向的是一个非常经典的面向对象练手项目用 C 实现插卡、验密、查询、取款、存款、转账、修改密码并把数据落盘到文件。它不需要图形界面也不需要网络库却能一次性练到类设计、输入校验、异常分支、文件流这几块硬功夫。这个方向适合课程设计选题的学生也适合刚学完类和文件流、想用一个完整项目把知识点串起来的入门者。我按自己重写这套代码的顺序往下拆先定数据模型再谈主流程最后把持久化和避坑彻底讲透。2. 先定数据模型再说功能卡号、密码与余额的类型选择写这类项目最大的坑就是一上来先写菜单。我一般会先花十几分钟把数据模型定下来后续登录、取款、存款全部是在这个模型上做操作。常见做法是定义一个 Account 类而不是把卡号、密码、余额拆成三个全局变量来回传——拆成全局变量的版本我也见过功能能跑但每加一个功能就要改多个函数签名改着改着连哪个函数操作哪个账户都分不清了。2.1 Account 类为什么用类而不是三个数组先看头文件这是整个项目的地基// Account.h #ifndef ACCOUNT_H #define ACCOUNT_H #include string class Account { public: Account(const std::string id, const std::string pwd, double bal); std::string getCardId() const; double getBalance() const; std::string toStorageString() const; // 序列化成一行文本 bool verifyPassword(const std::string pwd) const; bool withdraw(double amount); // 取款成功返回 true void deposit(double amount); // 存款负数直接忽略 bool transferTo(Account receiver, double amount); // 转账 void updatePassword(const std::string newPwd); static Account fromStorageString(const std::string line); private: std::string cardId_; std::string password_; double balance_; }; #endif这段头文件里有三个点值得展开。第一verifyPassword 声明成 const因为校验密码不该修改账户的任何字段如果哪天你发现一个 const 方法里想改 balance_那说明设计反了。第二withdraw 返回 bool 而不是 void目的是把“能不能取”的判断结果交还给调用方由菜单层决定打印“余额不足”还是“取款成功”业务类不直接碰 cout这样后面想接图形界面也不会被输出逻辑卡死。第三toStorageString 和 fromStorageString 是专门给文件持久化用的字段之间用 | 分隔因为卡号和密码里几乎不会出现这个字符。类型选择上也有讲究。cardId_ 必须用 string卡号是 6222 开头的长串还可能以 0 开头int 一存就会丢掉前导 0password_ 同理用户密码可能是 001234用 int 会直接改变语义。balance_ 我建议用 double 而不是 floatfloat 只有大约 7 位有效十进制数字余额上万再带两位小数就开始出现舍入误差double 在模拟场景下足够稳定。真实银行系统不会用 double 记账一般按“分”存整数但那是精度和算力都敏感的领域模拟项目用 double 是最直观的。2.2 登录校验把次数限制写在使用方而不是类内部有了 Account接下来处理登录。我的习惯是让 Account 只负责单次密码校验把“允许试几次、失败后怎么办”放在登录函数里。这样 Account 保持通用以后换一个业务场景还能复用不会被 ATM 的三次限制绑死。// login.cpp 片段 bool loginWithRetry(Account acc, int maxTry) { for (int i 0; i maxTry; i) { std::string inputPwd; std::cout 请输入密码剩余 maxTry - i 次机会: ; std::cin inputPwd; if (acc.verifyPassword(inputPwd)) { return true; } std::cout 密码错误 std::endl; } return false; }这个函数逻辑很简单但有几点值得解释。为什么用 for 而不用 while因为尝试次数是固定上限值for 的计数结构更直观也不会出现忘了递增导致死循环的情况。maxTry 作为参数传进来默认 3 次还是 5 次由调用方决定后续想改成“连续输错 3 次就锁定账户”也只需要在这个函数里扩展。唯一要注意的是 cin 读字符串时如果遇到流错误比如用户在终端按了 CtrlDinputPwd 会是空串循环会一直在原地打转这个问题我会在第 5 章展开。登录成功后返回 true调用方才能拿当前账户继续后面的操作。2.3 多卡组织用 vector 保存全部账户按卡号查找单张卡解决了整套系统还需要管理多张卡。常见做法是开一个 vector 启动时从文件加载退出时整体写回。查找方式通常是线性的ATM 模拟的卡数量不会上万顺序查找完全够用没必要过早优化成二分。// find_account.cpp 片段 #include vector Account* findAccount(std::vectorAccount accounts, const std::string cardId) { for (auto acc : accounts) { if (acc.getCardId() cardId) { return acc; } } return nullptr; }返回裸指针而不是迭代器是为了让调用方少写 begin/end 那套判断拿到 nullptr 就知道卡号不存在省得再去解引用。这里有一个需要熟手注意的细节返回的指针指向 vector 内部的元素如果在查找之后又往 vector 里 push_back旧指针很可能失效因为 vector 扩容会重新分配内存。所以正确的用法是所有插入动作都放在查找之前完成查找期间不要往容器里加元素。模拟项目的流程是登录前已加载完所有卡登录后不再新增所以裸指针方案是安全的。编译时把源文件一起编就行g main.cpp Account.cpp login.cpp -o atm_sim到这里数据模型就立住了。接下来主流程的每个分支都是在 Account 这几个方法之上做简单的逻辑判断不会再出现全局变量满天飞的情况。这也是我建议初学者先画类图、再写菜单的原因。3. 主流程与核心功能把菜单循环写成看得懂的状态机数据模型落定之后迎来的第一个复杂点就是主菜单循环。这个循环看似简单写砸的概率却不低选择菜单时要处理非数字输入处理完一个功能后要正确回到菜单退出时要保证数据已经保存。我见过不少版本菜单里套着另一个菜单一进去就出不来了。我这里按状态机的思路来写主循环只负责“展示菜单、读选项、分派、回到菜单”具体业务全部放在 switch 分支里每个分支对应一个独立函数。3.1 主循环骨架cin 失败时先自救再重试菜单循环是整个程序的中枢我给一个可以直接跑起来的骨架// main.cpp 主循环骨架 bool running true; while (running) { std::cout \n1.查询余额 2.取款 3.存款 4.转账 5.改密 6.退出\n; std::cout 请选择: ; int choice 0; std::cin choice; if (std::cin.fail()) { // 用户输入了非数字流进入失败态必须先清除再继续 std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 输入无效请重新选择 std::endl; continue; } switch (choice) { case 1: std::cout 当前余额: cur-getBalance() std::endl; break; case 6: running false; break; default: std::cout 没有这个选项 std::endl; } }fail() 判断是这段代码的关键。用户输入“abc”时cin 会进入失败态之后所有读操作都会直接失败不处理就会出现“输入一次 abc 之后程序像死了一样一直跳过输入”。必须先 clear() 清掉失败标志再用 ignore() 把缓冲区剩下的字符全部消费掉最后 continue 直接开始下一轮菜单避免拿着非法 choice 继续往下走。这里还要提一个习惯每次成功读取数字后最好也紧跟一个 ignore把残留的换行符清掉否则下一次读字符串时可能读到空串。这个问题第 5 章会专门展开。完整代码里 case 2 到 case 5 分别调用取款、存款、转账、改密的处理函数我下一节拆开讲。3.2 取款与存款校验顺序决定用户体验取款逻辑放到一个独立函数里主循环只负责调用它这样每个功能的输入输出都收在自己函数内菜单不会越写越长。// handle_withdraw.cpp 片段 void handleWithdraw(Account* cur) { std::cout 请输入取款金额: ; double amount 0; std::cin amount; if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 金额格式错误 std::endl; return; } if (amount 0) { std::cout 金额必须大于 0 std::endl; return; } if (amount kMaxWithdraw) { // kMaxWithdraw 可定义为 5000.0 std::cout 超过单笔取款上限 std::endl; return; } if (cur-withdraw(amount)) { std::cout 取款成功请取走现金 std::endl; } else { std::cout 余额不足 std::endl; } }三个校验的顺序是故意的先拦负数和零再拦超限最后才查余额。原因很简单前两项不依赖账户状态可以先判断余额不足的判断要进入 withdraw 内部逻辑放在最后避免前面金额格式错误时白查一次。cur-withdraw(amount) 返回 bool 的取值就体现在这里true 表示余额足够且扣款成功false 表示余额不够调用方根据返回值输出不同提示。金额读取同样要做 cin.fail() 检查否则用户输入“abc”时amount 会保留初值 0后面的校验顺序会被直接打乱。对应的类实现// Account.cpp 取款与存款 bool Account::withdraw(double amount) { if (amount 0 || amount balance_) { return false; } balance_ - amount; return true; } void Account::deposit(double amount) { if (amount 0) { balance_ amount; } }存款为什么是 void 而不是 bool因为存款只要金额合法就一定能成功没有余额不足这种分支void 更诚实。如果把所有函数都写成 bool反而会制造一堆永远不会为 false 的返回值调用方也不好区分哪些才是真正的失败分支。存款的调用方还需要在存款后把新余额打印给用户并触发文件保存这些属于外层职责不放进 deposit 里。3.3 转账与改密别忘了余额校验和输入确认转账是取款的延伸区别在于要指定收款账户还要防止给自己转账。一个典型实现bool Account::transferTo(Account receiver, double amount) { if (receiver this) { return false; // 没意义同卡互转 } if (amount 0 || amount balance_) { return false; } balance_ - amount; receiver.balance_ amount; // 同一类可访问私有字段 return true; }这里有个 C 新手常卡的点receiver.balance_ 是私有字段在类外不能直接访问但 transferTo 是 Account 的成员函数C 允许同类对象之间互相访问私有字段所以这一行能编译通过。如果把这个函数改成普通函数就会立刻报错。另一个细节是 receiver this 的判断它比较的是地址用来拦截自己转自己。转账前先检查余额转成功后才减法这两步必须在一个函数内完成否则拆成两步会出现“扣款成功、加款失败”的中间状态。改密则简单一些类只提供更新方法业务层负责确认流程void Account::updatePassword(const std::string newPwd) { password_ newPwd; }改密的重点是外层处理逻辑让用户输入一次新密码再输入一次确认密码两次一致才调用 updatePassword避免用户盲打的密码变成一串自己也记不住的字符。确认密码这一步虽然多写几行但能挡住绝大多数低级失误属于性价比很高的防御逻辑。4. 把账目落到文件读写方案与数据一致性的三个细节这套模拟程序如果只活在内存里程序一退出余额就全没了那它就只能算一个演示壳子。所以要加持久化。常见做法是把账户序列化到纯文本文件格式自己定一行一张卡。为什么不用数据库模拟项目的核心诉求是展示业务逻辑引入 MySQL 会让环境配置变成门槛文本文件还能直接用记事本查看方便手动改数据做测试。4.1 序列化格式一行一张卡用 | 分隔字段以“卡号|密码|余额”作为存储行。这里要刻意避开逗号和空格当分隔符因为余额打印出来可能带小数点密码里也可能出现空格。| 在正常人设置的密码里几乎不会出现是最省事的选择。读的时候按 | 拆分每次变更后调用 saveAccounts 整体覆盖写回。// file_storage.cpp 片段 std::string Account::toStorageString() const { return cardId_ | password_ | std::to_string(balance_); } Account Account::fromStorageString(const std::string line) { size_t first line.find(|); size_t second line.find(|, first 1); std::string id line.substr(0, first); std::string pwd line.substr(first 1, second - first - 1); std::string bal line.substr(second 1); return Account(id, pwd, std::stod(bal)); }逻辑说明先找第一个 |再以 first 1 为起点找第二个 |三个字段分别落在三段时间里。std::stod 负责把字符串余额转成 double。这里有三个细节一是第二个 | 的查找起点必须是 first 1写错的话会在余额为负数时把负号吞掉二是如果文件行没有第二个 |substr 会越界所以 fromStorageString 在上层调用前最好先做一次格式校验三是 std::stod 遇到 abc 这种非数字字符串会抛异常稳妥的写法是先检查 bal 字段的每个字符都是数字或小数点。课程设计里可以考虑更稳健的做法先把行按 | 拆成 vector 检查 size 等于 3 再构造 Account。4.2 读取与写出启动加载、操作后落盘还是退出时落盘启动时读文件是常规操作但有一个很多人踩过的点文件不存在时不要报错崩溃而是返回一个空的账户列表并打印提示。默认测试数据可以在第一次保存时生成。// file_storage.cpp 加载侧 std::vectorAccount loadAccounts(const std::string filename) { std::vectorAccount result; std::ifstream in(filename); if (!in.is_open()) { std::cerr 数据文件不存在将创建新账户库 std::endl; return result; } std::string line; while (std::getline(in, line)) { if (line.empty()) { continue; // 跳过空行 } if (line.back() \r) { // 兼容 Windows 行尾 line.pop_back(); } result.push_back(Account::fromStorageString(line)); } return result; }保存侧我一般用每次修改后立即写回的策略。ATM 模拟的操作频率低文件很小写一次最多几毫秒但换来了“任何一步操作都即时落盘”即使程序中途崩溃也不会丢最近一笔。退出时再统一保存虽然也能用但一旦业务函数里出现 return 提前退出很容易漏掉保存。我的习惯是取款、存款、转账、改密四个分支的成功路径末尾都调一次 saveAccounts性能完全不是问题。提示如果你用 std::ios::app 追加写交易流水账户主文件仍然要用覆盖写注意不要把两种模式混用。主文件用 std::ios::app 会导致旧记录一直累积下次加载出现同一张卡多份数据这是最容易出隐蔽问题的地方。4.3 数据一致性把“操作成功”和“保存成功”分开看一个需要理清的认知内存扣款成功不等于磁盘保存成功。saveAccounts 返回 false 时内存余额已经变了磁盘还是旧值下次启动就会对不上账。稳妥做法是写回失败时回滚内存数据或者在保存失败时把错误打印出来并暂停后续操作。模拟项目里最简单的回滚方案是操作前记录旧值保存失败就恢复回去double oldBalance cur-getBalance(); if (cur-withdraw(amount)) { if (!saveAccounts(accounts, accounts.txt)) { cur-deposit(amount); // 简易回滚 std::cout 保存失败本次取款已取消 std::endl; } }这个回滚很简陋但足够让程序在“磁盘满”“文件被占用”这些异常场景下不至于让内存和磁盘对不上账。能把失败路径讲清楚的人不多答辩时主动提这一句往往比堆功能更容易拿分。如果你觉得回滚逻辑散落在各个分支里太乱可以统一封装成一个“取款并保存”的服务函数把扣款、落盘、回滚三步收敛到一个文件里。5. 避坑复盘ATM模拟最常见的五个翻车点这一章我直接按“现象 → 原因 → 解决”来写每一条都是我自己重写这个项目时踩过或帮别人排查过的。5.1 密码总是校验失败打印出来发现尾部多了 \r现象从文件读回的密码用 verifyPassword 校验永远失败但是在代码里写死一个常量密码却能通过。原因Windows 下文本行尾是 \r\nstd::getline 只消费 \n\r 会留在字符串末尾fromStorageString 拆出来的密码字段尾带 \r自然和用户输入不一致。Linux 下没有这个问题于是同一套代码在两种环境下表现不同这是典型的环境差异导致的行为不一致。解决读取每一行后做一次行尾清理或者在拆分后用 trim 函数统一去空白。我在 loadAccounts 里特意做了 line.pop_back()就是为了先解决掉这个最常见的问题。如果你把数据文件从 Windows 拷到 Linux 跑这个兼容处理也不会带来副作用。5.2 cin choice 之后菜单直接跳过现象输入完一个选项后下一次循环不等输入直接跑飞甚至连续打印菜单停不下来。原因上一次读数字时留在缓冲区的换行符没有被消费下一次 cin choice 读取到的不是数字流进入失败态choice 保留旧值switch 就拿着旧值一直转。解决每次成功读数字后立刻 ignore 掉剩余字符失败后先 clear 再 ignore。ignore 的参数用 std::numeric_limits std::streamsize ::max()意思是清空到换行为止。顺序必须是 clear 在前、ignore 在后这个顺序搞反了失败标志还在ignore 也救不回来。这是很多人的后悔药没吃对的原因。5.3 取款 0.1 加 0.2 对不上账现象连续存取几次后余额出现 99.900000000001 这种尾巴或者打印出来是 99.8999999999。原因double 是二进制浮点0.1 和 0.2 无法精确表示累加多了误差就会浮现。float 更严重double 只是减轻症状。解决模拟项目两个办法二选一。一是打印层用 std::fixed std::setprecision(2) 把误差遮掉二是把余额单位改成“分”用 long long 存整数所有金额乘 100 再做运算。我更推荐直接按分存储// 用 long long 存分取款 123.45 元时传入 12345 bool Account::withdrawInCent(long long amountInCent) { if (amountInCent 0 || amountInCent balanceInCent_) { return false; } balanceInCent_ - amountInCent; return true; }用分之后0.1 元的含义是 10 分整数运算没有任何精度问题打印时除以 100 显示即可。这是把 double 的浮点地雷从根上拆掉。5.4 菜单中文乱码字符编码的连环坑现象Windows 上编译运行菜单里的“请选择”显示成乱码Linux 下写出的数据文件用 Windows 记事本打开又是一片乱码。原因源文件编码、运行环境代码页、数据文件编码三方不一致任何一环错位都会乱。中文乱码的排查成本往往高于写代码本身。解决模拟项目没必要和编码死磕。如果交付环境允许菜单统一用英文或拼音是成本最低的做法。必须用中文时源文件存 UTF-8并在 Windows 控制台先执行 chcp 65001 切到 UTF-8 代码页。注意数据文件一旦用 UTF-8 写入读取端也要按 UTF-8 解析别在 GBK 和 UTF-8 之间混用。5.5 只保存余额没有流水重启后说不清账现象程序跑了一下午演示时被问到刚才那笔交易记录在哪发现只有余额变来变去完全拿不出交易证据。原因持久化只覆盖账户表没考虑业务日志。这不是运行错误而是“不可审计”的设计缺陷。解决加一个交易日志文件用追加模式写每一笔操作字段按时间|卡号|类型|金额|操作后余额排布。追加模式用 std::ios::app和主文件的覆盖写严格区分void appendTransactionLog(const std::string cardId, const std::string type, double amount) { std::ofstream log(transaction.log, std::ios::app); if (!log.is_open()) return; // 实际项目里请用 chrono 生成时间字符串这里简化为固定值 log 20250101T120000 | cardId | type | amount \n; }这段代码本身不难但值得做。答辩时一条完整的交易流水比口头解释有说服力得多也方便自己排查哪一步出了问题。最后补充一个通用工具trim 函数。处理从文件读回的密码、卡号时统一做一次首尾空白清理很多坑会从根源上消失。#include cctype std::string trim(const std::string s) { size_t start 0; size_t end s.size(); while (start end std::isspace(static_castunsigned char(s[start]))) { start; } while (end start std::isspace(static_castunsigned char(s[end - 1]))) { --end; } return s.substr(start, end - start); }注意 isspace 接收的是 unsigned char 转出的 int不能直接把 char 传进去否则非 ASCII 字符可能是负数在不同编译器下行为不一致。这个细节很隐蔽但就是它造成了很多“明明去掉了空格怎么还是不对”的玄学。6. 从能跑到能用哈希、状态枚举与一张验收表如果代码已经能稳定跑完主流程我最后还会做三件事。第一件把密码从明文改成哈希。std::hash std::string 开箱即用但它不保证跨进程、跨编译器稳定我会自己写一个 FNV-1a 之类的简单哈希十几行就能做完不需要引入外部库。第二件把业务结果从散落的 true/false 改成枚举把取款结果清晰定义成 WithdrawOk、InsufficientBalance、InvalidAmount、ExceedLimit 四类switch 里就不再靠猜返回值。第三件写一张验收用例表把所有分支过一遍确认没有死循环和崩溃。| 用例 | 操作步骤 | 预期结果 | | 登录失败 | 连续输错密码 3 次 | 提示次数用完退出当前流程 | | 取款超余额 | 余额 1000 元输入 2000 | 提示余额不足余额不变 | | 取款为负 | 输入 -50 | 提示金额非法余额不变 | | 文件缺失 | 删除 accounts.txt 后启动 | 程序正常启动并提示创建新账户库 | | 重复交易 | 连续取款 5 次每次 100 | 每笔写进交易日志主文件余额递减 |这张表建议在开写代码之前就列好写完一个功能就回头对照一行。我现在的习惯是先列测试表再写业务函数而不是先把功能写完再手忙脚乱找 bug。像“取款为负”这种分支很多代码不写表根本想不起来测结果就是一直跑一直错找不到方向。把枚举状态、哈希存储和这张验收表三个点补上这套 C 模拟 ATM 取款项目才算从“能跑”进到“能用”。希望帮到你。本文还有配套的精品资源点击获取