关于 C++17 标准库 std::filesystem 的整理笔记。

概述

std::filesystem 是 C++17 引入的标准库模块,用于对文件和目录进行跨平台操作。 它提供了文件路径管理、目录操作、文件判断、遍历、创建和删除等功能,使用时无需依赖操作系统特定 API。

由于 Linux 系统和 Windows 系统的文件系统确实存在很多差异,虽然 std::filesystem 的设计目标是屏蔽平台差异,但实际使用中仍然会存在细微差异。 这里以 Linux 平台为主进行实验,Windows 平台会存在分隔符等差异。

在导入头文件时,一般对命名空间使用如下别名缩写

1
2
3
#include <filesystem>

namespace fs = std::filesystem;

路径基础

std::filesystem 的核心类型是 std::filesystem::path,用于表示文件或目录的路径,它并不会具体指代相对路径或绝对路径,只是简化了一些对路径常用的字符串解析操作,然后再对接底层提供的文件系统操作。

创建路径对象

可以直接使用字符串创建路径

1
2
std::filesystem::path p1("outputs/run1");
std::filesystem::path p2 = "outputs/run2";

也可以从路径获取对应的字符串

1
std::string s = p1.string(); // "outputs/run1"

C++ 允许从字符串到路径类型的隐式转换,因此下面的很多操作的输入参数都可以直接使用字符串类型。

在进行文件读写操作时,可以直接使用路径类型,例如

1
2
3
std::ofstream out(file);
out << "x,y,z\n";
out.close();

路径输出

路径类可以直接通过流输出,例如

1
2
3
std::filesystem::path p1("outputs/run1");
std::cout << p1 << '\n';
std::cout << p1.string() << '\n';

输出形如

1
2
"outputs/run1"
outputs/run1

对比可知,直接输出相比于转换为字符串再输出,会多出一对双引号。

路径信息

路径类不仅仅是一个字符串,还有其它的附属信息,可以通过如下方法获取

1
2
3
4
5
6
std::filesystem::path dir = "outputs";
std::filesystem::path file = dir / "data.csv";

std::cout << "parent path: " << file.parent_path() << '\n';
std::cout << "file name: " << file.filename() << '\n';
std::cout << "extension: " << file.extension() << '\n';

输出如下

1
2
3
parent path: "outputs"
file name: "data.csv"
extension: ".csv"

注意这里的获取扩展名包括点号。

还可以获取文件的一些基本信息,例如文件的最后修改时间和文件大小

1
2
auto ftime = std::filesystem::last_write_time(file);
auto size = std::filesystem::file_size(file);

空路径判断

可以使用 empty() 方法判断路径是否为空(只是判断对应字符串是不是空,并不涉及路径是否存在)

1
2
3
4
std::filesystem::path p;
if (p.empty()) {
std::cout << "path is empty\n";
}

路径处理

当前工作路径

和其他语言一样,C++ 程序在启动时同样会有一个当前工作路径的概念,这会决定程序中所有相对路径的处理方式。

可以通过 std::filesystem::current_path() 获取当前工作路径,也可以通过 std::filesystem::current_path(path) 修改当前工作路径。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <filesystem>
#include <fstream>
#include <iostream>

int main() {
std::cout << "=== Before changing current_path ===\n";
auto cwd_before = std::filesystem::current_path();
std::cout << "current_path = " << cwd_before << "\n\n";

std::filesystem::create_directories("outputs/run1");

{
std::ofstream out("before.txt");
out << "created before changing current_path\n";
}

std::cout << "Changing current_path to outputs/run1 ...\n";
std::filesystem::current_path("outputs/run1");

std::cout << "=== After changing current_path ===\n";
auto cwd_after = std::filesystem::current_path();
std::cout << "current_path = " << cwd_after << "\n\n";

{
std::ofstream out("after.txt");
out << "created after changing current_path\n";
}

std::cout << "Files created:\n"
<< " - before.txt (in original CWD)\n"
<< " - after.txt (in outputs/run1)\n";

return 0;
}

解释一下这个程序会依次进行如下操作:

  • 在当前位置创建一个目录 outputs/run1
  • 创建一个文件 before.txt(使用相对路径,也就是在当前工作路径下)
  • 将当前工作路径修改为 outputs/run1
  • 创建一个文件 after.txt(使用相对路径,也就是在 outputs/run1 目录下)

除此之外,还可以通过 std::filesystem::temp_directory_path() 获取一个系统提供的临时目录路径,可以用于存放缓存文件或临时输出,对于 Linux 通常为 /tmp,对于 Windows 通常为 %TEMP%

路径拼接

对路径的常用操作是路径拼接,可以直接使用 / 运算符,例如

1
2
std::filesystem::path dir = "outputs";
std::filesystem::path file = dir / "data.csv";

拼接操作在不同平台下会自动选择对应的路径分隔符:

  • Linux 下拼接结果为 "outputs/data.csv"
  • Windows 下默认结果为 "outputs\\data.csv"

如果在输出时希望无论 Linux 还是 Windows 都统一使用 /,可以在输出时通过 generic_string() 方法进行转换。

绝对路径和相对路径

