C++实战:从零构建命令行通讯录系统,掌握面向对象与数据持久化

发布时间:2026/8/28 13:59:25
C++实战:从零构建命令行通讯录系统,掌握面向对象与数据持久化 1. 项目概述从零构建一个命令行通讯录最近在带几个刚入门C的朋友做项目发现很多人学完基础语法后面对“做一个项目”这个要求就有点懵。指针、类、STL容器这些概念单独看都懂但怎么把它们串起来解决一个实际问题中间还隔着一条鸿沟。我常跟他们说别急着去啃那些复杂的网络库或者图形界面先从最经典的“通讯录管理系统”开始练手。这玩意儿别看名字简单它几乎涵盖了C面向对象编程和基础数据管理的核心骨架。一个命令行下的通讯录核心功能无非就是增删改查。但真要动手实现你会发现需要考虑的细节非常多数据用什么结构存是数组、链表还是向量用户输入怎么处理怎么防止程序崩溃数据要不要持久化保存到文件这些问题的背后是内存管理、数据结构、输入输出流和异常处理等知识的综合运用。我这次带大家实现的这个版本会采用std::vector来管理联系人列表用文件读写来实现数据持久化并且会重点处理一些边界情况和常见的“坑”。整个项目代码量不大但麻雀虽小五脏俱全非常适合用来巩固C基础并初步建立“工程化”的思维。2. 核心设计思路与数据结构选型2.1 为什么选择面向对象的设计很多新手的第一反应可能是不就存几个人的名字电话吗我用一个结构体数组再写几个函数不就行了这种面向过程的写法当然可以但不利于代码的扩展和维护。一旦你想增加新的功能比如按分组管理、添加生日字段或者想把数据存储方式从数组改成链表就会发现牵一发而动全身需要改动很多地方的代码。采用面向对象的设计我们把“通讯录”本身抽象成一个类比如叫AddressBook把“联系人”抽象成另一个类比如叫Contact。Contact类负责封装一个联系人的所有信息姓名、电话、地址等以及对这些信息的操作如显示、修改。AddressBook类则负责管理一个Contact对象的集合并提供增删改查、保存、加载等高级操作。这样设计的好处是职责清晰Contact类不关心自己是被存在数组里还是链表里AddressBook类也不关心Contact内部的具体字段是什么。两者通过清晰的接口耦合未来无论修改哪一部分对另一部分的影响都最小。2.2 联系人数据结构的权衡数组 vs. vector vs. list确定了类的设计接下来要决定在AddressBook类内部用什么容器来存储Contact对象。这里有三个常见选择原生数组这是最基础的选择。优点是内存连续访问速度快概念简单。但缺点非常致命大小固定无法动态扩容。你必须在编译时就确定通讯录的最大容量比如Contact contacts[1000];这要么造成内存浪费要么在联系人数量超出时导致程序崩溃或数据丢失。而且在数组中插入或删除一个元素非末尾位置需要手动移动后续所有元素非常麻烦且容易出错。结论不推荐用于此类动态数据管理场景。std::vector这是C标准模板库STL中的动态数组。它具备了原生数组内存连续、随机访问快的优点同时可以动态扩容。当元素数量超过当前容量时vector会自动申请一块更大的内存并把原有数据拷贝过去。对于通讯录这种需要频繁按索引查找例如显示所有联系人的场景vector的随机访问特性O(1)时间复杂度非常高效。虽然在中部插入删除元素依然需要移动数据但通讯录的更新频率通常远低于查询频率这个代价是可以接受的。结论综合性能、易用性和功能std::vectorContact是本项目的首选。std::listSTL中的双向链表。它的优点是在序列的任何位置插入和删除元素都非常快O(1)因为只需要修改指针不需要移动数据。但缺点是内存不连续缓存不友好访问特定位置的元素需要遍历O(n)。对于需要频繁在中间插入删除但很少按位置随机访问的场景list是合适的。但通讯录更常见的操作是遍历显示和按姓名查找也需要遍历list在遍历上并不比vector有优势反而失去了随机访问的便利。结论除非你的通讯录有极高频的中间插入删除需求否则vector是更优解。注意这里的选择体现了软件工程中的一个重要思想根据实际场景和操作频率来选择数据结构没有绝对的好坏。对于初学者项目vector在易用性和性能上取得了很好的平衡。2.3 数据持久化方案文本文件 vs. 二进制文件程序运行时的数据保存在内存中程序关闭就消失了。为了让通讯录数据能够长期保存必须实现“持久化”也就是把数据写入硬盘。文本文件将每个联系人的信息按照一定的格式例如一行一条记录字段用逗号分隔写入.txt文件。优点是文件人类可读可以直接用记事本打开查看和修改调试方便。缺点是需要自己定义格式并编写解析逻辑处理包含分隔符的字段如地址中有逗号比较麻烦且读写效率相对较低。二进制文件直接将内存中Contact对象的内存映像写入文件。优点是读写速度快格式紧凑不需要复杂的解析。缺点是文件人类不可读且存在严重隐患如果Contact类中包含指针例如指向动态分配字符串的指针直接写入指针值是毫无意义的它指向的内存地址在下次程序运行时无效。这要求类必须是“平凡可复制”的或者需要实现序列化/反序列化逻辑。对于本项目为了简单和可读性我们选择使用文本文件。我们将采用一种简单的格式每行存储一个联系人字段之间用特定符号如竖线|分隔。虽然这要求我们在读写时进行字符串分割和组合但逻辑清晰易于理解和调试。3. 核心类设计与实现细节3.1 Contact 类封装联系人信息我们先从最小的单元Contact类开始。一个联系人至少需要姓名和电话号码我们可以再增加地址作为可选字段。// Contact.h #ifndef CONTACT_H #define CONTACT_H #include string #include iostream class Contact { public: // 默认构造函数 Contact(); // 带参数的构造函数 Contact(const std::string name, const std::string phone, const std::string address ); // Getter 和 Setter std::string getName() const; void setName(const std::string name); std::string getPhone() const; void setPhone(const std::string phone); std::string getAddress() const; void setAddress(const std::string address); // 显示联系人信息 void display() const; // 用于文件读写将对象转换为字符串行以及从字符串行解析 std::string toFileString() const; bool fromFileString(const std::string line); private: std::string m_name; std::string m_phone; std::string m_address; }; #endif // CONTACT_H这里有几个设计要点使用std::string而不是C风格的字符数组char[]。std::string自动管理内存无需担心缓冲区溢出大大提升了安全性和易用性。成员变量私有化通过公有的getter和setter方法来访问和修改这是封装的基本原则可以在方法内添加校验逻辑比如检查电话号码格式。const成员函数getName(),display()等方法被声明为const表示它们不会修改对象状态这允许在const对象上调用这些方法是良好的习惯。文件字符串转换方法toFileString和fromFileString是实现持久化的关键。前者将对象数据拼接成一行字符串如张三|13800138000|北京市海淀区后者则负责解析这样的字符串并填充到当前对象中。Contact.cpp的实现需要注意字符串处理的细节// Contact.cpp #include “Contact.h“ #include sstream Contact::Contact() : m_name(), m_phone(), m_address() {} Contact::Contact(const std::string name, const std::string phone, const std::string address) : m_name(name), m_phone(phone), m_address(address) {} // ... 其他getter/setter实现略 ... void Contact::display() const { std::cout “姓名“ m_name “\t电话“ m_phone; if (!m_address.empty()) { std::cout “\t地址“ m_address; } std::cout std::endl; } std::string Contact::toFileString() const { // 使用|作为分隔符。注意如果字段本身包含|会导致解析错误。 // 这是一个简化实现实际项目中可能需要转义处理或选择更稳妥的分隔符。 return m_name “|“ m_phone “|“ m_address; } bool Contact::fromFileString(const std::string line) { std::istringstream iss(line); std::string token; std::vectorstd::string tokens; // 使用getline配合分隔符‘|’进行分割 while (std::getline(iss, token, ‘|‘)) { tokens.push_back(token); } // 确保至少有两个字段姓名和电话 if (tokens.size() 2) { m_name tokens[0]; m_phone tokens[1]; m_address (tokens.size() 3) ? tokens[2] : ““; // 地址可能为空 return true; } return false; // 行格式错误 }实操心得在fromFileString中我使用了std::istringstream和std::getline来分割字符串这是一种比手动查找子串更清晰和安全的方式。同时函数返回bool值表示解析是否成功让上层调用者能处理文件格式错误的情况增强了程序的健壮性。3.2 AddressBook 类管理联系人集合AddressBook类是核心它内部持有一个std::vectorContact并提供各种操作方法。// AddressBook.h #ifndef ADDRESSBOOK_H #define ADDRESSBOOK_H #include “Contact.h“ #include vector #include string class AddressBook { public: AddressBook(); // 增删改查 bool addContact(const Contact contact); bool deleteContact(const std::string name); // 按姓名删除 bool updateContact(const std::string name, const Contact newInfo); Contact* findContact(const std::string name); // 返回指针便于修改 // 显示所有联系人 void displayAll() const; // 数据持久化 bool loadFromFile(const std::string filename); bool saveToFile(const std::string filename) const; // 获取当前联系人数量 size_t getCount() const; private: std::vectorContact m_contacts; // 一个辅助函数通过姓名查找在vector中的索引 int findIndexByName(const std::string name) const; }; #endif // ADDRESSBOOK_H关键点解析使用std::vectorContact作为数据成员这是我们的核心容器。操作方法的返回值addContact,deleteContact,updateContact等方法都返回bool用于指示操作是否成功例如添加重复联系人、删除不存在的联系人都会失败。这提供了错误反馈机制。查找函数返回指针findContact返回Contact*如果找到可以通过这个指针直接修改找到的联系人对象在vector中。如果没找到则返回nullptr。这是一种常见的做法。注意这里返回的是容器内对象的指针/引用调用者需要小心不要在其生命周期外使用。私有辅助函数findIndexByName被实现为私有函数因为查找索引是deleteContact、updateContact和findContact都需要用到的公共逻辑将其抽取出来避免代码重复。我们来看几个关键方法的实现// AddressBook.cpp #include “AddressBook.h“ #include fstream #include algorithm // 用于std::find_if #include iostream AddressBook::AddressBook() {} bool AddressBook::addContact(const Contact contact) { // 检查是否已存在同名联系人简单起见假设姓名唯一 if (findIndexByName(contact.getName()) ! -1) { std::cout “错误联系人 ‘“ contact.getName() “’ 已存在“ std::endl; return false; } m_contacts.push_back(contact); std::cout “联系人 ‘“ contact.getName() “’ 添加成功。“ std::endl; return true; } bool AddressBook::deleteContact(const std::string name) { int index findIndexByName(name); if (index -1) { std::cout “错误未找到联系人 ‘“ name “’。“ std::endl; return false; } // vector的erase方法参数是迭代器 m_contacts.erase(m_contacts.begin() index); std::cout “联系人 ‘“ name “’ 删除成功。“ std::endl; return true; } Contact* AddressBook::findContact(const std::string name) { int index findIndexByName(name); if (index ! -1) { // 注意这里返回的是容器内部元素的地址。 // 如果后续vector发生重分配如push_back导致扩容这个指针可能会失效。 // 在本项目逻辑中查找后通常紧接着是显示或修改不会穿插可能导致扩容的操作因此是安全的。 // 更严谨的做法是返回索引或者在进行可能导致扩容的操作前完成修改。 return m_contacts[index]; } return nullptr; } void AddressBook::displayAll() const { if (m_contacts.empty()) { std::cout “通讯录为空。“ std::endl; return; } std::cout “\n 通讯录列表 “ std::endl; for (const auto contact : m_contacts) { // 范围for循环C11 contact.display(); } std::cout “\n“ std::endl; } bool AddressBook::loadFromFile(const std::string filename) { std::ifstream inFile(filename); if (!inFile.is_open()) { std::cout “无法打开文件 ‘“ filename “’ 进行读取。将创建新通讯录。“ std::endl; return false; // 文件不存在是正常情况不一定是错误 } m_contacts.clear(); // 加载前清空现有数据 std::string line; while (std::getline(inFile, line)) { if (line.empty()) continue; // 跳过空行 Contact contact; if (contact.fromFileString(line)) { m_contacts.push_back(contact); } else { std::cerr “警告跳过格式错误的行 “ line std::endl; } } inFile.close(); std::cout “从文件 ‘“ filename “’ 加载了 “ m_contacts.size() “ 个联系人。“ std::endl; return true; } bool AddressBook::saveToFile(const std::string filename) const { std::ofstream outFile(filename); if (!outFile.is_open()) { std::cerr “错误无法打开文件 ‘“ filename “’ 进行写入。“ std::endl; return false; } for (const auto contact : m_contacts) { outFile contact.toFileString() std::endl; } outFile.close(); std::cout “通讯录已保存至文件 ‘“ filename “’。“ std::endl; return true; } int AddressBook::findIndexByName(const std::string name) const { // 使用标准库算法和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 std::distance(m_contacts.begin(), it); // 计算索引 } return -1; // 未找到 }注意事项文件打开失败处理在loadFromFile中如果文件打不开我们输出提示并返回false但不清空现有m_contacts。这允许程序第一次运行时文件不存在正常启动。在saveToFile中打开失败则是一个严重错误需要告知用户。findIndexByName的实现这里使用了C11的lambda表达式[name](const Contact c) { return c.getName() name; }作为std::find_if的谓词代码非常简洁。std::distance用于计算找到的迭代器与起始迭代器的距离即索引。指针有效性警告findContact返回内部数据的指针。这是一个需要注意的地方。如果在获取指针后进行了可能导致vector内存重分配的操作比如addContact触发了扩容那么之前获取的指针就失效了成为悬空指针。在简单的控制台交互程序中我们通常按顺序操作风险较小。但在复杂场景下更安全的做法是返回索引或迭代器或者使用std::list插入删除不影响其他元素指针的有效性。4. 主程序逻辑与用户交互实现有了坚实的底层类主程序main.cpp的任务就变得清晰了创建AddressBook对象提供一个简单的文本菜单根据用户输入调用相应的方法并处理循环。// main.cpp #include “AddressBook.h“ #include iostream #include limits // 用于清除输入缓冲区 // 函数声明 void displayMenu(); void handleAddContact(AddressBook book); void handleFindContact(AddressBook book); void handleUpdateContact(AddressBook book); void handleDeleteContact(AddressBook book); void clearInputBuffer(); int main() { AddressBook myBook; const std::string filename “contacts.txt“; // 程序启动时尝试从文件加载数据 myBook.loadFromFile(filename); int choice 0; do { displayMenu(); std::cout “请输入您的选择 (0-6): “; std::cin choice; clearInputBuffer(); // 清除换行符等残留 switch (choice) { case 1: myBook.displayAll(); break; case 2: handleAddContact(myBook); break; case 3: handleFindContact(myBook); break; case 4: handleUpdateContact(myBook); break; case 5: handleDeleteContact(myBook); break; case 6: myBook.saveToFile(filename); std::cout “数据已保存感谢使用“ std::endl; break; case 0: std::cout “退出前是否需要保存(y/n): “; char saveChoice; std::cin saveChoice; clearInputBuffer(); if (saveChoice ‘y‘ || saveChoice ‘Y‘) { myBook.saveToFile(filename); } std::cout “再见“ std::endl; break; default: std::cout “无效的选择请重新输入。“ std::endl; } std::cout std::endl; } while (choice ! 0); return 0; } void displayMenu() { std::cout “\n********** 通讯录管理系统 **********“ 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 “0. 退出“ std::endl; std::cout “************************************“ std::endl; } void handleAddContact(AddressBook book) { std::string name, phone, address; std::cout “请输入姓名 “; std::getline(std::cin, name); std::cout “请输入电话 “; std::getline(std::cin, phone); std::cout “请输入地址 (可选直接回车跳过) “; std::getline(std::cin, address); if (name.empty() || phone.empty()) { std::cout “错误姓名和电话不能为空“ std::endl; return; } Contact newContact(name, phone, address); book.addContact(newContact); } void handleFindContact(AddressBook book) { std::string name; std::cout “请输入要查找的姓名 “; std::getline(std::cin, name); Contact* found book.findContact(name); if (found) { std::cout “找到联系人“ std::endl; found-display(); } else { std::cout “未找到姓名为 ‘“ name “’ 的联系人。“ std::endl; } } void handleUpdateContact(AddressBook book) { std::string oldName; std::cout “请输入要修改的联系人姓名 “; std::getline(std::cin, oldName); Contact* oldContact book.findContact(oldName); if (!oldContact) { std::cout “未找到该联系人。“ std::endl; return; } std::cout “当前信息“ std::endl; oldContact-display(); std::string newName, newPhone, newAddress; std::cout “\n请输入新的姓名 (直接回车保持不变) “; std::getline(std::cin, newName); std::cout “请输入新的电话 (直接回车保持不变) “; std::getline(std::cin, newPhone); std::cout “请输入新的地址 (直接回车保持不变) “; std::getline(std::cin, newAddress); // 构造新对象如果输入为空则沿用旧值 Contact newInfo( newName.empty() ? oldContact-getName() : newName, newPhone.empty() ? oldContact-getPhone() : newPhone, newAddress.empty() ? oldContact-getAddress() : newAddress ); // 调用AddressBook的更新接口 if (book.updateContact(oldName, newInfo)) { std::cout “联系人信息更新成功。“ std::endl; } } void handleDeleteContact(AddressBook book) { std::string name; std::cout “请输入要删除的联系人姓名 “; std::getline(std::cin, name); book.deleteContact(name); } void clearInputBuffer() { // 清除cin缓冲区中残留的字符直到遇到换行符 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), ‘\n‘); }4.1 输入处理中的关键技巧与避坑指南主程序里最值得细说的就是输入处理这是新手最容易出问题的地方。问题1混合使用std::cin 和std::getline导致的输入错乱观察我们的代码在main函数的switch之前我们用了std::cin choice;来读取整数菜单选项。cin 会读取数字但不会读取数字后面用户按下的回车符‘\n‘。这个回车符会留在输入缓冲区中。紧接着如果我们调用std::getline(std::cin, name)来读取姓名getline会立刻读到缓冲区里残留的那个回车符并将其解释为一个空行导致程序看起来“跳过”了这次输入。解决方案在std::cin 之后立即清除输入缓冲区。这就是clearInputBuffer()函数的作用。它使用std::cin.ignore忽略掉缓冲区中直到换行符的所有字符。void clearInputBuffer() { // std::numeric_limitsstd::streamsize::max() 表示一个非常大的数确保清空所有残留 // ‘\n‘ 是分隔符忽略直到遇到换行符 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), ‘\n‘); }问题2输入验证缺失我们的代码对用户输入做了最基本的非空检查在handleAddContact中但还远远不够。例如电话号码应该是一串数字但我们没有验证。更健壮的程序应该添加输入验证逻辑比如bool isValidPhone(const std::string phone) { // 简单示例检查是否全为数字且长度合理 if (phone.empty()) return false; for (char c : phone) { if (!std::isdigit(c)) return false; } return phone.length() 7 phone.length() 15; }在handleAddContact中调用此函数进行验证。对于姓名可能还需要检查是否包含非法字符等。问题3文件路径与权限程序默认在当前目录下寻找或创建contacts.txt文件。如果程序没有当前目录的写权限保存就会失败。在实际部署时可以考虑将文件保存在用户的文档目录等固定位置。可以使用操作系统相关的API来获取这样的路径。5. 编译、运行与功能测试5.1 项目编译假设你的项目文件结构如下your_project/ ├── Contact.h ├── Contact.cpp ├── AddressBook.h ├── AddressBook.cpp └── main.cpp使用g编译Linux/macOS或Windows下的MinGWg -stdc11 main.cpp Contact.cpp AddressBook.cpp -o addressbook-stdc11指定使用C11标准因为我们的代码中使用了基于范围的for循环和lambda表达式。-o addressbook指定输出的可执行文件名为addressbook。在Windows上使用Visual Studio可以创建一个空项目将这些文件添加进去然后编译运行。5.2 功能测试流程编译成功后运行程序你应该能看到菜单。建议按以下顺序进行测试以覆盖所有功能分支空状态测试首次运行直接选择“1. 显示所有联系人”应提示“通讯录为空”。添加功能选择“2. 添加联系人”输入姓名“张三”电话“13800138000”地址“北京”。提示添加成功。再添加一个“李四”。重复添加测试再次尝试添加“张三”应提示“联系人已存在”添加失败。显示功能选择“1”应能看到张三和李四的信息。查找功能选择“3”输入“张三”应能正确显示。输入“王五”应提示未找到。修改功能选择“4”输入“张三”先显示旧信息。然后尝试修改电话为新号码姓名和地址直接回车保持不变。修改成功后再次显示所有联系人确认。删除功能选择“5”输入“李四”进行删除。再次显示应只剩张三。持久化测试选择“6”保存。然后选择“0”退出程序。重新启动程序程序启动时应自动从contacts.txt加载数据。选择“1”显示应该能看到上次保存的张三的信息。这证明了数据持久化成功。边界测试添加联系人时姓名或电话为空程序应给出错误提示。修改联系人时所有新信息都直接回车保持原样程序应正常处理。尝试打开一个被其他程序独占锁定的文件模拟保存失败观察程序行为。5.3 文件内容查看你可以用文本编辑器打开生成的contacts.txt文件内容应该类似于张三|13800138000|北京这验证了我们定义的文本存储格式是有效的。6. 项目扩展方向与高级优化思路这个基础版本已经实现了核心功能但它还有很大的改进和扩展空间。你可以尝试以下方向来深化对C的理解6.1 功能扩展更丰富的联系人字段增加邮箱、生日、分组家人、朋友、同事、备注等字段。这需要修改Contact类并更新文件读写和显示逻辑。批量导入导出支持从CSV文件批量导入联系人或者将通讯录导出为CSV格式方便与其他软件如Excel交换数据。高级搜索除了按姓名精确查找还可以实现按电话号码部分匹配、按地址模糊查找、按分组筛选等。排序功能让用户可以选择按姓名拼音首字母、按添加时间等对联系人列表进行排序。这需要了解std::sort算法和自定义比较函数。去重与合并提供检查并合并重复联系人的功能例如姓名和电话都相同。6.2 架构与性能优化使用更高效的数据结构进行查找当前按姓名查找需要遍历整个vector时间复杂度是O(n)。如果联系人数量巨大比如超过1万查找会变慢。可以考虑维护一个按姓名排序的vector这样可以使用std::binary_search进行二分查找将时间复杂度降到O(log n)。但插入和删除时需要保持有序会增加一点开销。使用std::map或std::unordered_map将姓名作为键keyContact对象或索引作为值value。std::unordered_map基于哈希表查找的平均时间复杂度是O(1)非常高效。但这会引入数据冗余联系人数据存两份或需要更复杂的管理map存索引。改进数据持久化使用JSON或XML库放弃自定义的文本格式使用nlohmann/json或pugixml等库来读写JSON/XML文件。这样格式更标准支持嵌套结构更容易扩展字段。使用数据库对于真正需要管理大量数据的场景可以集成SQLite这样的轻量级数据库。C有很好的SQLite接口如sqlite3库。这将使数据管理、复杂查询和并发访问能力得到质的提升。异常处理目前我们通过返回bool值和输出错误信息来处理异常。更规范的C做法是使用try-catch块和抛出标准异常如std::runtime_error,std::invalid_argument使错误处理逻辑更集中和清晰。使用智能指针如果Contact类中有动态分配的成员比如指向复杂对象的指针可以考虑使用std::unique_ptr来管理资源避免内存泄漏。实现复制控制如果未来需要深拷贝AddressBook对象就需要根据“三五法则”正确实现拷贝构造函数、拷贝赋值运算符和析构函数。目前我们使用std::vector和std::string它们自己管理资源所以编译器生成的默认版本是够用的Rule of Zero。但这是一个重要的C概念。6.3 界面与交互优化使用ncurses库Linux/macOS或Windows Console API实现一个真正的全屏文本用户界面TUI支持上下键选择、分页显示等比简单的数字菜单体验好得多。跨平台图形界面使用Qt、wxWidgets或Dear ImGui等框架为通讯录开发一个图形界面。这将带你进入一个全新的领域涉及事件驱动编程、控件布局和信号槽机制。这个简单的通讯录项目就像一棵树的种子从它出发你可以探索C和软件开发的各个分支。每实现一个扩展功能你都会对相关的知识点有更深刻的理解。我建议你先把这个基础版本吃透运行起来然后挑选一两个最感兴趣的方向进行扩展在实践中学习和成长。