4. 文件系统抽象:std::filesystem的跨平台使用、路径处理、目录遍历

文件系统操作,说白了就是跟文件夹和文件打交道。你在Windows上写路径用反斜杠,到了Linux上就得用正斜杠,光这个就够让人头疼的。C++17引入的std::filesystem,就是为了解决这类跨平台问题。

我个人习惯,只要项目允许用C++17,文件操作一律用std::filesystem。为什么?因为它把平台差异都封装好了,你写一遍代码,Windows、Linux、macOS都能跑。省心。

4.1 路径处理:别再手动拼接字符串了

很多新手喜欢这么写路径:

std::string path = "C:\\Users\\me\\data\\" + filename;

嗯,这里有个坑。换到Linux上,这个路径就废了。而且你还要处理末尾有没有斜杠、路径里有没有非法字符……烦不烦?

std::filesystem::path就清爽多了:

#include <filesystem>
namespace fs = std::filesystem;

fs::path base = "C:/Users/me/data";
fs::path full = base / "report.txt";  // 自动处理分隔符

std::cout << full.string();  // 输出: C:/Users/me/data/report.txt

注意那个/运算符。它会自动根据当前平台插入正确的分隔符。你在Windows上跑,它就用反斜杠;在Linux上跑,它就用正斜杠。我在项目中遇到过有人手动加\\,结果代码移植到服务器上直接崩溃——路径全错了。

小技巧:path::preferred_separator可以获取当前平台的分隔符字符。调试时打印出来看看,心里有数。

4.2 路径的常用操作

path对象提供了很多实用的成员函数。我列几个最常用的:

函数 作用 示例
filename() 获取文件名(含扩展名) "report.txt"
stem() 获取文件名(不含扩展名) "report"
extension() 获取扩展名 ".txt"
parent_path() 获取父目录 "C:/Users/me/data"
root_path() 获取根路径 "C:/"
relative_path() 获取相对路径部分 "Users/me/data/report.txt"

举个例子,假设你有一个路径"/home/user/docs/file.txt"

fs::path p("/home/user/docs/file.txt");
std::cout << "文件名: " << p.filename() << "\n";
std::cout << "主名: " << p.stem() << "\n";
std::cout << "扩展名: " << p.extension() << "\n";
std::cout << "父目录: " << p.parent_path() << "\n";

输出:

文件名: "file.txt"
主名: "file"
扩展名: ".txt"
父目录: "/home/user/docs"

你看,不用自己写字符串解析,干净利落。

4.3 目录遍历:递归与非递归

遍历目录是文件系统操作里的家常便饭。比如你要统计一个项目里有多少个.cpp文件,或者要批量重命名图片。

std::filesystem提供了两种遍历方式:

  • 非递归遍历:只遍历当前目录,不进入子目录。
  • 递归遍历:遍历所有子目录,直到最底层。

先看非递归的:

fs::path dir = "./data";
for (const auto& entry : fs::directory_iterator(dir)) {
    std::cout << entry.path() << "\n";
}

这个只会列出./data下的直接内容。如果你要深入子目录,就用recursive_directory_iterator

for (const auto& entry : fs::recursive_directory_iterator(dir)) {
    if (entry.is_regular_file()) {
        std::cout << "文件: " << entry.path() << "\n";
    } else if (entry.is_directory()) {
        std::cout << "目录: " << entry.path() << "\n";
    }
}

这里有个细节:entry是一个directory_entry对象,它不光有路径,还缓存了文件状态。你可以直接调用is_regular_file()is_directory()file_size()等方法,不用再单独做一次系统调用。性能上会好一些。

注意:递归遍历时,如果目录结构很深(比如超过100层),或者有符号链接循环,可能会出问题。我曾经在某个项目里遇到一个符号链接指向了父目录,结果遍历直接死循环了。解决方案是用recursive_directory_iterator的构造函数,传入directory_options::skip_permission_denied来跳过权限不足的目录,或者自己控制递归深度。

4.4 文件状态与常用操作

除了遍历,你还需要判断文件是否存在、获取文件大小、修改时间等。这些std::filesystem都提供了:

fs::path p = "test.txt";

