C++17实战:构建跨平台终端文件管理器,打通理论与工程实践

发布时间:2026/7/23 5:39:56

C++17实战:构建跨平台终端文件管理器,打通理论与工程实践 1. 项目概述从“知道”到“做到”的C实践鸿沟在C的学习道路上我们常常会遇到一个经典的困境语法书翻了一遍又一遍指针、类、模板的概念似乎都懂但一旦被要求“写一个完整的项目”大脑就瞬间一片空白。这就像学游泳在岸上把动作要领背得滚瓜烂熟不下水永远学不会。网络上充斥着“C八股文”的面试题解析或是“Hello World”级别的语法示例但关于如何将这些零散的知识点串联起来构建一个结构清晰、可维护、有一定复杂度的真实项目系统的指导却少之又少。这正是“C程序设计项目实践”这个标题背后无数学习者最核心的痛点与需求。这个实践项目的目标绝非仅仅是完成一段能运行的代码。它的深层价值在于模拟一个接近真实开发环境的场景让你亲身体验从需求分析、技术选型、架构设计、编码实现、调试排错到最终测试的完整软件生命周期。你会遇到教科书上不会讲的“脏活累活”比如如何组织成千上万行的代码文件如何处理令人头疼的第三方库依赖如何设计既灵活又高效的接口以及如何用调试工具追踪那些神出鬼没的内存错误。通过这样一个项目C将从一门“考试语言”转变为你手中解决实际问题的“工程工具”。本次实践我们将聚焦于开发一个简易的跨平台终端文件管理器。选择这个方向有几个考量首先它不涉及复杂的图形界面避免陷入Qt或MFC等特定框架的细节让我们专注于核心的C逻辑与系统编程。其次它覆盖了C工程实践的多个关键面文件I/O操作、字符串处理、容器如std::vector,std::map的使用、自定义数据结构和算法以及可能的跨平台编译配置。最后它具有清晰的扩展性未来可以轻松加入文件搜索、批量重命名、目录同步等高级功能。无论你是正在学习《程序设计基础》的学生还是希望巩固C工程能力的开发者这个项目都将是一次极具价值的“下水”体验。2. 项目整体设计与技术栈选型在动手写第一行代码之前花时间进行合理的整体设计和技术选型是专业开发与业余脚本的最大区别。这一步决定了项目的骨架是否健壮以及后续开发是会顺风顺水还是举步维艰。2.1 核心功能模块拆解我们的终端文件管理器主要面向命令行操作目标是实现以下核心功能模块目录浏览与导航列出当前目录下的文件和子目录支持基本的cd切换目录、ls列表展示操作。展示信息需包括文件名、类型、大小、最后修改时间等。文件操作实现基本的cp复制、mv移动/重命名、rm删除、mkdir创建目录功能。这是检验你C标准库文件系统操作熟练度的关键。信息显示与交互设计一个清晰、可读的命令行界面。需要处理用户输入解析命令和参数并给出明确的操作反馈或错误提示。扩展功能预留为未来可能增加的功能如按模式搜索文件、计算目录总大小、文件属性修改等设计良好的接口和代码结构保证可扩展性。2.2 技术栈与工具链决策基于上述功能我们选择以下技术栈并解释为何如此选择语言标准C17。这是当前在兼容性和现代特性之间一个非常好的平衡点。它提供了我们亟需的std::filesystem库用于跨平台文件操作以及诸如结构化绑定、std::optional、std::string_view等能大幅提升代码简洁性和安全性的特性。避免使用过于陈旧的C11也暂不追求最新的C20以保证教程的广泛适用性。编译与构建CMake。这是管理C项目构建的事实标准。它解决了源代码如何编译、链接成可执行文件以及如何管理依赖库的问题。使用CMake我们可以轻松地生成适用于不同平台Windows的Visual Studio项目、Linux/macOS的Makefile的构建文件这是实现“跨平台”目标的基础。与之相比手动编写Makefile或直接使用IDE的项目文件在项目稍复杂时就会变得难以维护。开发环境Visual Studio Code 对应的C插件。VSCode轻量、免费、插件生态丰富是学习和小型项目开发的绝佳选择。配合微软的“C/C”插件它能提供强大的代码补全、跳转、静态检查和调试支持。当然你也可以使用Visual Studio、CLion等全功能IDE原理相通。关键在于要熟悉如何配置调试环境这是高效排错的生命线。核心依赖库std::filesystemC17标准库的一部分用于所有文件和目录的路径操作、遍历、属性查询。它是我们放弃平台特定API如Windows的windows.h或POSIX的dirent.h的底气是实现跨平台的核心。std::chrono和std::iomanip用于处理文件时间戳的格式化和输出对齐让ls命令的列表看起来更专业。可选一个命令行参数解析库如cxxopts或argparse。当命令参数变得复杂时手动解析会非常繁琐且易错。在项目初期我们可以先实现简单的空格分割式解析但了解并使用这类库是工程实践的必备技能。注意很多新手会纠结于开发环境的“完美配置”在配置VSCode的c_cpp_properties.json、tasks.json、launch.json上花费过多时间甚至因一个头文件找不到的错误而卡住数天。我的建议是环境配置为开发服务而非目标本身。如果配置遇到困难不妨先使用系统命令行g/clang/MSVC进行最简单的编译确保核心逻辑正确再回头完善IDE配置。记住编译器和标准库才是核心IDE只是帮手。2.3 项目目录结构规划一个清晰的项目结构是代码可维护性的第一道保障。在项目根目录下我们建议创建如下结构TerminalFileManager/ ├── CMakeLists.txt # 项目总构建脚本 ├── src/ # 所有源代码文件 │ ├── core/ # 核心业务逻辑 │ │ ├── FileOperator.cpp │ │ ├── FileOperator.h │ │ ├── DirectoryLister.cpp │ │ └── DirectoryLister.h │ ├── ui/ # 用户界面与交互逻辑 │ │ ├── CommandParser.cpp │ │ ├── CommandParser.h │ │ └── TerminalUI.cpp │ └── main.cpp # 程序入口 ├── include/ # 对外公开的头文件如有 ├── tests/ # 单元测试代码 │ └── test_file_operator.cpp ├── third_party/ # 第三方库源码如采用 ├── build/ # 构建输出目录由CMake生成应加入.gitignore └── README.md # 项目说明文档这种分层的结构将不同的职责模块化。core负责底层文件操作ui负责解析和响应用户命令main.cpp作为粘合剂。当你想增加一个“文件搜索”功能时只需在core下新增FileSearcher.h/cpp并在ui中增加对应的命令解析即可不会影响其他模块。3. 核心模块实现与关键技术点剖析接下来我们深入核心模块看看如何用C17的特性稳健地实现文件管理器的“心脏”功能。这里会涉及大量实际编码中的“坑”和最佳实践。3.1 利用std::filesystem实现稳健的目录遍历目录列表ls是文件管理器的基础。使用std::filesystem可以优雅地实现跨平台遍历。关键实现DirectoryLister类// DirectoryLister.h #pragma once // 使用pragma once防止头文件重复包含比#ifndef更简洁 #include filesystem #include vector #include string namespace fs std::filesystem; // 别名简化书写 class DirectoryLister { public: struct FileInfo { std::string name; std::string type; // dir, file, symlink等 uintmax_t size; std::filesystem::file_time_type lastWriteTime; // 可以扩展权限、所有者等信息 }; // 列出指定路径下的所有条目 std::vectorFileInfo listDirectory(const fs::path dirPath, bool showHidden false); // 获取一个条目的详细信息供listDirectory内部调用或单独使用 FileInfo getFileInfo(const fs::path entryPath); };// DirectoryLister.cpp #include DirectoryLister.h #include algorithm #include iomanip #include iostream std::vectorDirectoryLister::FileInfo DirectoryLister::listDirectory(const fs::path dirPath, bool showHidden) { std::vectorFileInfo result; // 1. 检查路径是否存在且是否为目录 if (!fs::exists(dirPath)) { throw std::runtime_error(目录不存在: dirPath.string()); } if (!fs::is_directory(dirPath)) { throw std::runtime_error(路径不是目录: dirPath.string()); } // 2. 使用directory_iterator遍历 for (const auto entry : fs::directory_iterator(dirPath)) { const auto path entry.path(); std::string filename path.filename().string(); // 过滤隐藏文件以点开头根据参数决定 if (!showHidden filename.size() 0 filename[0] .) { continue; } // 3. 获取并存储文件信息 result.push_back(getFileInfo(entry)); } // 4. 按文件名排序可选但符合用户习惯 std::sort(result.begin(), result.end(), [](const FileInfo a, const FileInfo b) { // 目录优先然后按名称字母序 if (a.type dir b.type ! dir) return true; if (a.type ! dir b.type dir) return false; return a.name b.name; }); return result; } DirectoryLister::FileInfo DirectoryLister::getFileInfo(const fs::path entryPath) { FileInfo info; info.name entryPath.filename().string(); // 使用status获取类型信息比多次调用is_xxx更高效 auto fileStatus fs::status(entryPath); if (fs::is_directory(fileStatus)) info.type dir; else if (fs::is_regular_file(fileStatus)) info.type file; else if (fs::is_symlink(fileStatus)) info.type symlink; else info.type other; try { // 获取文件大小目录大小通常需要递归计算这里先简单处理 if (info.type file) { info.size fs::file_size(entryPath); } else { info.size 0; // 目录大小暂记为0 } info.lastWriteTime fs::last_write_time(entryPath); } catch (const fs::filesystem_error e) { // 权限不足或文件正在被占用可能导致异常 std::cerr 警告: 无法获取 info.name 的完整信息: e.what() std::endl; info.size 0; info.lastWriteTime fs::file_time_type::min(); } return info; }实操心得与避坑指南错误处理是重中之重std::filesystem的操作如file_size,last_write_time可能会因为权限不足、文件被锁定、路径无效等原因抛出std::filesystem_error异常。绝对不能忽略这些异常上面的代码在getFileInfo中使用了try-catch块捕获异常并输出警告同时赋予默认值保证了程序在遇到个别问题文件时不会崩溃而是继续处理其他文件。这是一种“优雅降级”的策略。性能考量fs::directory_iterator默认不递归进入子目录性能较好。如果需要递归应使用fs::recursive_directory_iterator但要小心符号链接可能导致的循环遍历。另外对每个条目调用fs::is_directory,fs::file_size等是独立的系统调用。在极端性能敏感场景可以考虑平台特定的批量读取API但对于学习项目标准库的简洁性和可读性优先。路径处理始终使用std::filesystem::path类型来表示路径。它自动处理不同操作系统Windows的\和Unix的/的路径分隔符问题。使用.string()或.u8string()方法在需要时转换为字符串。3.2 实现安全的文件复制与移动操作文件复制cp和移动mv是数据操作必须格外小心防止数据丢失。关键实现FileOperator类// FileOperator.h #pragma once #include filesystem #include system_error // 用于std::error_code namespace fs std::filesystem; class FileOperator { public: // 复制文件或目录递归 bool copy(const fs::path source, const fs::path destination, std::error_code ec); // 移动/重命名文件或目录 bool move(const fs::path source, const fs::path destination, std::error_code ec); // 删除文件或空目录 bool remove(const fs::path target, std::error_code ec); // 递归删除目录及其所有内容危险操作 bool removeAll(const fs::path target, std::error_code ec); // 创建目录包括必要的父目录 bool createDirectories(const fs::path dirPath, std::error_code ec); };// FileOperator.cpp #include FileOperator.h #include iostream bool FileOperator::copy(const fs::path source, const fs::path dest, std::error_code ec) { ec.clear(); // 清空错误码 if (!fs::exists(source, ec)) { std::cerr 源路径不存在: source std::endl; return false; } if (ec) return false; // 检查目标是否是源的子目录防止递归复制到自身 if (fs::is_directory(source, ec)) { for (const auto entry : fs::recursive_directory_iterator(source)) { if (entry.path() dest) { std::cerr 错误: 目标路径是源路径的子目录可能导致无限递归。 std::endl; return false; } } } // 使用copy_options控制行为递归复制目录、覆盖已存在文件 fs::copy_options options fs::copy_options::recursive | fs::copy_options::overwrite_existing; fs::copy(source, dest, options, ec); if (ec) { std::cerr 复制失败: ec.message() std::endl; return false; } std::cout 成功复制: source - dest std::endl; return true; } bool FileOperator::move(const fs::path source, const fs::path dest, std::error_code ec) { ec.clear(); // 先尝试直接重命名/移动最快速同分区 fs::rename(source, dest, ec); if (!ec) { std::cout 成功移动/重命名: source - dest std::endl; return true; } // 如果rename失败可能因为跨设备则采用“复制删除”策略 std::cout 跨设备移动采用复制后删除策略... std::endl; if (copy(source, dest, ec)) { return removeAll(source, ec); // 复制成功后删除源 } return false; } bool FileOperator::removeAll(const fs::path target, std::error_code ec) { ec.clear(); // 这是一个危险操作在实际项目中应考虑先移动到“回收站”或备份。 if (!fs::exists(target, ec)) { return true; // 不存在视为删除成功 } // 重要在递归删除前再次确认尤其是对根目录或重要路径 // 这里可以加入一个交互式确认或至少打印警告。 std::uintmax_t count fs::remove_all(target, ec); if (!ec) { std::cout 已删除 count 个文件或目录。 std::endl; return true; } return false; }注意事项与安全警示std::error_codevs 异常文件操作函数我们选择了使用std::error_code输出参数来接收错误而非让函数抛出异常。这是因为在工具类函数中调用者往往希望以更可控的方式处理错误比如复制100个文件其中一个失败不应导致整个程序崩溃。ec.clear()在函数开始处调用是一个好习惯避免传入的旧错误码干扰本次判断。递归复制与移动的陷阱fs::copy的递归选项很方便但必须警惕目标目录是源目录子目录的情况这会导致无限循环。上面的代码提供了一个简单的检查逻辑。对于移动操作fs::rename在同一个文件系统内是原子操作且极快但跨设备如从C盘到D盘会失败此时必须降级为“复制删除”。remove_all是“核弹”fs::remove_all会递归删除目录下所有内容且默认不可恢复。在生产代码或任何严肃的工具中必须对此操作施加极其严格的限制和确认机制。例如可以设计一个“安全删除”模式先将文件移入一个临时回收站目录定期清理。永远不要轻易对用户输入的直接路径调用此函数。3.3 构建灵活的命令行解析与用户交互用户界面层负责连接用户输入和核心逻辑。一个健壮的解析器能极大提升用户体验。关键实现CommandParser 和 TerminalUI// CommandParser.h #pragma once #include string #include vector #include map struct ParsedCommand { std::string command; // ls, cp, mv等 std::vectorstd::string args; // 命令参数 std::mapstd::string, std::string options; // 例如 {-l: , -a: } 或 {--sort: size} }; class CommandParser { public: ParsedCommand parse(const std::string inputLine); };// CommandParser.cpp #include CommandParser.h #include sstream #include algorithm ParsedCommand CommandParser::parse(const std::string inputLine) { ParsedCommand result; std::istringstream iss(inputLine); std::string token; // 第一个token是命令 if (iss token) { result.command token; } // 解析后续token区分参数和选项 while (iss token) { if (token.empty()) continue; if (token[0] -) { // 以‘-’开头的是选项 // 简单处理短选项如 -l -a长选项如 --all // 更复杂的解析可以支持 --optionvalue 形式 if (token.size() 1 token[1] -) { // 长选项 result.options[token] ; // 值暂为空 } else { // 短选项可能合并如 -la for (size_t i 1; i token.size(); i) { std::string opt std::string(-) token[i]; result.options[opt] ; } } } else { // 普通参数 result.args.push_back(token); } } return result; }// TerminalUI.cpp (部分核心逻辑) #include TerminalUI.h #include CommandParser.h #include DirectoryLister.h #include FileOperator.h #include iostream #include iomanip #include chrono void TerminalUI::run() { std::string currentPath fs::current_path().string(); CommandParser parser; DirectoryLister lister; FileOperator op; std::cout 简易终端文件管理器 (输入 help 获取帮助, exit 退出) std::endl; while (true) { // 显示提示符 std::cout \n[ currentPath ]$ ; std::string input; if (!std::getline(std::cin, input)) break; // 处理EOF (CtrlD/Z) if (input.empty()) continue; auto parsedCmd parser.parse(input); if (parsedCmd.command exit || parsedCmd.command quit) { break; } else if (parsedCmd.command cd) { // 处理cd命令 fs::path newPath; if (parsedCmd.args.empty() || parsedCmd.args[0] ~) { // 回家目录简化处理实际应读取HOME环境变量 newPath getHomeDirectory(); } else { newPath fs::path(parsedCmd.args[0]); if (newPath.is_relative()) { newPath fs::path(currentPath) / newPath; } } std::error_code ec; fs::current_path(newPath, ec); if (!ec) { currentPath fs::current_path().string(); } else { std::cerr cd: 无法切换到目录 newPath : ec.message() std::endl; } } else if (parsedCmd.command ls) { // 处理ls命令 bool showHidden parsedCmd.options.find(-a) ! parsedCmd.options.end(); bool longFormat parsedCmd.options.find(-l) ! parsedCmd.options.end(); fs::path listPath currentPath; if (!parsedCmd.args.empty()) { listPath fs::path(parsedCmd.args[0]); if (listPath.is_relative()) { listPath fs::path(currentPath) / listPath; } } try { auto files lister.listDirectory(listPath, showHidden); displayFileList(files, longFormat); } catch (const std::exception e) { std::cerr ls: e.what() std::endl; } } else if (parsedCmd.command cp) { // 处理cp命令需要源和目标两个参数 if (parsedCmd.args.size() 2) { std::cerr 用法: cp 源 目标 std::endl; continue; } std::error_code ec; fs::path src parsedCmd.args[0]; fs::path dst parsedCmd.args[1]; if (src.is_relative()) src fs::path(currentPath) / src; if (dst.is_relative()) dst fs::path(currentPath) / dst; if (!op.copy(src, dst, ec)) { std::cerr cp操作失败。 std::endl; } } // ... 其他命令如 mv, rm, mkdir 的实现类似 else if (parsedCmd.command help) { printHelp(); } else { std::cerr 未知命令: parsedCmd.command . 输入 help 查看可用命令。 std::endl; } } }交互设计要点路径解析用户输入可能是绝对路径/home/user或C:\Users也可能是相对路径./docs或..。在传递给std::filesystem的函数前必须将其与当前工作目录currentPath组合成绝对路径。使用fs::path的/操作符可以安全地拼接路径。命令解析的进化上面的CommandParser是一个极简版本。当命令需要支持像ls -l -a /some/path或cp -r src/ dst/这样的复杂选项时解析逻辑会迅速膨胀。这就是为什么在真实项目中强烈推荐集成成熟的第三方命令行解析库它们能处理长短选项、可选/必选参数、自动生成帮助信息等能节省大量开发时间并减少错误。用户体验提供清晰的错误提示如“用法: cp 源 目标”、对危险操作如rm -r的二次确认、以及一个help命令都是提升工具友好度的关键。4. 项目构建、调试与高级议题有了核心代码如何将它们组织起来并运行是项目实践的另一个重要环节。4.1 使用CMake构建跨平台项目CMakeLists.txt是项目的构建蓝图。一个基础的配置如下cmake_minimum_required(VERSION 3.15) # 指定最低CMake版本 project(TerminalFileManager VERSION 1.0.0 LANGUAGES CXX) # 项目名和语言 # 设置C标准为17并开启常用警告 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 禁用编译器扩展保证可移植性 if(MSVC) # 针对Visual Studio编译器设置警告等级 add_compile_options(/W4 /WX) # /W4 高警告等级/WX 视警告为错误 else() # 针对GCC/Clang编译器 add_compile_options(-Wall -Wextra -Wpedantic -Werror) endif() # 将源代码文件分组到变量中方便管理 set(CORE_SOURCES src/core/FileOperator.cpp src/core/DirectoryLister.cpp ) set(UI_SOURCES src/ui/CommandParser.cpp src/ui/TerminalUI.cpp ) # 创建可执行文件链接所有源文件 add_executable(tfm src/main.cpp ${CORE_SOURCES} ${UI_SOURCES} ) # 在Windows上需要显式链接标准库的文件系统组件C17起分离 if(CMAKE_CXX_COMPILER_ID MATCHES MSVC OR CMAKE_CXX_COMPILER_ID MATCHES Clang) target_link_libraries(tfm PRIVATE stdcfs) # 对于某些GCC版本 elseif(CMAKE_SYSTEM_NAME MATCHES Windows) # MSVC 通常不需要额外链接 else() target_link_libraries(tfm PRIVATE stdcfs) # 对于GCC endif() # 如果包含第三方库在这里用 find_package 或 add_subdirectory 引入 # find_package(SomeLib REQUIRED) # target_link_libraries(tfm PRIVATE SomeLib::SomeLib) # 可选安装规则用于 make install 或类似的打包 install(TARGETS tfm DESTINATION bin)构建步骤在项目根目录TerminalFileManager/下创建一个build目录mkdir build cd build。运行CMake生成构建文件cmake ..。这会根据你的系统生成MakefileLinux/macOS或Visual Studio解决方案Windows。编译项目cmake --build .或直接make在Unix系统。编译完成后在build目录下或子目录如Debug/会生成可执行文件tfm或tfm.exe。4.2 调试技巧与常见问题排查即使设计再完善编码中也一定会遇到Bug。掌握调试技巧至关重要。使用调试器在VSCode中配置好launch.json后可以设置断点、单步执行、查看变量值。这是定位逻辑错误最有效的方法。对于崩溃问题调试器能直接带你到崩溃的代码行。日志输出在关键函数入口、出口和决策点添加std::cout或std::cerr输出打印变量状态。这是一种古老但极其有效的调试手段尤其适用于并发或难以复现的问题。Valgrind / AddressSanitizer这是C/C程序员的神器用于检测内存泄漏、非法内存访问、使用未初始化内存等问题。在Linux/macOS上使用Valgrindvalgrind --leak-checkfull ./tfm。现代编译器GCC/Clang也集成了AddressSanitizer编译时添加-fsanitizeaddress标志即可启用。常见问题速查表问题现象可能原因排查思路与解决方案编译错误‘filesystem’ is not a namespace-name编译器未启用C17模式或GCC版本较旧8需要额外链接库。1. 检查CMake中CMAKE_CXX_STANDARD是否设置为17。2. 对于GCC尝试在target_link_libraries中添加stdcfs。运行时崩溃Segmentation fault空指针解引用、数组越界、访问已释放内存。1. 使用调试器查看崩溃堆栈。2. 使用Valgrind/AddressSanitizer检查内存错误。3. 检查所有指针和容器索引是否在有效范围内。文件操作失败无错误信息未检查std::filesystem操作的返回值或异常。1. 确保所有fs::函数调用都有错误处理try-catch或检查std::error_code。2. 打印ec.message()或异常信息e.what()。ls命令显示乱码或程序异常文件名包含非ASCII字符如中文编码处理不当。1. 使用fs::path::u8string()获取UTF-8编码的字符串进行输出。2. 确保终端和控制台支持UTF-8编码。复制大文件时程序卡住或内存飙升可能试图一次性将整个文件读入内存。对于大文件操作应使用流std::ifstream/std::ofstream以缓冲区方式分块读写而不是依赖fs::copy虽然它内部通常是优化的。4.3 项目扩展与进阶思考完成基础版本后你可以尝试以下扩展将项目提升到一个新的水平实现文件搜索功能在core模块添加FileSearcher类利用fs::recursive_directory_iterator遍历目录树根据文件名、扩展名、内容简单文本匹配或大小范围进行过滤。这涉及到更复杂的算法和可能的并发优化。添加批量操作支持通配符如*.txt或正则表达式来匹配多个文件然后对匹配的文件列表执行复制、移动、删除等操作。这需要增强CommandParser对参数模式的识别能力。集成压缩/解压通过链接像libzip或zlib这样的第三方库为你的文件管理器添加压缩和解压功能如zip格式。这是学习如何在C项目中集成外部库的绝佳练习。实现简单的文件预览对于文本文件.txt,.cpp,.py等可以实现一个cat或less命令在终端内分页查看文件内容。这需要处理终端控制码和分页逻辑。编写单元测试使用Google Test或Catch2等测试框架为FileOperator、DirectoryLister等核心类编写单元测试。例如测试copy函数能否正确复制文件并在目标文件存在时能否按预期覆盖。良好的测试是代码质量的守护神。通过这样一个从零到一的完整项目实践你收获的将不仅仅是几个C语法点的熟练运用。你会深刻理解模块化设计、错误处理、跨平台开发、构建系统、调试技巧等软件工程的核心概念。当你能独立完成这样一个项目并对其进行扩展和优化时你便已经跨越了从“C语法学习者”到“C初级开发者”的关键门槛。记住编程是一门实践的艺术最好的学习方式就是去构建去踩坑然后爬出来。现在打开你的编辑器开始构建属于你自己的文件管理器吧。

相关新闻