C++通讯录管理系统:面向对象三大特征与文件持久化实战
2026/9/16 18:34:13 网站建设 项目流程

简介:一份基于C++面向对象思想实现的通讯录管理系统完整工程,适合正在学习OOP、文件I/O与STL的初学者及课程设计使用者。项目通过Contact、AddressBook等类,完整实现联系人添加、查找、修改、删除、列表显示以及文件保存与加载功能,代码结构清晰,便于二次扩展。实现中运用std::vector或std::map管理联系人数据,借助fstream完成持久化,并包含输入验证与交互提示等细节,能帮助读者理解真实管理系统的搭建思路。压缩包共22个文件,约7.93MB,核心包括cpp源码、exe可执行程序、Visual Studio工程文件(sln/vcxproj)及编译调试过程生成的pdb、obj、tlog等辅助文件,可直接打开运行或进行断点调试。目前已有1467人学习下载。借助这份代码,读者既能参考面向对象封装与交互逻辑设计,也能对照编译产物理解构建流程,适合用于课程设计、期末项目或C++编程进阶练习。

1. 引入通讯录管理系统:C++面向对象三大特征的最小落地容器

通讯录管理系统是C++课程设计和入门进阶里出现率最高的题目之一,它的核心价值不在“通讯录”本身,而在于它刚好覆盖了面向对象的三件事:用类对现实事物建模、用私有成员把数据保护起来、用接口把行为暴露给外部。它天然需要一个持久化环节——用户录入的联系人不能程序一关就没了,这就逼着你去碰文件读写和对象与字节流之间的转换。加上逻辑量适中,不依赖第三方库,纯标准库加一个控制台就能跑通,适合用来检验自己对类的设计、STL容器选择和防御式编程的掌握程度。下文以课程设计最常见的形态为底,给出类设计、增删改查、文件存储和扩展路径。C++新手可以照代码敲一遍,有经验的工程师也可以对照检查自己的类边界和异常处理习惯。

2. 面向对象建模:Contact与ContactBook的类边界怎么划才不别扭

2.1 Contact为什么必须用私有成员而不是struct裸字段

初学C++写通讯录时,最自然的冲动是定义一个struct,里面放name、phone、email四个字段,然后在main里直接操作这个结构体数组。这样做在只有几百行代码时确实能跑,但一旦要在多个函数里传参、做排序、做查找,就会发现问题:数据字段散落在外,任何函数都能随意改动联系人内容,改到一半才发现“这名字为空行不行?电话带不带区号?”这些约束根本没有地方去写。面向对象的做法是把约束和字段绑在一起,用private把数据关进黑盒,只留必要的读写接口。

我一般会把Contact设计成值对象,只负责“一个联系人是什么样”,不关心它被存在哪里:

// contact.h #ifndef CONTACT_H #define CONTACT_H #include <string> #include <iostream> class Contact { private: std::string name_; std::string phone_; std::string email_; std::string note_; public: Contact() = default; Contact(const std::string& name, const std::string& phone, const std::string& email = "", const std::string& note = "") : name_(name), phone_(phone), email_(email), note_(note) {} const std::string& name() const { return name_; } const std::string& phone() const { return phone_; } const std::string& email() const { return email_; } const std::string& note() const { return note_; } void set_phone(const std::string& phone) { phone_ = phone; } void set_email(const std::string& email) { email_ = email; } void display() const { std::cout << "姓名: " << name_ << " 电话: " << phone_; if (!email_.empty()) { std::cout << " 邮箱: " << email_; } if (!note_.empty()) { std::cout << " 备注: " << note_; } std::cout << std::endl; } }; #endif

这段代码里有几个值得留意的点。name()phone()返回的是const std::string&,不是std::string,避免每次取值都触发一次字符串拷贝,也不是const char*,保留字符串的语义操作;调用方如果只需要读,那就只能拿到const引用,想修改必须走set接口。Contact() = default;保留默认构造,让std::vector<Contact>能在扩容时无参构造对象,而带参构造则用初始化列表直接给四个字段赋值,不进函数体再赋一遍。