一个最常见的需求是基于当前工作路径,把一个相对路径变成绝对路径,例如

1
2
3
std::filesystem::current_path("/home/me/project");
std::cout << std::filesystem::absolute("data/output");
// "/home/me/project/data/output"

路径规范化

路径的字符串形式可能存在 .././ 等不规范的表示,需要进行规范化处理。

std::filesystem::lexically_normal() 函数会对字符串进行简单的处理:

  • 只会处理 ... 这种简单文本问题
  • 不会去访问文件系统去检查路径是否存在等实际问题。

例如

1
2
std::filesystem::path p = "a/b/../c/./d";
std::cout << p.lexically_normal(); // "a/c/d"

标准路径

有时我们需要通过文件系统获取真实可靠的绝对路径,此时可以使用 std::filesystem::canonical()std::filesystem::weakly_canonical() 函数。前者更严格,会检查路径的所有部分,确保它们都存在。后者是更灵活,会尝试解析已有部分,忽略不存在的部分。

考虑下面的例子,需要处理相对路径 data/missing_dir/input.txt,其中 data 目录存在,但是 missing_dir 目录不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <filesystem>
#include <iostream>

int main() {
std::filesystem::path bad = "data/missing_dir/input.txt";

try {
std::cout << "canonical(bad) = " << std::filesystem::canonical(bad) << '\n';
}
catch (const std::filesystem::filesystem_error &e) {
std::cout << "canonical(bad) ERROR: " << e.what() << '\n';
}

std::cout << "weakly_canonical(bad) = " << std::filesystem::weakly_canonical(bad)
<< '\n';
}

输出如下

1
2
canonical(bad) = canonical(bad) ERROR: filesystem error: cannot make canonical path: No such file or directory [data/missing_dir/input.txt]
weakly_canonical(bad) = "/home/xxx/test/data/missing_dir/input.txt"

这表明 canonical() 函数在部分路径不存在时会直接抛出异常,而 weakly_canonical() 函数会尽量处理,返回的路径可能并不存在。

文件和目录操作

下面的很多函数其实都有两个版本,其中一个版本在操作失败时会抛异常,另一个版本多出一个错误码参数,操作失败会通过错误码返回错误信息。

例如

1
2
bool create_directory(const std::filesystem::path& p);
bool create_directory(const std::filesystem::path& p, std::error_code& ec) noexcept;

这里的操作失败可能有很多因素,例如权限不足或者文件被占用等,但是都超出了 C++ 程序本身可以控制的范畴。

判断存在性

使用下面的语句可以判断路径是否存在(包括文件或目录)

1
if (std::filesystem::exists(file)) { ... }

也可以使用如下方法来进行更精细的判断

1
2
3
if (std::filesystem::is_regular_file(file)) { ... }

if (std::filesystem::is_directory(dir)) { ... }

除此之外,还有很多特殊的文件类型,例如符号链接、硬链接等,对于符号链接和硬链接,显然可以有多种处理方式,这里不做讨论。

创建新目录

至于创建新文件,直接写入新文件即可,不需要这里的文件系统库。

创建新目录的相关函数如下

1
2
3
std::filesystem::create_directory("outputs/run1");

std::filesystem::create_directories("outputs/run2/a/b");

这两个函数的差异在于是否递归创建多级目录:

  • std::filesystem::create_directory 只能创建一个目录,要求它的父目录必须存在;
  • std::filesystem::create_directories 可以自动创建缺少的中间各级目录。

注意:如果要创建的目录已经存在,会跳过操作,这不会被视作错误。

删除文件和目录

删除单个文件或空目录

1
std::filesystem::remove(file);

删除文件或递归地删除目录以及它的所有子目录和其中的内容

1
std::filesystem::remove_all(dir);

复制和移动文件

文件或目录的复制,例如

1
std::filesystem::copy("src.txt", "dst.txt");

注意:

  • 如果目标文件已存在,则会被直接覆盖。
  • 如果目标位置的父文件夹不存在,则会导致错误,抛出异常。
  • 复制文件夹时的行为取决于第三个选项,可能递归或非递归。

文件或目录的重命名(或移动)

1
std::filesystem::rename("old.txt", "new.txt");

关于单个文件的复制,有如下更精细的操作接口

1
std::filesystem::copy_file("src.txt", "dst.txt", std::filesystem::copy_options::overwrite_existing);

第三个参数通常有:

  • copy_options::overwrite_existing:如果目标文件存在,直接覆盖
  • copy_options::skip_existing:如果目标文件存在,直接跳过
  • copy_options::update_existing:如果目标文件存在,且目标文件的修改时间比源文件旧,则用源文件进行更新

目录遍历

可以使用下面的迭代器进行目录遍历,使用 path() 方法获取文件路径

1
2
3
4
5
6
7
for (auto &entry : std::filesystem::directory_iterator("outputs")) {
std::cout << entry.path() << '\n';
}

for (auto &entry : std::filesystem::recursive_directory_iterator("outputs")) {
std::cout << entry.path() << '\n';
}

两者的区分在于:

  • directory_iterator 只遍历当前目录
  • recursive_directory_iterator 递归遍历子目录