平时开发桌面软件或工具程序时,除了读写文件里面的内容,我们还经常需要处理文件路径与文件夹。比如拼接多层目录、提取文件扩展名,或者扫描整个文件夹等。
前面学习的文件流主要负责读写文件内部的数据。从 C++17 标准开始,C++ 提供了 <filesystem> 标准库,专门用来管理文件路径、文件与目录。
C++ filesystem 与 path 简介
在 C++ 中,如果想要使用文件系统库,我们需要先通过 #include <filesystem> 引入头文件。如果在本地终端手动调用 g++ 编译代码,记得加上 -std=c++20(或 -std=c++17)参数开启现代标准支持。
该库中的所有类型和函数都放在 std::filesystem 中。由于这个命名空间的名字比较长,在实际开发中,我们通常会写一行 namespace fs = std::filesystem; 给它起个简短的别名 fs。
在整个 <filesystem> 库中,最基础也最常用的类型是 fs::path,专门用来表示一条 “文件或目录路径”。
语法:
fs::path 路径变量 = "路径字符串";说明:
这里的 fs 是 std::filesystem 的别名。定义 fs::path 对象非常简单,我们既可以直接传入 "data/report.txt" 这样的字符串字面量,也可以传入一个 std::string 变量。下面 2 种方式都是可以的。
// 方式 1
fs::path path1 = "data/report.txt";
// 方式 2
std::string str = "data/report.txt";
fs::path path2 = str;示例 1:创建并输出 path 对象
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
fs::path filePath = "lvyenet/data/info.txt";
std::cout << "直接输出:" << filePath << '\n';
std::cout << "转为字符串:" << filePath.string() << '\n';
return 0;
}运行结果如下。
直接输出:"lvyenet/data/info.txt"
转为字符串:lvyenet/data/info.txt分析:
在这个例子中,我们先给命名空间起了别名 fs,接着定义了一个 fs::path 对象 filePath。直接使用 std::cout << filePath 打印时,输出的路径两端会自动带上双引号 ""。如果我们希望拿到不带外层双引号的普通字符串,调用 filePath.string() 即可。
这里有一点小伙伴们不要误解:定义一个 fs::path 对象,只是在内存中保存了一串路径名称。此时程序并不会去访问磁盘,也不会检查这个文件在电脑上到底存不存在。
注意 fs::path 只是一个内存中的 “路径表示对象”。哪怕磁盘上根本没有 no_such_file.txt,执行 fs::path p = "no_such_file.txt"; 也完全合法,不会报错。
使用 / 运算符拼接路径
如果使用普通字符串来拼接多层目录路径,很多人会使用 + 号手动补斜杠,比如写成 root + "/" + folder + "/" + filename。这样写不仅繁琐,还很容易出现漏写斜杠或重复写出两个斜杠 // 的错误。
为了解决这个问题,fs::path 专门重载了 / 运算符。注意,这里的 / 不是数学里的除法,而是用来把上下级目录和文件名自动拼接起来。
语法:
fs::path 完整路径 = 父路径 / "子目录" / "文件名";说明:
使用 / 运算符拼接路径时,只要最左边是一个 fs::path 对象,后面我们就可以通过 / 连续拼接字符串或其他的 fs::path 对象。fs::path 会自动处理中间的路径分隔符,不需要我们手动补斜杠。
示例 2:使用 / 拼接多层路径
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
fs::path root = "project";
fs::path fullPath = root / "data" / "report.txt";
std::cout << fullPath.generic_string() << '\n';
return 0;
}运行结果如下。
project/data/report.txt分析:
root / "data" / "report.txt" 表示将顶层目录 "project"、子目录 "data" 与文件名 "report.txt" 自动组合成完整的三层路径。我们可以对比一下普通字符串拼接与 fs::path 拼接的区别:
// 不推荐
std::string s = rootStr + "/" + "data" + "/" + "report.txt";
// 推荐
fs::path p = root / "data" / "report.txt";上面第 1 种写法需要我们手动管理斜杠,稍不注意就会漏写或多写。第 2 种写法由 fs::path 的 / 运算符自动处理分隔符,写起来省心很多。
另外大家要注意,不能直接把两个普通字符串字面量写成 "project" / "data"。使用 / 拼接路径时,必须保证最左边已经是 fs::path 类型,比如写成 fs::path("project") / "data"。
string() 与 generic_string() 的区别
在前面的两个示例中,示例 1 将路径转为字符串时调用的是 string(),而示例 2 调用的是 generic_string()。这两个函数有什么区别呢?
在 Linux 和 macOS 系统中,两者的返回结果完全相同。但在 Windows 系统中,默认的路径分隔符是反斜杠 \,调用 string() 时会返回本地格式的 "project\\data\\report.txt"。
而调用 generic_string() 则保证在任何操作系统下都统一使用正斜杠 / 返回 "project/data/report.txt",非常适合跨平台打印展示或路径比对。
拆解路径的组成部分
fs::path 不仅可以方便地把多层目录拼接起来,还可以反过来把一条完整路径拆解成文件名、主文件名、扩展名和父目录。常用的 4 个路径拆解成员函数如下表所示。
| 成员函数 | 作用说明 | 以 "project/data/report.txt" 为例 |
|---|---|---|
| filename() | 获取包含扩展名的完整文件名 | report.txt |
| stem() | 获取不包含扩展名的主文件名 | report |
| extension() | 获取包含点号的文件扩展名 | .txt |
| parent_path() | 获取当前路径所在的父目录路径 | project/data |
下面我们通过一个完整的例子,来看看这 4 个成员函数的运行效果。
示例 3:拆解路径的各个部分
#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
int main() {
fs::path p = "project/data/report.txt";
std::string name = p.filename().string();
std::string stem = p.stem().string();
std::string ext = p.extension().string();
std::string dir = p.parent_path().generic_string();
std::cout << "完整文件名:" << name << '\n';
std::cout << "主文件名:" << stem << '\n';
std::cout << "文件扩展名:" << ext << '\n';
std::cout << "父目录路径:" << dir << '\n';
return 0;
}运行结果如下。
完整文件名:report.txt
主文件名:report
文件扩展名:.txt
父目录路径:project/data分析:
从运行结果可以看出,p.filename() 获取的是带后缀的完整文件名 "report.txt",而 p.stem() 则去掉后缀,只保留主文件名 "report"。其中,stem 在英文里是 “树干、主干” 的意思,用来表示去掉后缀的文件主名,很容易记住。
在调用这 4 个拆解函数时,小伙伴们需要注意以下 2 个细节:
- 返回值依然是 path 对象:这 4 个函数返回的结果本身还是
fs::path类型。如果我们需要普通字符串或避免打印出双引号,可以继续在后面调用.string()或.generic_string()。 - 多点号文件名的提取规则:如果文件名包含多个点号,比如压缩包
"archive.tar.gz",extension()只会提取最后一个点号开始的部分".gz",stem()则返回"archive.tar"。
结合 path 定位与读写文件
掌握了 fs::path 的拼接与拆解后,我们再来看看怎样用它排查相对路径的位置,以及怎样直接配合文件流读写数据。
1. 查看工作目录与绝对路径
学习读取文件时我们讲过,相对路径是按照程序运行时的 “当前工作目录” 去查找的。如果遇到相对路径打不开文件的情况,我们可以调用 fs::current_path() 查看当前工作目录在哪里,或者调用 fs::absolute() 把相对路径转成完整的绝对路径:
fs::path cwd = fs::current_path();
fs::path absPath = fs::absolute("report.txt");
std::cout << "当前工作目录:" << cwd.string() << '\n';
std::cout << "完整绝对路径:" << absPath.string() << '\n';在本地排查找不到文件的问题时,我们先用上面这两行代码把路径打印出来核对一下,很快就能发现是不是文件放错了文件夹。
2. 将 path 对象传给文件流
在前面的章节中,我们打开 std::ifstream 或 std::ofstream 时传入的都是字符串路径。学会 fs::path 之后,我们可以直接把 fs::path 对象传给文件流使用,中间不需要手动调用 .string() 转换。
示例 4:传入 path 打开文件流
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
int main() {
// 组合当前目录下的文件路径
fs::path dir = ".";
fs::path p = dir / "site.txt";
// 1. 直接传入 path 对象写入数据
std::ofstream outFile(p);
if (!outFile.is_open()) {
std::cout << "写入文件打开失败\n";
return 1;
}
outFile << "绿叶网 lvyenet.com\n";
outFile.close();
// 2. 直接传入 path 对象读取数据
std::ifstream inFile(p);
std::string line;
std::getline(inFile, line);
std::cout << "读取内容:" << line << '\n';
std::cout << "文件名称:" << p.filename().string() << '\n';
return 0;
}运行结果如下。
读取内容:绿叶网 lvyenet.com
文件名称:site.txt分析:
代码里的 "." 代表程序运行时的当前工作目录,".." 则代表上一级父目录。我们先通过 dir / "site.txt" 拼接出路径对象 p,随后直接把 p 传给 std::ofstream 和 std::ifstream 写入并读取了文件内容。
平时小伙伴们在编写现代 C++ 项目时,凡是涉及文件路径的保存、拼接、传递和拆解,我们都更推荐优先使用 fs::path 代替普通字符串。这样写出来的路径代码在 Windows、Linux 和 macOS 下都能稳定运行。
