十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++通讯录管理系统实战:从类设计到文件持久化的完整实现

C++通讯录管理系统实战:从类设计到文件持久化的完整实现 1. 项目缘起从零到一一个C通讯录管理系统的诞生最近在整理电脑里的老项目翻到了一个我大学时期写的C通讯录管理系统。虽然现在看来代码有些稚嫩但它的结构清晰功能完整是很多C初学者在掌握了基础语法后第一个有挑战性的综合练手项目。它不像“Hello World”那样简单也不像大型游戏或框架那样复杂正好卡在“学了理论想动手实践”这个关键节点上。今天我就把这个项目的完整实现思路、代码细节以及当年踩过的坑、总结的经验系统地梳理一遍。无论你是正在学习C想找一个项目来巩固基础还是已经工作想回顾一下面向过程到面向对象的设计思想这篇文章都能给你提供一个可以直接“抄作业”的蓝本。这个系统的核心目标很简单用C在控制台实现一个能对联系人信息进行增、删、改、查、清空、排序、保存和加载的程序。别看功能列表简单要实现得健壮、易用里面涉及的知识点可不少结构体/类的设计、文件流操作、动态内存管理或标准容器使用、字符串处理、排序算法、基本的用户交互逻辑以及最重要的——程序结构的组织。接下来我们就抛开那些花哨的界面和网络功能回归C的本源用最纯粹的语法构建一个扎实的、能在你简历上写一笔的“通讯录管理系统”。2. 系统架构与核心数据结构设计在动手写第一行代码之前我们必须想清楚两件事数据怎么存和功能怎么组织。这直接决定了代码的扩展性、可读性和稳定性。2.1 联系人数据模型从结构体到类最初级的做法是定义一个结构体struct来存储单条联系人信息。但为了引入C的封装思想我们直接使用class。// Contact.h #ifndef CONTACT_H #define CONTACT_H #include string class Contact { public: // 构造函数 Contact(); Contact(const std::string name, const std::string gender, int age, const std::string phone, const std::string address); // Getter 和 Setter 方法 std::string getName() const; void setName(const std::string name); std::string getGender() const; void setGender(const std::string gender); int getAge() const; void setAge(int age); std::string getPhone() const; void setPhone(const std::string phone); std::string getAddress() const; void setAddress(const std::string address); // 显示联系人信息的成员函数 void display() const; // 重载比较运算符用于排序按姓名 bool operator(const Contact other) const; private: std::string m_name; // 姓名 std::string m_gender; // 性别 int m_age; // 年龄 std::string m_phone; // 电话 std::string m_address; // 地址 }; #endif // CONTACT_H设计理由与避坑点使用std::string而非C风格字符串这是新手和老手的一个重要分水岭。std::string自动管理内存避免了char[]数组长度限制和手动内存拷贝的繁琐与风险如缓冲区溢出。m_phone虽然看起来是数字但作为字符串处理更通用可能包含国家码86、分机号-等。将数据成员设为private这是封装的基本原则。外部代码不能直接修改m_age为-10这样的非法值必须通过setAge()函数我们可以在函数内添加合法性检查例如年龄范围0-150。提供完整的Getter/Setter虽然在某些强调性能或设计模式的场景下会争议但对于这种简单的数据模型提供它们是最清晰、最安全的做法便于未来在设值时加入验证逻辑。重载运算符这是为后续使用标准库的std::sort算法做准备。按姓名排序是一个常见需求重载后我们可以直接对Contact对象进行排序代码非常简洁。头文件保护#ifndef防止同一个头文件被多次包含导致重复定义错误。这是编写多文件C项目必须养成的习惯。2.2 通讯录容器与管理类设计有了单个联系人我们需要一个容器来管理所有联系人并封装所有操作增删改查等。这里有两种主流选择方案A使用原生指针和动态数组。这是最接近C语言、最能锻炼对内存管理理解的方式。你需要手动new[]和delete[]维护当前容量和大小在扩容时进行数据拷贝。过程繁琐且易出错但教学意义重大。方案B使用std::vectorContact。这是现代C推荐的生产方式。vector自动管理连续内存动态扩容提供了丰富的成员函数push_back,erase,find_if等安全高效。为了兼顾教学深度和代码简洁我选择方案B。但我会在关键部分解释如果手动管理数组该如何操作让你理解vector在背后做了什么。我们创建一个AddressBook类来作为通讯录的管理者。// AddressBook.h #ifndef ADDRESSBOOK_H #define ADDRESSBOOK_H #include Contact.h #include vector #include string class AddressBook { public: AddressBook(); ~AddressBook() default; // 使用vector析构函数无需特殊处理 // 核心功能 void addContact(const Contact contact); bool deleteContactByName(const std::string name); bool deleteContactByPhone(const std::string phone); Contact* findContactByName(const std::string name); Contact* findContactByPhone(const std::string phone); void displayAllContacts() const; void sortContactsByName(); void clearAllContacts(); // 文件操作 bool saveToFile(const std::string filename) const; bool loadFromFile(const std::string filename); // 获取当前联系人数量 size_t getContactCount() const; private: std::vectorContact m_contacts; // 核心容器 }; #endif // ADDRESSBOOK_H为什么选择vectorContact而不是vectorContact*这是一个关键设计决策。存储对象Contact意味着vector内部管理着Contact对象的生命周期和内存。当vector扩容或析构时它会自动调用每个Contact对象的析构函数。而存储指针Contact*则意味着你需要自己为每一个new出来的Contact对象负责delete否则会造成内存泄漏。对于我们这个场景Contact对象本身不大且复制开销可接受使用对象存储更简单安全。如果Contact对象非常大包含图片等二进制数据则可以考虑存储智能指针std::unique_ptrContact来避免大对象拷贝同时保持自动内存管理。3. 核心功能模块的逐行实现与详解有了清晰的设计图接下来就是砌砖盖瓦。我们深入到几个最具代表性的功能实现中看看代码怎么写以及为什么要这么写。3.1 添加联系人输入验证与容器操作addContact函数看似简单但健壮的程序必须考虑输入有效性。// AddressBook.cpp (部分) #include AddressBook.h #include iostream #include algorithm // 用于std::find_if #include cctype // 用于std::isdigit void AddressBook::addContact(const Contact contact) { // 1. 基础验证姓名不能为空 if (contact.getName().empty()) { std::cout 错误联系人姓名不能为空 std::endl; return; } // 2. 查重根据姓名或电话判断是否已存在 auto it std::find_if(m_contacts.begin(), m_contacts.end(), [contact](const Contact c) { return c.getName() contact.getName() || c.getPhone() contact.getPhone(); }); if (it ! m_contacts.end()) { std::cout 错误已存在同名或同电话的联系人 std::endl; return; } // 3. 电话格式简易验证可选更复杂验证可用正则表达式 const std::string phone contact.getPhone(); if (!phone.empty()) { // 简单检查是否全为数字忽略可能存在的或-这里做简化 bool all_digit std::all_of(phone.begin(), phone.end(), [](unsigned char c) { return std::isdigit(c); }); if (!all_digit) { std::cout 警告电话号码包含非数字字符已保存但请注意格式。 std::endl; } } // 4. 年龄范围验证应在Contact的setAge中做这里做二次检查 if (contact.getAge() 0 || contact.getAge() 150) { std::cout 警告年龄值异常已保存但请确认。 std::endl; } // 5. 一切正常添加到容器 m_contacts.push_back(contact); std::cout 联系人 \ contact.getName() \ 添加成功 std::endl; }关键点解析使用Lambda表达式进行查重std::find_if是STL算法它遍历容器对每个元素执行我们提供的判断函数Lambda。这里我们定义了一个Lambda它捕获了要添加的contact对象[contact]然后判断已有联系人的姓名或电话是否与之相同。这种方式比手写for循环更简洁、更“C”。验证逻辑的分层验证分为“错误”阻止操作和“警告”提示但允许操作。姓名为空和重复是硬性错误而电话格式和年龄异常可能源于输入错误程序给出警告但依然保存把最终决定权留给用户或上层逻辑。push_back的底层m_contacts.push_back(contact)会调用Contact的拷贝构造函数在vector尾部创建一份contact的副本。如果vector的当前容量不足它会自动分配一块更大的内存通常是原容量的1.5或2倍把所有现有元素移动或拷贝到新内存然后释放旧内存。这个过程对使用者是透明的这正是vector的便利之处。如果自己用数组实现就需要手动管理这个realloc的过程非常容易出错。3.2 查找与删除理解迭代器和失效问题查找和删除是紧密相关的操作通常先查找定位再执行删除。// AddressBook.cpp (续) Contact* AddressBook::findContactByName(const std::string name) { // 使用std::find_if和Lambda查找 auto it std::find_if(m_contacts.begin(), m_contacts.end(), [name](const Contact c) { return c.getName() name; }); if (it ! m_contacts.end()) { // 找到了返回指向该对象的指针 return (*it); // it是迭代器*it是引用取地址得到指针 } return nullptr; // 未找到 } bool AddressBook::deleteContactByName(const std::string name) { auto it std::find_if(m_contacts.begin(), m_contacts.end(), [name](const Contact c) { return c.getName() name; }); if (it m_contacts.end()) { std::cout 未找到姓名为 \ name \ 的联系人。 std::endl; return false; } // 关键步骤从vector中删除元素 m_contacts.erase(it); std::cout 联系人 \ name \ 已删除。 std::endl; return true; }“迭代器失效”陷阱与vector的删除这是C STL容器操作中的一个经典坑。对于vectorerase(it)函数会移除it指向的元素并且它之后的所有元素都会向前移动一个位置。这会导致一个严重后果指向被删除元素之后位置的所有迭代器、指针和引用都会失效。在我们的代码中it在erase之后立即就失效了。但因为我们删除后直接返回没有继续使用这个it所以是安全的。然而考虑下面这个错误的示例它想删除所有年龄小于18岁的联系人// !!! 错误代码示例 !!! for (auto it m_contacts.begin(); it ! m_contacts.end(); it) { if (it-getAge() 18) { m_contacts.erase(it); // 删除后it失效 // 下一轮循环的 it 操作在一个失效的迭代器上进行导致未定义行为通常崩溃 } }正确的写法是利用erase的返回值它返回指向被删除元素之后那个元素的新迭代器。// 正确删除遍历中元素的写法 for (auto it m_contacts.begin(); it ! m_contacts.end(); /* 这里不写 it */) { if (it-getAge() 18) { it m_contacts.erase(it); // erase返回新的有效迭代器赋给it } else { it; // 只有没删除元素时才手动递增迭代器 } }或者更现代、更简洁的写法是使用“擦除-移除”惯用法Erase-Remove Idiom结合std::remove_if算法auto new_end std::remove_if(m_contacts.begin(), m_contacts.end(), [](const Contact c) { return c.getAge() 18; }); m_contacts.erase(new_end, m_contacts.end());std::remove_if并不会真的删除元素它只是把不满足条件年龄18的元素移动到容器前面并返回一个指向新的逻辑结尾的迭代器。随后erase再删除从新结尾到旧结尾的所有元素。这种方式通常更高效也是C标准库推荐的模式。3.3 排序功能利用STL算法与自定义比较排序是通讯录的常用功能。我们利用之前重载的运算符和std::sort算法可以一行代码实现。// AddressBook.cpp (续) void AddressBook::sortContactsByName() { if (m_contacts.empty()) { std::cout 通讯录为空无需排序。 std::endl; return; } // 使用std::sort默认使用Contact::operator进行升序排序 std::sort(m_contacts.begin(), m_contacts.end()); std::cout 已按姓名升序排列所有联系人。 std::endl; }如果想按其他字段如年龄降序、电话升序排序怎么办我们可以不依赖operator而是向std::sort传递一个自定义的比较函数或Lambda。例如按年龄降序排列std::sort(m_contacts.begin(), m_contacts.end(), [](const Contact a, const Contact b) { return a.getAge() b.getAge(); // 降序 });排序的稳定性std::sort默认不保证稳定性即相等元素的原始相对顺序可能改变。如果姓名相同你想按添加时间或其他次要字段保持顺序需要使用std::stable_sort。3.4 文件持久化数据的保存与加载程序关闭后数据不能丢失。我们需要将vectorContact中的数据保存到硬盘文件并在下次启动时加载。这里采用文本文件如.txt或.csv格式便于阅读和调试。保存到文件bool AddressBook::saveToFile(const std::string filename) const { std::ofstream outFile(filename); // 创建输出文件流 if (!outFile.is_open()) { std::cerr 错误无法打开文件 filename 进行写入 std::endl; return false; } // 可以写一个简单的文件头例如版本号或字段说明 outFile 姓名,性别,年龄,电话,住址\n; for (const auto contact : m_contacts) { // 将每个字段用逗号分隔构成CSV格式 // 注意如果字段本身包含逗号或换行符需要处理如用引号包裹这里简化处理 outFile contact.getName() , contact.getGender() , contact.getAge() , contact.getPhone() , contact.getAddress() \n; } outFile.close(); std::cout 通讯录已成功保存至 filename 共 m_contacts.size() 条记录。 std::endl; return true; }从文件加载加载比保存复杂因为需要解析字符串并处理可能的格式错误。bool AddressBook::loadFromFile(const std::string filename) { std::ifstream inFile(filename); if (!inFile.is_open()) { std::cerr 错误无法打开文件 filename 进行读取 std::endl; return false; } std::string line; std::getline(inFile, line); // 读取并丢弃标题行 std::vectorContact loadedContacts; // 先加载到临时容器 int lineNum 1; // 用于错误提示 bool hasError false; while (std::getline(inFile, line)) { lineNum; if (line.empty()) continue; // 跳过空行 std::istringstream ss(line); // 使用字符串流方便分割 std::string name, gender, phone, address, ageStr; int age 0; // 使用getline按逗号分割字段 if (!std::getline(ss, name, ,) || !std::getline(ss, gender, ,) || !std::getline(ss, ageStr, ,) || !std::getline(ss, phone, ,) || !std::getline(ss, address)) { std::cerr 警告第 lineNum 行格式错误已跳过。 std::endl; hasError true; continue; } // 转换年龄字符串为整数 try { age std::stoi(ageStr); } catch (const std::invalid_argument e) { std::cerr 警告第 lineNum 行年龄格式错误已设置为0。 std::endl; age 0; // 提供默认值 hasError true; } catch (const std::out_of_range e) { std::cerr 警告第 lineNum 行年龄数值超出范围已设置为0。 std::endl; age 0; hasError true; } // 创建Contact对象并加入临时容器 loadedContacts.emplace_back(name, gender, age, phone, address); } inFile.close(); if (!hasError) { // 如果加载过程没有致命错误用加载的数据替换当前通讯录 m_contacts.swap(loadedContacts); // swap操作高效且异常安全 std::cout 从 filename 成功加载 m_contacts.size() 条联系人记录。 std::endl; return true; } else { // 有错误但可能部分数据加载成功。可以选择合并或放弃。 std::cerr 文件加载完成但过程中发现错误。已加载 loadedContacts.size() 条有效记录。 std::endl; // 这里选择用已加载的有效记录替换原数据激进策略 // 也可以选择 m_contacts.insert(m_contacts.end(), loadedContacts.begin(), loadedContacts.end()) 合并保守策略 m_contacts.swap(loadedContacts); return false; // 返回false表示加载不完全成功 } }文件操作的经验与坑文件打开模式std::ofstream默认会覆盖原文件。如果想追加需要使用std::ofstream outFile(filename, std::ios::app)。std::ifstream默认只读。异常安全使用std::stoi等转换函数时一定要用try-catch捕获异常因为用户可能手动修改了文本文件导致内容不是合法数字。如果不处理程序会崩溃。资源管理文件流对象在离开作用域时会自动调用close()但显式调用close()是个好习惯并且可以立即检查关闭状态outFile.close()后可以检查outFile.fail()。swap技巧m_contacts.swap(loadedContacts)这行代码很精妙。它交换了两个vector的内部指针效率是O(1)。并且如果加载过程中发生异常m_contacts的原始数据不会被破坏因为所有操作都在临时容器loadedContacts中进行。这是一种实现“强异常安全保证”的常用技巧。字段包含分隔符这是CSV格式的一个经典问题。如果“住址”字段里包含了一个逗号我们的简单解析就会出错。生产级的代码需要处理字段引用如用双引号包裹北京,朝阳区或转义。对于学习项目我们约定输入时避免使用逗号即可。4. 用户界面与主程序循环将模块串联起来核心功能类写好了我们需要一个“外壳”来与用户交互。这就是main函数和菜单驱动循环的工作。// main.cpp #include AddressBook.h #include iostream #include limits // 用于 std::numeric_limits // 函数声明 void displayMenu(); void handleAddContact(AddressBook book); void handleSearchContact(AddressBook book); // ... 其他处理函数声明 int main() { AddressBook myBook; std::string loadedFile contacts.csv; // 程序启动时尝试加载数据 std::cout 正在尝试从 loadedFile 加载通讯录... std::endl; if (!myBook.loadFromFile(loadedFile)) { std::cout 未找到存档文件或文件有误将使用空通讯录开始。 std::endl; } int choice 0; bool running true; while (running) { displayMenu(); std::cout 请输入您的选择 (0-9): ; // 安全的输入处理防止输入非数字导致死循环 if (!(std::cin choice)) { std::cin.clear(); // 清除错误状态 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); // 忽略错误行 std::cout 输入无效请输入一个数字 std::endl; continue; } std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); // 忽略换行符 switch (choice) { case 1: // 添加联系人 handleAddContact(myBook); break; case 2: // 显示所有 myBook.displayAllContacts(); break; case 3: // 查找联系人 handleSearchContact(myBook); break; case 4: // 删除联系人 // 调用删除处理函数 break; case 5: // 修改联系人 // 先查找再提供修改选项 break; case 6: // 清空通讯录 myBook.clearAllContacts(); break; case 7: // 按姓名排序 myBook.sortContactsByName(); break; case 8: // 保存通讯录 myBook.saveToFile(loadedFile); break; case 9: // 加载通讯录 std::cout 请输入要加载的文件名: ; std::string filename; std::getline(std::cin, filename); myBook.loadFromFile(filename); break; case 0: // 退出 std::cout 是否保存修改(y/n): ; char saveChoice; std::cin saveChoice; if (saveChoice y || saveChoice Y) { myBook.saveToFile(loadedFile); } running false; std::cout 感谢使用再见 std::endl; break; default: std::cout 无效的选择请重新输入。 std::endl; } std::cout std::endl; // 菜单间隔 } return 0; } void displayMenu() { std::cout \n*************************** std::endl; std::cout ***** 通讯录管理系统 ***** std::endl; std::cout *************************** std::endl; std::cout 1. 添加联系人 std::endl; std::cout 2. 显示所有联系人 std::endl; std::cout 3. 查找联系人 std::endl; std::cout 4. 删除联系人 std::endl; std::cout 5. 修改联系人 std::endl; std::cout 6. 清空通讯录 std::endl; std::cout 7. 按姓名排序 std::endl; std::cout 8. 保存通讯录到文件 std::endl; std::cout 9. 从文件加载通讯录 std::endl; std::cout 0. 退出系统 std::endl; std::cout *************************** std::endl; } void handleAddContact(AddressBook book) { std::string name, gender, phone, address; int age; std::cout --- 添加新联系人 --- std::endl; std::cout 姓名: ; std::getline(std::cin, name); std::cout 性别: ; std::getline(std::cin, gender); std::cout 年龄: ; // 年龄输入需要特殊处理防止输入非数字 while (!(std::cin age)) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 年龄输入无效请重新输入数字: ; } std::cin.ignore(); // 忽略年龄后的换行符 std::cout 电话: ; std::getline(std::cin, phone); std::cout 住址: ; std::getline(std::cin, address); Contact newContact(name, gender, age, phone, address); book.addContact(newContact); } // ... 其他handle函数的实现控制台交互的细节与 robustness输入缓冲区的清理这是控制台程序最常见的坑。混合使用std::cin 和std::getline时操作会留下换行符\n在输入缓冲区中接下来的getline会立刻读到这个空行导致跳过输入。解决方案是在后使用std::cin.ignore()。上面代码中std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n)是更安全的做法它能清空缓冲区直到遇到换行符。数字输入的验证std::cin choice或 age时如果用户输入了字母cin会进入错误状态后续所有输入都会失败。必须用cin.clear()清除错误状态并用ignore清空错误输入然后让用户重新输入。菜单循环结构使用switch-case是清晰的做法。每个case里调用对应的处理函数保持main函数简洁。处理函数再调用AddressBook类的相应方法。自动保存与加载程序启动时自动尝试加载默认文件退出时提示保存。这提供了基本的数据持久化体验避免用户忘记保存而丢失数据。5. 项目构建、测试与进阶思考5.1 编译与构建从单文件到多文件当项目有多个.cpp和.h文件时你需要告诉编译器如何把它们链接在一起。假设你的文件结构如下通讯录项目/ ├── main.cpp ├── Contact.h ├── Contact.cpp ├── AddressBook.h └── AddressBook.cpp使用GCC/G命令行编译g -stdc11 -o address_book main.cpp Contact.cpp AddressBook.cpp-stdc11指定使用C11标准我们需要Lambda表达式。-o address_book指定输出的可执行文件名。后面列出所有需要编译的.cpp源文件。使用CMake推荐更专业创建CMakeLists.txt文件cmake_minimum_required(VERSION 3.10) project(AddressBook) set(CMAKE_CXX_STANDARD 11) add_executable(address_book main.cpp Contact.cpp AddressBook.cpp )然后在项目目录下执行mkdir build cd build cmake .. make ./address_book5.2 基础功能测试与边界情况写完代码一定要测试。以下是一些必须测试的场景正常流程添加、显示、查找、删除、修改、排序、保存、加载。边界与异常添加姓名为空、重复添加、年龄输入非数字、电话输入超长字符串。查找/删除查找不存在的名字、删除后再次查找。文件尝试加载一个不存在的文件、加载一个格式损坏的文件如年龄列是字母、保存到一个没有写入权限的目录。容器清空空通讯录、对空通讯录排序。内存检查如果用了new对于手动管理内存的版本可以使用工具如valgrind来检查是否有内存泄漏。valgrind --leak-checkfull ./address_book5.3 从“能用”到“好用”可能的进阶方向这个基础版本已经完成了核心功能。如果你想把它当作一个更深入的学习项目可以考虑以下扩展方向每一个都能加深你对C某一方面理解使用智能指针管理联系人将std::vectorContact改为std::vectorstd::unique_ptrContact或std::shared_ptrContact。这会让你深入理解移动语义、独占所有权和共享所有权。引入数据库将文件存储替换为SQLite数据库。学习使用C的SQLite API如sqlite3库来执行CREATE,INSERT,SELECT,UPDATE,DELETE语句。这会让数据管理更强大、更高效。实现图形化界面使用Qt框架或Dear ImGui等库为你的通讯录做一个窗口界面。这会带你进入事件驱动编程和GUI开发的世界。增加高级搜索实现模糊搜索如姓名部分匹配、组合条件搜索如“性别为女且年龄大于30”。设计模式应用尝试应用一些设计模式。例如使用“命令模式”来封装每一个操作添加、删除等以实现撤销(Undo)/重做(Redo)功能。使用“观察者模式”在数据变更时自动更新文件或UI。编写单元测试使用Google Test或Catch2等测试框架为Contact和AddressBook类编写单元测试确保每个函数的行为符合预期。回过头看这个通讯录项目麻雀虽小五脏俱全。它串联起了C从基础语法变量、循环、分支、函数、类与对象、运算符重载到标准库vector,string,algorithm,fstream、内存管理思想、异常处理、文件I/O等核心知识点。通过亲手实现它、调试它、扩展它你对C的理解将从分散的知识点凝聚成解决实际问题的能力。这远比死记硬背“C八股文”要有效得多。
返回列表