30、综合项目实战:图书管理系统或学生成绩管理系统

终于走到这一步了。说实话,每次我带新人做这个综合项目时,都能看到他们眼睛一亮——前面学了一堆零散的知识点,终于能串起来了。我自己当年学C++时,也是做完这个项目后,才真正感觉自己「入门了」。

这个项目,说白了就是把你学过的面向对象、STL、文件操作、多线程这些东西,揉进一个完整的系统里。我选的是图书管理系统来讲,因为它的业务逻辑更直观。你如果喜欢做成绩管理,换一下数据模型就行,架构完全通用。

项目需求概览

先看看我们要做什么:

功能模块 说明
图书管理 增删改查图书信息(书名、作者、ISBN、库存)
用户管理 读者注册、登录、权限控制
借还书 借书、还书、逾期计算
数据持久化 程序退出后数据不丢失,用文件存储
并发支持 多用户同时操作时数据安全

嗯,这里要注意:不要一上来就写代码。我见过太多人直接开干,写到一半发现类设计有问题,回头重构,浪费时间。先画图,再动手。

整体架构设计

我习惯用分层架构来做这种系统。下面这张图展示了核心模块之间的关系:

图书管理系统架构图 用户界面层 (CLI / 控制台) 业务逻辑层 BookManager | UserManager | BorrowManager 多线程锁控制 | 事务管理 数据访问层 (FileDAO) 文件读写 | 序列化/反序列化 数据存储层 (books.dat / users.dat)

你想想看,每一层只负责自己的事。UI层只管展示和接收输入,业务层只管逻辑判断,数据层只管读写文件。这样改一个地方,不会牵一发动全身。

核心类设计

我直接给出类的骨架,你感受一下:

// 图书实体类
class Book {
public:
    string isbn;
    string title;
    string author;
    int totalCopies;
    int borrowedCopies;
    
    // 序列化到字符串,方便文件存储
    string serialize() const;
    static Book deserialize(const string& data);
};

// 用户实体类
class User {
public:
    string userId;
    string name;
    vector<string> borrowedBooks;  // 借的书的ISBN列表
    double fine;                   // 罚款金额
    
    string serialize() const;
    static User deserialize(const string& data);
};

// 图书管理器
class BookManager {
private:
    unordered_map<string, Book> books;  // ISBN -> Book
    mutable shared_mutex mtx;           // 读写锁
public:
    bool addBook(const Book& book);
    bool removeBook(const string& isbn);
    Book* findBook(const string& isbn);
    bool borrowBook(const string& isbn);
    bool returnBook(const string& isbn);
    void saveToFile(const string& filename);
    void loadFromFile(const string& filename);
};

关键点:我用 shared_mutex 来实现读写锁。读操作可以并发,写操作必须独占。这在多线程场景下性能提升很明显。我之前做过一个项目,用普通 mutex 锁整个容器,结果并发一高就卡死,换成 shared_mutex 后吞吐量翻了三倍。

多线程安全:借书操作的完整流程

借书这个操作,看起来简单,但多线程下容易出问题。你想想看:两个线程同时检查库存,都发现还剩1本,然后都执行借出,库存就变成-1了。这就是经典的竞态条件

我的做法是这样的:

bool BookManager::borrowBook(const string& isbn) {
    // 写操作,用独占锁
    unique_lock lock(mtx);
    
    auto it = books.find(isbn);
    if (it == books.end()) return false;
    
    if (it->second.borrowedCopies >= it->second.totalCopies) {
        return false;  // 库存不足
    }
    
    it->second.borrowedCopies++;
    return true;
}

注意:千万不要在锁外面检查库存、在锁里面修改。我曾经犯过这个错——先读库存,发现够,然后加锁,再修改。结果两个线程在「读库存」那一步都通过了,最后都执行了修改。这个bug在测试时很难复现,上线后才暴露,教训深刻。

文件操作:数据持久化

我选择用文本文件 + 自定义序列化格式。为什么不用二进制?因为文本文件出错了还能手动编辑修复,调试起来方便。

