1. 项目概述与核心价值
最近在整理硬盘里的老项目,翻出来一个当年练手做的C++停车场收费系统。这个项目虽然不是什么企业级应用,但麻雀虽小五脏俱全,从控制台交互、文件读写、时间计算到简单的计费逻辑都涵盖了,非常适合C++初学者用来理解面向对象编程和项目实战。网上类似的源码很多,但要么逻辑混乱,要么注释不清,我这个版本是我自己边学边做,反复重构了几次,自认为在结构清晰度和代码可读性上还算“独一无二”。今天就把这个项目的完整源码和设计思路分享出来,希望能给正在学习C++、想找个完整小项目练手的你一些启发。
这个系统模拟了一个小型停车场的核心业务流程:车辆入场登记、出场计费结算、以及基础的数据查询统计。它完全运行在控制台,使用文件来持久化存储停车记录,避免了数据库的复杂性,让你能聚焦于C++核心语法和面向对象设计。通过这个项目,你能实战演练类的设计(如Vehicle、ParkingRecord)、STL容器的使用(vector、map)、文件流操作(fstream)以及时间处理(<ctime>)等关键知识点。无论你是刚学完C++基础语法,还是想巩固面向对象思想,这个项目都是一个不错的起点。
2. 系统整体设计与核心类解析
2.1 需求分析与功能模块划分
做任何项目,第一步都是理清需求。对于一个最基础的停车场收费系统,我们需要模拟以下场景:一辆车开进来,记录它的车牌号和入场时间;车开走时,根据停放时长和预设的费率计算停车费,并生成记录;管理员需要能查看当前在场车辆、历史记录以及营收统计。
基于此,我将系统划分为四大核心模块:
- 车辆与记录管理模块:这是系统的数据核心,需要定义车辆(
Vehicle)和停车记录(ParkingRecord)的数据结构,并管理它们的生命周期。 - 计费策略模块:这是业务逻辑的核心,需要封装不同的计费规则,比如按小时计费、有免费时长、夜间费率不同等。这里我采用策略模式的思想,便于未来扩展。
- 数据持久化模块:因为没连接数据库,我们需要将车辆入场记录、出场结算记录保存到本地文件中,确保程序重启后数据不丢失。
- 用户交互模块:提供控制台菜单,让用户(模拟管理员)可以通过输入数字选项来执行各种操作。
这样的模块化设计,使得代码结构清晰,各司其职,也方便你分步实现和调试。
2.2 核心类设计详解
接下来,我们深入看看几个关键类的设计。这是面向对象编程的精华所在。
2.2.1 Vehicle类:车辆的抽象车辆类很简单,主要就是封装车牌号这个属性。为什么不把入场时间也放进来?因为一辆车可能有多条停车记录(多次进出),入场时间是属于某一次具体的停车行为的。所以Vehicle类更像是一个实体标识。
class Vehicle { private: std::string licensePlate; // 车牌号 public: Vehicle(const std::string& plate) : licensePlate(plate) {} std::string getLicensePlate() const { return licensePlate; } // 重载==运算符,便于在容器中查找 bool operator==(const Vehicle& other) const { return licensePlate == other.licensePlate; } };注意:这里重载了
==运算符,这样我们就可以直接把Vehicle对象放入std::vector,并使用std::find来查找,非常方便。这是STL应用的一个小技巧。
2.2.2 ParkingRecord类:一次停车行为的完整描述停车记录类是系统的核心数据单元,它关联了一辆车和一次具体的停车事件。
class ParkingRecord { private: Vehicle vehicle; std::time_t entryTime; // 入场时间戳 std::time_t exitTime; // 出场时间戳,-1表示尚未出场 double fee; // 停车费 bool isPaid; // 是否已支付 public: ParkingRecord(const Vehicle& v, std::time_t entry) : vehicle(v), entryTime(entry), exitTime(-1), fee(0.0), isPaid(false) {} // 结算费用 void calculateFee(double hourlyRate) { if (exitTime == -1) return; // 未出场 double durationHours = difftime(exitTime, entryTime) / 3600.0; // 确保最小计费单位,比如不足1小时按1小时算 durationHours = ceil(durationHours); fee = durationHours * hourlyRate; } void setExitTime(std::time_t exit) { exitTime = exit; } std::time_t getEntryTime() const { return entryTime; } // ... 其他getter方法 };实操心得:时间处理是停车场系统的关键。这里使用
std::time_t(通常是自1970年1月1日以来的秒数)来存储时间,计算时长时用difftime函数。注意difftime返回的是秒,要转换成小时。另外,ceil函数用于向上取整,实现“不足一小时按一小时计”的常见计费规则。exitTime初始化为-1是一个状态标识,用来判断车辆是否已出场。
2.2.3 BillingStrategy类:计费策略的抽象为了让计费规则灵活可变,我定义了一个计费策略基类。这是设计模式中“策略模式”的简单应用。
class BillingStrategy { public: virtual ~BillingStrategy() = default; // 传入入场和出场时间,返回计算出的费用 virtual double calculateFee(std::time_t entry, std::time_t exit) const = 0; };然后,我们可以派生出具体的计费策略,比如最简单的按小时计费:
class HourlyBilling : public BillingStrategy { private: double ratePerHour; // 每小时费率 int freeMinutes; // 免费时长(分钟) public: HourlyBilling(double rate, int free = 15) : ratePerHour(rate), freeMinutes(free) {} double calculateFee(std::time_t entry, std::time_t exit) const override { double totalSeconds = difftime(exit, entry); double totalMinutes = totalSeconds / 60.0; // 扣除免费时长 double chargeableMinutes = totalMinutes - freeMinutes; if (chargeableMinutes <= 0) return 0.0; // 转换为小时并向上取整 double chargeableHours = ceil(chargeableMinutes / 60.0); return chargeableHours * ratePerHour; } };为什么用策略模式?想象一下,如果停车场节假日打折,或者夜间收费更便宜。如果没有策略模式,你就得在
ParkingRecord::calculateFee里写一堆if-else。有了它,你只需要在系统初始化时传入不同的BillingStrategy子类对象(比如HolidayBilling),核心业务代码一行都不用改。这体现了“对扩展开放,对修改关闭”的原则。
3. 核心功能实现与代码拆解
3.1 数据管理:使用STL容器与文件持久化
系统运行时,我们需要在内存中管理两类主要数据:当前在场的车辆(便于快速查找和出场结算)和所有的历史停车记录(用于查询统计)。我选择使用std::map和std::vector。
class ParkingSystem { private: std::map<std::string, ParkingRecord> activeParkings; // 车牌号 -> 停车记录(未出场) std::vector<ParkingRecord> allRecords; // 所有历史记录 std::unique_ptr<BillingStrategy> billingStrategy; // 计费策略 const std::string dataFile = “parking_data.txt”; // 数据文件 // ... };activeParkings: 用std::map存储正在停放的车辆。键(Key)是车牌号(std::string),值(Value)是对应的ParkingRecord对象。选择map是因为车牌号是唯一的,并且我们需要根据车牌号快速查找车辆进行出场操作,map的查找时间复杂度是O(log n),效率很高。allRecords: 用std::vector存储所有记录,包括已出场的。因为历史记录主要是遍历查询,vector的连续内存访问效率高。billingStrategy: 使用std::unique_ptr智能指针来管理计费策略对象,避免内存泄漏。
数据持久化是让程序“记住”状态的关键。我设计了两个简单的函数loadData()和saveData(),使用std::fstream来读写文件。
void ParkingSystem::saveData() { std::ofstream outFile(dataFile); if (!outFile) { std::cerr << “无法保存数据到文件!” << std::endl; return; } for (const auto& record : allRecords) { // 将每条记录的关键信息(车牌、入场时间、出场时间、费用)格式化输出到文件 outFile << record.getVehicle().getLicensePlate() << “,” << record.getEntryTime() << “,” << record.getExitTime() << “,” << record.getFee() << “\n”; } outFile.close(); }loadData()函数则是按行读取文件,解析出各个字段,重新构建ParkingRecord对象并加入allRecords。对于出场时间为-1的记录,还需要将其放入activeParkings中。
踩坑记录:文件读写最容易出问题的地方是格式。必须确保写入和读取的格式严格一致。我选择用逗号分隔(CSV格式),简单可靠。另外,每次程序退出(或关键操作后)调用
saveData(),程序启动时调用loadData(),这样才能保证数据一致性。记得处理文件打开失败的情况,给用户明确的错误提示。
3.2 车辆入场与出场流程实现
这是用户最常交互的两个功能,我们来看看具体实现。
3.2.1 车辆入场 (vehicleEntry)
bool ParkingSystem::vehicleEntry(const std::string& licensePlate) { // 1. 检查车辆是否已在场内 if (activeParkings.find(licensePlate) != activeParkings.end()) { std::cout << “错误:车牌号 ” << licensePlate << “ 的车辆已在停车场内!” << std::endl; return false; } // 2. 创建车辆和记录对象 Vehicle newVehicle(licensePlate); std::time_t now = std::time(nullptr); // 获取当前时间 ParkingRecord newRecord(newVehicle, now); // 3. 存入活跃记录map activeParkings[licensePlate] = newRecord; // 4. 同时存入总记录vector(此时出场时间为-1) allRecords.push_back(newRecord); std::cout << “车辆 ” << licensePlate << “ 入场成功,时间:” << std::ctime(&now); return true; }流程非常清晰:查重 -> 创建 -> 存储 -> 反馈。std::time(nullptr)获取当前系统的日历时间。std::ctime(&now)可以将时间戳转换成易读的字符串格式输出。
3.2.2 车辆出场与计费 (vehicleExit)出场逻辑比入场稍复杂,因为它涉及查找、计费、更新状态和移出活跃列表。
bool ParkingSystem::vehicleExit(const std::string& licensePlate) { // 1. 查找车辆 auto it = activeParkings.find(licensePlate); if (it == activeParkings.end()) { std::cout << “错误:未找到车牌号为 ” << licensePlate << “ 的在场车辆!” << std::endl; return false; } // 2. 获取记录,设置出场时间 ParkingRecord& record = it->second; std::time_t exitTime = std::time(nullptr); record.setExitTime(exitTime); // 3. 计算费用 if (billingStrategy) { double fee = billingStrategy->calculateFee(record.getEntryTime(), exitTime); record.setFee(fee); // 假设ParkingRecord有setFee方法 record.markAsPaid(); // 标记为已支付 } else { std::cerr << “计费策略未设置!” << std::endl; return false; } // 4. 显示账单 std::cout << “\n====== 停车账单 ======\n”; std::cout << “车牌号:” << licensePlate << “\n”; std::cout << “入场时间:” << std::ctime(&record.getEntryTime()); std::cout << “出场时间:” << std::ctime(&exitTime); std::cout << “应付费用:” << std::fixed << std::setprecision(2) << record.getFee() << “ 元\n”; std::cout << “=======================\n”; // 5. 从活跃map中移除 activeParkings.erase(it); // 6. 保存数据(可选,也可定期保存) saveData(); return true; }注意事项:这里有一个关键点,
allRecords里的记录和activeParkings里的记录,在出场前指向的是同一个对象吗?在我的设计里,不是。allRecords.push_back(newRecord)是值拷贝,activeParkings[licensePlate] = newRecord也是值拷贝。出场时,我们修改的是activeParkings中那个副本,然后这个副本被移除了。allRecords里的那个副本还是旧状态!这是一个严重的Bug。为了解决这个问题,我后来修改了设计,让activeParkings存储指向allRecords中元素的指针(或迭代器),或者只使用一个容器,通过状态字段来区分。这是初期设计考虑不周导致的,也提醒我们数据结构的设计需要深思熟虑。
3.3 查询与统计功能
一个基本的系统还需要数据查看功能。我实现了三个查询:
- 查看当前在场车辆:遍历
activeParkings这个map即可。 - 按车牌查询历史记录:遍历
allRecords这个vector,匹配车牌号。 - 统计今日营收:遍历
allRecords,筛选出出场时间在今天内的记录,累加费用。
这里重点讲一下今日营收统计,它涉及时间范围的判断。
double ParkingSystem::calculateDailyRevenue() const { double total = 0.0; std::time_t now = std::time(nullptr); std::tm* localNow = std::localtime(&now); int todayYear = localNow->tm_year; int todayMonth = localNow->tm_mon; int todayDay = localNow->tm_mday; for (const auto& record : allRecords) { if (record.isPaid()) { // 只统计已支付的 std::time_t exitTime = record.getExitTime(); if (exitTime != -1) { std::tm* localExit = std::localtime(&exitTime); // 比较年、月、日是否相同 if (localExit->tm_year == todayYear && localExit->tm_mon == todayMonth && localExit->tm_mday == todayDay) { total += record.getFee(); } } } } return total; }技术细节:
std::localtime函数将time_t时间戳转换为本地日历时间的结构体tm,从中可以取出年、月、日、时、分、秒等字段。注意tm_year是从1900开始的年数,tm_mon月份是0-11(0代表一月)。比较日期时,就是比较这三个字段是否完全相同。另外,std::localtime返回的是指向静态内存的指针,不是线程安全的,但在我们这个单线程控制台程序里没有问题。
4. 主程序架构与用户交互
4.1 控制台菜单驱动
为了让用户能操作,需要一个简单的文本菜单。我使用一个while循环来持续显示菜单并处理用户输入。
void runParkingSystem() { ParkingSystem system; system.loadData(); // 启动时加载数据 // 设置计费策略,例如:每小时5元,前15分钟免费 system.setBillingStrategy(std::make_unique<HourlyBilling>(5.0, 15)); int choice = 0; do { printMenu(); std::cin >> choice; std::cin.ignore(); // 清除输入缓冲区中的换行符 switch (choice) { case 1: { // 车辆入场 std::string plate; std::cout << “请输入车牌号:”; std::getline(std::cin, plate); system.vehicleEntry(plate); break; } case 2: { // 车辆出场 std::string plate; std::cout << “请输入车牌号:”; std::getline(std::cin, plate); system.vehicleExit(plate); break; } case 3: // 查看在场车辆 system.showActiveVehicles(); break; case 4: // 查询历史记录 // ... 输入车牌查询 break; case 5: // 统计营收 std::cout << “今日总营收:” << system.calculateDailyRevenue() << “ 元\n”; break; case 6: // 保存并退出 system.saveData(); std::cout << “数据已保存,再见!\n”; break; default: std::cout << “无效选项,请重新输入!\n”; } std::cout << “\n按回车键继续...”; std::cin.get(); // 暂停一下,让用户看清结果 } while (choice != 6); }交互细节:这里有两个容易忽略的输入处理技巧。第一,
std::cin >> choice之后,输入缓冲区会留下一个换行符\n。如果接下来直接用std::getline(std::cin, plate)读取车牌号,getline会立刻读到这个换行符,得到一个空字符串。所以需要用std::cin.ignore()忽略掉它。第二,在每个操作结束后,我用std::cin.get()暂停程序,等用户按回车后再清屏(或直接)显示主菜单,这样用户就有时间看清操作结果,体验更好。
4.2 错误处理与输入验证
一个健壮的程序必须处理各种非法输入。对于菜单选择,要处理非数字输入;对于车牌号,可以做简单的格式检查(比如不能为空)。
int getMenuChoice() { int choice; while (true) { std::cout << “请选择操作 (1-6): “; if (std::cin >> choice) { if (choice >= 1 && choice <= 6) { std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); return choice; } else { std::cout << “输入错误,请输入1-6之间的数字!\n”; } } else { // 输入的不是数字,清除错误状态和缓冲区 std::cin.clear(); std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); std::cout << “输入错误,请输入一个有效的数字!\n”; } } }这个getMenuChoice函数实现了完整的输入验证和错误恢复。std::cin >> choice如果失败(比如用户输入了字母),cin会进入错误状态,后续所有输入操作都会失败。所以需要用std::cin.clear()清除错误状态,再用std::cin.ignore清空输入缓冲区,然后重新提示用户输入。std::numeric_limits<std::streamsize>::max()表示忽略缓冲区中直到换行符的所有字符。
5. 项目编译、运行与扩展思考
5.1 环境搭建与编译命令
这个项目是纯C++标准库实现,不依赖任何第三方库,因此可以在任何支持C++11及以上标准的编译器中运行。我推荐使用GCC(MinGW-w64) 或Clang在命令行编译,或者使用Visual Studio创建控制台项目。
使用GCC/Clang命令行编译:假设你的源码文件是main.cpp,parkingsystem.cpp,parkingsystem.h等。
# 编译所有.cpp文件,生成可执行文件parking_system.exe (Windows) 或 parking_system (Linux/macOS) g++ -std=c++11 -o parking_system main.cpp parkingsystem.cpp vehicle.cpp billing.cpp # 或者使用clang++ clang++ -std=c++11 -o parking_system main.cpp parkingsystem.cpp vehicle.cpp billing.cpp-std=c++11指定使用C++11标准,确保智能指针等功能可用。-o指定输出文件名。
在Visual Studio中:
- 新建一个“控制台应用”项目。
- 将所有的
.h和.cpp文件添加到项目中。 - 确保项目属性中“C++语言标准”设置为“ISO C++11标准”或更高。
- 直接点击“本地Windows调试器”运行即可。
5.2 运行效果与测试建议
编译成功后,运行程序,你会看到一个简单的文本菜单。建议你按以下流程测试,覆盖主要功能分支:
- 正常流程:输入车牌
京A12345入场 -> 再次输入同一车牌,应提示“车辆已在场” -> 输入另一车牌沪B67890入场 -> 选择“查看在场车辆”,应看到两辆车 -> 为京A12345办理出场,应正确计算并显示费用 -> 再次查看在场车辆,应只剩沪B67890-> 查询京A12345的历史记录,应能看到完整记录和费用 -> 统计营收,应包含刚收取的费用。 - 边界测试:测试免费时长(停车14分钟出场,费用应为0)。测试不足一小时按一小时计(停车61分钟,按2小时计费)。
- 异常测试:出场时输入一个不存在的车牌号,程序应给出友好提示,而不是崩溃。
- 数据持久化测试:录入几辆车后,选择“保存并退出”。重新启动程序,选择“查看在场车辆”或“查询历史记录”,检查数据是否成功加载。
5.3 可能的扩展方向与进阶思考
这个基础版本完成了核心功能,但离一个“实用”的系统还有距离。你可以尝试以下扩展,这会让你的C++和软件设计能力再上一个台阶:
修复数据结构缺陷:如前所述,当前
activeParkings和allRecords存储了数据副本,导致状态不一致。进阶实现:让activeParkings存储指向allRecords中元素的指针(ParkingRecord*)或迭代器(std::vector<ParkingRecord>::iterator)。出场时,通过指针/迭代器直接修改allRecords中的对象,然后从activeParkings中移除该指针/迭代器。这涉及到对STL迭代器失效规则的深入理解。引入更复杂的计费策略:实现
DailyMaxBilling(每日封顶)、NightBilling(夜间半价)等策略。这需要你设计更复杂的BillingStrategy子类,并在ParkingSystem中能够动态切换或组合策略。改用数据库存储:将文件存储替换为SQLite数据库。你可以学习使用C++的SQLite API(如
sqlite3库)。定义ParkingRecords表,使用SQL语句进行增删改查。这能让你接触真实的数据持久化方案。增加图形化界面:使用Qt或Dear ImGui等库为系统制作一个简单的图形界面。把控制台的菜单和输入输出,换成按钮、文本框和表格。这会是一个全新的挑战,但成就感十足。
实现多线程与网络通信(高级):模拟多个入口/出口同时操作。你可以用
std::thread创建多个线程模拟并发入场出场。此时,对activeParkings和allRecords的访问就需要加锁(如std::mutex),这是学习并发编程的好例子。更进一步,可以设计一个客户端-服务器模型,服务器运行核心逻辑,多个控制台或GUI客户端通过网络连接操作。
这个停车场收费系统项目,就像一块很好的“毛坯房”。基础框架我已经搭好了,里面的装修、扩建、甚至改建,都留给了你。每解决一个上述的扩展问题,你都会对C++和软件工程有更深的理解。编程学习,最好的方式就是从一个能运行的小项目开始,然后不断地“折腾”它,在解决问题的过程中成长。希望这份源码和解析能成为你“折腾”之旅的一个可靠起点。