简介:这是一份以C++模拟ATM取款机系统为主线的源码资源,面向C++初学阶段和面向对象课程设计者,完整展示登录、密码核对与修改、取款、余额查询、退出以及连续输错触发锁机等功能流程。包体为单个docx文档,大小约23KB,文档将需求分析说明和关键类源代码放在一起,便于对照阅读。实现围绕ATM和consumer两个类展开,consumer把姓名、卡号、余额和密码封装为私有成员,仅通过公有接口访问,ATM借助友元关系读取用户资料,清晰体现了封装、友元类和类职责划分等面向对象思想。文档中还给出ATM操作界面与功能菜单的实现框架,并包含功能菜单的显示与循环调用逻辑,适合在普通C++环境下直接编译学习,对理解构造函数、私有成员保护和友元机制尤其有帮助。已有272人学习浏览,适合用来临摹源码、梳理ATM业务逻辑,并可在此基础上扩展取款限额、交易记录、异常处理及安全机制,形成更完整的银行模拟系统。
1. 一个C++模拟ATM取款源代码:把面向对象最实在的用法摆在你面前
第一次看到这份C++模拟ATM取款源代码时,我以为是某个课程设计的模板,但读进去才发现它把面向对象最基础也最关键的封装、依赖注入、状态判断全演了一遍。这个项目模拟一台自动取款机,用户登录、密码校验、修改密码、取款、查询余额、退出和三次输错锁机,全部由两个类完成——ATM类和consumer类。适合正在学C++面向对象的人,或者想快速找一个能跑起来、能二次开发的命令行小项目练手的人。它不用数据库、不用网络,编译完就能在终端里走一遍完整的取款流程,代码量不大但结构清晰,你能直接看到“机器依赖用户”这种关系是怎么用引用传参表达的。
2. 项目结构与类设计:ATM和consumer是怎么协作的
2.1 文件拆分:一份头文件和两份实现文件加一个入口
整个工程没有用IDE自动生成的工程文件,就是三个核心文件加一个主文件,目录结构非常直白:
ATM_Project/ ├── function.h // 类声明,ATM和consumer的定义 ├── consumer.cpp // consumer类成员函数实现 ├── ATM.cpp // ATM类成员函数实现 └── main.cpp // 入口,构造用户并启动ATMfunction.h里声明了两个类,consumer在下面,ATM在上面,但ATM的构造函数参数是consumer&,说明ATM需要引用一个已存在的consumer对象。这种设计在真实项目中叫依赖注入,取款机不负责创建用户,只负责操作外部传进来的用户。consumer类提供了一组get和set方法,ATM类通过这层访问接口读写用户数据,用户密码和余额被封装成private,外部不能直接改。
我一般会把这种结构理解为“数据类加业务类”的经典组合。consumer是数据实体,ATM是业务操作,两者通过friend class ATM成为友元,这样ATM能直接调用consumer的protected方法,而外部代码只能走public接口。这个设计在小型项目里够用,但到了真实系统,友元会破坏封装,后面改造时可以换成公有接口或独立服务层。
2.2 consumer类:把用户数据封成黑匣子
先看consumer的声明和实现。它的私有成员是char passwd[8]、char name[20]、char num[20]和float money,全部是C风格字符数组和浮点数。构造函数用strcpy拷贝字符串,同时初始化余额:
consumer::consumer(char Name[], char Num[], float Money, char Password[]) { strcpy(name, Name); strcpy(num, Num); money = Money; strcpy(passwd, Password); }这里注意,密码数组只有8个字节,如果传入的密码超过7个字符,strcpy就会越界写内存,这是经典的安全隐患。真实项目中至少应该用std::string,或者对输入长度做硬校验。
set_money方法做的是减法,不是赋值,它表示扣款:
void consumer::set_money(float m) { money -= m; }所以ATM取款时只要传入取款金额,余额就会自动减少。但它没有做余额判断,判断逻辑放在fetchmoney里。这样设计的好处是“扣钱”这个动作只允许减少,不允许直接覆盖,防止业务层把余额改乱。缺点是如果以后要支持存款,还得再加一个add_money方法。
2.3 ATM类:依赖引用外部用户,内置次数状态
ATM类的成员包括一个int times和一个consumer& cnsm引用。构造函数里ATM(consumer& cn):cnsm(cn)把外部传入的用户绑定到引用上,这样整个生命周期内ATM操作的都是同一个用户对象。
class ATM { public: ATM(consumer& cn):cnsm(cn) {} void welcome(); bool check_passwd(char n[], char pwd[]); void change_passwd(); void fetchmoney(); void information(); void exitATM(); void functionshow(); void lock(); private: int times; consumer& cnsm; };times变量用来记录密码输入错误次数,每次welcome开始时清零。三次错误就触发lock(),模拟银行卡被吞。这个“次数状态”是ATM类内部的关键状态,它没有暴露成public,外部无法篡改,只能通过实际操作来影响。
这里有个值得注意的点:ATM持有的是引用不是指针,所以ATM对象本身不拥有用户的生命周期。main里先构造consumer,再构造ATM,这样用户对象一定先于ATM存在,ATM使用期间用户不会被释放。如果你改成指针,就得操心空指针和所有权问题,引用在这个场景下更省心。
2.4 编译与运行:一条命令就把项目跑起来
这份代码是早期C++风格,用的是iostream.h和stdlib.h,编译时需要注意。我用的命令如下:
g++ main.cpp ATM.cpp consumer.cpp -o atm_simulator ./atm_simulator如果你的编译器版本较新,可能提示找不到iostream.h,这是老式头文件名。解决方法是把#include<iostream.h>改成#include<iostream>,并在下面加一行using namespace std;。stdlib.h也建议换成cstdlib。改完后用上面的命令编译,就能看到终端里的欢迎界面:
$ 欢迎使用若雪银行 ATM 自动取款机!~! 请输入卡号:注意这里卡号和密码的读取用了cin.get()逐字符读,回车键才会结束输入,和直接用cin>>的行为不一样。后面在第3章会详细讲这个细节。
3. 核心模块拆解:登录、取款、锁机的完整链路
3.1 welcome()里隐藏的输入读取陷阱
welcome()是整个程序的入口流程。它的第一行把times清零,然后进入一个do-while循环,循环条件while(times<3)。每次循环让用户输入卡号和密码,如果check_passwd返回false,就提示重新输入,同时times加一;如果校验通过,就进入functionshow()功能菜单。
看这段输入逻辑:
cout << "请输入卡号:"; do { cin.get(ch); num[i++] = ch; } while (ch != '\n'); num[i - 1] = '\0';这个循环用cin.get(ch)读取每一个字符,包括最后的回车符。当ch等于换行符时循环退出,然后把数组最后一个位置(也就是回车符的位置)替换成字符串结束符\0。这样做的好处是卡号可以包含空格,而cin >> num遇到空格会截断。坏处是如果你在输入两个字符前就按回车,数组里可能会残留一些不可见字符,而且没有对下标i做边界检查,全靠用户自觉不要输入超过19个字符的卡号。
这里常见做法是改用cin.getline(num, 20),它能指定最大长度,自动处理换行符,更安全:
cin.getline(num, 20); cin.getline(pwd, 8);不过原始代码这种逐字符读法用来演示“缓冲区里到底发生了什么”很有教学价值,新手能看到回车被当作字符读进来的全过程。
3.2 check_passwd()与三次错卡没收
check_passwd非常朴素,就用strcmp把输入的卡号和密码和customer对象里的值逐一比对:
bool ATM::check_passwd(char num[], char pwd[]) { if (strcmp(num, cnsm.get_num()) == 0 && strcmp(pwd, cnsm.get_passwd()) == 0) return true; else return false; }三个错误后,do-while循环退出,调用lock():
void ATM::lock() { cout << "对不起,由于你的操作有误,你的卡已经被没收!" << endl; exit(1); }exit(1)直接终止整个进程,没有任何恢复机会。真实ATM不可能因为三次输错就杀进程,它会把卡吞掉然后等待下一个用户。改造时至少应该让lock()回到初始状态,把times清零,重新欢迎下一个用户,而不是退出程序。
这里有个隐藏逻辑:修改密码功能里也有times计数,如果在修改密码时连续输错旧密码三次,同样会调用lock()。也就是说,登录和改密都会触发锁机,但welcome里的times和change_passwd里的times是独立的,后者在函数开头被重置成0。这是一个状态管理不一致的地方,多次操作后times数值可能让人迷惑。
3.3 fetchmoney()的余额校验和取款循环
取款函数是业务逻辑最重的一块。先看代码:
void ATM::fetchmoney() { float m; char ch; do { cout << "你要取多少钱:" << endl; cin >> m; while (m <= 0) { cout << "请输入正确的数字!" << endl; cin >> m; } if (cnsm.get_money() - m < 0) { cout << "对不起,你的余额不足!" << endl; } else { cout << "操作成功,请收好钱!" << endl; cnsm.set_money(m); } cout << "是否要继续该项操作:(Y/N)" << endl; cin >> ch; while (ch != 'n' && ch != 'N' && ch != 'Y' && ch != 'y') { cin >> ch; } } while (ch == 'y' || ch == 'Y'); }逻辑分三步:先读一个浮点数m,小于等于0就死循环重读;然后判断余额 - m是否小于0,如果余额不足就提示但不会扣款;如果足够,就调用cnsm.set_money(m)扣款。最后询问是否继续,输入Y或y就再来一轮。
注意set_money实现的是money -= m,所以取款成功后余额确实是变少了。但这里没有限制单次取款金额,比如余额有5万,你输100万只会提示余额不足,你输4万全都给你,这在真实银行不可能发生。改造时可以加上单次限额和每日累计限额,后面第5章会讲。
这段还有一个比较隐蔽的雷:如果你输入的不是数字,比如输入“abc”,cin >> m会失败,m保持原值,但错误状态位被置位,后面所有输入操作都会失效。需要用cin.clear()和cin.ignore()恢复流状态。这类输入校验在原始代码里完全没处理。
3.4 change_passwd()的两次新密码校验
修改密码的逻辑分为两步,先验证旧密码,再让用户输入两次新密码并比对。上面那段旧密码验证使用了check_passwd,但它传入的是cnsm.get_num(),也就是说用当前用户的卡号和新输入的密码去匹配。因为check_passwd只负责字符串比较,不关心调用者怎么取卡号,所以这样写也能通过。
新密码验证是do-while循环:
int t = 0; do { cout << "请输入新密码:"; cin >> pwd; cout << "请再输入一次新密码:"; cin >> repwd; if ((t = strcmp(pwd, repwd)) != 0) cout << "你输入的两次密码不一样,请重新输入!" << endl; } while (t != 0); cnsm.set_passwd(pwd);strcmp返回值在相等时为0,所以循环条件while(t!=0)表示只要两次不一致就重来。一致后调用cnsm.set_passwd(pwd)写入新密码。这段代码在正常输入下没问题,但数组边界依然没有保护,输入超过7个字符的新密码会直接覆盖栈上其他变量,可能连旧密码都一起被破坏。
我建议把pwd和repwd都改成std::string,然后用==比较,再检查长度。或者至少在做strcpy前加一句strlen(pwd) < 8的判断。
4. 避坑/常见问题:这段代码里最容易踩的坑
4.1 现象:编译时报错找不到iostream.h
原因:这是老式头文件,现代GCC默认不再提供。解决:把#include<iostream.h>改成#include<iostream>,并在全局加using namespace std;。stdlib.h改成cstdlib,string.h改成cstring。改完后再编译就正常了。
4.2 现象:输入卡号或密码时,还没输完就自动进入下一步
原因:逐字符读取的do-while循环会把回车符算进字符数组,如果上一个输入缓冲里残留了换行符,cin.get()会先把那个换行读走,导致看起来“什么都没输入就跳过了”。解决:在每次读取前用cin.ignore()清理掉残留换行,或者直接改用cin.getline()。
4.3 现象:密码超过8个字符时程序异常崩溃
原因:char passwd[8]只能容纳7个字符加一个结束符,strcpy会把超出的部分写到栈上,破坏相邻变量。解决:把密码数组改成std::string,或者在所有strcpy之前检查strlen。这是最值得新手注意的缓冲区越界问题。
4.4 现象:取款时输入英文或符号,程序像是卡死了
原因:cin >> m读取失败会设置流错误状态,后续所有cin >>都会直接失败,循环无法退出。解决:在读取浮点数后判断cin.fail(),如果是就cin.clear()清掉错误标志,再用cin.ignore()丢弃非法字符重新读取。这是命令行程序最常见的翻车点。
4.5 现象:改名密码后再次登录,用旧密码竟然还能通过
原因:仔细看change_passwd的旧密码验证,它调用check_passwd时用的是cnsm.get_num()和用户输入的pwd,但pwd是从char pwd[8]读入的。如果你的输入包含回车或缓冲区残留,strcmp可能匹配上旧密码的残留内容。解决:统一用getline读取密码,别混用cin>>和cin.get()。
5. 扩展改造:把单用户demo变成多用户档案系统
5.1 用文件读写保存用户数据
原始代码只有一个写死的consumer对象,这显然不贴近真实。最直接的改造是把用户信息存到文本文件里,程序启动时读取文件建立用户列表,退出时写回。我一般会先定义一个简单的存储格式:
卡号,姓名,余额,密码 12345,jim,5200.30,123 67890,amy,8800.00,456再封装一个UserStore类,负责加载和保存。用标准库里的ifstream和ofstream就能搞定:
#include <fstream> #include <sstream> #include <vector> struct UserRecord { std::string num; std::string name; float money; std::string passwd; }; std::vector<UserRecord> loadUsers(const std::string& path) { std::vector<UserRecord> users; std::ifstream fin(path); std::string line; while (std::getline(fin, line)) { std::stringstream ss(line); UserRecord ur; std::getline(ss, ur.num, ','); std::getline(ss, ur.name, ','); ss >> ur.money; ss.ignore(1, ','); std::getline(ss, ur.passwd, ','); users.push_back(ur); } return users; }这段代码把每一行按逗号拆成四个字段。注意ss >> ur.money读完后要ignore跳过逗号,否则后面的getline会读出空字符串。这是文件解析里最常见的坑,顺手记一下。改造后ATM类不再固定引用一个consumer,而是接收一个用户列表,登录时遍历匹配卡号。
5.2 用vector管理多个账户
原始代码的ATM(consumer& cn)把用户写死,改造的第一步是让ATM持有一个用户集合,或者每次登录后在集合里查找目标用户。如果你不想大改,可以做一个轻量改动:在main里预创建多个consumer对象,按卡号查找后传给ATM。但更好的做法是给consumer增加一个bool check_identity(const char* num, const char* pwd)方法,把校验封装进用户自己,ATM只负责调用。这样ATM不再需要知道用户内部数据结构,减少耦合。
我倾向把集合用std::vector<consumer>存,consumer类里加一个静态成员函数findByCard,传入卡号返回迭代器。注意consumer原来拷贝构造函数里是多个char数组,直接复制没问题,但改成string以后更省心。
5.3 增加异常处理和单日取款限额
真实业务系统里,余额不足不能直接返回一条错误就完事,最好用异常来终止业务逻辑。我给fetchmoney加一层防护:
class InsufficientFunds : public std::exception { public: const char* what() const noexcept override { return "余额不足,取款失败"; } }; void ATM::fetchmoney() { if (m <= 0) { throw std::invalid_argument("取款金额必须为正数"); } if (dailyLimit - todayWithdraw < m) { throw InsufficientFunds(); } if (cnsm.get_money() < m) { throw InsufficientFunds(); } todayWithdraw += m; cnsm.set_money(m); }注意这里我新增了dailyLimit和todayWithdraw两个成员变量,用来模拟每日取款上限。每次登录时todayWithdraw清零,取款前先检查当天累计取款和本次金额之和是否超过上限。这个逻辑在原始代码里完全没有,加进来后你会发现业务边界清晰了很多。
6. 二次开发实战:给ATM加一个转账功能要动哪些类
转账是ATM核心业务里最自然的扩展点。在原始代码上实现它不需要大改架构,只需要三步:在consumer里增加转出方法,在ATM里增加transferMoney方法,在functionshow的菜单里加一个选项。先从consumer开始:
void consumer::transfer_out(float amount) { money -= amount; } void consumer::transfer_in(float amount) { money += amount; }这里我特意把拆成两个方法,区分出账和入账,避免用一个set_money做加减导致语义混乱。原代码的set_money只做减法,现在加上transfer_in,语义清晰多了。
然后是ATM的转账方法。你需要接收目标卡号和转账金额,先检查自己的余额是否足够,再检查目标卡号是否存在。因为当前ATM只引用了一个consumer,所以转账目标需要外部传入一个指向目标消费者的指针:
void ATM::transferMoney(consumer* target, float amount) { if (target == nullptr) { cout << "目标账户不存在,转账失败" << endl; return; } if (amount <= 0) { cout << "转账金额必须为正数" << endl; return; } if (cnsm.get_money() < amount) { cout << "余额不足,转账失败" << endl; return; } cnsm.transfer_out(amount); target->transfer_in(amount); cout << "转账成功,当前余额:" << cnsm.get_money() << endl; }这里有一个我自己踩过的坑:转账时一定要先检查余额再动手扣款和入账,否则中间任何一步出错都会导致两边账不平。如果是真实线上系统,这个操作应该放到数据库事务里,要么都成功,要么都回滚。模拟系统里至少要把三处逻辑写成一个不可分割的顺序块,别在中间加任何可能return的路径。
改完consumer和ATM,再把functionshow的菜单从四项改成五项:
cout << "1) 修改密码 " << endl << "2) 取款 " << endl << "3) 查询余额 " << endl << "4) 转账 " << endl << "5) 退出系统 " << endl;switch里加一个case 4,调用transferMoney。注意原代码的退出系统是case 4,你加了一个选项后要把退出改成case 5,别让转账和退出重叠。改完后建议按这个顺序验证:先登录,查余额,转账到另一个账户,再查询余额确认扣款成功,再登录目标账户确认入账。这条路径走通,整个改造就没有大问题。
我从这个项目里学到的习惯是:改老代码前先把输入边界和状态计数列出来,比如times会在哪几个函数被清零、会在哪几个函数被增加,列完再动手。用户输入永远是最大的雷,后来我每写一个带交互的C++程序,都会先处理一次cin.clear()和cin.ignore(),再写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取