前言
"用 C++ 写一个通讯录"是很多人学完struct、std::vector和文件流之后的第一个综合练习。题目看着简单,但它一次性把几个真正容易出错的地方串在一起:数据结构怎么选、增删改查的接口怎么设计、输入缓冲区怎么处理、数据怎么落盘。
一个常见的做法是定义一个全局的Contact数组,然后写一堆void add() { ... }这样的函数,用cin >> name读字符串。这种写法能跑起来,但一旦姓名里带空格就会截断,一旦记录数超过数组容量就会越界写,删除操作还要腾挪数组元素。本文不写这种"能跑但到处都是坑"的版本。
本文实现一个基于std::vector和std::string的通讯录:用class把数据和对数据的操作封装在一起,用std::find_if做查找,用制表符分隔的文本文件做持久化,输入全部走std::getline以避免被空格和换行坑到。代码以 C++17 为基准,在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译,只需要 C++ 标准库,不依赖任何第三方库。
一、数据模型与接口设计
一条联系人记录需要三个字段:姓名、电话、邮箱。用std::string而不是char[32],原因是后者需要手动管理长度、容易截断、复制时还要考虑strcpy的缓冲区溢出风险。
struct Contact { std::string name; std::string phone; std::string email; };用struct而不是class,是因为这是一个纯数据聚合(aggregate),没有不变量需要维护——任何name、phone、email的组合都是合法的。而"通讯录"这个整体是有不变量的(比如姓名唯一、不能插入空姓名),所以它适合用class封装:
class AddressBook { public: bool add(const Contact& c); // 新增,失败返回 false bool remove(const std::string& name); // 按姓名删除 bool update(const std::string& name, const Contact& fresh); const Contact* find(const std::string& name) const; // 找不到返回 nullptr std::vector<Contact> search(const std::string& keyword) const; std::vector<Contact> sorted() const; // 按姓名排序后的副本 std::size_t size() const; bool save(const std::string& path) const; bool load(const std::string& path); private: std::vector<Contact> contacts_; };接口设计上有三点值得说:
第一,find返回的是const Contact*而不是Contact。因为"找不到"是一个必须表达的合法结果,返回指针时用nullptr表达最自然。
第二,sorted()返回的是副本而不是内部容器的引用。这样调用方可以随意处理,不会破坏内部的插入顺序,也避免了"暴露内部容器导致外部可以随意修改"的封装问题。
二、增删改查的实现
查找是所有操作的基础,用标准库算法而不是手写下标循环:
const Contact* AddressBook::find(const std::string& name) const { auto it = std::find_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name == name; }); return it == contacts_.end() ? nullptr : &*it; }std::find_if的第三个参数是一元谓词,返回bool。这里用捕获引用的 lambda[&name]避免每次都拷贝一份姓名。&*it中*it得到const Contact&,取地址得到const Contact*——这正是const成员函数下begin()返回const_iterator的结果类型,类型匹配。
删除操作的关键点是删除之后迭代器失效,所以不能一边遍历一边删。用std::find_if找到位置后直接用erase删掉,是最干净的写法:
bool AddressBook::remove(const std::string& name) { auto it = std::find_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name == name; }); if (it == contacts_.end()) { return false; } contacts_.erase(it); // erase 之后 it 及之后的迭代器全部失效 return true; }这里要区分两种erase:std::vector::erase会让被删除位置及其后的所有迭代器失效;而std::vector::push_back在触发扩容时会让全部迭代器、指针、引用失效。下面这个写法就是典型的错误:
// ❌ 错误:push_back 触发扩容后,it 已经悬垂 for (auto it = v.begin(); it != v.end(); ++it) { if (need_copy(*it)) { v.push_back(*it); // it 可能失效,下一次 ++it 是 UB } }新增操作需要检查两个前置条件:姓名非空、姓名不重复;排序展示则是"拷贝一份再排",不破坏内部的插入顺序。这两处的实现见第四节完整清单。这里只强调一点:std::sort要求比较函数必须是严格弱序(strict weak ordering),a.name < b.name满足条件,而a.name <= b.name不满足——排序过程中可能出现自比较返回true,属于 UB,标准不保证任何行为。
三、持久化:制表符分隔的文本文件
落盘格式选简单的制表符分隔(TSV)。不选 CSV 是因为要处理逗号和引号转义,对练习程序不划算。必须避开的一种写法是二进制直接写整个对象:std::string里存的是堆上的指针,把含有它的对象reinterpret_cast成字节流写进文件,读回来时那些指针指向的是上一次进程的地址,解引用就是 UB。
保存时的关键是用'\t'做分隔符而不是空格,因为姓名里可能有空格:
bool AddressBook::save(const std::string& path) const { std::ofstream out(path, std::ios::trunc); if (!out) { return false; // 文件打不开(路径不存在、无权限等) } for (const auto& c : contacts_) { out << c.name << '\t' << c.phone << '\t' << c.email << '\n'; } return static_cast<bool>(out); // 检查写入过程中是否出错 }std::ios::trunc表示以截断方式打开,即文件原有的内容会先被清空。如果写成std::ios::app,每次保存都会追加一份旧数据,这是很常见的失误。返回时用static_cast<bool>(out)检查流状态,能捕捉到磁盘写满这类运行期错误。
读取时用std::istringstream按行拆字段,比手动找'\t'的位置再substr更不容易写错:
bool AddressBook::load(const std::string& path) { std::ifstream in(path); if (!in) { return false; // 文件不存在是正常情况,调用方当作空通讯录处理 } contacts_.clear(); std::string line; while (std::getline(in, line)) { if (line.empty()) { continue; } std::istringstream iss(line); Contact c; if (!std::getline(iss, c.name, '\t')) { continue; // 这一行连姓名都没有,跳过 } std::getline(iss, c.phone, '\t'); // 字段可缺省,读失败时保持空串 std::getline(iss, c.email, '\t'); contacts_.push_back(c); } return true; }这里std::getline(iss, c.name, '\t')的三参数版本以'\t'为分隔符(默认是'\n')。读失败时c.phone/c.email保持默认构造的空串,所以缺字段的行也能正确解析。
四、完整示例
下面是可以直接复制进main.cpp编译运行的完整程序。菜单里所有输入统一走std::getline,把选项当字符串读进来再用std::stoi解析——这样从根上避开了std::cin >> n遗留换行符的问题。
#include <algorithm> #include <exception> #include <fstream> #include <iostream> #include <sstream> #include <string> #include <vector> struct Contact { std::string name; std::string phone; std::string email; }; class AddressBook { public: bool add(const Contact& c) { if (c.name.empty()) { return false; } if (find(c.name) != nullptr) { return false; } contacts_.push_back(c); return true; } bool remove(const std::string& name) { auto it = std::find_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name == name; }); if (it == contacts_.end()) { return false; } contacts_.erase(it); return true; } bool update(const std::string& name, const Contact& fresh) { auto it = std::find_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name == name; }); if (it == contacts_.end()) { return false; } *it = fresh; return true; } const Contact* find(const std::string& name) const { auto it = std::find_if(contacts_.begin(), contacts_.end(), [&name](const Contact& c) { return c.name == name; }); return it == contacts_.end() ? nullptr : &*it; } std::vector<Contact> search(const std::string& keyword) const { std::vector<Contact> hits; for (const auto& c : contacts_) { if (c.name.find(keyword) != std::string::npos || c.phone.find(keyword) != std::string::npos) { hits.push_back(c); } } return hits; } std::vector<Contact> sorted() const { std::vector<Contact> copy = contacts_; std::sort(copy.begin(), copy.end(), [](const Contact& a, const Contact& b) { return a.name < b.name; }); return copy; } std::size_t size() const { return contacts_.size(); } bool save(const std::string& path) const { std::ofstream out(path, std::ios::trunc); if (!out) { return false; } for (const auto& c : contacts_) { out << c.name << '\t' << c.phone << '\t' << c.email << '\n'; } return static_cast<bool>(out); } bool load(const std::string& path) { std::ifstream in(path); if (!in) { return false; } contacts_.clear(); std::string line; while (std::getline(in, line)) { if (line.empty()) { continue; } std::istringstream iss(line); Contact c; if (!std::getline(iss, c.name, '\t')) { continue; } std::getline(iss, c.phone, '\t'); std::getline(iss, c.email, '\t'); contacts_.push_back(c); } return true; } private: std::vector<Contact> contacts_; }; namespace { void print_contacts(const std::vector<Contact>& list) { if (list.empty()) { std::cout << "(无记录)\n"; return; } std::cout << "姓名\t电话\t邮箱\n"; for (const auto& c : list) { std::cout << c.name << '\t' << c.phone << '\t' << c.email << '\n'; } } std::string prompt_line(const std::string& label) { std::cout << label; std::string s; std::getline(std::cin, s); return s; } } // namespace int main() { AddressBook book; const std::string db_path = "contacts.tsv"; if (book.load(db_path)) { std::cout << "已载入通讯录。\n"; } else { std::cout << "未找到通讯录文件,将新建。\n"; } bool running = true; while (running) { std::cout << "\n1)列出 2)新增 3)查找 4)删除 5)修改 6)保存 0)退出\n"; const std::string line = prompt_line("请选择: "); int choice = -1; try { choice = std::stoi(line); } catch (const std::exception&) { std::cout << "输入无效\n"; continue; } switch (choice) { case 1: print_contacts(book.sorted()); break; case 2: { Contact c; c.name = prompt_line("姓名: "); c.phone = prompt_line("电话: "); c.email = prompt_line("邮箱: "); std::cout << (book.add(c) ? "新增成功\n" : "新增失败(姓名为空或已存在)\n"); break; } case 3: { const std::string kw = prompt_line("关键词: "); print_contacts(book.search(kw)); break; } case 4: { const std::string name = prompt_line("要删除的姓名: "); std::cout << (book.remove(name) ? "删除成功\n" : "未找到\n"); break; } case 5: { const std::string name = prompt_line("要修改的姓名: "); if (book.find(name) == nullptr) { std::cout << "未找到\n"; break; } Contact c; c.name = name; c.phone = prompt_line("新电话: "); c.email = prompt_line("新邮箱: "); book.update(name, c); std::cout << "修改完成\n"; break; } case 6: std::cout << (book.save(db_path) ? "保存成功\n" : "保存失败\n"); break; case 0: running = false; break; default: std::cout << "无效选项\n"; break; } } book.save(db_path); std::cout << "再见,共 " << book.size() << " 条记录。\n"; return 0; }编译命令(文件名为main.cpp):
g++ -std=c++17 -Wall -Wextra -O2 main.cpp -o address_book程序会在当前工作目录下读写contacts.tsv,用-Wall -Wextra编译应当没有任何警告。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 读姓名带空格 | std::cin >> c.name遇到空格就停 | std::getline(std::cin, c.name) |
| 菜单选项混用 | std::cin >> choice;后直接getline读到残留换行 | 全部用getline读入再std::stoi解析 |
| 保存文件 | std::ofstream out(path)默认trunc,但用app会一直追加 | 明确写std::ios::trunc,或依赖默认行为并作注释 |
| 二进制写对象 | out.write(reinterpret_cast<const char*>(&c), sizeof c) | 逐字段写文本;Contact内含std::string,直接写字节是 UB |
| 删除后继续遍历 | 在for循环里erase(it)后继续++it | 用std::find_if定位后单独erase,或让erase返回新迭代器 |
| 持有内部指针 | const Contact* p = book.find("Tom"); book.add(...);后再用p | 立刻使用find结果,或拷贝一份Contact出来 |
| 比较函数不严格 | [](auto& a, auto& b){ return a.name <= b.name; } | 用a.name < b.name,<=不满足严格弱序,是 UB |
| 文件打开失败 | 不检查if (!out)直接写 | 检查流状态;load中文件不存在应视为空通讯录 |
最后一条补充一句:load里文件不存在返回false,main用它来打印"将新建"。不要把打不开文件当成致命错误直接退出——第一次运行的正常场景就是还没有数据文件。
总结
| 需求 | 采用的手段 | 关键理由 |
|---|---|---|
| 存一条记录 | struct Contact+std::string | 免手动内存管理,字符串长度无上限 |
| 存多条记录 | std::vector<Contact> | 自动扩容,连续内存,遍历有缓存局部性 |
| 查找 | std::find_if+ lambda | 无需手写下标循环,语义清晰 |
| 排序展示 | 拷贝后std::sort | 不破坏内部插入顺序,比较函数用< |
| 输入 | 全程std::getline | 避开>>遗留换行、姓名含空格两大坑 |
| 持久化 | 制表符分隔文本文件 | 可读、可手工编辑;避免二进制写指针带来的 UB |
这个通讯录示范的模式适用于绝大多数小工具:数据用扁平的聚合类型表示、容器负责内存、内部容器不直接暴露、所有变更都经过带返回值校验的成员函数、持久化走纯文本。把这几条守住,代码规模再翻几倍也不容易出现悬垂指针和越界写。