void BookManager::saveToFile(const string& filename) {
    shared_lock lock(mtx);  // 读操作,共享锁
    ofstream ofs(filename);
    if (!ofs) {
        cerr << "无法打开文件: " << filename << endl;
        return;
    }
    
    for (const auto& [isbn, book] : books) {
        ofs << book.serialize() << "\n";
    }
}

void BookManager::loadFromFile(const string& filename) {
    unique_lock lock(mtx);  // 写操作,独占锁
    ifstream ifs(filename);
    if (!ifs) return;  // 文件不存在,不报错
    
    books.clear();
    string line;
    while (getline(ifs, line)) {
        if (line.empty()) continue;
        Book book = Book::deserialize(line);
        books[book.isbn] = book;
    }
}

序列化格式我设计成这样的:

// 每行一条记录,字段用 | 分隔
// 图书: B|ISBN|书名|作者|总库存|已借出
// 用户: U|用户ID|姓名|已借书ISBN列表(逗号分隔)|罚款

// 示例数据:
B|978-7-111-12345-6|C++ Primer|Lippman|5|2
B|978-7-111-67890-1|Effective Modern C++|Meyers|3|1
U|U001|张三|978-7-111-12345-6|0.0
U|U002|李四|978-7-111-12345-6,978-7-111-67890-1|5.5

小技巧:| 做分隔符,是因为书名里可能有逗号,但很少有条形符。如果你用CSV格式,书名带逗号就解析错了。这个坑我踩过,后来统一改用 | 再也没出过问题。

STL容器选型心得

这个项目里我用到了几个STL容器,说说我的选择理由:

容器 用途 为什么选它
unordered_map 按ISBN查书、按ID查用户 O(1)查找,比map快。不需要排序
vector 用户借书记录列表 数量不大,随机访问多,插入删除少
shared_mutex 读写锁 读多写少的场景,性能最优
string 所有文本数据 现代C++ string 够快,别用char*了

说实话,选容器这事,很多人纠结半天。我的建议是:先用最直观的,性能不够再优化。比如你一开始用 map 完全没问题,等数据量到十万级了,再考虑换成 unordered_map 也不迟。

主程序流程

最后看看主函数怎么把这些串起来:

int main() {
    BookManager bookMgr;
    UserManager userMgr;
    
    // 启动时加载数据
    bookMgr.loadFromFile("books.dat");
    userMgr.loadFromFile("users.dat");
    
    // 启动后台线程:每天检查逾期
    thread overdueThread([&]() {
        while (true) {
            this_thread::sleep_for(chrono::hours(24));
            userMgr.checkOverdueBooks(bookMgr);
            userMgr.saveToFile("users.dat");
        }
    });
    
    // 主线程:处理用户交互
    while (true) {
        showMenu();
        int choice = getChoice();
        switch (choice) {
            case 1: handleBorrow(bookMgr, userMgr); break;
            case 2: handleReturn(bookMgr, userMgr); break;
            case 3: handleAddBook(bookMgr); break;
            // ... 其他选项
            case 0: 
                // 退出前保存数据
                bookMgr.saveToFile("books.dat");
                userMgr.saveToFile("users.dat");
                return 0;
        }
    }
    
    overdueThread.detach();  // 或者 join,看设计
    return 0;
}

核心思路:后台线程做定时任务(检查逾期),主线程做交互。退出时一定要保存数据,不然用户白干了。我见过有人程序崩溃没来得及保存,用户借书记录全丢了,那场面...嗯,你懂的。

总结一下这个项目教会你什么

  • 面向对象:用类封装数据和操作,用继承实现不同用户类型(普通用户、管理员)
  • STL:unordered_map 做快速查找,vector 做列表,shared_mutex 做并发控制
  • 文件操作:序列化/反序列化,程序启动加载、退出保存
  • 多线程:后台线程做定时任务,读写锁保证数据安全
  • 分层架构:UI、业务、数据三层分离,好维护好扩展

做完这个项目,你基本就能应付大部分C++开发岗位的面试了。别问我怎么知道的——我面过的人里,能把这种综合项目讲清楚的,最后都拿到了offer。


公众号:蓝海资料掘金营,微信deep3321