1. 项目概述与核心价值
最近在整理硬盘,翻出来一个大学时期写的通讯录查询系统,代码虽然青涩,但逻辑清晰,功能完整。现在回头看,这个项目简直是C/C++初学者从“会写代码”到“会做项目”的绝佳跳板。它不像“学生管理系统”那样庞大复杂,也不像“计算器”那样过于简单,而是恰到好处地融合了数据结构、文件操作、用户交互和算法应用。今天,我就把这个项目的完整实现思路和盘托出,尤其会结合我后来工作中积累的经验,补充那些当年没人告诉我的“坑”和技巧。
这个通讯录查询系统的核心目标很简单:让用户能够方便地存储、查找、修改和删除联系人信息。听起来基础,对吧?但正是这种基础项目,最能考验你对C/C++核心功底的掌握程度。你需要思考:数据用什么结构存?是数组、链表还是更高级的容器?信息怎么持久化保存到文件里?查询功能怎么做才高效?用户界面是命令行还是图形化?每一个选择背后,都对应着不同的技术实现路径和复杂度。通过亲手实现它,你能把课本上散落的知识点——比如结构体、指针、文件I/O、字符串处理、排序查找算法——像串珍珠一样串联起来,形成一个完整的、可运行的应用。这对于巩固基础、准备面试或者单纯享受创造的乐趣,都大有裨益。
2. 系统整体设计与架构选型
2.1 需求分析与功能模块拆解
在动手写第一行代码之前,我们必须把系统要做什么想清楚。一个完整的通讯录查询系统,至少应该包含以下几个核心功能模块:
- 信息录入模块:负责添加新的联系人。需要收集的信息通常包括:姓名、电话号码(可能有多个)、电子邮箱、住址、分组(如家人、朋友、同事)等。
- 信息展示模块:能够以清晰、易读的格式列出所有联系人,或者展示单个联系人的详细信息。
- 查询检索模块:这是系统的“灵魂”。用户应该能通过多种方式快速找到目标联系人,例如:
- 精确查询:通过完整的姓名或电话号码查找。
- 模糊查询:输入姓名的一部分(如“张”),就能列出所有姓张的联系人。
- 复合查询:结合多个条件,比如“找出所有在‘北京’的‘同事’”。
- 信息维护模块:包括修改已有联系人的信息,以及删除不再需要的联系人。
- 数据持久化模块:通讯录数据不能只存在于程序运行时的内存中,程序关闭后数据必须保存到硬盘上的文件里,下次启动时能重新加载。这涉及到文件的读写操作。
- 辅助功能模块:例如对联系人按姓名拼音排序、数据备份与恢复、清空所有数据等。
把这些功能模块画成一个简单的框图,你就能清晰地看到整个系统的骨架。在设计时,我建议采用“自顶向下,逐步求精”的方法。先搭建好主菜单框架,让用户可以选择不同的功能编号,然后再逐个去实现每个功能背后的具体函数。
2.2 数据结构选型:从数组到STL容器的演进
选择什么样的数据结构来在内存中存储联系人信息,是第一个关键决策。这里有几个常见的方案,各有优劣:
方案一:静态数组
#define MAX_CONTACTS 1000 struct Contact { char name[50]; char phone[20]; char email[50]; }; struct Contact addressBook[MAX_CONTACTS]; int contactCount = 0;优点:实现简单,访问速度快(O(1)随机访问),内存连续,对缓存友好。是C语言初学者最直接的选择。缺点:容量固定,
MAX_CONTACTS定死了最大联系人数量,不灵活。插入和删除中间的元素需要移动后续所有元素,效率低(O(n))。在实际项目中,这种硬编码上限的方式很不优雅。方案二:动态数组(C语言手动管理)
struct Contact* addressBook = NULL; int capacity = 10; // 初始容量 int count = 0; // 当count == capacity时,需要realloc扩容 addressBook = (struct Contact*)realloc(addressBook, (capacity * 2) * sizeof(struct Contact)); capacity *= 2;优点:解决了静态数组容量固定的问题,可以动态增长。缺点:需要手动管理内存(
malloc,realloc,free),极易出错,导致内存泄漏或野指针。插入删除的效率问题依然存在。方案三:链表
struct ContactNode { struct Contact data; struct ContactNode* next; }; struct ContactNode* head = NULL;优点:插入和删除操作效率高(O(1),如果已知位置),不需要预先分配固定大小,理论上可以无限添加(受限于内存)。缺点:访问效率低,要找到第i个联系人需要遍历i个节点(O(n))。内存不连续,缓存不友好。同样需要复杂的指针操作和手动内存管理。
方案四:C++ STL容器(推荐)
#include <vector> #include <string> struct Contact { std::string name; std::string phone; std::string email; }; std::vector<Contact> addressBook;优点:
std::vector封装了动态数组,自动管理内存,无需担心malloc/free。提供了丰富的接口(push_back,insert,erase,at等)。配合std::string,彻底告别繁琐的char数组和strcpy。这是现代C++项目中最常用、最推荐的方式。缺点:对于C语言纯初学者,需要先学习C++的基础和STL概念。
我的选择与建议:如果你是C语言学习者,我建议从动态数组开始。它比静态数组更实用,能让你深刻理解内存管理的复杂性(这本身就是重要的学习目标)。在实现过程中,务必封装好addContact,removeContact,findContact等函数,把realloc和元素搬移的脏活藏在函数内部。如果你是C++学习者或希望项目更健壮、更现代,那么毫不犹豫地选择std::vector。它大幅降低了开发难度,让你能把精力集中在业务逻辑而非内存细节上。
注意:对于“查询”这个核心功能,如果联系人数量巨大(比如超过1万),无论是数组还是
vector,线性查找(O(n))都会变慢。此时可以考虑引入std::map或std::unordered_map来建立“姓名->联系人索引”的映射,将查找效率提升到O(log n)甚至O(1)。我们可以在后续优化环节讨论这个“索引”技巧。
2.3 文件存储格式设计:文本 vs 二进制
数据需要保存到文件。用哪种格式?主要有两种:
文本格式(如.csv, .txt):
张三,13800138000,zhangsan@example.com 李四,13900139000,lisi@example.com优点:文件人类可读,可以直接用记事本打开查看和编辑。易于与其他程序(如Excel)交换数据。实现简单,用
fprintf和fscanf或fgets即可。缺点:存储空间相对较大,解析速度稍慢。如果数据本身包含分隔符(如姓名中有逗号),需要处理转义,增加复杂度。二进制格式(.dat):
fwrite(&contact, sizeof(struct Contact), 1, fp);优点:存储紧凑,读写速度极快。直接内存映射,代码简洁。缺点:文件是乱码,人类不可读。最大的坑在于结构体对齐和内存布局。如果你的
struct Contact里使用了指针(比如char* name),那么fwrite写入的是指针值(一个内存地址),而不是指针指向的字符串内容!下次程序启动时,这个地址毫无意义,会导致程序崩溃或数据错误。此外,不同编译器、不同平台的结构体内存对齐方式可能不同,导致在此机器上写入的文件,在彼机器上读不出来。
我的选择与建议:对于初学者项目,强烈推荐使用文本格式(特别是CSV)。它简单、安全、可调试。当你用fprintf写入“张三,13800138000”时,你能确切地知道文件里有什么。读取时,可以使用fgets读取一整行,再用strtok或C++的std::getline配合std::stringstream来分割字符串。这虽然比二进制读写多几行代码,但避免了无数潜在的坑。等你对内存布局、序列化有了深刻理解后,再尝试二进制存储也不迟。
3. 核心模块实现详解
3.1 联系人数据结构的定义
这是所有功能的基石。一个设计良好的数据结构会让后续编码事半功倍。
C语言版本(使用动态内存管理字符串):
// contact.h #ifndef CONTACT_H #define CONTACT_H #define MAX_PHONE_NUM 3 // 一个人可能有多个电话号码 #define MAX_EMAIL_LEN 50 #define MAX_NAME_LEN 30 #define MAX_ADDR_LEN 100 typedef struct { char* name; // 使用指针,动态分配内存 char phones[MAX_PHONE_NUM][15]; // 固定二维数组存储多个电话 char email[MAX_EMAIL_LEN]; char address[MAX_ADDR_LEN]; int group; // 0:未分组,1:家人,2:朋友,3:同事... } Contact; // 操作Contact的函数声明 Contact* createContact(const char* name); void destroyContact(Contact* c); void printContact(const Contact* c); int addPhoneToContact(Contact* c, const char* phone); // ... 其他函数 #endif在这个C版本中,我故意混用了动态分配(name)和静态数组(phones,email)。目的是展示两种方式。name用char*意味着我们需要在createContact中malloc,在destroyContact中free,这是C语言内存管理的核心练习。而phones用二维数组,简化了存储,但限制了最多3个电话号码。
C++版本(现代、安全):
// contact.hpp #ifndef CONTACT_HPP #define CONTACT_HPP #include <string> #include <vector> enum class Group { NONE, FAMILY, FRIEND, COLLEAGUE }; struct Contact { std::string name; std::vector<std::string> phoneNumbers; // 使用vector存储多个电话,数量不限 std::string email; std::string address; Group group = Group::NONE; // 成员函数,更面向对象 void print() const; bool addPhoneNumber(const std::string& phone); bool isValid() const; // 检查联系人信息是否有效(如姓名非空) }; #endifC++版本明显更简洁、更强大。std::string和std::vector自动管理内存,enum class提供了类型安全的枚举。将打印、添加电话等操作作为成员函数,符合面向对象的思想。phoneNumbers用vector使得联系人的电话号码数量不受限制。
3.2 数据持久化:文件的读写操作
让我们实现文本格式(CSV)的保存与加载。假设我们使用C++版本。
保存到文件:
// 保存通讯录到CSV文件 bool saveToFile(const std::vector<Contact>& contacts, const std::string& filename) { std::ofstream outFile(filename); if (!outFile.is_open()) { std::cerr << "错误:无法打开文件 " << filename << " 用于写入!" << std::endl; return false; } // 写入表头(可选,方便人类阅读) outFile << "姓名,电话1,电话2,电话3,邮箱,地址,分组\n"; for (const auto& contact : contacts) { outFile << contact.name << ","; // 写入电话号码,用分号隔开,或者每个占一列。这里用分号隔开放入一个字段。 for (size_t i = 0; i < contact.phoneNumbers.size(); ++i) { if (i != 0) outFile << ";"; outFile << contact.phoneNumbers[i]; } outFile << ","; outFile << contact.email << ","; outFile << contact.address << ","; outFile << static_cast<int>(contact.group); // 将枚举转换为整数存储 outFile << "\n"; // 换行 } outFile.close(); if (outFile.fail()) { std::cerr << "错误:写入文件时发生错误!" << std::endl; return false; } std::cout << "通讯录已成功保存至 " << filename << std::endl; return true; }这里有个细节:电话号码有多个,我选择用分号;将它们连接成一个字符串存入一个CSV字段。也可以选择为电话预留多个列(如电话1、电话2、电话3),但那样不够灵活。分号分隔是处理可变数量子项的常用方法。
从文件加载:
// 从CSV文件加载通讯录 bool loadFromFile(std::vector<Contact>& contacts, const std::string& filename) { std::ifstream inFile(filename); if (!inFile.is_open()) { // 文件不存在是正常的,第一次运行程序时就会这样。不一定是错误。 std::cout << "提示:文件 " << filename << " 不存在,将创建新的通讯录。" << std::endl; return false; // 返回false表示没有加载到数据,但不是致命错误 } contacts.clear(); // 清空现有数据 std::string line; std::getline(inFile, line); // 跳过表头(如果有) while (std::getline(inFile, line)) { std::stringstream ss(line); std::string token; Contact c; // 解析姓名 if (!std::getline(ss, token, ',')) continue; c.name = token; // 解析电话号码(可能由分号连接) if (!std::getline(ss, token, ',')) continue; std::stringstream phoneStream(token); std::string phone; while (std::getline(phoneStream, phone, ';')) { if (!phone.empty()) { c.phoneNumbers.push_back(phone); } } // 解析邮箱 if (!std::getline(ss, token, ',')) continue; c.email = token; // 解析地址 if (!std::getline(ss, token, ',')) continue; c.address = token; // 解析分组 if (!std::getline(ss, token, ',')) continue; try { c.group = static_cast<Group>(std::stoi(token)); } catch (...) { c.group = Group::NONE; // 转换失败,设为默认值 } if (c.isValid()) { // 简单的有效性检查 contacts.push_back(c); } else { std::cerr << "警告:跳过无效的联系人行: " << line << std::endl; } } inFile.close(); std::cout << "从 " << filename << " 成功加载了 " << contacts.size() << " 个联系人。" << std::endl; return true; }加载逻辑是保存逻辑的逆过程。这里使用了std::getline的第三个参数(分隔符)来分割CSV行。注意异常处理(try-catch),因为文件内容可能被手动修改过,导致数字转换失败。isValid()函数可以检查例如姓名是否为空等基本规则。
实操心得:文件操作一定要进行错误检查!
is_open()、fail()这些状态判断不能省。在加载数据时,特别是对于用户可能手动编辑过的文本文件,要抱有“不信任”的态度,做好防御性编程,避免无效数据导致程序崩溃。
3.3 查询功能的深度实现:从线性查找到索引优化
查询是通讯录系统的核心。我们先实现基础版本,再讨论优化。
基础线性查找:
// 根据姓名进行精确查找(返回索引,未找到返回-1) int findContactByName(const std::vector<Contact>& contacts, const std::string& name) { for (size_t i = 0; i < contacts.size(); ++i) { if (contacts[i].name == name) { // string可以直接比较 return static_cast<int>(i); } } return -1; } // 根据姓名进行模糊查找(返回所有匹配的索引) std::vector<int> fuzzyFindContactByName(const std::vector<Contact>& contacts, const std::string& keyword) { std::vector<int> results; for (size_t i = 0; i < contacts.size(); ++i) { // 使用 find 方法判断 keyword 是否是 name 的子串 if (contacts[i].name.find(keyword) != std::string::npos) { results.push_back(i); } } return results; } // 根据电话号码查找(精确) int findContactByPhone(const std::vector<Contact>& contacts, const std::string& phone) { for (size_t i = 0; i < contacts.size(); ++i) { for (const auto& p : contacts[i].phoneNumbers) { if (p == phone) { return static_cast<int>(i); } } } return -1; }线性查找简单直接,在联系人数量少(比如几百个)时完全够用,且实现简单。模糊查找使用了std::string::find方法。
性能瓶颈与索引优化: 当联系人数量上升到数千甚至更多时,每次查询都遍历整个vector(O(n))就会显得缓慢。特别是精确查找姓名,这种场景非常适合建立索引。
我们可以维护一个std::unordered_map<std::string, int>,键是联系人的姓名,值是该联系人在vector中的索引。这样,精确查找姓名的时间复杂度就从O(n)降到了平均O(1)。
class AddressBook { private: std::vector<Contact> contacts; std::unordered_map<std::string, size_t> nameIndex; // 姓名->索引 的映射 public: // 添加联系人时,同时更新索引 bool addContact(const Contact& c) { if (nameIndex.find(c.name) != nameIndex.end()) { std::cout << "错误:联系人 \"" << c.name << "\" 已存在!" << std::endl; return false; } contacts.push_back(c); nameIndex[c.name] = contacts.size() - 1; return true; } // 根据姓名精确查找(利用索引,极快) const Contact* findContactByName(const std::string& name) const { auto it = nameIndex.find(name); if (it != nameIndex.end()) { return &contacts[it->second]; } return nullptr; // 未找到 } // 删除联系人时,需要从索引中也删除,并处理索引更新(稍复杂) bool deleteContactByName(const std::string& name) { auto it = nameIndex.find(name); if (it == nameIndex.end()) { return false; } size_t idx = it->second; // 从vector中删除 contacts.erase(contacts.begin() + idx); // 从索引中删除该条目 nameIndex.erase(it); // 关键步骤:更新索引中所有索引大于idx的项 for (auto& pair : nameIndex) { if (pair.second > idx) { pair.second--; // 因为后面元素前移了一位,索引减1 } } return true; } };使用索引后,精确查找变得非常快。但代价是增加了复杂性:必须在每次增、删、改操作时同步维护索引,确保索引和实际数据一致。特别是删除操作,需要更新那些索引值大于被删除元素索引的所有映射,这是一个O(n)的操作。但对于查询远多于修改的场景,这种权衡是值得的。
注意事项:
std::unordered_map的键(姓名)必须是唯一的。如果你的系统允许重名,那么索引的键就不能只用姓名,可能需要“姓名+电话”的组合,或者使用std::unordered_multimap。这取决于你的业务逻辑。
3.4 用户交互:控制台菜单与输入处理
一个友好的命令行界面能极大提升用户体验。我们需要处理用户的菜单选择和各种输入。
void displayMainMenu() { std::cout << "\n========== 通讯录管理系统 ==========\n"; std::cout << "1. 添加联系人\n"; std::cout << "2. 显示所有联系人\n"; std::cout << "3. 查找联系人(精确)\n"; std::cout << "4. 查找联系人(模糊)\n"; std::cout << "5. 修改联系人\n"; std::cout << "6. 删除联系人\n"; std::cout << "7. 按姓名排序\n"; std::cout << "8. 保存到文件\n"; std::cout << "9. 从文件加载\n"; std::cout << "0. 退出\n"; std::cout << "===================================\n"; std::cout << "请选择操作 (0-9): "; } int getMenuChoice() { int choice; while (!(std::cin >> choice) || choice < 0 || choice > 9) { std::cin.clear(); // 清除错误状态 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 忽略错误输入 std::cout << "输入无效,请输入0-9之间的数字: "; } std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 忽略换行符 return choice; } void handleAddContact(AddressBook& book) { Contact c; std::cout << "请输入姓名: "; std::getline(std::cin, c.name); std::cout << "请输入电话号码(输入空行结束): \n"; std::string phone; while (std::getline(std::cin, phone) && !phone.empty()) { if (!c.addPhoneNumber(phone)) { std::cout << "电话号码格式无效或已满,请重新输入。\n"; } std::cout << "请输入下一个电话号码(或直接回车结束): "; } if (c.phoneNumbers.empty()) { std::cout << "警告:至少需要一个电话号码。\n"; return; } std::cout << "请输入邮箱: "; std::getline(std::cin, c.email); std::cout << "请输入地址: "; std::getline(std::cin, c.address); // 分组选择... if (book.addContact(c)) { std::cout << "联系人添加成功!\n"; } }输入处理是命令行程序的痛点。std::cin和std::getline混用容易出问题(std::cin >>会留下换行符)。上面的代码在getMenuChoice后使用了ignore来清空输入缓冲区,确保后续的getline能正确读取。对于电话号码的循环输入,提供了一个清晰的交互方式。
4. 项目进阶与优化方向
一个基础功能完备的通讯录系统已经完成了。但如果你想把它做得更像一个“作品”,或者用于深入学习,这里有几个进阶方向:
4.1 引入数据库持久化
文本文件在数据量小、结构简单时没问题,但缺乏查询能力、并发访问控制和事务支持。你可以尝试引入轻量级数据库,例如SQLite。SQLite是一个C语言库,零配置、无服务器,整个数据库就是一个文件,完美适合这种桌面小应用。
你需要学习基本的SQL语句(CREATE TABLE,INSERT,SELECT,UPDATE,DELETE),然后使用SQLite的C/C++ API来操作。这将使你接触到真实世界的数据持久化方案,意义重大。你的联系人结构体会对应数据库里的一张表,查询功能可以直接转化为SQL的WHERE语句,效率和管理性都大大提升。
4.2 实现图形用户界面
命令行对于学习和测试足够了,但对于最终用户不够友好。你可以使用以下工具之一为你的C++核心逻辑套上一个GUI壳子:
- Qt:功能强大、跨平台,是C++ GUI开发的主流选择之一。学习曲线稍陡,但做出来的界面专业。
- Dear ImGui:一个轻量级的即时模式GUI库,非常适合工具、调试界面。代码直截了当,能快速做出可用的界面。
- Windows API / Win32:如果你只想在Windows上运行,直接学习Win32编程是最原生的方式,但对初学者挑战较大。
将你的核心业务逻辑(AddressBook类)与GUI表现层分离是一个很好的软件工程实践。GUI层只负责显示和接收用户输入,然后调用核心逻辑层的函数。这种MVC(模型-视图-控制器)的雏形对编程思维是很好的锻炼。
4.3 增加高级查询与数据统计
超越简单的查找,实现更复杂的查询:
- 复合查询:“找出所有邮箱包含‘gmail.com’的‘朋友’”。
- 范围查询:“找出最近一个月添加的联系人”(需要增加
time_t addTime字段)。 - 数据统计:统计各个分组的联系人数量、生成简单的报表。
这需要你设计更灵活的查询接口,可能涉及组合多个判断条件。你可以实现一个简单的查询解析器,或者提供一组过滤函数让用户勾选。
4.4 网络化与数据同步(拓展思路)
这是一个更前沿的方向:将通讯录数据同步到云端,实现多设备访问。这涉及到:
- 设计网络协议:定义客户端和服务器之间如何通信(添加、查询、同步)。
- 选择网络库:如C++的
Boost.Asio或C的libcurl。 - 服务器端开发:可以用任何你熟悉的语言(Python、Go、Java)写一个简单的HTTP/WebSocket服务器,提供RESTful API。
- 数据安全:考虑对传输和存储的数据进行加密。
这个方向能带你走进网络编程和分布式系统的大门,但复杂度也呈指数级增长,可以作为长期的业余项目来迭代。
5. 开发中的常见陷阱与调试技巧
即使思路清晰,动手编码时还是会遇到各种问题。这里分享几个我踩过的坑和解决方法。
5.1 内存管理问题(C语言特供)
如果你用C语言和动态内存,以下错误几乎一定会遇到:
- 内存泄漏:
malloc了没有free。解决方法:为每个createContact配对编写destroyContact函数,并在删除联系人时调用。使用valgrind(Linux)或Visual Studio的内存诊断工具来检测。 - 野指针/悬挂指针:
free了内存后,继续使用该指针。解决方法:free之后立即将指针置为NULL。在访问指针前检查是否为NULL。 - 缓冲区溢出:使用
strcpy拷贝到固定大小的数组,源字符串可能比目标数组长。解决方法:使用strncpy并手动添加终止符\0,或者使用snprintf。// 危险! strcpy(contact.name, inputName); // 安全做法 strncpy(contact.name, inputName, MAX_NAME_LEN - 1); contact.name[MAX_NAME_LEN - 1] = '\0';
5.2 文件读写中的数据一致性问题
- 问题:程序运行时,联系人数据在内存中。如果程序崩溃,最后一次保存之后的所有修改都会丢失。
- 缓解策略:
- 定期自动保存:可以设置一个计数器,每修改N次后自动保存一次。
- 操作日志:在修改数据前,先将操作(如“添加:张三”)追加到一个日志文件中。程序启动时,除了加载主数据文件,也检查并重放日志。这类似于数据库的WAL(Write-Ahead Logging)机制。
- 临时文件:保存时,先写入一个临时文件(如
addressbook.csv.tmp),写入成功后再用rename函数原子性地替换旧文件。这可以防止保存过程中程序崩溃导致原文件损坏。
5.3 输入验证与程序健壮性
用户输入是不可信的。你必须验证所有输入。
- 姓名:是否为空?是否包含非法字符(如逗号,如果逗号是你的CSV分隔符)?
- 电话:是否符合电话号码格式(数字、长度、可能包含
+、-、空格)?可以用正则表达式(C++11的<regex>)或简单规则检查。 - 邮箱:是否有
@符号?域名部分是否合理? - 数字选择:菜单选择是否在有效范围内?
对于无效输入,要给用户清晰、友好的提示,并允许其重新输入,而不是直接崩溃或存入垃圾数据。
5.4 跨平台兼容性问题
如果你的代码想在Linux、macOS和Windows上都能编译运行,要注意:
- 文件路径:Windows用反斜杠
\,类Unix用正斜杠/。建议使用/,它在Windows上也通常被接受,或者使用C++17的std::filesystem::path。 - 换行符:文本文件中,Windows是
\r\n,Linux/macOS是\n。用文本模式打开文件("r","w")时,C/C++标准库会进行转换,但如果你以二进制模式("rb","wb")打开,就需要自己处理。 - 控制台编码:中文字符在控制台显示乱码。在Windows上,可能需要
SetConsoleOutputCP(65001)设置为UTF-8。这是一个深水区,对于课程项目,可以暂时只使用英文界面避免麻烦。
从头实现一个通讯录查询系统,就像搭积木,每一块都对应着一个重要的编程知识点。当你看到自己写的程序能成功添加、查找、保存联系人时,那种成就感是无可替代的。这个项目没有终点,你可以根据自己的兴趣不断添加新功能,把它当作一个长期的编程练习场。最重要的是动手去写,在调试中学习,在踩坑中成长。