// 判断是否存在
if (fs::exists(p)) {
    std::cout << "文件存在\n";
}

// 获取文件大小
auto size = fs::file_size(p);
std::cout << "大小: " << size << " bytes\n";

// 获取最后修改时间
auto ftime = fs::last_write_time(p);
// 转换成时间戳(C++20有更优雅的方式)
auto cftime = decltype(ftime)::clock::to_time_t(ftime);
std::cout << "修改时间: " << std::ctime(&cftime);

创建和删除目录也很简单:

// 创建单级目录
fs::create_directory("new_folder");

// 创建多级目录(如果父目录不存在,会自动创建)
fs::create_directories("a/b/c/d");

// 删除空目录
fs::remove("empty_folder");

// 删除目录及其所有内容(危险操作!)
fs::remove_all("folder_to_delete");
核心原则:每次调用文件系统API后,记得检查返回值或捕获异常。std::filesystem的大多数函数在失败时会抛出std::filesystem::filesystem_error异常。如果你不想用异常,可以传入一个std::error_code参数来接收错误信息。

4.5 跨平台注意事项

虽然std::filesystem帮你屏蔽了大部分差异,但有些坑还是得自己注意:

  • 路径大小写敏感:Windows不区分大小写,Linux区分。所以"File.txt""file.txt"在Windows上是同一个文件,在Linux上不是。写跨平台代码时,最好统一用小写。
  • 权限模型不同:Windows的权限和Linux的权限机制完全不同。std::filesystem不提供修改权限的跨平台接口(C++20的permissions函数是实验性的)。如果你需要处理权限,得用平台特定的API。
  • 符号链接:Windows的快捷方式和Linux的符号链接不是一回事。std::filesystemis_symlink()在Windows上只对真正的符号链接有效,对.lnk文件无效。

我个人建议,在项目初期就定好文件操作的封装层。把所有std::filesystem调用集中到一个模块里,这样万一以后要换底层实现(比如用boost::filesystem),改动范围也小。

4.6 知识结构图

下面这张图概括了本章的核心内容,你可以把它当作一个快速参考:

std::filesystem 路径处理 path 类 / 运算符拼接 filename() / stem() extension() parent_path() 目录遍历 directory_iterator recursive_directory_iterator directory_entry is_regular_file() is_directory() 文件操作 exists() file_size() last_write_time() create_directory() remove() / remove_all() 跨平台注意事项 大小写敏感 权限模型差异 符号链接处理

这张图把std::filesystem的核心功能分成了四大块:路径处理、目录遍历、文件操作和跨平台注意事项。你在实际项目中,基本上就是在这四个维度里打转。

4.7 一个完整的例子

最后,我给你一个完整的实战例子。假设你要写一个工具,统计某个目录下所有.cpp.h文件的总行数:

#include <iostream>
#include <filesystem>
#include <fstream>
#include <string>

namespace fs = std::filesystem;

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "用法: " << argv[0] << " <目录路径>\n";
        return 1;
    }

    fs::path root = argv[1];
    if (!fs::exists(root) || !fs::is_directory(root)) {
        std::cerr << "无效的目录\n";
        return 1;
    }

    size_t total_lines = 0;
    size_t file_count = 0;

    for (const auto& entry : fs::recursive_directory_iterator(root)) {
        if (!entry.is_regular_file()) continue;

        auto ext = entry.path().extension();
        if (ext != ".cpp" && ext != ".h") continue;

        std::ifstream file(entry.path());
        std::string line;
        size_t lines = 0;
        while (std::getline(file, line)) {
            ++lines;
        }

        total_lines += lines;
        ++file_count;
        std::cout << entry.path() << ": " << lines << " 行\n";
    }

    std::cout << "\n总计: " << file_count << " 个文件, "
              << total_lines << " 行代码\n";

    return 0;
}

这个例子用到了本章讲的大部分知识点:路径拼接、扩展名判断、递归遍历、文件状态检查。你把它编译出来,在项目根目录跑一下,就能看到每个源文件的行数统计。

嗯,文件系统抽象这块,说白了就是让你少操心平台差异,多关注业务逻辑。用好std::filesystem,你的代码会干净很多,也更容易维护。


公众号:蓝海资料掘金营,微信deep3321