display是一个const成员函数,声明为const的意思是“这个函数不修改对象状态”,这样const Contact&也能调用。可以在find返回的只读对象上直接打印,编译器会帮你拦住“在const函数里改字段”的误操作,这道防线比注释可靠得多。

2.2 ContactBook的职责:容器、操作、持久化三合一

联系人实体定义好后,还需要一个管理层。我不建议把所有功能都塞进main函数的switch里,原因有二:一是switch分支里写太多逻辑后,函数体动辄两三百行,后面改一个查询规则就要在main里翻半天;二是通讯录的“存放介质”可能是vector、可能是list、也可能是数据库,把容器操作隔离在一个类里,以后换存储结构时调用方不用跟着改。

ContactBook的接口设计为:

// contact_book.h #ifndef CONTACT_BOOK_H #define CONTACT_BOOK_H #include <vector> #include <string> #include "contact.h" class ContactBook { private: std::vector<Contact> contacts_; std::string filename_; std::vector<std::string> split(const std::string& line, char delim) const; public: explicit ContactBook(const std::string& filename) : filename_(filename) {} bool load(); bool save() const; void add(const Contact& c); bool remove_by_name(const std::string& name); std::vector<Contact*> find_by_name(const std::string& keyword); void sort_by_name(); void list() const; size_t size() const { return contacts_.size(); } }; #endif

构造函数是explicit的,防止ContactBook book = "data.tsv";这种隐式转换把字符串悄悄变成一个通讯录对象。add接收const Contact&,只做“把对象复制进容器”这一件事,读入数据、校验合法性由main层负责,这样层与层的职责是单向的。size()返回size_t而不是int,因为vector的大小类型就是size_t,用int会在编译期产生符号性转换警告,这个警告在开发期能帮你发现“用负数判断容器大小”的蠢代码。

联系人对象是存储在std::vector里的值,而不是指针。为什么要用值?因为在“增删改查”这个场景里,联系人的生命周期完全属于ContactBook,不存在多个地方共享同一个联系人对象的情况。值存储让内存管理交给vector自己处理,不需要写析构函数,也不容易造成内存泄漏。下表总结了两个类的分工:

角色持有数据对外的承诺
Contact值对象姓名、电话、邮箱、备注字段只读,行为内聚
ContactBook聚合服务vector容器、文件名持久化、增删改查、排序

2.3 多文件编译与头文件包含:Visual C++环境下最容易翻车的环节

写好的三个文件是contact.hcontact_book.hcontact_book.cpp,再加上后面会写的main.cpp。头文件里都写了#ifndef宏保护,防止一个编译单元里重复包含同一个头文件。很多人在Visual Studio里新建多个cpp文件后直接按F5,报错说“无法解析的外部符号”,原因通常是contact_book.cpp没有加入项目源文件,或者函数定义没写。命令行编译时用:

# MinGW / Linux GCC 下编译多文件项目 g++ -std=c++17 -Wall -Wextra main.cpp contact_book.cpp -o address_book.exe

-Wall-Wextra打开警告,宁可编译时多几条提醒,也不要运行时崩溃。在Windows上用Visual Studio时,要确认contact_book.cppmain.cpp都在项目的“源文件”里,头文件放到“头文件”目录只是给工程看组织结构用的,真正参与编译的是cpp文件本身。

3. 数据落盘与核心操作:文件读写、添加删除查询的完整代码

3.1 菜单循环与std::cin的换行残留处理

控制台通讯录的交互本质是一个无限循环,反复读取用户输入的数字,再分发到对应的操作分支。这个循环本身不难,难在每次读数字之后如何吞掉残留的回车符。C++里std::cin >> choice读到的是整数,但用户输入1之后按下的那个换行符还留在输入缓冲区里,如果下一行立刻调用std::getline读取姓名,读到的就会是一个空字符串,表现为“我输名字怎么直接跳过了”。

处理方式是在每次读完数字之后主动清掉缓冲区的遗留内容:

// main.cpp(片段) #include <iostream> #include <limits> int choice = -1; while (choice != 0) { std::cout << "\n==== 通讯录管理系统 ====\n" << "1. 添加联系人\n" << "2. 删除联系人\n" << "3. 查询联系人\n" << "4. 显示所有联系人\n" << "5. 按姓名排序\n" << "6. 保存到文件\n" << "7. 从文件加载\n" << "0. 退出\n" << "请选择: "; std::cin >> choice; if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); std::cout << "输入无效,请输入数字。\n"; continue; } std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); switch (choice) { case 1: { std::string name, phone, email, note; std::cout << "姓名: "; std::getline(std::cin, name); std::cout << "电话: "; std::getline(std::cin, phone); std::cout << "邮箱(可空): "; std::getline(std::cin, email); std::cout << "备注(可空): "; std::getline(std::cin, note); book.add(Contact(name, phone, email, note)); std::cout << "已添加。\n"; break; } case 2: { std::string name; std::cout << "输入要删除的姓名: "; std::getline(std::cin, name); std::cout << (book.remove_by_name(name) ? "删除成功。\n" : "未找到该联系人。\n"); break; } // 其余case省略,按同样模式实现 case 0: std::cout << "退出程序。\n"; break; default: std::cout << "无效选项。\n"; } }

注意ignore的两个参数:第一个是最大丢弃字符数,用std::numeric_limits<std::streamsize>::max()表示“不限多少字符”,第二个是停止字符,'\n'表示遇到换行就停。这样无论用户输入的是1还是1 后面跟一堆空格再加回车,缓冲区都会被清干净。std::cin.fail()分支处理的是用户输入了字母或符号的情况,清空failbit后再清缓冲区,否则后续所有输入都会失败。

3.2 TSV格式的save与load:让通讯录存进文件还能读回来

通讯录的持久化有两条路:一条是把对象二进制序列化后存文件,简单粗暴,但文件和编译器版本绑定,换个编译器可能就读不出来了;另一条是存成文本格式,每行一条记录,字段用Tab分隔。文本格式的优点是能直接用文本编辑器打开,损坏时也能定位到具体行,我倾向于用TSV而非CSV,因为姓名不受逗号影响时不用处理转义。

下面给出ContactBook的两个关键方法:

// contact_book.cpp #include <fstream> #include <sstream> #include <algorithm> #include "contact_book.h" std::vector<std::string> ContactBook::split(const std::string& line, char delim) const { std::vector<std::string> tokens; std::stringstream ss(line); std::string token; while (std::getline(ss, token, delim)) { tokens.push_back(token); } return tokens; } bool ContactBook::save() const { std::ofstream out(filename_); if (!out.is_open()) { return false; } for (const auto& c : contacts_) { out << c.name() << '\t' << c.phone() << '\t' << c.email() << '\t' << c.note() << '\n'; } return out.good(); } bool ContactBook::load() { std::ifstream in(filename_); if (!in.is_open()) { return false; } std::string line; while (std::getline(in, line)) { if (line.empty()) { continue; } auto parts = split(line, '\t'); if (parts.size() < 2) { continue; // 损坏行,跳过 } std::string email = parts.size() >= 3 ? parts[2] : ""; std::string note = parts.size() >= 4 ? parts[3] : ""; contacts_.emplace_back(parts[0], parts[1], email, note); } return true; }

save里需要注意out.good()的判断:写完所有行之后,缓冲区的错误状态可能导致最后一个\n没有真正落盘,检查这个返回值是为了确认文件写入过程中没有发生磁盘错误。load里对每行先split再判断parts.size(),意在一份可能被手工改坏的文件不至于让整个程序崩溃,缺邮箱或缺备注的行会被填上空字符串。emplace_back直接在vector尾部就地构造Contact对象,省略了“先构造再拷贝”的中间步骤。

3.3 删除用erase-remove惯用法,不要手动erase循环

