☰
C++模拟ATM取款:类设计、边界条件与工程实践
2026/10/15 21:53:31 网站建设 项目流程

简介:C++模拟ATM取款源代码是一份面向C++初学者与面向对象编程学习者的课程设计资源,完整演示了ATM取款机系统的核心功能,包括用户登录、密码核对与修改、取款、余额查询、退出及连续输错三次密码后的锁机机制。资源以docx格式打包,共1个文件,大小仅23KB,文档中既包含需求分析报告,也提供了ATM与consumer两个类的完整实现代码与注释,方便读者直接阅读和运行。目前已吸引272人浏览学习。通过这份源码,读者可以清晰看到类与对象的设计思路:consumer类封装用户信息(姓名、卡号、密码、余额)并提供受保护访问接口,ATM类通过引用关联用户,实现各项业务操作。代码还展示了密码验证、取款金额校验等基础逻辑,并留有扩展空间,如每日取款限额、交易记录等。适合作为C++课程作业参考或理解银行ATM业务流程的入门案例。

1. C++模拟ATM取款源代码:不是编译过就完事,边界条件才是验收线

“C++模拟ATM取款源代码”这个题目,几乎每隔一阵就会出现在课程设计清单里。很多人以为这是个小项目,写完能编译、能登录、能取钱就结束了,但真正拿去演示的时候,总在几个意想不到的地方翻车:密码错一次就锁卡、取款 0.1 元十次后余额小数位漂移、输入一个字母菜单直接死循环。这些问题的根源不是 C++ 语法没过关,而是边界条件没有想清楚。

这篇文章就按一条能直接交作业的路线来讲:先搭类骨架,再补取款核心逻辑和输入防御,最后给五个高频坑的排查记录和验收加分方案。照着走能跑通,调完你也会发现,这类模拟项目比纯算法题更锻炼工程习惯——因为它逼着你思考“状态什么时候改、数据存在哪、用户乱输入怎么办”。

2. 先搭骨架:用类把账户和ATM拆分,别在main里堆状态

2.1 账户类做数据载体:卡号、密码、余额的字段类型取舍

ATM 程序的核心数据就是账户,先想清楚三个字段:卡号、密码、余额。卡号我建议用 string,而不是 int 或 long long。原因很直接:真实卡号是 16 到 19 位,long long 勉强能存但容易出现前导零丢失,比如 “000123” 这样的卡号在数值类型里直接变成 123;而且卡号根本不需要做算术运算,用字符串更安全。密码同样用 string,因为密码可能是 “0123”,整型存会丢前导零,字符串比较也直观。

余额这一版先用 double 存“元”,好处是写起来直观,但第 5 章我会专门批评这个选择,并给出改成“分”的落地方案。这里先按多数教程的习惯走,让你先跑起来。

#include <iostream> #include <string> using namespace std; class Account { private: string cardNo; string password; double balance; public: Account() : cardNo(""), password(""), balance(0.0) {} Account(const string& id, const string& pwd, double money) : cardNo(id), password(pwd), balance(money) {} string getCardNo() const { return cardNo; } string getPassword() const { return password; } double getBalance() const { return balance; } bool checkPassword(const string& pwd) const { return password == pwd; } void setBalance(double money) { balance = money; } };

代码逻辑顺序是这样设计的:构造函数保证每个账户创建时三样数据必须齐全;checkPassword 把“验证密码”这个动作收进账户类,ATM 类不需要知道密码明文是怎么存的;setBalance 是余额唯一修改入口。这里有个设计习惯值得记住:余额字段是 private,外面想改必须走函数,后续要加“修改后自动记录流水”时,只需要改 setBalance 这一处。

为什么不让 main 直接访问 balance?因为一旦余额的读写散落在各函数里,你很难查清“这个值到底是在哪里被改没了”。把数据封装进类,定位问题就变成了查三个函数的事。

2.2 ATM类做流程控制器:账户数组、当前登录态、菜单循环

账户有了,接下来需要一台“机器”。ATM 类负责持有账户列表、当前登录状态和菜单流程。这里的关键设计是:登录成功后只记录账户在数组里的下标 currentIndex,而不是把 Account 对象拷贝一份出来。原因是后续取款、存款都要直接修改 accounts 数组里那个对象的余额,如果拷贝一份,改的是副本,原账户纹丝不动——这就是第 5 章“余额没回写”最常见的根因。

