在本章中,我们将介绍以下食谱:
- 实现路径规格化器
- 从相对路径获取规范文件路径
- 列出目录中的所有文件
- 实现类似 grep 的文本搜索工具
- 实现自动文件重命名器
- 实现磁盘使用计数器
- 计算文件类型的统计信息
- 实现一个工具,通过用符号链接替换重复项来减小文件夹大小
如果我们没有一个库来帮助我们,那么使用文件系统路径总是乏味的,因为我们需要处理许多情况。
有些路径是绝对的,有些是相对的,也许它们甚至不直接,因为它们还包含.
(当前目录)和..
(父目录)间接。然后,与此同时,不同的操作系统使用斜线/
来分隔目录(Linux、MacOS 和不同的 UNIX 衍生产品),或者使用反斜杠(Windows)。当然还有不同类型的文件。
因为其他处理文件系统相关事务的程序都需要这样的功能,所以在 C++ 17 STL 中拥有新的文件系统库是很棒的。最棒的是,它对不同操作系统的工作方式相同,因此我们不必为支持不同操作系统的程序版本编写不同的代码。
在本章中,我们将首先看到path
类是如何工作的,因为它是这个库中其他任何东西的核心。然后,我们将看到使用directory_iterator
和recursive_directory_iterator
类是多么强大但又简单,同时我们用文件做有用的事情。最后,我们将使用一些小而简单的示例工具来完成一些与文件系统相关的实际任务。从这一点来看,构建更复杂的工具将变得容易。
我们从一个非常简单的关于std::filesystem::path
类的例子和一个帮助函数开始这一章,该函数智能地规范化文件系统路径。
这个方法的结果是一个小应用,它采用任何文件系统路径,并以规范化的形式向我们返回相同的路径。规范化意味着我们得到的绝对路径不包含.
或..
路径间接。
在实现它的同时,我们还将看到在使用文件系统库的这个基本部分时,我们需要注意哪些细节。
在本节中,我们将实现一个程序,它只接受一个文件系统路径作为命令行参数,然后以规范化的形式打印它。
- 包含优先,然后我们声明使用命名空间
std
和filesystem
。
#include <iostream>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 在主函数中,我们检查用户是否提供了命令行参数。如果不是这样,我们会打印出如何使用该程序。如果提供了一个路径,我们就从它实例化一个
filesystem::path
对象。
int main(int argc, char *argv[])
{
if (argc != 2) {
cout << "Usage: " << argv[0] << " <path>n";
return 1;
}
const path dir {argv[1]};
- 因为我们可以从任何字符串实例化
path
对象,所以我们不能确定路径是否真的存在于计算机的文件系统中。为此,我们可以使用filesystem::exists
功能。如果没有,我们就再次出错。
if (!exists(dir)) {
cout << "Path " << dir << " does not exist.n";
return 1;
}
- 好吧,在这一点上,我们非常确定用户提供了一些现有的路径,知道我们可以要求它的规范化版本,然后我们打印出来。
filesystem::canonical
返回给我们另一个path
对象。我们可以直接打印出来,但是<<
运算符的path
类型重载用引号将路径括起来。为了避免这种情况,我们可以通过其.c_str()
或.string()
方法打印路径。
cout << canonical(dir).c_str() << 'n';
}
- 让我们编译程序并玩它。当我们在相对路径
"src"
上的我的主目录中执行时,它会打印完整的绝对路径。
$ ./normalizer src
/Users/tfc/src
- 当我们再次在我的主目录中运行程序,但给它一个古怪的相对路径描述,首先进入我的
Desktop
文件夹,然后使用..
再次步出,然后进入Documents
文件夹,再次步出,为了最终进入src
目录,程序打印出与之前相同的路径!
$ ./normalizer Desktop/../Documents/../src
/Users/tfc/src
作为std::filesystem
的开胃菜,这个食谱仍然相当简单明了。我们从包含文件系统路径描述的字符串中初始化了一个path
对象。std::filesystem::path
类在我们使用文件系统库时起着非常重要的作用,因为大多数函数和类都与它相关。
使用filesystem::exists
函数,我们能够检查路径是否真的存在。到目前为止,我们还不能确定这一点,因为确实有可能创建与现有文件系统对象无关的path
对象。exists
只接受一个path
实例,如果它真的存在,就返回true
。这个函数已经能够自己确定我们给它的是绝对路径还是相对路径,这让它使用起来非常舒服。
最后,我们在目录上使用filesystem::canonical
,以便以规范化的形式打印出来。
path canonical(const path& p, const path& base = current_path());
canonical
接受一个路径,作为可选的第二个参数,它接受另一个路径。如果p
是相对路径,则第二条路径base
在路径p
之前。之后,canonical
尝试移除任何.
和..
路径间接。
打印时,我们在规范化路径上使用了.c_str()
方法。其原因是输出流的operator<<
过载包围了带引号的路径,这可能不是我们一直想要的。
canonical
如果我们要规范化的路径不存在,抛出filesystem_error
类型异常。为了防止这种情况,我们用exists
检查了文件系统路径。但是这种检查真的足以避免出现未处理的异常吗?号码
exists
和canonical
都可以抛出bad_alloc
例外。如果这些打击了我们,人们可能会说这个项目无论如何都是注定要失败的。如果在我们检查文件是否存在和将其规范化之间,有人重命名或删除了底层文件,那么将会出现一个更为严重,也更有可能发生的问题!在这种情况下,canonical
会抛出一个filesystem_error
,虽然我们之前检查过文件的存在。
大多数文件系统函数都有一个额外的重载,它采用相同的参数,但也有一个std::error_code
引用。
path canonical(const path& p, const path& base = current_path());
path canonical(const path& p, error_code& ec);
path canonical(const std::filesystem::path& p,
const std::filesystem::path& base,
std::error_code& ec );
这样,我们可以选择是用try
- catch
构造包围文件系统函数调用,还是手动检查错误。请注意,这只会改变与文件系统相关的错误的行为!不管有没有ec
参数,如果系统内存不足,还是会抛出更基本的异常,例如bad_alloc
。
在上一个食谱中,我们已经规范化了路径。filesystem::path
类当然能够做更多的事情,而不仅仅是保持和检查路径。它还帮助我们轻松地从字符串中组成路径,并再次分解它们。
在这一点上,path
确实已经将操作系统的细节从我们身边抽象了出来,但是也有某些情况我们仍然需要记住这些细节。
我们将看到如何通过处理绝对和相对路径来处理路径及其合成/分解。
在本节中,我们将使用绝对路径和相对路径来查看path
类及其辅助函数的优势。
- 首先,我们包括所有必要的头,并声明我们使用命名空间
std
和sfilesystem
。
#include <iostream>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 然后,我们声明一个示例路径。此时,它所引用的文本文件是否真实存在并不重要。但是,如果基础文件不存在,有些函数会抛出异常。
int main()
{
path p {"testdir/foobar.txt"};
- 我们现在来看看四个不同的文件系统库函数。
current_path
返回程序当前执行的路径,工作目录。absolute
接受像我们的路径p
这样的相对路径,并返回整个文件系统中的绝对非组合路径。system_complete
在 Linux、MacOS 或类似 UNIX 的操作系统上的表现与absolute
几乎相同。在 Windows 上,我们将获得由磁盘卷号附加前置的绝对路径(例如,"C:"
)。canonical
再次执行与absolute
相同的操作,但随后会额外删除任何"."
(该目录的缩写为*)或".."
(该目录的缩写为*“向上一个目录”)间接引用。我们将在以下步骤中使用这种间接方法:
cout << "current_path : " << current_path()
<< "nabsolute_path : " << absolute(p)
<< "nsystem_complete : " << system_complete(p)
<< "ncanonical(p) : " << canonical(p)
<< 'n';
path
类的另一个好处是它重载了/
运算符。这样,我们可以使用/
连接文件夹名称和文件名,并由此组成路径。让我们尝试一下,打印一个合成的路径。
cout << path{"testdir"} / "foobar.txt" << 'n';
- 让我们一起玩
canonical
和组合路径。通过给canonical
一个相对路径如"foobar.txt"
和一个合成绝对路径current_path() / "testdir"
,它应该会返回给我们现有的绝对路径。在另一个召唤中,我们给它我们的路径p
(也就是"testdir/foobar.txt"
)并给它一个绝对的路径current_path()
,它指引我们进入"testdir"
并再次上升。这个应该和current_path()
一样,因为间接。在这两个调用中,canonical
应该返回给我们相同的绝对路径。
cout << "canonical testdir : "
<< canonical("foobar.txt",
current_path() / "testdir")
<< "ncanonical testdir 2 : "
<< canonical(p, current_path() / "testdir/..")
<< 'n';
- 我们还可以测试两条非规范路径的等价性。
equivalence
规范化路径,它接受这些路径作为参数,如果它们描述了相同的路径,则返回true
。对于这个测试,路径必须真的*存在,*否则抛出异常。
cout << "equivalence: "
<< equivalent("testdir/foobar.txt",
"testdir/../testdir/foobar.txt")
<< 'n';
}
- 编译并运行程序会产生以下输出。
current_path()
返回我笔记本电脑上的主文件夹,因为我从那里执行了应用。我们的相对路径p
已经被absolute_path
、system_complete
和canonical
添加到这个目录中。我们看到absolute_path
和system_complete
在我的系统上产生完全相同的路径,因为它是一个 Mac(在 Linux 上也是一样的)。在 Windows 机器上,system_complete
会在前面加上"C:"
,或者工作目录所在的任何驱动器。
$ ./canonical_filepath
current_path : "/Users/tfc"
absolute_path : "/Users/tfc/testdir/foobar.txt"
system_complete : "/Users/tfc/testdir/foobar.txt"
canonical(p) : "/Users/tfc/testdir/foobar.txt"
"testdir/foobar.txt"
canonical testdir : "/Users/tfc/testdir/foobar.txt"
canonical testdir 2 : "/Users/tfc/testdir/foobar.txt"
equivalence: 1
- 在我们的短程序中,我们不处理任何异常。如果我们删除
testdir
目录中的foobar.txt
文件,那么程序会因异常而中止执行。canonical
功能要求路径存在。还有一个weakly_canonical
功能没有这个要求。
$ ./canonial_filepath
current_path : "/Users/tfc"
absolute_path : "/Users/tfc/testdir/foobar.txt"
system_complete : "/Users/tfc/testdir/foobar.txt"
terminate called after throwing an instance of
'std::filesystem::v1::__cxx11::filesystem_error'
what(): filesystem error: cannot canonicalize:
No such file or directory [testdir/foobar.txt] [/Users/tfc]
这个方法的目标是看看动态合成新路径有多容易。这主要是因为path
类有一个方便的/
运算符重载。除此之外,文件系统函数与相对和绝对路径以及包含.
和..
间接寻址的路径相处得很好。
有相当多的函数返回path
实例的一部分,有或没有转换。我们不打算在这里列出所有的函数,因为浏览一下 C++ 引用是最好的方法。
然而,path
类的成员函数可能值得仔细看看。让我们看看path
的哪个成员函数返回了路径的哪个部分。下图还显示了 Windows 路径与 UNIX/Linux 路径的细微差别。
可以看到图中显示了path
的成员函数返回到一个绝对路径。对于相对路径,root_path
、root_name
和root_directory
为空。relative_path
然后只要返回路径,如果它已经是相对的。
当然,每一个提供文件系统支持的操作系统都带有某种工具,它只列出文件系统目录中的所有文件*。最简单的例子是 Linux、MacOS 和其他 UNIX 相关操作系统上的ls
命令。在 DOS 和 Windows 下,都有dir
命令。两者都列出目录中的所有文件,并提供补充信息,如文件大小、权限等。*
*然而,重新实现这样一个工具也是一个很好的标准任务,可以继续进行目录和文件遍历。所以,我们就这么做吧!
我们自己的ls
/ dir
工具将能够按名称列出目录中的所有项目,指示有哪种项目,列出它们的访问权限标志,并显示它们在文件系统上占用的字节数。
在这一节中,我们将实现一个小工具,列出任何用户提供的目录中的所有文件。它不仅会列出文件名,还会列出文件名的类型、大小和访问权限。
- 首先,我们需要包含一些头,并声明我们默认使用名称空间
std
和filesystem
。
#include <iostream>
#include <sstream>
#include <iomanip>
#include <numeric>
#include <algorithm>
#include <vector>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 我们需要的一个辅助功能是
file_info
。它接受一个directory_entry
对象引用并从中提取路径,以及一个包含文件类型和权限信息的file_status
对象(使用status
函数)。最后,如果是常规文件,它还会提取条目的大小。对于目录或其他特殊文件,我们显然会返回一个大小为0
的文件。所有这些信息被打包成一个元组。
static tuple<path, file_status, size_t>
file_info(const directory_entry &entry)
{
const auto fs (status(entry));
return {entry.path(),
fs,
is_regular_file(fs) ? file_size(entry.path()) : 0u};
}
- 我们需要的另一个辅助功能是
type_char
。路径不能只代表目录和简单的文本/二进制文件。操作系统提供了多种其他类型的抽象,例如以所谓的字符/块文件形式的硬件设备接口。STL 文件系统库为它们提供了许多谓词函数。这样我们可以返回字母'd'
用于目录,字母'f'
用于常规文件,等等。
static char type_char(file_status fs)
{
if (is_directory(fs)) { return 'd'; }
else if (is_symlink(fs)) { return 'l'; }
else if (is_character_file(fs)) { return 'c'; }
else if (is_block_file(fs)) { return 'b'; }
else if (is_fifo(fs)) { return 'p'; }
else if (is_socket(fs)) { return 's'; }
else if (is_other(fs)) { return 'o'; }
else if (is_regular_file(fs)) { return 'f'; }
return '?';
}
- 我们需要的另一个助手是
rwx
功能。它接受一个perms
变量(它只是文件系统库中的一个enum
类类型),并返回一个描述文件权限设置的字符串,比如"rwxrwxrwx"
。第一组"rwx"
字符描述了文件所有者的 r ead、 w rite 和 ex*execution权限。下一组描述了属于文件所属的用户组*的所有用户的相同权限。最后一个字符组描述了其他人对文件的访问权限。"rwxrwxrwx"
之类的字符串表示每个人都可以通过任何方式访问对象。"rw-r--r--"
表示只有所有者可以读取和修改文件,而其他任何人只能读取。 我们只需要用这样的读/写/执行字符值组成一个字符串,一个许可一个许可。lambda 表达式帮助我们进行重复的工作,检查perms
变量p
是否包含特定的所有者位,然后返回'-'
或正确的字符。
static string rwx(perms p)
{
auto check ([p](perms bit, char c) {
return (p & bit) == perms::none ? '-' : c;
});
return {check(perms::owner_read, 'r'),
check(perms::owner_write, 'w'),
check(perms::owner_exec, 'x'),
check(perms::group_read, 'r'),
check(perms::group_write, 'w'),
check(perms::group_exec, 'x'),
check(perms::others_read, 'r'),
check(perms::others_write, 'w'),
check(perms::others_exec, 'x')};
}
- 最后,最后一个 helper 函数接受一个完整的文件大小,并将其转换为更易于阅读的形式。我们只是忽略了周期,而划分数字,并把他们地板到最近的千,兆,或千兆边界。
static string size_string(size_t size)
{
stringstream ss;
if (size >= 1000000000) {
ss << (size / 1000000000) << 'G';
} else if (size >= 1000000) {
ss << (size / 1000000) << 'M';
} else if (size >= 1000) {
ss << (size / 1000) << 'K';
} else { ss << size << 'B'; }
return ss.str();
}
- 现在我们终于可以实现主功能了。我们首先检查用户是否在命令行中提供了路径。如果他没有,我们就取当前目录“
.
”。然后,我们检查目录是否存在。如果没有,我们不可能列出任何文件。
int main(int argc, char *argv[])
{
path dir {argc > 1 ? argv[1] : "."};
if (!exists(dir)) {
cout << "Path " << dir << " does not exist.n";
return 1;
}
- 现在,我们将使用文件信息元组填充一个
vector
,就像我们的第一个助手函数file_info
从directory_entry
对象返回一样。我们实例化一个directory_iterator
并给它的构造函数path
对象,这是我们在最后一步中创建的。在使用目录迭代器进行迭代时,我们将directory_entry
对象转换为文件信息元组,并将它们插入到向量中。
vector<tuple<path, file_status, size_t>> items;
transform(directory_iterator{dir}, {},
back_inserter(items), file_info);
- 现在我们已经将所有信息保存在矢量项中,并且可以使用我们编写的所有辅助函数简单地打印出来。
for (const auto &[path, status, size] : items) {
cout << type_char(status)
<< rwx(status.permissions()) << " "
<< setw(4) << right << size_string(size)
<< " " << path.filename().c_str()
<< 'n';
}
}
- 使用 C++ 文档脱机版本中的文件路径编译和运行项目会产生以下输出。我们看到文件夹只包含目录和普通文件,因为只有
'd'
和'f'
条目作为所有输出行的第一个字符。这些文件有不同的访问权限,当然大小也不同。请注意,文件是按照名称的字母顺序出现的,但是我们不能真正依赖于此,因为 C++ 17 标准不要求字母顺序。
$ ./list ~/Documents/cpp_reference/en/cpp
drwxrwxr-x 0B algorithm
frw-r--r-- 88K algorithm.html
drwxrwxr-x 0B atomic
frw-r--r-- 35K atomic.html
drwxrwxr-x 0B chrono
frw-r--r-- 34K chrono.html
frw-r--r-- 21K comment.html
frw-r--r-- 21K comments.html
frw-r--r-- 220K compiler_support.html
drwxrwxr-x 0B concept
frw-r--r-- 67K concept.html
drwxr-xr-x 0B container
frw-r--r-- 285K container.html
drwxrwxr-x 0B error
frw-r--r-- 52K error.html
在这个方法中,我们遍历文件,对于每个文件,我们检查它的状态和大小。虽然我们所有的每文件操作都相当简单明了,但是我们实际的目录遍历看起来有点神奇。
为了遍历我们的目录,我们只是实例化了一个directory_iterator
,然后迭代它。使用文件系统库遍历目录非常简单。
for (const directory_entry &e : directory_iterator{dir}) {
// do something
}
关于这门课,除了以下几点,没什么好说的了:
- 它访问目录的每个元素一次
- 目录元素的迭代顺序未指定
- 目录元素
.
和..
已经被过滤掉了
然而,可能值得注意的是directory_iterator
似乎同时是一个迭代器和一个可迭代范围。为什么呢?在我们刚刚看到的最小for
循环示例中,它被用作可迭代范围。在实际的配方代码中,我们像迭代器一样使用它:
transform(directory_iterator{dir}, {},
back_inserter(items), file_info);
事实是,它只是一个迭代器类类型,但是std::begin
和std::end
函数为这个类型提供了重载。这样我们可以在这种迭代器上调用begin
和end
函数,它们会再次返回给我们迭代器。乍一看这可能很奇怪,但它使这个类更有用。
大多数操作系统都配备了某种本地搜索引擎。用户可以用一些键盘快捷键启动它,然后只需输入他们正在寻找的本地文件。
在这些功能出现之前,命令行用户已经使用grep
或awk
等工具搜索过文件。用户只需输入“grep -r foobar .
”,该工具将递归搜索当前目录,找到包含"foobar"
字符串的任何文件。
在这个食谱中,我们将实现这样一个应用。我们的小 grep 克隆将只从命令行接受一个模式,然后递归搜索我们在应用启动时所在的目录。然后,它将打印与我们的模式匹配的每个文件的名称。模式匹配将逐行应用,因此我们也可以打印文件匹配模式的确切行号。
我们将实现一个小工具,在文件中搜索用户提供的文本模式。该工具的工作原理类似于 UNIX 工具grep
,但为了简单起见,不会那么成熟和强大。
- 首先,我们需要包含所有必要的头,并声明我们使用命名空间
std
和filesystem
。
#include <iostream>
#include <fstream>
#include <regex>
#include <vector>
#include <string>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 我们首先实现一个助手函数。它接受一个文件路径和一个描述我们正在寻找的模式的正则表达式对象。然后,我们实例化一个
vector
,它应该包含匹配的行号对及其内容。我们实例化一个输入文件流对象,从中我们将一行行地读取内容并对其进行模式匹配。
static vector<pair<size_t, string>>
matches(const path &p, const regex &re)
{
vector<pair<size_t, string>> d;
ifstream is {p.c_str()};
- 我们使用
getline
功能逐行遍历文件。regex_search
如果字符串包含我们的模式,则返回true
。如果是这种情况,那么我们把行号和字符串放入向量中。最后,我们返回所有收集到的匹配。
string s;
for (size_t line {1}; getline(is, s); ++ line) {
if (regex_search(begin(s), end(s), re)) {
d.emplace_back(line, move(s));
}
}
return d;
}
- 在主函数中,我们首先检查用户是否提供了可以用作模式的命令行参数。如果没有,我们就会出错。
int main(int argc, char *argv[])
{
if (argc != 2) {
cout << "Usage: " << argv[0] << " <pattern>n";
return 1;
}
- 接下来,我们从输入模式中构造一个正则表达式对象。如果模式不是有效的正则表达式,这将导致异常。如果出现这样的异常,我们会抓住它并出错。
regex pattern;
try { pattern = regex{argv[1]}; }
catch (const regex_error &e) {
cout << "Invalid regular expression provided.n";
return 1;
}
- 现在,我们终于可以遍历文件系统并寻找模式匹配。我们使用
recursive_directory_iterator
来迭代工作目录中的所有文件。它的工作原理与上一个食谱中的directory_iterator
完全一样,但它也下降到子目录中。这样我们就不用管理递归了。在每个条目上,我们都调用我们的助手函数matches
。
for (const auto &entry :
recursive_directory_iterator{current_path()}) {
auto ms (matches(entry.path(), pattern));
- 对于每个匹配项(如果有的话),我们打印文件路径、行号和匹配行的完整内容。
for (const auto &[number, content] : ms) {
cout << entry.path().c_str() << ":" << number
<< " - " << content << 'n';
}
}
}
- 让我们准备一个名为
"foobar.txt"
的文件,其中包含一些我们可以搜索的测试行。
foo
bar
baz
- 编译和运行会产生以下输出。我在笔记本电脑的
/Users/tfc/testdir
文件夹中启动了这个应用,首先是模式"bar"
。在该目录中,它找到了我们的foobar.txt
文件的第二行和位于testdir/dir1
的另一个文件"text1.txt"
。
$ ./grepper bar
/Users/tfc/testdir/dir1/text1.txt:1 - foo bar bla blubb
/Users/tfc/testdir/foobar.txt:2 - bar
- 再次启动应用,但这次是模式
"baz"
,它找到了我们的示例文本文件的第三行。
$ ./grepper baz
/Users/tfc/testdir/foobar.txt:3 - baz
设置和使用一个正则表达式来过滤文件的内容当然是这个食谱的主要任务。然而,让我们把注意力集中在recursive_directory_iterator
上,因为过滤递归迭代的文件只是我们在这个食谱中使用这个特殊迭代器类的动机。
就像directory_iterator
,recursive_directory_iterator
迭代一个目录的元素。正如它的名字所表明的,它的专长是递归地做这件事。每当它遇到一个属于目录的文件系统元素时,它会为这个路径产生一个directory_entry
实例,但是为了迭代它的子节点,它也会向下进入这个路径。
recursive_directory_iterator
有一些有趣的成员函数:
depth()
:这告诉我们迭代器目前已经下降到子目录中多少层。recursion_pending()
:这告诉我们迭代器是否会在当前指向的元素之后下降。disable_recursion_pending()
:如果迭代器当前指向的是它将要下放到的目录,可以调用这个函数来防止迭代器下放到下一个子目录。这意味着如果我们过早的调用*,调用这个方法没有效果。* **pop()
:这将中止当前的递归级别,并在目录层次结构中上升一个级别以继续。*
*# 还有更多...
另一个需要了解的是directory_options
枚举类。recursive_directory_iterator
的构造函数确实接受这种类型的值作为第二个参数。我们一直在隐式使用的默认值是directory_options::none
。其他值有:
follow_directory_symlink
:这允许递归迭代器跟随到目录的符号链接skip_permission_denied
:这告诉迭代器跳过否则会导致错误的目录,因为文件系统拒绝访问权限
这些选项可以与|
运算符组合使用。
这个食谱的动机是我经常遇到的一种情况。当从节假日收集图片文件时,例如,从不同的朋友以及同一文件夹中的不同照片设备收集图片文件时,文件结尾通常会有所不同。有些 JPEG 文件有.jpg
扩展名,有些有.jpeg
,有些甚至有.JPEG
。
有些人可能更喜欢将所有扩展都同质化。用一个命令重命名所有文件会很有用。与此同时,我们可以删除空格' '
并用下划线'_'
来代替,例如。
在这个食谱中,我们将实现这样一个工具,并将其称为renamer
。它将接受一系列输入模式及其替代品,如下所示:
$ renamer jpeg jpg JPEG jpg
在这种情况下,重命名器将递归地遍历当前目录,并在所有文件名中搜索模式jpeg
和JPEG
。它将用jpg
代替这两者。
我们将实现一个工具,递归扫描目录中的所有文件,并将它们的文件名与模式匹配。所有匹配项都将替换为用户提供的令牌,受影响的文件也将相应地重命名。
- 首先,我们需要包含一些头,并声明我们使用名称空间
std
和filesystem
。
#include <iostream>
#include <regex>
#include <vector>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 我们实现了一个简短的助手函数,它接受字符串形式的输入文件路径和一系列替换对。每个替换对由一个模式及其替换组成。在循环替换范围时,我们使用
regex_replace
向它输入输入字符串,并让它返回转换后的字符串。之后,我们返回结果字符串。
template <typename T>
static string replace(string s, const T &replacements)
{
for (const auto &[pattern, repl] : replacements) {
s = regex_replace(s, pattern, repl);
}
return s;
}
- 在主函数中,我们首先验证命令行。我们接受对中的命令行参数,因为我们想要模式和它们的替换。
argv
的第一个元素始终是可执行名称。这意味着如果用户提供至少一对或更多,那么argc
必须是奇数并且不小于3
。
int main(int argc, char *argv[])
{
if (argc < 3 || argc % 2 != 1) {
cout << "Usage: " << argv[0]
<< " <pattern> <replacement> ...n";
return 1;
}
- 一旦我们检查到有成对的输入,我们将用这些来填充一个向量。
vector<pair<regex, string>> patterns;
for (int i {1}; i < argc; i += 2) {
patterns.emplace_back(argv[i], argv[i + 1]);
}
- 现在我们可以遍历文件系统了。为了简单起见,我们将应用的当前路径定义为要迭代的目录。
对于每个目录条目,我们提取其到
opath
变量的原始路径。然后,我们只取文件名而不取路径的其余部分,并根据我们之前收集的模式和替换列表对其进行转换。我们复制一份opath
,称之为rpath
,并用新的文件名替换它的文件名部分。
for (const auto &entry :
recursive_directory_iterator{current_path()}) {
path opath {entry.path()};
string rname {replace(opath.filename().string(),
patterns)};
path rpath {opath};
rpath.replace_filename(rname);
- 对于所有受我们的模式影响的文件,我们打印并重命名它们。如果替换模式产生的文件名已经存在,我们不能继续。让我们跳过这些文件。当然,我们也可以在路径上附加一些数字或其他东西来解决名称冲突。
if (opath != rpath) {
cout << opath.c_str() << " --> "
<< rpath.filename().c_str() << 'n';
if (exists(rpath)) {
cout << "Error: Can't rename."
" Destination file exists.n";
} else {
rename(opath, rpath);
}
}
}
}
- 在示例目录中编译和运行程序会产生以下输出。我在目录中放了一些 JPEG 图片,但是给了它们不同的名字结尾
jpg
、jpeg
和JPEG
。然后,我用模式jpeg
和JPEG
执行程序,并选择jpg
作为两者的替代。结果是一个具有同质文件扩展名的文件夹。
$ ls
birthday_party.jpeg holiday_in_dubai.jpg holiday_in_spain.jpg
trip_to_new_york.JPEG
$ ../renamer jpeg jpg JPEG jpg
/Users/tfc/pictures/birthday_party.jpeg --> birthday_party.jpg
/Users/tfc/pictures/trip_to_new_york.JPEG --> trip_to_new_york.jpg
$ ls
birthday_party.jpg holiday_in_dubai.jpg holiday_in_spain.jpg
trip_to_new_york.jpg
我们已经实现了一个类似于 Linux/MacOS 上的ls
或者 Windows 上的dir
的工具,但是就像这些工具一样,它不打印目录的文件大小。
为了得到相当于一个目录的大小,我们必须深入到它里面,并总结它包含的所有文件的大小。
在这个食谱中,我们将实现一个工具,它可以做到这一点。该工具可以在任何文件夹上运行,并将汇总所有目录条目的累积大小。
在本节中,我们将实现一个应用,它遍历一个目录并列出每个条目的文件大小。这对于常规文件来说很简单,但是如果我们看到的目录条目本身就是一个目录,那么我们必须查看它并总结它所包含的所有文件的大小。
- 首先,我们需要包含所有必要的头,并声明我们使用命名空间
std
和filesystem
。
#include <iostream>
#include <sstream>
#include <iomanip>
#include <numeric>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 然后我们实现一个助手函数,它接受一个
directory_entry
作为参数,并返回它在文件系统中的大小。如果不是目录,我们简单返回file_size
计算的文件大小。
static size_t entry_size(const directory_entry &entry)
{
if (!is_directory(entry)) { return file_size(entry); }
- 如果它是一个目录,我们需要遍历它的所有条目并计算它们的大小。如果我们再次遇到子目录,我们最终会递归调用自己的
entry_size
辅助函数。
return accumulate(directory_iterator{entry}, {}, 0u,
[](size_t accum, const directory_entry &e) {
return accum + entry_size(e);
});
}
- 为了更好的可读性,我们使用与本章其他食谱相同的
size_string
功能。它只是把大文件分成更短更好的文件来读取带有 kilo、mega 或 giga 后缀的字符串。
static string size_string(size_t size)
{
stringstream ss;
if (size >= 1000000000) {
ss << (size / 1000000000) << 'G';
} else if (size >= 1000000) {
ss << (size / 1000000) << 'M';
} else if (size >= 1000) {
ss << (size / 1000) << 'K';
} else { ss << size << 'B'; }
return ss.str();
}
- 我们在主函数中需要做的第一件事是检查用户是否在命令行上提供了文件系统路径。如果不是这样,我们只取当前文件夹。在继续之前,我们检查它是否存在。
int main(int argc, char *argv[])
{
path dir {argc > 1 ? argv[1] : "."};
if (!exists(dir)) {
cout << "Path " << dir << " does not exist.n";
return 1;
}
- 现在,我们可以遍历所有目录条目,并打印它们的大小和名称。
for (const auto &entry : directory_iterator{dir}) {
cout << setw(5) << right
<< size_string(entry_size(entry))
<< " " << entry.path().filename().c_str()
<< 'n';
}
}
- 编译和运行程序会产生以下结果。我在 C++ 离线参考中的一个文件夹中启动了它。因为它也包含子文件夹,所以我们的递归文件大小摘要帮助器会立即有所帮助。
$ ./file_size ~/Documents/cpp_reference/en/
19M c
12K c.html
147M cpp
17K cpp.html
22K index.html
22K Main_Page.html
整个程序围绕在常规文件上使用file_size
展开。如果程序看到一个目录,它会递归地向下进入该目录,并对其所有条目调用file_size
。
我们所做的唯一区分是直接调用file_size
还是需要递归策略的事情是询问is_directory
谓词。这对于只包含常规文件和目录的目录非常有效。
尽管我们的示例程序很简单,但由于未处理的异常,它会在以下情况下崩溃:
file_size
只对常规文件和符号链接有效。它在任何其他情况下都会引发异常。- 虽然
file_size
在符号链接上起作用,但是如果我们在断开的符号链接上调用它,它仍然会抛出一个异常。
为了使这个示例配方程序更加成熟,我们需要针对错误类型的文件和异常处理进行更多的防御编程。
在上一个食谱中,我们实现了一个工具,列出了任何目录的所有成员的大小。
在这个食谱中,我们也将递归地计算大小,但是这次我们将把每个文件的大小累加到它们的文件名扩展名中。这样,我们可以向用户打印一个表格,列出我们拥有的每种文件类型的文件数量,以及这些文件类型的平均大小。
在本节中,我们将实现一个小工具,它递归地遍历给定的目录。在此过程中,它会计算所有文件的数量和大小,并按扩展名分组。最后,它打印该目录中存在哪些文件扩展名,每个扩展名有多少,以及它们的平均文件大小。
- 我们需要包含必要的头,我们声明我们使用命名空间
std
和filesystem
。
#include <iostream>
#include <sstream>
#include <iomanip>
#include <map>
#include <filesystem>
using namespace std;
using namespace filesystem;
size_string
功能已经在其他食谱中有所帮助。它将文件大小转换为人类可读的字符串。
static string size_string(size_t size)
{
stringstream ss;
if (size >= 1000000000) {
ss << (size / 1000000000) << 'G';
} else if (size >= 1000000) {
ss << (size / 1000000) << 'M';
} else if (size >= 1000) {
ss << (size / 1000) << 'K';
} else { ss << size << 'B'; }
return ss.str();
}
- 然后,我们实现一个助手函数,该函数接受一个
path
对象作为其参数,并遍历该路径中的所有文件。在途中,它会收集一个映射中的所有信息,该映射从文件扩展名映射到包含具有相同扩展名的所有文件的总数和累积大小的对。
static map<string, pair<size_t, size_t>> ext_stats(const path &dir)
{
map<string, pair<size_t, size_t>> m;
for (const auto &entry :
recursive_directory_iterator{dir}) {
- 如果一个目录条目本身就是一个目录,我们就跳过它。在这一点上跳过它并不意味着我们没有递归地进入它。
recursive_directory_iterator
仍然是这样,但是我们不想看目录条目本身。
const path p {entry.path()};
const file_status fs {status(p)};
if (is_directory(fs)) { continue; }
- 接下来,我们提取目录条目字符串的扩展部分。如果它没有扩展名,我们就跳过它。
const string ext {p.extension().string()};
if (ext.length() == 0) { continue; }
- 接下来,我们计算我们正在查看的文件的大小。然后,我们在地图中查找这个扩展的聚合对象。如果此时还没有,则隐式创建。我们只需增加文件数量,并将文件大小添加到大小累加器中。
const size_t size {file_size(p)};
auto &[size_accum, count] = m[ext];
size_accum += size;
count += 1;
}
- 之后,我们归还地图。
return m;
}
- 在主函数中,我们要么从命令行获取用户提供的路径,要么获取当前目录。当然,我们需要检查它是否存在,因为否则继续下去是没有意义的。
int main(int argc, char *argv[])
{
path dir {argc > 1 ? argv[1] : "."};
if (!exists(dir)) {
cout << "Path " << dir << " does not exist.n";
return 1;
}
- 我们可以立即迭代
ext_stats
给我们的地图。因为地图中的accum_size
项包含所有扩展名相同的文件的总和,所以在打印之前,我们将这个总和除以此类文件的总数。
for (const auto &[ext, stats] : ext_stats(dir)) {
const auto &[accum_size, count] = stats;
cout << setw(15) << left << ext << ": "
<< setw(4) << right << count
<< " items, avg size "
<< setw(4) << size_string(accum_size / count)
<< 'n';
}
}
- 编译并运行程序会产生以下输出。我从离线 C++ 引用中给了它一个文件夹作为命令行参数。
$ ./file_type ~/Documents/cpp_reference/
.css : 2 items, avg size 41K
.gif : 7 items, avg size 902B
.html : 4355 items, avg size 38K
.js : 3 items, avg size 4K
.php : 1 items, avg size 739B
.png : 34 items, avg size 2K
.svg : 53 items, avg size 6K
.ttf : 2 items, avg size 421K
有很多以各种方式压缩数据的工具。文件打包算法/格式最著名的例子是 ZIP 和 RAR。这类工具试图通过减少内部冗余来减小文件的大小。
在压缩档案中的文件之前,减少磁盘使用的一个非常简单的方法就是删除 重复的文件。在这个食谱中,我们将实现一个递归抓取目录的小工具。爬行时,它会寻找内容相同的文件。如果它找到这样的文件,它将删除除一个以外的所有重复文件。所有删除的文件将被指向当前唯一文件的符号链接所替代。这无需任何压缩即可节省空间,同时保留所有数据。
在这一节中,我们将实现一个小工具,找出目录中哪些文件是彼此重复的。有了这些知识,它将删除除一个文件之外的所有重复文件,并用符号链接替换它们,从而减小文件夹的大小。
Make sure to have a backup of your system's data. We will be playing with STL functions that remove files. A simply misspelled path in such a program can lead to a program that greedily removes too many files in unwanted ways.
- 首先,我们需要包含必要的头,然后我们声明我们默认使用名称空间
std
和filesystem
。
#include <iostream>
#include <fstream>
#include <unordered_map>
#include <filesystem>
using namespace std;
using namespace filesystem;
- 为了找出哪些文件彼此重复,我们将构建一个哈希映射,从文件内容的哈希映射到生成该哈希的第一个文件的路径。对 MD5 或 SHA 变体等文件使用生产哈希算法会是一个更好的主意。为了保持配方干净简单,我们只需将整个文件读入一个字符串,然后使用
unordered_map
已经用于字符串的相同哈希函数对象来计算哈希。
static size_t hash_from_path(const path &p)
{
ifstream is {p.c_str(),
ios::in | ios::binary};
if (!is) { throw errno; }
string s;
is.seekg(0, ios::end);
s.reserve(is.tellg());
is.seekg(0, ios::beg);
s.assign(istreambuf_iterator<char>{is}, {});
return hash<string>{}(s);
}
- 然后,我们实现构造这样一个哈希映射并删除重复项的函数。它递归地遍历目录及其子目录。
static size_t reduce_dupes(const path &dir)
{
unordered_map<size_t, path> m;
size_t count {0};
for (const auto &entry :
recursive_directory_iterator{dir}) {
- 对于每个目录条目,它检查它本身是否是一个目录。跳过所有目录项目。对于每个文件,我们生成它的哈希值,并尝试将其插入到哈希映射中。如果哈希映射已经包含相同的哈希,那么这意味着我们已经插入了一个具有相同哈希的文件。这意味着我们刚刚发现了一个复制品!如果在插入过程中发生冲突,
try_emplace
返回的第二个值是false
。
const path p {entry.path()};
if (is_directory(p)) { continue; }
const auto &[it, success] =
m.try_emplace(hash_from_path(p), p);
- 使用来自
try_emplace
的返回值,我们可以告诉用户我们刚刚插入了一个文件,因为我们第一次看到了它的散列。如果我们发现了一个副本,我们会告诉用户它是哪个文件的副本,然后删除它。删除后,我们创建一个符号链接来替换副本。
if (!success) {
cout << "Removed " << p.c_str()
<< " because it is a duplicate of "
<< it->second.c_str() << 'n';
remove(p);
create_symlink(absolute(it->second), p);
++ count;
}
- 在文件系统迭代之后,我们返回删除并替换为符号链接的文件数量。
}
return count;
}
- 在主函数中,我们确保用户在命令行上提供了一个目录,并且这个目录存在。
int main(int argc, char *argv[])
{
if (argc != 2) {
cout << "Usage: " << argv[0] << " <path>n";
return 1;
}
path dir {argv[1]};
if (!exists(dir)) {
cout << "Path " << dir << " does not exist.n";
return 1;
}
- 我们现在唯一需要做的就是在这个目录上调用
reduce_dupes
并打印它删除了多少文件。
const size_t dupes {reduce_dupes(dir)};
cout << "Removed " << dupes << " duplicates.n";
}
- 在包含一些重复文件的示例目录中编译和运行程序如下所示。我使用
du
工具在启动我们的程序之前和之后检查文件夹大小,以证明该方法是有效的。
$ du -sh dupe_dir
1.1M dupe_dir
$ ./dupe_compress dupe_dir
Removed dupe_dir/dir2/bar.jpg because it is a duplicate of
dupe_dir/dir1/bar.jpg
Removed dupe_dir/dir2/base10.png because it is a duplicate of
dupe_dir/dir1/base10.png
Removed dupe_dir/dir2/baz.jpeg because it is a duplicate of
dupe_dir/dir1/baz.jpeg
Removed dupe_dir/dir2/feed_fish.jpg because it is a duplicate of
dupe_dir/dir1/feed_fish.jpg
Removed dupe_dir/dir2/foo.jpg because it is a duplicate of
dupe_dir/dir1/foo.jpg
Removed dupe_dir/dir2/fox.jpg because it is a duplicate of
dupe_dir/dir1/fox.jpg
Removed 6 duplicates.
$ du -sh dupe_dir
584K dupe_dir
我们使用create_symlink
函数来使文件系统入口指向文件系统中的另一个文件。这样我们就可以避免重复的文件。我们也可以使用create_hard_link
设置硬链接。在语义上,这是相似的,但是硬链接比软链接有其他的技术含义。不同的文件系统格式可能根本不支持硬链接,或者只支持引用同一文件的一定数量的硬链接。另一个问题是硬链接不能从一个文件系统链接到另一个文件系统。
但是除了实现细节,在使用create_symlink
或者create_hard_link
的时候还有一个明目张胆的错误来源。以下几行包含一个错误。你能马上发现它吗?
path a {"some_dir/some_file.txt"};
path b {"other_dir/other_file.txt"};
remove(b);
create_symlink(a, b);
在执行这个程序的时候没有什么不好的事情发生,但是符号链接会被破坏。符号链接指向"some_dir/some_file.txt"
,这是错误的。问题是它真的应该指向"/absolute/path/some_dir/some_file.txt"
,或者"../some_dir/some_file.txt"
。create_symlink
调用使用一个正确的绝对路径,如果我们这样写的话:
create_symlink(absolute(a), b);
create_symlink
does not check whether the path we are linking to is correct.
我们已经注意到我们的散列函数太简单了。为了保持这个食谱的简单和没有外部依赖,我们选择了这种方式。
我们的哈希函数有什么问题?实际上有两个问题:
- 我们把整个文件读入一个字符串。这对大于系统内存的文件来说是灾难性的。
- C++ 散列函数特性
hash<string>
很可能不是为这种散列设计的。
如果我们正在寻找一个更好的散列函数,我们应该选择一个快速的,内存友好的,并确保没有两个真正大但不同的文件得到相同的散列。后一个要求可能是最重要的。如果我们确定一个文件是另一个文件的副本,尽管它们不包含相同的数据,那么删除后肯定会有一些数据丢失。
例如,更好的哈希算法是 MD5 或 SHA 变体之一。例如,为了在我们的程序中访问这些函数,我们可以使用 OpenSSL 加密应用编程接口。****