删除联系人时,新手常见的写法是遍历vector,找到匹配的姓名后立刻调用contacts_.erase(it)。这在只有一个匹配项时正确,但如果有两个人同名,循环里erase之后迭代器就会失效,继续it++就是未定义行为,程序可能不崩溃,也可能随机崩溃,排错成本极高。

标准做法是erase-remove惯用法:

bool ContactBook::remove_by_name(const std::string& name) { auto it = std::remove_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name() == name; }); if (it == contacts_.end()) { return false; } contacts_.erase(it, contacts_.end()); return true; }

std::remove_if做的事不是“删除”,而是把符合条件的元素搬到容器的末尾,并返回一个迭代器,指向“第一个被搬走的元素”。真正的删除由后面这行erase(it, contacts_.end())完成。lambda捕获了外部变量name的引用,按姓名比对联系人。这个惯用法一次能删掉所有同名联系人,且不会让迭代器失效,面试时也常被问到,在通讯录管理系统里写它是很自然的落点。

3.4 按姓名排序:std::sort和std::stable_sort怎么选

给通讯录加一个按姓名排序的功能只要几行,但排序稳定性的选择值得想一下。如果之后给联系人加上“分组”字段,比如家人、同事、朋友,你会希望先按分组排,组内再按姓名排,这时就必须用稳定排序,否则人员会在不同分组间跳来跳去。

void ContactBook::sort_by_name() { std::sort(contacts_.begin(), contacts_.end(), [](const Contact& a, const Contact& b) { return a.name() < b.name(); }); }

std::sort不保证稳定,但对纯粹按姓名排序的场景足够了。如果排序键退化为“先按分组,再按姓名”,把lambda换成:

[](const Contact& a, const Contact& b) { if (a.group() != b.group()) { return a.group() < b.group(); } return a.name() < b.name(); }

并把std::sort换成std::stable_sort,就能保持同组内原有的先后顺序。std::sort在数据量小时混合使用插入排序,数据量大时用快排,平均复杂度O(n log n),而面试里爱问的冒泡排序是O(n²),在这个系统里除了加深对循环的理解,没有实际意义,工程上别用它。

4. 边界处理与坑:输入校验、vector选型、文件损坏

4.1 vector为什么比list更适合通讯录场景

通讯录的增删操作发生在任意位置,看起来std::list更合适?实际上正好相反。一个联系人的Contact对象大约包含四个字符串,占几十字节,std::vector存储连续内存,拷贝几十字节的开销远小于list为每个节点单独分配内存的malloc开销。查找联系人时,list只能挨个指针跳着访问,cache miss严重;vector的连续内存让CPU预取友好。除非通讯录有几百万条数据且大量在头部插入,否则list没有胜出的理由。

vector唯一的坑是扩容带来的迭代器失效。添加联系人时,如果容量不够,vector会重新分配更大的内存块,把所有旧元素拷贝过去,此时之前取得的Contact*或迭代器全部失效。解决方式是在不会增删的循环里使用下标访问,或者等全部添加完之后再做查找和排序。另外,reserve可以预分配容量,比如初始化时调用contacts_.reserve(1000),减少反复malloc的次数。

4.2 防御式编程:空名字、长电话、损坏文件三条线

面向对象设计约束了“怎么访问数据”,但管不住用户输入什么内容。运行一段稳定不崩的程序,至少需要处理三类脏数据。

第一类是空姓名。所有操作都以姓名为索引,空姓名会导致删除时误删全部空名联系人,查找时匹配到一堆空白。我一般在add之前检测:名字去掉首尾空格后长度小于1,就拒绝写入,提示重新输入。

第二类是电话内容不合法。实务做法是只限制长度,不强制格式,因为国际长途加号、分机号、英文连字符都可能出现,写死“必须全是数字”反而误伤。你可以在add时做一次轻量校验:

bool is_valid_phone(const std::string& phone) { // 至少3个字符,总长不超过20,允许 + - 空格 数字 if (phone.empty() || phone.size() > 20) { return false; } for (char ch : phone) { if (!isdigit(static_cast<unsigned char>(ch)) && ch != '+' && ch != '-' && ch != ' ') { return false; } } return true; }

第三类是文件损坏。load里对每行split后要检查字段数,字段数不足的行直接跳过;更严格的做法是记录损坏行号,加载完成后打印“第X行数据格式有误,已跳过”,而不是让程序静默丢数据。下面是结合表格的对比:

异常场景不处理时的表现推荐处理方式
姓名输入为空删除/查找行为诡异校验后拒绝写入
电话含除+/-/数字外字符数据污染,无法拨号限制字符集和长度
数据文件某行缺列split后访问越界检查parts.size()后跳过
输出目录不可写save返回失败,数据丢失检查out.is_open()并返回false

4.3 内存生命周期:值语义优先于裸指针的底层逻辑

如果把std::vector<Contact>换成std::vector<Contact*>,并手动new每个联系人,系统中就会多出两类bug:忘记delete导致内存泄漏,或者两个指针指向同一个对象导致二次delete。面向对象不代表“没事就用指针”,值语义让对象在离开作用域时自动析构,这是RAII的核心。只有当对象需要被多态使用时,才需要指针,而且优先用std::unique_ptr,这个点下一章会展开。

cppcheck和AddressSanitizer是这类小项目的质量保证工具,编译时加-fsanitize=address -g,运行结束后如果有内存问题,会自动输出堆栈。这个开关只在调试期打开,发布时去掉。

5. 用多态和一组黄金用例验证系统健壮性

5.1 用抽象基类引入联系人类型扩展

基础版的Contact类是普通类,没有继承关系。想展示面向对象的继承与多态,可以引入一个抽象基类,让朋友和工作联系人各自实现展示逻辑:

class ContactBase { public: virtual ~ContactBase() = default; virtual void display() const = 0; virtual const std::string& name() const = 0; }; class FriendContact : public ContactBase { public: FriendContact(const std::string& name, const std::string& phone, const std::string& birthday) : name_(name), phone_(phone), birthday_(birthday) {} const std::string& name() const override { return name_; } void display() const override { std::cout << "[朋友] " << name_ << " 电话:" << phone_ << " 生日:" << birthday_ << std::endl; } private: std::string name_; std::string phone_; std::string birthday_; };

有了虚函数,ContactBook可以改成持有std::vector<std::unique_ptr<ContactBase>>,这样display()调用时会根据对象实际类型进入不同分支,实现多态。注意基类析构函数必须是virtual,否则通过基类指针删除派生类对象会漏掉派生类部分的资源。这一步的价值在于:通讯录类型从“朋友/同事”扩展到“客户/家人”时,不用改ContactBook的循环代码,只用增加新的派生类。实际课程设计中如果题目没有要求多态,可以保留简单版,但把这一节作为架构演进方向来理解。

5.2 用黄金用例表做回归验证

写功能只算完成一半,验证要有一套固定的用例,每次改动后跑一遍同一批数据,确认老功能没被改坏:

编号操作序列期望结果
G1空库添加 A/123,保存后退出contacts.tsv 出现一行 A\t123\t\t
G2重新加载文件,显示全部联系人 A 仍在,字段不丢失
G3删除不存在的 Z提示“未找到”,列表不变
G4添加 A 和 B 后按姓名排序A 在 B 前,且二者顺序稳定
G5手工删掉tsv中一行电话号码程序加载不崩溃,提示跳过坏行
G6退出前不保存,重新加载文件程序内是新数据,文件里还是旧数据,结果可预期

验证命令可以写成一个shell脚本,编译后逐条跑并比对输出:

g++ -std=c++17 -Wall -Wextra main.cpp contact_book.cpp -o address_book echo -e "7\n4\n0\n" | ./address_book # 加载并列出

管道符把三行输入喂给程序,自动验证第G2条。跑通了这一套的语义之后,你手里就有一个可以放心往上叠加功能的基底,后续加分组、加模糊搜索、加批量导入,都不必担心把已经稳定的功能弄坏。

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

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

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

立即咨询