class ATM { private: static const int MAX_ACCOUNTS = 50; Account accounts[MAX_ACCOUNTS]; int accountCount; int currentIndex; // 当前登录账户下标,-1 表示未登录 public: ATM() : accountCount(0), currentIndex(-1) {} void addAccount(const Account& acc) { if (accountCount >= MAX_ACCOUNTS) { cout << "账户数量已达上限" << endl; return; } accounts[accountCount++] = acc; } bool login(const string& cardNo, const string& pwd) { for (int i = 0; i < accountCount; i++) { if (accounts[i].getCardNo() == cardNo && accounts[i].checkPassword(pwd)) { currentIndex = i; return true; } } return false; } void run(); };

login 用线性遍历找账户,账户量小的时候完全够用,没必要上 map。这个函数只负责“匹配并登录”,不负责输出错误提示,提示交给调用方写,这样函数职责更干净。currentIndex 初始化为 -1 表示“无人登录”,run() 开头检查这个值再决定是弹登录界面还是直接进菜单,避免了“跳过登录直接操作别人账户”的逻辑漏洞。

addAccount 里的数量检查不是摆设。数组上限 50 是模拟银行开户上限,如果没有这行检查,第 51 次 addAccount 会越界写入,程序不一定立刻崩,但会随机破坏内存里的任意数据,这是最难受的 bug。

2.3 最小可运行版本:预置测试账户并跑通登录与查询

骨架齐了,先拼一个最小可运行版本。run() 负责两件事:第一,未登录就先登录;第二,循环弹菜单。取款先简单实现,完整判断留到第 3 章。

void ATM::run() { if (currentIndex < 0) { string cardNo, pwd; cout << "请输入卡号: "; cin >> cardNo; cout << "请输入密码: "; cin >> pwd; if (!login(cardNo, pwd)) { cout << "卡号或密码错误" << endl; return; } } int choice = 0; while (choice != 4) { cout << "\n1.取款 2.存款 3.查询 4.退卡" << endl; cout << "请选择: "; cin >> choice; if (choice == 1) { double amount; cout << "请输入取款金额: "; cin >> amount; withdraw(amount); } else if (choice == 3) { cout << "当前余额: " << accounts[currentIndex].getBalance() << endl; } } currentIndex = -1; cout << "请取走您的卡" << endl; }

这段代码看着简单,但菜单循环用choice != 4做条件有个隐藏问题:如果用户输入了一个字符而不是数字,cin 会进入 fail 状态,choice 的值保持不变,程序就死循环刷菜单,这个坑留到第 4 章统一补。

main 这里注意预置测试账户的方式。ATM 是“银行已开户”场景,不该在程序里让用户开户,所以直接在 main 里塞两个测试账户:

int main() { ATM atm; atm.addAccount(Account("621700001", "123456", 5000.0)); atm.addAccount(Account("621700002", "000012", 1200.0)); atm.run(); return 0; }

两个测试账户覆盖了两种常见情况:一个余额充足,一个余额偏少还要带前导零密码。密码 “000012” 用 string 存完全没问题,要是当初图省事用 int 存密码,这里就已经翻车了。这样跑起来,登录、查询、退卡三个功能就能演示,剩下的是把取款和存款做厚。

3. 把取款逻辑做扎实:三层判定、单笔限额、余额回写

3.1 为什么取款要分成三个if-return,而不是一层嵌套包到底

取款这个操作,新手最容易写成一长串嵌套 if:外层判金额合法、内层判余额充足、再内层执行扣款。三层还能看,等到你加“单笔上限”“每日限额”“最小面额”之后,嵌套会变成金字塔,最里层的扣款代码缩进五六层,读起来眼睛疼,改起来更是噩梦。

我习惯用“前置校验 + 提前返回”的方式:每个 if 只拦一种非法情况,命中就 return false,不命中就放行到下一步。这样所有非法路径都平铺在函数上半部分,下半部分就是“既然都合法,那就执行扣款”,逻辑一目了然。这种写法在真实业务代码里叫 guard clause,应对“多个独立非法条件”时比嵌套 if 清爽得多。

bool ATM::withdraw(double amount) { // 前置校验一:金额必须大于 0 if (amount <= 0.0) { cout << "取款金额必须大于 0" << endl; return false; } // 前置校验二:单笔取款上限,模拟 ATM 限额 if (amount > MAX_SINGLE_WITHDRAW) { cout << "单笔取款不能超过 " << MAX_SINGLE_WITHDRAW << " 元" << endl; return false; } // 前置校验三:余额是否充足 if (amount > accounts[currentIndex].getBalance()) { cout << "余额不足,当前余额: " << accounts[currentIndex].getBalance() << endl; return false; } // 所有校验通过,执行扣款并回写 accounts[currentIndex].setBalance( accounts[currentIndex].getBalance() - amount); cout << "取款成功,请取走现金" << endl; return true; }

在 ATM 类里补一个常量:static const int MAX_SINGLE_WITHDRAW = 5000;,模拟常见 ATM 的单笔限额。三个 if 的顺序也是刻意安排的:先拦“非法金额”,再拦“超限”,最后查余额。因为余额是否充足,只在金额本身合法时才有意义,这个顺序能保证错误提示和用户的直觉一致。

还有一个细节:第三层判断用的是amount > balance而不是>=。这意味着余额刚好等于取款金额时是允许取出的,取完后余额为 0,这符合常识。如果你是新手,很容易写成>=,结果刚好把卡里最后一分钱取不出来,演示时非常尴尬。

3.2 取款函数完整实现:金额上限、余额不足、成功回写

把上面代码块整合进去后,withdraw 的完整逻辑就有了。这里要重点说三个参数边界:amount 为 0、复数、超上限,全部在防;余额等于取款额时允许;取款后必须调用 setBalance 回写。

// 存款函数与取款对称,同样先做前置校验 void ATM::deposit(double amount) { if (amount <= 0.0) { cout << "存款金额必须大于 0" << endl; return; } accounts[currentIndex].setBalance( accounts[currentIndex].getBalance() + amount); cout << "存款成功" << endl; }

存款的校验只有一条,因为“存款没有上限”在模拟场景里成立,真实银行会有单日存款限额,这里不展开。注意 deposit 同样走 setBalance,而不是直接在局部变量上加钱——很多人在这一步偷懒,写成double b = getBalance(); b += amount;,结果余额纹丝不动。这个毛病在第 5 章会专门拉到台面上批。

3.3 存款和查询:两个必须读真实状态的操作

查询余额是最容易被低估的操作。正规写法是每次查询都从 accounts[currentIndex] 实时读:

void ATM::queryBalance() { cout << "当前余额: " << accounts[currentIndex].getBalance() << endl; }

不要在 run() 里开一个局部变量存余额,然后查余额打印这个局部变量。原因是取款、存款之后,局部变量不会自动同步,用户看到的是“过期货”。正确原则:余额只存在 Account 对象里一份,显示和计算全部通过 getBalance() 读取,修改全部通过 setBalance() 完成,其他地方不缓存副本。

存款这里还可以加一个真实 ATM 的规则:纸币一般是 100 元整数倍。如果要加,判断fmod(amount, 100.0) != 0就拒绝。但这里牵扯出浮点精度问题——0.1 在二进制里是无限循环小数,double 运算会出现 0.30000000000000004 这种结果。所以更稳妥的做法是把余额单位改成“分”,用整型存,这个方案我会在 5.1 里给出完整迁移代码。

4. 数据组织与输入防御:多账户存放、三次锁定、cin清理

4.1 定长数组还是vector :先看有没有动态开户需求

ATM 类里的账户存储,我前面用了定长数组,这是课程设计最常见的形态。选型判断依据只有一个:你的需求里有没有“开户/销户”这个动态动作。

维度定长数组vector
容量固定 50,满了要改常量动态扩容,理论无上限
越界风险addAccount 忘记检查上限就会踩内存push_back 自动管理,不会越界
增删账户删账户要手动搬移元素erase 一行搞定
演示稳定度高,模拟“银行已开户”高,但代码略多
适合场景只做取款/存款/查询需求里有“开户、销户”

如果题目描述出现了“用户可自行开户”或“管理员可以新增账户”,直接上 vector。ATM 场景真实世界是银行柜面开户、ATM 只管用,所以定长数组也说得通。两种写法在 main 里就差一行:vector 版用atm.addAccount(...)完全兼容,改造代价不大,建议动手改成 vector 感受一下差异。

4.2 密码连续错三次锁卡:计数是账户属性,不是会话属性

很多人写锁卡逻辑时,习惯在 ATM 类里放一个临时变量failCount,每次 login 失败就加一。但这有个致命问题:ATM 程序一次运行对应一个会话,会话结束 failCount 清零,重启程序后锁卡状态也丢了。真实 ATM 是账户维度锁定——这个卡在哪个机器上错三次都该锁。所以 failCount 必须放进 Account 类,成为账户的持久属性。

class Account { private: int failCount; bool locked; // ...其他成员... public: // 构造函数里初始化 failCount = 0; locked = false; void addFailCount() { failCount++; } int getFailCount() const { return failCount; } void resetFailCount() { failCount = 0; } bool isLocked() const { return locked; } void lock() { locked = true; } };

login 的逻辑也要跟着改:先按卡号定位账户,如果已经锁定直接拒绝;密码验证失败时去做 failCount 递增,而不是在 ATM 类里用临时变量记。

bool ATM::login(const string& cardNo, const string& pwd) { for (int i = 0; i < accountCount; i++) { if (accounts[i].getCardNo() != cardNo) { continue; } if (accounts[i].isLocked()) { cout << "该账户已锁定,请联系柜台处理" << endl; return false; } if (accounts[i].checkPassword(pwd)) { currentIndex = i; accounts[i].resetFailCount(); cout << "登录成功" << endl; return true; } accounts[i].addFailCount(); if (accounts[i].getFailCount() >= 3) { accounts[i].lock(); cout << "密码连续错误 3 次,账户已锁定" << endl; } else { cout << "密码错误,还剩 " << 3 - accounts[i].getFailCount() << " 次机会" << endl; } return false; } cout << "卡号不存在" << endl; return false; }

这个版本把“锁定”和“卡号不存在”分开提示,避免泄露账户是否存在的信息——真实 ATM 也不会告诉你卡号是否存在,只会说“卡号或密码错误”。如果你不需要这么严谨,可以把两个提示合并。

4.3 cin读入非法字符:一杯咖啡换来的死循环

?#include #include using namespace std;

if (cin.ignore(1000, '\n')) ...

不对,直接写代码。 ```cpp int readMenuChoice(const string& tip) { int value; while (true) { cout << tip; cin >> value; if (!cin.fail()) { return value; } cin.clear(); // 清除 fail 状态 cin.ignore(1000, '\n'); // 把缓冲区里的坏字符丢到换行为止 cout << "输入不合法,请重新输入" << endl; } }

cin.clear() 的作用是恢复流状态,cin.ignore(1000, '\n') 表示最多丢弃 1000 个字符或直到遇到换行符,哪个先到算哪个。参数 1000 是个保险值,实际坏输入一般不会超过 1000 字符;如果用户真的塞了 2000 个字符,ignore 只清掉前 1000 个,下一次循环还会遇到残党,所以这个值要设得比“正常最坏输入”大不少。

有了 readMenuChoice,run() 里的cin >> choice就可以替换成choice = readMenuChoice("请选择: ");。取款和存款的金额输入也应该用同类函数包一层——用户在手输金额时打错一个字母太常见了,不处理就是死循环或者程序闪退。

readMenuChoice 还有一个好处:把“读取 + 校验 + 重试”收敛成一个函数,菜单循环里面每处读取只有一行,代码可读性提升不少。

5. 模拟ATM取款必踩的五个坑:从浮点余额到菜单死循环

5.1 用double存余额,取着取着钱变多了

现象:余额 100.0,连续取 10 次 0.1,理想结果是 99.0,实际输出可能是 98.9999999999997。表面看是“钱少了”,但你如果反过来连续存 0.1,余额会变成 100.00000000000001,感觉像银行倒贴。

原因:0.1 在二进制浮点数里是无限循环小数,double 只能存近似值,每次加减都在累积误差。

解决:把余额从 double 改成 long long,单位用“分”。金额输入用元,内部存储用分。

class Account { private: long long balanceFen; // 余额单位:分 public: void setBalanceYuan(double yuan) { // 元转分,+0.5 做四舍五入 balanceFen = static_cast<long long>(yuan * 100 + 0.5); } double getBalanceYuan() const { return balanceFen / 100.0; } };

注意元转分的小数位:输入 12.34 元时,12.34 * 100在浮点里可能是 1233.9999999999998,直接转 long long 会截成 1233 分,少 1 分钱。加 0.5 再截断是常见的舍入技巧。取款判断也要改成比较分的整数:

if (amountFen > balanceFen) // 余额不足

这样取款、存款全走整数加减,误差彻底消失。这一处改动值得做,它让你的程序从“看起来对”变成“真的对”。

5.2 三次锁定变成“输一次密码判三次”

现象:第一次输错密码,程序直接提示“账户已锁定”;或者连续错两次后,第三次无论输什么都还能继续试。

原因:典型的循环嵌套错误——外层循环遍历错误次数,内层循环读取密码并比对,实际效果是“同一次输入被判断了三次”。另一个常见原因是 failCount 定义在 login 函数内部,每次调用都重新初始化为 0,永远锁不了。

解决:锁定计数必须存到 Account 对象里(如 4.2 的代码),login 每次失败就 addFailCount,达到阈值立即 lock。判断“第几次错误”和“哪一次输入”要分清楚:每输一次密码,计数只允许加 1。

5.3 回车被上一轮输入吃掉,菜单自动跳项

现象:取完款后菜单自动执行了一次“查询”或者直接退卡,明明没有输入任何选择。

原因:cin >> choice只读取数字,把用户按下的回车符\n留在输入缓冲区,下一次读取时先遇到换行符,读到一个空内容,choice 保持旧值,流程错乱。

解决:统一使用 readMenuChoice 这类函数,在每次读取后清空缓冲区。如果程序里还有 getline 读取字符串,更要注意与cin >>的混用——getline 和>>对换行符的处理不同,最容易踩坑。建议要么全用>>配合 ignore,要么全用 getline 再手动转类型,不要混着来。

5.4 取款成功但余额没回写

现象:取款后立刻查询,余额显示正确;退出菜单重进,余额变回取款前的值。更诡异的情况是开了两个测试账户,登录 A 取款后,登录 B 发现余额跟 A 一样。

原因:没有调用 setBalance,而是操作了局部变量;或者不小心把 Account 对象按值传给了函数,函数里改的是临时副本。

解决:坚持“余额只存在于 Account 对象里一份”,所有变更必须走 setBalance,所有读取必须走 getBalance,不要在 run() 或其他地方缓存余额副本。

验证方法:取款后加一句cout << accounts[currentIndex].getBalance() << endl;,立刻检查是否变化。

5.5 预置数组越界:第 51 个账户注册时炸掉

现象:addAccount 连续调用到第 51 次时,程序崩溃,或者更隐蔽地——其他数据被篡改,菜单选项混乱。

原因:定长数组 accounts[50] 只能存 50 个对象,第 51 次写入越界。C++ 不做越界检查,写入“看不见的地方”,不会立刻报错,所以这种 bug 最难定位。

解决:addAccount 里先检查accountCount >= MAX_ACCOUNTS就拒绝(开头代码块里已有);更彻底是直接改用 vector ,从机制上消灭越界可能。你以为容量够大就不会触发,但用户或测试程序不会按你的预期调用。

6. 加分验收:文件持久化、取款逻辑自检、交易流水

6.1 把账户存进文本文件,重启后余额还在

内存数组一关机就全没了,真实 ATM 显然不是这样。给程序加个文件存储:启动时从文本读账户,退出时把当前状态写回去。

void ATM::saveToFile(const string& filename) { ofstream out(filename); for (int i = 0; i < accountCount; i++) { out << accounts[i].getCardNo() << " " << accounts[i].getPassword() << " " << accounts[i].getBalance() << "\n"; } }

格式是“每行一个账户,空格分隔三个字段”,加载时用 ifstream 按相同顺序读回再 addAccount 即可。如果你按 5.1 改成了“分”存储,文件里保存的应该是整数分,读取时再包一层单位换算,保持文件格式自洽。

6.2 给取款逻辑写一段自测,别再手工点菜单

手工测试效率低还容易漏。写一个不含菜单的测试函数,直接调用 ATM 的核心方法,三个断言覆盖三条关键路径:

void testWithdraw() { ATM atm; atm.addAccount(Account("T001", "123", 100.0)); if (!atm.login("T001", "123")) { cout << "FAIL: 登录失败" << endl; return; } if (!atm.withdraw(50.0)) { cout << "FAIL: 正常取款被拒绝" << endl; return; } if (atm.withdraw(60.0)) { cout << "FAIL: 余额不足未拦截" << endl; return; } cout << "取款逻辑 PASS" << endl; }

测试函数要放到 main 里先执行再进菜单,这样每次改代码都能快速回归。测试的重点不是覆盖所有输入,而是守住三条最容易改崩的路径:登录验证、余额充足时放行、余额不足时拦截。

6.3 还想加东西的话:不同面额的张数计算和流水记录

演示时最出效果的加分项是“取款面额拆分”:用户取 1350 元,程序打印需要 100 元面额 13 张、50 元面额 1 张。实现也不难,从大到小取模。另一个是被问频率很高的“交易流水”:把每次取款、存款、查询记录追加到 login.log,时间、卡号、操作类型、金额一行。

早期我做这类模拟项目时也绕了不少弯,最深刻的教训是:账户状态只保留一份,显示永远从它读,修改永远走函数;改动任何核心逻辑前先跑一遍自测用例。养成这两个习惯之后,ATM 这种小项目的 bug 基本能在五分钟内定位。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询