Back

第三部分-新的标准库组件

第三部分 新的标准库组件

本部分介绍了 C++17 的新库组件。

15 std::optional<>

在编程中,我们经常遇到可能返回/传递/使用某种类型的对象的情况。也就是说,我们可以有一个特定类型的值,或者我们可能根本没有任何值。因此,我们需要一种模拟类似于指针的语义的方法,我们可以通过使用 nullptr 来表示没有值。处理这个问题的方法是定义一个特定类型的对象,带有一个额外的布尔成员/标志,表明一个值是否存在。 std::optional<> 以类型安全的方式提供此类对象。

可选对象仅具有包含对象的内部存储器以及布尔标志。因此,大小通常比包含的对象大一个字节。对于某些包含的类型,甚至可能根本没有大小开销,前提是可以将附加信息放置在包含的对象中。没有分配堆内存。对象使用与包含类型相同的对齐方式。

但是,可选对象不仅仅是将布尔标志的功能添加到值成员的结构。例如,如果没有值,则不会为包含的类型调用构造函数(因此,您可以为对象提供没有的默认状态)。

与 std::variant<> 和 std::any 一样,结果对象具有值语义。也就是说,复制被实现为深度复制,创建一个独立的对象,带有标志和包含的值(如果有的话)在它自己的内存中。复制没有包含值的 std::optional<> 很便宜;复制带有包含值的 std::optional<> 与复制包含的类型/值一样便宜/昂贵。支持移动语义。

15.1 使用 std::optional<>

std::optional<>模型是一个任意类型的可忽略的实例。这个实例可能是一个成员,一个参数,或者一个返回值。你也可以说,std::optional<>是一个容纳零或一个元素的容器。

15.1.1 可选的返回值

以下程序演示了 std::optional<> 用作返回值的能力: lib/optional.cpp

#include <optional>
#include <string>
#include <iostream>
// 如果可能,将字符串转换为 int:
std::optional<int> asInt(const std::string& s)
{
    try {
        return std::stoi(s);
    }
    catch (...) {
        return std::nullopt;
    }
}
int main()
{
    for (auto s : {"42", " 077", "hello", "0x33"} ) {
        // 如果可能,尝试将 s 转换为 int 并打印结果:
        std::optional<int> oi = asInt(s);
        if (oi) {
            std::cout << "convert '" << s << "' to int: " << *oi << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

在程序中 asInt() 是将传递的字符串转换为整数的函数。 但是,这可能不会成功。 出于这个原因,使用了 std::optional<> 以便我们可以返回"no int"并避免为其定义一个特殊的 int 值或向调用者抛出异常。 因此,我们要么返回调用 stoi() 的结果,它用一个 int 初始化返回值,要么我们返回 std::nullopt,表明我们没有一个 int 值。 我们可以实现如下相同的行为:

std::optional<int> asInt(const std::string& s)
{
    std::optional<int> ret; // 最初没有值
    try {
        ret = std::stoi(s);
    }
    catch (...) {
    }
    return ret;
}

在 main() 中,我们为不同的字符串调用此函数。

for (auto s : {"42", " 077", "hello", "0x33"} ) {
    // 将 s 转换为 int 并尽可能使用结果:
    std::optional<int> oi = asInt(s);
    ...
}

对于我们评估的每个返回的 std::optional oi,我们是否有一个值(通过将对象评估为布尔表达式)并通过“取消引用”可选对象来访问该值:

if (oi) {
    std::cout << "convert '" << s << "' to int: " << *oi << "\n";
}

请注意,对于字符串“0x33”,asInt() 产生 0,因为 stoi() 不会将字符串解析为十六进制值。 有其他方法可以实现对返回值的处理,例如:

std::optional<int> oi = asInt(s);
if (oi.has_value()) {
    std::cout << "convert '" << s << "' to int: " << oi.value() << "\n";
}

在这里,has_value() 用于检查是否返回了一个值,并使用 value() 访问它。 value() 比 operator * 更安全:如果不存在值,它会抛出异常。 运算符 * 仅应在您确定可选项包含值时使用; 否则你的程序将有未定义的行为。 请注意,我们可以通过使用新类型 std::string_view 来改进 asInt()。

15.1.2 可选参数和数据成员

另一个使用 std::optional<> 的例子是参数的可选传递 and/or 数据成员的可选设置:

lib/optionalmember.cpp

#include <string>
#include <optional>
#include <iostream>
class Name
{
private:
    std::string first;
    std::optional<std::string> middle;
    std::string last;
public:
    Name (std::string f, std::optional<std::string> m, std::string l)
        : first{std::move(f)}, middle{std::move(m)}, last{std::move(l)} 
    {}
    friend std::ostream& operator << (std::ostream& strm, const Name& n) {
        strm << n.first << ' ';
        if (n.middle) {
            strm << *n.middle << ' ';
        }
        return strm << n.last;
    }
};
int main()
{
    Name n{"Jim", std::nullopt, "Knopf"};
    std::cout << n << '\n';
    Name m{"Donald", "Ervin", "Knuth"};
    std::cout << m << '\n';
}

类名表示由名字、可选的中间名和姓氏组成的名称。 成员 middle 被相应地定义,并且构造函数允许在没有中间名时传递 std::nullopt 。 这是与中间名是空字符串不同的状态。

请注意,与通常具有值语义的类型一样,定义初始化相应成员的构造函数的最佳方法是按值获取参数并将参数移动到成员:

还要注意 std::optional<> 改变了对成员中间值的访问。 使用 middle 作为布尔表达式会产生是否存在中间名,必须使用 *middle 来访问当前值(如果有)。

访问该值的另一个选项是使用成员函数 value_or(),它可以在不存在值的情况下指定一个备用值。 例如,在类 Name 中,我们还可以实现:

std::cout << middle.value_or(""); // 打印中间名或什么都没有

15.2 std::optional<> 类型和操作

本节详细介绍 std::optional<> 的类型和操作。

15.2.1 std::optional<> 类型

在头文件 中,C++ 标准库定义类 std::optional<> 如下:

namespace std {
    template<typename T> class optional;
}

此外,还定义了以下类型和对象:

  • std::nullopt_t 类型的 nullopt 作为没有值的可选对象的“值”。
  • 异常类std::bad_optional_access,它派生自std::exception,用于没有值的值访问。

可选对象还使用 中定义的对象 std::in_place(std::in_place_t 类型)来初始化具有多个参数的可选对象的值(见下文)。

15.2.2 std::optional<> 操作

表 std::optional Operations 列出了为 std::optional<> 提供的所有操作。

建造

特殊构造函数可以将参数直接传递给包含的类型。

  • 您可以创建一个没有值的可选对象。 在这种情况下,您必须指定包含的 类型:

    std::optional<int> o1;
    std::optional<int> o2(std::nullopt);
    

    这不会调用包含类型的任何构造函数.

  • 您可以传递一个值来初始化包含的类型。 由于推导指南,您不必指定包含的类型,然后:

    std::optional o3{42}; // 推导出optional<int>
    std::optional<std::string> o4{"hello"};
    std::optional o5{"hello"}; // 推导出 optional<const char*>
    
操作 作用
constructors 创建一个可选对象(可能为包含的调用构造函数类型)
make_optional<>() 创建一个可选对象(传递值来初始化它)
destructor 销毁一个可选对象
= 分配一个新值
emplace() 为包含的类型分配一个新值
reset() 销毁任何值(使对象为空)
has_value() 返回对象是否有值
conversion to bool 返回对象是否有值
* 值访问(如果没有值,则为未定义行为)
-> 访问值的成员(如果没有值,则行为未定义)
value() 值访问(如果没有值则例外)
value_or() 值访问(如果没有值,则为后备参数)
swap() 在两个对象之间交换值
==, !=, <, <=, >, >= 比较可选对象
hash<> 计算哈希值的函数对象类型
  • 要使用多个参数初始化可选对象,您必须创建对象或添加 std::in_place 作为第一个参数(无法推断包含的类型):

    std::optional o6{std::complex{3.0, 4.0}};
    std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0};
    

    请注意,后一种形式避免了创建临时对象。 通过使用这种形式,你甚至可以传递一个初始化列表和额外的参数:

    // 使用 lambda 作为排序标准初始化集合:
    auto sc = [] (int x, int y) {
        return std::abs(x) < std::abs(y);
    };
    
    std::optional<std::set<int,decltype(sc)>> o8{std::in_place,
                                                 {4, 8, -7, -2, 0, 5}, 
                                                 sc
                                                };
    
  • 您可以复制可选对象(包括类型转换)。

    std::optional o5{"hello"}; // 推导出 optional<const char*>
    std::optional<std::string> o9{o5}; // OK
    

请注意,还有一个便利函数 make_optional<>(),它允许使用单个或多个参数进行初始化(不需要 in_place 参数)。 像往常一样 make… 函数它会衰减:

auto o10 = std::make_optional(3.0); // optional<double>
auto o11 = std::make_optional("hello"); // optional<const char*>
auto o12 = std::make_optional<std::complex<double>>(3.0, 4.0);

但是,请注意,没有构造函数获取值并根据其值来决定是使用值初始化可选项还是 nullopt。 为此,必须使用运算符 ?:。 例如:

std::multimap<std::string, std::string> englishToGerman;
...
auto pos = englishToGerman.find("wisdom");
auto o13 = pos != englishToGerman.end()
    		? std::optional{pos->second}
			: std::nullopt;

在这里,由于 std::optional{pos->second} 的类模板参数推导,o13 被初始化为 std::optionalstd::string。 对于 std::nullopt 类模板参数推导不起作用,但 operator ?: 在推导表达式的结果类型时也将其转换为这种类型。

访问值

要检查可选对象是否具有值,您可以在布尔表达式中使用它或调用 has_value():

std::optional o{42};
if (o) ... // true
if (!o) ... // false
if (o.has_value()) ... // true

然后,为了访问该值,提供了一种指针语法。 也就是说,使用 operator* 您可以直接访问它的值,而 operator-> 可以访问该值的成员:

std::optional o{std::pair{42, "hello"}};
auto p = *o; // 将 p 初始化为 pair<int,string>
std::cout << o->first; // prints 42

请注意,这些运算符要求可选项包含一个值。 在没有值的情况下使用它们是未定义的行为:

std::optional<std::string> o{"hello"};
std::cout << *o; // OK: prints ”hello”
o = std::nullopt;
std::cout << *o; // 未定义的行为

请注意,实际上第二个输出仍然会编译并执行一些输出,例如再次打印“hello”,因为可选对象的值的底层内存没有被修改。

但是,您不能也不应该依赖它。 如果您不知道可选对象是否有值,则必须调用以下代码:

if (o) std::cout << *o; // OK (可能什么也不输出)

或者,您可以使用 value(),如果没有包含值,则会引发 std::bad_optional_access 异常:

std::cout << o.value(); // OK (如果没有值则抛出)

std::bad_optional_access 直接派生自 std::exception。

最后,您可以请求该值并传递一个备用值,如果可选对象没有值,则使用该值:

std::cout << o.value_or("fallback"); // OK (如果没有值则输出fallback)

后备参数作为右值引用传递,因此如果不使用后备,它不会花费任何成本,并且如果使用它,它支持移动语义。 请注意,operator* 和 value() 都通过引用返回包含的对象。 因此,在直接调用这些操作以获取临时返回值时,您必须小心。 例如:

std::optional<std::string> getString();
...;
auto a = getString().value(); // OK: 包含对象的副本
auto b = *getString(); // ERROR: 如果 std::nullopt 的行为未定义
const auto& r1 = getString().value(); // ERROR: 引用已删除的包含对象
auto&& r2 = getString().value(); // ERROR: 引用已删除的包含对象

一个示例可能是基于范围的 for 循环的以下用法:

std::optional<std::vector<int>> getVector();
...;
for (int i : getVector().value()) { // ERROR: 迭代已删除的vector
    std::cout << i << '\n';
}

请注意,迭代返回的 int 向量是可行的。 所以,不要盲目地将函数 foo() 的返回类型替换为相应的可选类型,而是调用 foo().value() 。

对比

您可以使用通常的比较运算符。 操作数可以是可选对象, 包含类型和 std::nullopt。

  • 如果两个操作数都是具有值的对象,则使用包含类型的相应运算符。
  • 如果两个操作数都是没有值的对象,则它们被认为是相等的(== 产生 true 并且 所有其他比较结果为假)。
  • 如果只有一个操作数是具有值的对象,则认为没有值的操作数小于另一个操作数。

例如:

std::optional<int> o0;
std::optional<int> o1{42};

/*
	o0 == std::nullopt // yields true
	o0 == 42 // yields false
	o0 < 42 // yields true
	o0 > 42 // yields false
	o1 == 42 // yields true
	o0 < o1 // yields true
*/

这意味着对于 unsigned int 的可选对象,有一个小于 0 的值,对于 bool 的可选对象,有一个小于 0 的值:

std::optional<unsigned> uo;
uo < 0 // yields true
std::optional<bool> bo;
bo < false // yields true

同样,支持底层类型的隐式类型转换:

std::optional<int> o1{42};
std::optional<double> o2{42.0};

/*
	o2 == 42 // yields true
	o1 == o2 // yields true
*/

请注意,可选的布尔值或原始指针值可能会导致一些意外。

更改值

赋值和 emplace() 操作对应于初始化存在:

std::optional<std::complex<double>> o; // 没有值
std::optional ox{77}; // optional<int> with value 77
o = 42; // 值变为 complex(42.0, 0.0)
o = {9.9, 4.4}; // 值变为 complex(9.9, 4.4)
o = ox; // OK, 因为 int 转换为 complex<double>
o = std::nullopt; // o 不再具有值
o.emplace(5.5, 7.7); // 值变为 complex(5.5, 7.7)

分配 std::nullopt 会删除该值,如果之前有值,则调用包含类型的析构函数。 您可以通过调用 reset() 来获得相同的效果:

o.reset(); // o 不再具有值

或分配空花括号:

o = {}; // o 不再具有值

最后,我们还可以使用 operator* 来修改值,因为它通过引用产生值。 但是,请注意,这需要有一个值要修改:

std::optional<std::complex<double>> o;
*o = 42; // 未定义的行为
...;
if (o) {
    *o = 88; // OK: 值变为 complex(88.0, 0.0)
    *o = {1.2, 3.4}; // OK: 值变为 complex(1.2, 3.4)
}
移动语义

std::optional<> 也支持移动语义。 如果将对象作为一个整体移动,则将复制状态并移动包含的对象(如果有)。 结果,移出的对象仍然具有相同的状态,但任何值都未指定。

​ 但是您也可以将值移入或移出包含的对象。 例如:

std::optional<std::string> os;
std::string s = "a very very very long string";
os = std::move(s); // OK, 移动
std::string s2 = *os; // OK 拷贝
std::string s3 = std::move(*os); // OK, 移动

请注意,在最后一次调用之后 os 仍然有一个字符串值,但对于已移动的对象,该值通常是未指定的。 因此,只要您不对它的值做任何假设,您就可以使用它。 您甚至可以在那里分配一个新的字符串值。

散列

可选对象的哈希值是包含的非常量类型(如果有)的哈希值。

15.3 特殊情况

特定的可选值类型可能会导致特殊或意外行为。

15.3.1 可选的布尔值或原始指针值

请注意,使用比较运算符与使用可选对象作为布尔值具有不同的语义。 如果包含的类型是 bool 或指针类型,这可能会变得令人困惑:例如:

std::optional<bool> ob{false}; // 有值,为假
if (!ob) ... // 产生错误
if (ob == false) ... // yields true
std::optional<int*> op{nullptr};
if (!op) ... // yields false
if (op == nullptr) ... // yields true
15.3.2 Optional 的 Optional

原则上,您还可以定义可选值的可选:

std::optional<std::optional<std::string>> oos1;
std::optional<std::optional<std::string>> oos2 = "hello";
std::optional<std::optional<std::string>>
    oos3{std::in_place, std::in_place, "hello"};
std::optional<std::optional<std::complex<double>>>
    ooc{std::in_place, std::in_place, 4.2, 5.3};

即使使用隐式转换,您也可以分配新值:

oos1 = "hello"; // OK: 分配新值
ooc.emplace(std::in_place, 7.2, 8.3);

由于没有值的两个层次,可选的可选使得在外部或内部具有“无值”,这可以具有不同的语义含义:

*oos1 = std::nullopt; // 内部可选没有值
oos1 = std::nullopt; // 外部可选没有值

但是您必须特别注意处理可选值:

if (!oos1) std::cout << "no value\n";
if (oos1 && !*oos1) std::cout << "no inner value\n";
if (oos1 && *oos1) std::cout << "value: " << **oos1 << '\n';

但是,因为这在语义上更像是一个具有两个不同状态的值,表示没有值,所以具有两个布尔或单态替代方案的 std::variant<> 可能更合适。

15.4 后记

可选对象于 2005 年由 Fernando Cacciola 在 https://wg21.link/n1878 中首次提出,将 Boost.Optional 作为参考实现。 正如 Fernando Cacciola 和 Andrzej Krzemienski 在 https://wg21.link/n3793 中提出的,该课程被采纳为图书馆基础知识 TS 的一部分。

正如 Beman Dawes 和 Alisdair Meredith 在 https://wg21.link/p0220r1 中提出的,该类与 C++17 的其他组件一起采用。

Tony van Eerd 使用 https://wg21.link/n3765https://wg21.link/p0307r2 显着改进了比较运算符的语义。 Vicente J. Botet Escriba 将 API 与 std::variant<> 和 std::any 与 https://wg21.link/p0032r3 进行了协调。 Jonathan Wakely 使用 https://wg21.link/p0504r0 修复了 in_place 标记类型的行为。

16 std::variant<>

借助 std::variant<>,C++ 标准库提供了一个新的联合类,其中包括支持多态性和处理非同质集合的新方法。 也就是说,它允许我们处理不同数据类型的元素,而无需公共基类和指针(原始或智能)。

16.1 std::variant<> 的时机

从 C 中采用,C++ 提供对联合的支持,联合是能够保存可能类型列表之一的对象。但是,此语言功能存在一些缺点:

  • 对象不知道它们当前持有哪种类型的值。
  • 出于这个原因,您不能拥有非平凡的成员,例如 std::string (无需特别努力)。
  • 你不能从工会派生。 使用 std::variant<>,C++ 标准库提供了一个封闭的可区分联合(这意味着有一个指定的可能类型列表,您可以指定您的意思),其中
  • 当前值的类型总是已知的,
  • 可以有任何指定类型的成员,并且
  • 你可以从中得到。 事实上,一个 std::variant<> 保存着各种选择的值,这些选择通常有不同的类型。但是两个替代也可以具有相同的类型,如果具有不同语义含义的替代具有相同的类型,这很有用(例如,持有两个字符串,它们代表不同的数据库列,以便您仍然知道该值代表哪些列) .

变体只是具有用于基础类型的最大大小的内部存储器以及一些固定的开销来管理使用哪个替代方案。 没有分配堆内存。

一般来说,变量不能为空,除非您使用特定的替代信号来表示空虚。 但是,在极少数情况下(例如由于在分配不同的新值期间出现异常) type) 变体可以进入一个没有任何价值的状态。

与 std::optional<> 和 std::any 一样,结果对象具有值语义。 通过在自己的内存中创建一个具有当前替代项的当前值的独立对象来进行深度复制。 因此,复制 std::variant<> 与复制当前替代的 type/value 一样便宜/昂贵。 支持移动语义。

16.2 使用 std::variant<>

以下示例演示了 std::variant<> 的核心功能:

lib/variant.cpp

#include <variant>
#include <iostream>
int main()
{
    std::variant<int, std::string> var{"hi"}; // 用字符串替代初始化
    std::cout << var.index() << '\n'; // prints 1
    var = 42; // 现在持有 int 替代品
    std::cout << var.index() << '\n'; // prints 0
    ...;
    try {
        int i = std::get<0>(var); // 按索引访问
        std::string s = std::get<std::string>(var); // 按类型访问(在这种情况下抛出异常)
        ...;
    }
    catch (const std::bad_variant_access& e) { // 如果使用了错误的type/index
        std::cerr << "EXCEPTION: " << e.what() << '\n';
        ...;
    }
}

成员函数 index() 可用于找出当前设置了哪个备选方案(第一个备选方案的索引为 0)。

初始化和赋值总是使用最佳匹配来找出新的替代方案。 如果类型不完全适合,可能会出现意外。

请注意,不允许使用空变体、具有引用成员的变体、具有 C 样式数组成员的变体以及具有不完整类型(例如 void)的变体。

没有空状态。 这意味着对于每个构造对象,必须调用至少一个构造函数。 默认构造函数使用默认构造函数初始化第一个类型:

std::variant<std::string, int> var; // => var.index() == 0, value == ””

如果没有为第一种类型定义默认构造函数,则调用变体的默认构造函数是编译时错误:

struct NoDefConstr {
    NoDefConstr(int i) {
        std::cout << "NoDefConstr::NoDefConstr(int) called\n";
    }
};
std::variant<NoDefConstr, int> v1; // ERROR: 不能默认构造第一类型

辅助类型 std::monostate 提供了处理这种情况的能力,也提供了模拟空状态的能力。

std::monostate

为了支持第一种类型没有默认构造函数的变体,提供了一个特殊的辅助类型:std::monostate。 std::monostate 类型的对象始终具有相同的状态。 因此,它们总是比较相等。 他们自己的目的是表示一个替代类型,以便该变体没有任何其他类型的值。

也就是说,struct std::monostate 可以作为第一个替代类型,以使变体类型默认可构造。 例如:

std::variant<std::monostate, NoDefConstr> v2; // OK
std::cout << "index: " << v2.index() << '\n'; // prints 0

在某种程度上,您可以将状态解释为表示空虚。 有多种方法可以检查单态,这也演示了其他一些操作,您可以调用变体:

if (v2.index() == 0) {
    std::cout << "has monostate\n";
}
if (!v2.index()) {
    std::cout << "has monostate\n";
}
if (std::holds_alternative<std::monostate>(v2)) {
    std::cout << "has monostate\n";
}
if (std::get_if<0>(&v2)) {
    std::cout << "has monostate\n";
}
if (std::get_if<std::monostate>(&v2)) {
    std::cout << "has monostate\n";
}

get_if<>() 使用指向变体的指针,如果当前备选方案是 T,则返回指向当前备选方案的指针。否则返回 nullptr。 这与 get() 不同,get() 采用对变体的引用,如果提供的类型正确,则按值返回当前替代项,否则抛出。

像往常一样,您可以分配另一个替代的值,甚至分配单态,再次表示空虚:

v2 = 42;
std::cout << "index: " << v2.index() << '\n'; // index: 1
v2 = std::monostate{};
std::cout << "index: " << v2.index() << '\n'; // index: 0
从变体派生

您可以从 std::variant 派生。 例如,您可以定义从 std::variant<> 派生的聚合,如下所示:

class Derived : public std::variant<int, std::string> {
};
Derived d = {{"hello"}};
std::cout << d.index() << '\n'; // prints: 1
std::cout << std::get<1>(d) << '\n'; // prints: hello
d.emplace<0>(77); // 初始化 int,销毁字符串
std::cout << std::get<0>(d) << '\n'; // prints: 77

16.3 std::variant<> 类型和操作

本节详细介绍 std::variant<> 的类型和操作。

16.3.1 std::variant<> 类型

在头文件 中,C++ 标准库定义类 std::variant<> 如下:

namespace std {
    template<typename Types...> class variant;
}

也就是说,std::variant<> 是一个可变参数类模板(C++11 引入的一个特性,允许处理任意数量的类型)。 此外,还定义了以下类型和对象:

  • 类型 std::variant_size

  • 类型 std::variant_alternative

  • 值 std::variant_npos

  • 类型 std::monostate

  • 异常类 std::bad_variant_access,派生自 std::exception。

变体还使用在 utility> 中定义的两个对象 std::in_place_type(std::in_place_type_t 类型)和 std::in_place_index(std::in_place_index_t 类型)。

16.3.2 std::variant<> 操作

表 std::variant Operations 列出了为 std::variant<> 提供的所有操作.

构造

默认情况下,变体的默认构造函数调用第一个替代的默认构造函数:

std::variant<int, int, std::string> v1; // 将第一个 int 设置为 0,index()==0

另一种方法是值初始化,这意味着对于基本类型,它是 0、false 或 nullptr。 如果为初始化传递了一个值,则使用最佳匹配类型:

std::variant<long, int> v2{42};
std::cout << v2.index() << '\n'; // prints 1

但是,如果两种类型匹配得一样好,则调用是模棱两可的:

std::variant<long, long> v3{42}; // ERROR: 模糊的
std::variant<int, float> v4{42.3}; // ERROR: 模糊的
std::variant<int, double> v5{42.3}; // OK
std::variant<int, long double> v6{42.3}; // ERROR: 模糊的
std::variant<std::string, std::string_view> v7{"hello"}; // ERROR: 模糊的
std::variant<std::string, std::string_view, const char*> v8{"hello"}; // OK
std::cout << v8.index() << '\n'; // prints 2
操作 作用
constructors 创建一个变体对象(可能调用底层类型的构造函数)
destructor 销毁一个变体对象
= 分配一个新值
emplace() 为具有类型 T 的替代项分配一个新值
emplace() 为索引 Idx 的替代项分配一个新值
valueless_by_exception() 返回变量是否由于异常而没有值
index() 返回当前替代的索引
swap() 在两个对象之间交换值
==, !=, <, <=, >, >= 比较不同的对象
hash<> 计算哈希值的函数对象类型
holds_alternative() 返回是否有类型 T 的值
get() 返回类型为 T 或 throws 的替代项的值
get() 返回具有索引 Idx 或 throws 的替代项的值
get_if() 返回指向 T 或 nullptr 类型的替代值的指针
get_if() 返回指向具有索引 Idx 或 nullptr 的替代项的值的指针
visit() 对当前备选方案执行操作

要为初始化传递多个值,您必须使用 in_place_type 或 in_place_index 标签:

std::variant<std::complex<double>> v9{3.0, 4.0}; // ERROR
std::variant<std::complex<double>> v10{{3.0, 4.0}}; // ERROR
std::variant<std::complex<double>> v11{std::in_place_type<std::complex<double>>,
                                       3.0, 4.0};
std::variant<std::complex<double>> v12{std::in_place_index<0>, 3.0, 4.0};

您还可以在初始化期间使用 in_place_index 标记来解决歧义或否决优先级:

std::variant<int, int> v13{std::in_place_index<1>, 77}; // 初始化第二个 int
std::variant<int, long> v14{std::in_place_index<1>, 77}; // 初始化长,而不是 int
std::cout << v14.index() << '\n'; // prints 1

你甚至可以传递一个初始化列表,后跟其他参数:

// initialize variant with a set with lambda as sorting criterion:
auto sc = [] (int x, int y) {
    return std::abs(x) < std::abs(y);
};
std::variant<std::vector<int>, std::set<int,decltype(sc)>> v15{std::in_place_index<1>,
                                                               {4, 8, -7, -2, 0, 5},
                                                               sc
                                                              };

你不能对 std::variant<> 使用类模板参数推导。 并且没有 make_variant<>() 便利函数(与 std::optional<> 和 std::any 不同)。 两者都没有意义,因为变体的整个目标是处理多种选择。

访问值

访问该值的常用方法是调用 get<>() 以获得相应的替代方法。 您可以传递它的索引,或者,如果一个类型不被多次使用,它的类型。 例如:

std::variant<int, int, std::string> var; // 将第一个 int 设置为 0,index()==0
auto a = std::get<double>(var); // compile-time 错误:没有双精度
auto b = std::get<4>(var); // compile-time 错误: 没有第四选择
auto c = std::get<int>(var); // compile-time 错误: int 两次
try {
    auto s = std::get<std::string>(var); // 抛出异常(当前设置的第一个 int)
    auto i = std::get<0>(var); // OK, i==0
    auto j = std::get<1>(var); // 抛出异常(当前设置的其他 int)
}
catch (const std::bad_variant_access& e) { // 在无效访问的情况下
    std::cout << "Exception: " << e.what() << '\n';
}

还有一个 API 可以通过选项检查它是否存在来访问该值:

if (auto ip = std::get_if<1>(&var); ip) {
    std::cout << *ip << '\n';
}
else {
    std::cout << "alternative with index 1 not set\n";
}

您必须将指向变体的指针传递给 get_if<>(),它要么返回指向当前值的指针,要么返回 nullptr。 请注意,如果使用 with 初始化,则可以检查刚刚初始化的值。 访问不同选项值的另一种方法是变体访问者。

更改值

赋值和 emplace() 操作对应于初始化存在:

std::variant<int, int, std::string> var; // 将第一个 int 设置为 0,index()==0
var = "hello"; // 设置字符串,index()==2
var.emplace<1>(42); // 设置第二个 int,index()==1

您还可以使用 get<>() 或 get_if<>() 为当前替代项分配一个新值:

std::variant<int, int, std::string> var; // 将第一个 int 设置为 0,index()==0
std::get<0>(var) = 77; // OK,因为第一个 int 已经设置
std::get<1>(var) = 99; // 抛出异常(当前设置的其他 int)
if (auto p = std::get_if<1>(&var); p) { // 如果第二个 int 设置
    *p = 42; // 修改它
}

修改不同选项值的另一种方法是变体访问者

比较

对于相同类型的两个变体(即具有相同顺序的相同替代项),您可以使用通常的比较运算符。 运营商按照以下规则行事:

  • 具有较早替代值的变体小于具有较晚替代值的变体。
  • 如果两个变体具有相同的备选方案,则评估备选方案类型的相应运算符。 注意 std::monostate 类型的所有对象总是相等的。
  • 特殊状态 valueless_by_exception() 为 true 的两个变体是相等的。 否则,任何 valueless_by_exception() 为 true 的变体都小于任何其他变体。

例如:

std::variant<std::monostate, int, std::string> v1, v2{"hello"}, v3{42};
std::variant<std::monostate, std::string, int> v4;
/*
    v1 == v4 // COMPILE-TIME ERROR
    v1 == v2 // yields false
    v1 < v2 // yields true
    v1 < v3 // yields true
    v2 < v3 // yields false
    v1 = "hello";
    v1 == v2 // yields true
    v2 = 41;
    v2 < v3 // yields true
*/
移动语义

std::variant<> 也支持移动语义。 如果您将对象作为一个整体移动,则将复制状态并移动当前替代项的值。 结果,移出的对象仍然具有相同的选择,但任何值都变得未指定。 您还可以将值移入或移出包含的对象。

散列

当且仅当每个成员类型都可以提供哈希值时,才启用变体对象的哈希值。 请注意,哈希值不是当前备选方案的哈希值。

16.3.3 访客

他们必须明确地为每种可能的类型提供函数调用运算符。 然后,使用相应的重载来处理当前的替代方案。

使用函数对象作为访问者

例子:

lib/variantvisit.cpp

#include <variant>
#include <string>
#include <iostream>
struct MyVisitor
{
    void operator() (int i) const {
        std::cout << "int: " << i << '\n';
    }
    void operator() (std::string s) const {
        std::cout << "string: " << s << '\n';
    }
    void operator() (long double d) const {
        std::cout << "double: " << d << '\n';
    }
};
int main()
{
    std::variant<int, std::string, double> var(42);
    std::visit(MyVisitor(), var); // 为 int 调用 operator()
    var = "hello";
    std::visit(MyVisitor(), var); // 为string调用 operator()
    var = 42.7;
    std::visit(MyVisitor(), var); // 为long double调用 operator()
}

如果 operator() 不支持所有可能的类型或调用不明确,则 visit() 调用是编译时错误。 此处的示例运行良好,因为 long double 比 int 更适合 double 值。 您还可以使用访问者来修改当前替代的值(但不能分配新的替代)。 例如:

struct Twice
{
    void operator()(double& d) const {
        d *= 2;
    }
    void operator()(int& i) const {
        i *= 2;
    }
    void operator()(std::string& s) const {
        s = s + s;
    }
};
std::visit(Twice(), var); // calls operator() for matching type

因为只有类型很重要,所以对于具有相同类型的替代方案,您不能有不同的行为。 请注意,函数调用运算符应标记为 const,因为它们是无状态的(它们不会改变行为,只会改变传递的值)。

使用通用 Lambda 作为访问者

使用此功能的最简单方法是使用通用 lambda,它是任意类型的函数对象:

auto printvariant = [](const auto& val) {
    std::cout << val << '\n';
};
...;
std::visit(printvariant, var);

在这里,通用 lambda 定义了一个闭包类型,其中函数调用运算符作为成员模板:

class CompilerSpecifyClosureTypeName {
public:
    template<typename T>
    auto operator() (const T& val) const {
        std::cout << val << '\n';
    }
};

因此,如果生成的函数调用运算符中的语句有效(即调用输出运算符有效),则传递给 std::visit() 的 lambda 调用将编译。 您还可以使用 lambda 来修改当前替代项的值:

// 将当前替代品的值翻倍:
std::visit([](auto& val) {
    val = val + val;
},
var);

或者:

// 恢复为当前替代的默认值;
std::visit([](auto& val) {
val = std::remove_reference_t<decltype(val)>{};
},
var);

您甚至可以使用编译时语言功能以不同方式处理不同的替代方案。 例如:

auto dblvar = [](auto& val) {
    if constexpr(std::is_convertible_v<decltype(val),
                 std::string>) {
        val = val + val;
    }
    else {
        val *= 2;
    }
};
...;
std::visit(dblvar, var);

在这里,对于 std::string 替代方案,通用 lambda 的调用实例化其通用函数调用模板以进行计算:

val = val + val;

而对于其他替代方案,例如 int 或 double,lambda 的调用实例化其通用函数调用模板来计算:

val *= 2;
使用重载的 Lambda 作为访问者

通过对函数对象和 lambda 使用重载器,您还可以定义一组 lambda,其中最佳匹配用作访问者。 假设重载器是重载定义如下:

tmpl/overload.hpp

// “继承”传递的基类型的所有函数调用运算符:
template<typename... Ts>
struct overload : Ts...
{
    using Ts::operator()...;
};
// 基类型是从传递的参数推导出来的:
template<typename... Ts>
overload(Ts...) -> overload<Ts...>;

您可以通过为每个替代方案提供 lambdas 来使用重载来访问变体:

std::variant<int, std::string> var(42);
...;
std::visit(overload{ // 调用当前替代的最佳匹配 lambda
    [](int i) { std::cout << "int: " << i << '\n'; },
    [](const std::string& s) {
        std::cout << "string: " << s << '\n'; },
},
var);

您还可以使用通用 lambda。 始终使用最佳匹配。 例如,要修改变体的当前替代项,您可以使用重载将字符串和其他类型的值“加倍”:

auto twice = overload{
    [](std::string& s) { s += s; },
    [](auto& i) { i *= 2; },
};

有了这个重载,对于字符串替代,当前值被附加,而对于所有其他类型,该值乘以 2,这演示了以下变体的应用:

std::variant<int, std::string> var(42);
std::visit(twice, var); // value 42 becomes 84
...;
var = "hi";
std::visit(twice, var); // value "hi" becomes "hihi"
16.3.4 例外情况下无值

当修改变体以获取新值并且此修改引发异常时,变体可能会进入一个非常特殊的状态:变体已经失去了旧值,但没有获得新值。 例如:

struct S {
    operator int() { throw "EXCEPTION"; } // 任何到 int 的转换都会抛出
};
std::variant<double,int> var{12.2}; // 初始化为 double
var.emplace<1>(S{}); // OOPS: 设置为 int 时抛出

如果发生这种情况,那么:

  • var.valueless_by_exception() 返回真
  • var.index() 返回 std::variant_npos 这表明该变体根本没有任何价值。 具体保证如下:
  • 如果 emplace() 抛出 valueless_by_exception() 总是设置为 true。
  • 如果 operator=() 抛出并且修改不会改变替代 valueless_by_exception() 并且 index() 保持它们的旧状态。值的状态取决于值类型的异常保证。
  • 如果 operator=() 抛出并且新值将设置不同的替代项,则该变体可能没有值(valueless_by_exception() 可能变为 true)。这取决于何时抛出异常。如果它发生在值的实际修改开始之前的类型转换期间,则变体仍将保留其旧值。

通常,只要您不再使用您尝试修改的变体,这种行为应该没有问题。如果你仍然想使用一个变体,尽管使用它会导致异常,你最好检查它的状态。例如:

std::variant<double,int> var{12.2}; // 初始化为 double
try {
    var.emplace<1>(S{}); // OOPS: 设置为 int 时抛出
}
catch (...) {
    if (!var.valueless_by_exception()) {
        ...;
    }
}

16.4 使用std::variant的多态性和非同质化的集合

具有 std::variant 的多态性和非同质集合

std::variant 启用了一种新形式的多态性并处理非同质集合。 它是一种具有一组紧密数据类型的 compile-time 多态性形式。

也就是说,通过使用 variant<>,您可以定义一个对象是多种可能的类型之一。 然后,该对象具有值语义,您可以将这些对象插入到非同质集合中。 因为每个变体都知道它拥有哪个替代方案,并且由于访问者界面,我们可以在运行时针对不同类型进行编程,调用不同的函数/方法(不需要任何虚函数、引用和指针)。

16.4.1 使用std::variant的几何对象

例如,假设我们必须对几何对象系统进行编程:

lib/variantpoly1.cpp

#include <iostream>
#include <variant>
#include <vector>
#include "coord.hpp"
#include "line.hpp"
#include "circle.hpp"
#include "rectangle.hpp"
// 所有几何对象类型的通用类型:
using GeoObj = std::variant<Line, Circle, Rectangle>;
// 创建和初始化几何对象的集合:
std::vector<GeoObj> createFigure()
{
    std::vector<GeoObj> f;
    f.push_back(Line{Coord{1,2},Coord{3,4}});
    f.push_back(Circle{Coord{5,5},2});
    f.push_back(Rectangle{Coord{3,3},Coord{6,4}});
    return f;
}
int main()
{
    std::vector<GeoObj> figure = createFigure();
    for (const GeoObj& geoobj : figure) {
        std::visit([] (const auto& obj) {
            obj.draw(); // draw() 的多态调用
        },
        geoobj);
    }
}

首先,我们为所有可能的类型定义一个通用数据类型:

using GeoObj = std::variant<Line, Circle, Rectangle>;

这三种类型不需要任何特殊关系。 事实上,它们不必有一个通用的基类,没有虚函数,它们的接口甚至可能不同。 例如:

lib/circle.hpp

#ifndef CIRCLE_HPP
#define CIRCLE_HPP
#include "coord.hpp"
#include <iostream>
class Circle {
    private:
    Coord center;
    int rad;
    public:
    Circle (Coord c, int r)
        : center{c}, rad{r} {
        }
    void move(const Coord& c) {
        center += c;
    }
    void draw() const {
        std::cout << "circle at " << center
            << " with radius " << rad << '\n';
    }
};
#endif

现在我们可以通过创建相应的对象并将它们按值传递到容器中来将这些类型的元素放入一个集合中:

std::vector<GeoObj> createFigure()
{
    std::vector<GeoObj> f;
    f.push_back(Line{Coord{1,2},Coord{3,4}});
    f.push_back(Circle{Coord{5,5},2});
    f.push_back(Rectangle{Coord{3,3},Coord{6,4}});
    return f;
}

这段代码在运行时多态下是不可能的,因为那样的话类型必须将 GeoObj 作为一个公共基类,我们需要一个 GeoObj 元素的指针向量,并且由于指针,我们必须使用 new 创建对象,这样 我们必须跟踪何时调用 delete 或使用智能指针(unique_ptr 或 shared_ptr)。 通过使用访问者,我们可以遍历元素并根据元素类型“做正确的事”:

std::vector<GeoObj> figure = createFigure();
for (const GeoObj& geoobj : figure) {
    std::visit([] (const auto& obj) {
        obj.draw(); // polymorphic call of draw()
    },
    geoobj);
}

在这里,visit() 使用通用 lambda 来为每个可能的 GeoObj 类型实例化。 也就是说,在编译 visit() 调用时,lambda 被实例化并编译为三个函数:

  • 编译 Line 类型的代码:

    [] (const Line& obj) {
        obj.draw(); // call of Line::draw()
    }
    
  • 编译 Circle 类型的代码:

    [] (const Circle& obj) {
        obj.draw(); // call of Circle::draw()
    }
    
  • 编译 Rectangle 类型的代码:

    [] (const Rectangle& obj) {
        obj.draw(); // call of Rectangle::draw()
    }
    

如果这些实例之一没有编译,则 visit() 的调用根本不会编译。 如果全部编译,则为每种元素类型生成调用相应函数的代码。 请注意,生成的代码没有 if-else 链。 该标准保证调用的性能不依赖于备选方案的数量。 也就是说,实际上我们得到了与虚函数表相同的行为(每个 visit() 都有一个本地虚函数表)。 请注意,调用的 draw() 函数不必是虚拟的。

如果类型接口不同,我们可以使用编译时 if 或访问者重载来处理这种情况(参见下面的第二个示例)。

16.4.2 其他使用std::variant的非同质集合

作为将非同质集合与 std::variant<> 一起使用的另一个示例,请考虑以下示例:

lib/variantpoly2.cpp

#include <iostream>
#include <string>
#include <variant>
#include <vector>
#include <type_traits>
int main()
{
    using Var = std::variant<int, double, std::string>;
    std::vector<Var> values {42, 0.19, "hello world", 0.815};
    
    for (const Var& val : values) {
        std::visit([] (const auto& v) {
            if constexpr(std::is_same_v<decltype(v),
                         const std::string&>) {
                std::cout << '"' << v << "\" ";
            }
            else {
                std::cout << v << ' ';
            }
        },
        val);
    }
}

同样,我们为表示多种可能类型之一的对象定义自己的类型:

using Var = std::variant<int, double, std::string>;

我们可以用它们创建一个初始化非同质集合:

std::vector<Var> values {42, 0.19, "hello world", 0.815};

请注意,我们可以使用不均匀的元素集合来初始化vector,因为它们都转换为变体类型。 只有当我们传递一个 long 时,编译器才会知道是将它转换为 int 还是 double,这样就不会编译。

当我们迭代时,我们使用访问者为他们调用不同的函数。 然而,因为在这里我们想做不同的事情(如果值是字符串,则在值周围加上引号),我们使用compile-time if:

for (const Var& val : values) {
    std::visit([] (const auto& v) {
        if constexpr(std::is_same_v<decltype(v),
                     const std::string&>) {
            std::cout << '"' << v << "\" ";
        }
        else {
            std::cout << v << ' ';
        }
    },
    val);
}

这样输出就变成了:

42 0.19 "hello world" 0.815

通过使用访问者重载,我们还可以如下实现:

for (const auto& val : values) {
    std::visit(overload{
        [] (auto v) {
            std::cout << v << ' ';
        },
        [] (const std::string& v) {
            std::cout << '"' << v << "\" ";
        }
    },
               val);
}
16.4.3 比较变体多态性

让我们总结一下使用 std::variant<> 处理多态性和非同质集合的优缺点。 好处是:

  • 您不需要常见的基本类型(非侵入式)。
  • 您不必为非同质集合使用指针。
  • 不需要虚拟成员函数。
  • 值语义(无法访问已释放的内存或内存泄漏)。
  • 向量中的元素位于一起(而不是通过堆内存中的指针分布)。

限制和缺点是:

  • 关闭类型集(您必须在编译时了解所有替代方案)。
  • 元素都具有最大元素类型的大小(如果元素类型大小差异很大,则会出现问题)。
  • 复制元素可能更昂贵。

一般来说,我现在建议默认使用 std::variant<> 来编程多态,因为它通常更快(没有 new 和 delete,没有用于非多态使用的虚函数),更安全(没有指针),并且 通常所有类型在所有代码的编译时都是已知的。 就在您必须处理引用语义(在多个地方使用相同的对象)或传递对象变得昂贵(即使使用移动语义)时,运行时多态性 继承可能仍然是合适的。

16.5 std::variant<> 的特殊情况

特定变体可能导致特殊或意外行为

16.5.1 同时拥有bool和std::string的选择

如果 std::variant<> 同时具有 bool 和 std::string 替代项,则分配字符串文字可能会变得令人惊讶,因为字符串文字转换为 bool 比转换为 std::string 更好。 例如:

std::variant<bool, std::string> v;
v = "hi"; // OOPS: sets the bool alternative
std::cout << "index: " << v.index() << '\n';
std::visit([](const auto& val) {
    std::cout << "value: " << val << '\n';
}, v);

此代码段将具有以下输出:

index: 0
value: true

因此,字符串文字被解释为通过布尔值 true 初始化变量(因为指针不为 0,所以为 true)。 这里有几个选项可以“修复”分配:

v.emplace<1>("hello"); // 明确分配给第二个备选方案
v.emplace<std::string>("hello"); // 显式分配给字符串替代
v = std::string{"hello"}; // 确保分配了一个字符串
using namespace std::literals; // 确保分配了一个字符串
v = "hello"s;

16.6 后记

变体对象于 2005 年由 Axel Naumann 在 https://wg21.link/n4218 中首次提出,将 Boost.Variant 作为参考实现。 制定了最终接受的措辞 Axel Naumann 在 https://wg21.link/p0088r3 中。

Tony van Eerd 使用 https://wg21.link/p0393r3 显着改进了比较运算符的语义。 Vicente J. Botet Escriba 将 API 与 std::optional<> 和 std::any 与 https://wg21.link/p0032r3 进行了协调。 Jonathan Wakely 使用 https://wg21.link/p0504r0 修复了 in_place 标记类型的行为。 Erich Keane 与 https://wg21.link/p0510r0 制定了禁止引用、不完整类型和数组以及空变体的限制。 在 C++17 发布后,Mike Spertus、Walter E. Brown 和 Stephan T. Lavavej 使用 https://wg21.link/p0739r0 修复了一个小缺陷。

17 std::any

一般来说,C++ 是一种具有类型绑定和类型安全性的语言。值对象被声明为具有特定类型,它定义了哪些操作是可能的以及它们的行为方式。并且值对象不能改变它们的类型。

std::any 是一种能够改变其类型的值类型,同时仍具有类型安全性。也就是说,对象可以保存任意类型的值,但它们知道当前保存的值是哪种类型。声明此类型的对象时无需指定可能的类型。

诀窍是对象既包含包含的值,也包含使用 typeid 的包含值的类型。因为该值可以具有任何大小,所以内存可能会在堆上分配。但是,实现应避免将堆内存用于包含的小值,例如 int。

也就是说,如果您分配一个字符串,该对象会为该值分配内存并复制该字符串,同时还在内部存储分配的字符串。稍后,可以进行运行时检查以找出当前值具有哪种类型,并将该值用作其类型,any_cast<> 是必要的。

至于 std::optional<> 和 std::variant<> 结果对象具有值语义。也就是说,通过在自己的内存中创建一个具有当前包含值及其类型的独立对象来进行深度复制。因为可能涉及堆内存,所以复制 std::any 通常很昂贵,您应该更喜欢通过引用传递对象或移动值。部分支持移动语义。

17.1 使用 std::any

以下示例演示了 std::any 的核心功能:

std::any a; // a 为空
std::any b = 4.3; // b 具有 double 类型的值 4.3
a = 42; // a 具有 int 类型的值 42
b = std::string{"hi"}; // b 具有 std::string 类型的值 "hi"
if (a.type() == typeid(std::string)) {
    std::string s = std::any_cast<std::string>(a);
    useString(s);
}
else if (a.type() == typeid(int)) {
    useInt(std::any_cast<int>(a));
}

您可以将 std::any 声明为空或由特定类型的值初始化。 初始值的类型成为包含值的类型。

通过使用成员函数 type(),您可以根据任何类型的类型 ID 检查包含值的类型。 如果对象为空,则类型 ID 为 typeid(void)。

要访问包含的值,您必须使用 std::any_cast<> 将其转换为它的类型:

auto s = std::any_cast<std::string>(a);

如果转换失败,因为对象为空或包含的类型不适合,则抛出 std::bad_any_cast。 因此,在不检查或不知道类型的情况下,您最好实现以下内容:

try {
    auto s = std::any_cast<std::string>(a);
    ...
}
catch (std::bad_any_cast& e) {
    std::cerr << "EXCEPTION: " << e.what() << '\n';
}

注意 std::any_cast<> 创建一个传递类型的对象。 如果您将 std::string 作为模板参数传递给 std::any_cast<>,它会创建一个临时字符串(prvalue),然后用于初始化新对象 s。 如果没有这样的初始化,通常最好转换为引用类型以避免创建临时对象:

std::cout << std::any_cast<const std::string&>(a);

为了能够修改该值,您需要强制转换为相应的引用类型:

std::any_cast<std::string&>(a) = "world";

您还可以调用 std::any_cast 以获得 std::any 对象的地址。 在这种情况下,如果类型适合,则强制转换返回相应的指针,否则返回 nullptr:

auto p = std::any_cast<std::string>(&a);
if (p) {
    ...
}

要清空现有的 std::any 对象,您可以调用:

a.reset(); // 使其为空

或者:

a = std::any{};

要不就:

a = {};

您可以直接检查对象是否为空:

if (a.has_value()) {
    ...
}

另请注意,值是使用其衰减类型存储的(数组转换为指针,顶级引用和 const 被忽略)。 对于字符串文字,这意味着值类型是 const char*。

要检查 type() 并使用 std::any_cast<> 您必须完全使用这种类型:

std::any a = "hello"; // type() is const char*
if (a.type() == typeid(const char*)) { // true
    ...
}
if (a.type() == typeid(std::string)) { // false
    ...
}
std::cout << std::any_cast<const char*>(v[1]) << '\n'; // OK
std::cout << std::any_cast<std::string>(v[1]) << '\n'; // EXCEPTION

这些或多或少都是所有操作。 没有定义比较运算符(因此,您不能比较或排序对象),没有定义散列函数,也没有定义 value() 成员函数。 并且因为类型只在运行时才知道,所以不能使用通用 lambda 来处理独立于其类型的当前值。 您总是需要运行时函数 std::any_cast<> 才能处理当前值,这意味着在处理值时需要一些特定于类型的代码来重新进入 C++ 类型系统。

但是,可以将 std::any 对象放入容器中。 例如:

std::vector<std::any> v;
v.push_back(42);
std::string s = "hello";
v.push_back(s);
for (const auto& a : v) {
    if (a.type() == typeid(std::string)) {
        std::cout << "string: " << std::any_cast<const std::string&>(a) << '\n';
    }
    else if (a.type() == typeid(int)) {
        std::cout << "int: " << std::any_cast<int>(a) << '\n';
    }
}

17.2 std::any 类型和操作

本节详细介绍 std::any 的类型和操作。

17.2.1 Any 类型

在头文件 中,C++ 标准库定义类 std::any 如下:

namespace std {
    class any;
}

也就是说, std::any 根本不是类模板。 此外,还定义了以下类型和对象:

  • 异常类std::bad_any_cast,派生自std::bad_cast,派生自std::exception,如果类型转换失败。

任何对象也使用 中定义的对象 std::in_place_type(类型为 std::in_place_type_t)。

17.2.2 Any 操作

表 std::any 操作列出了为 std::any 提供的所有操作。

Operation 作用
constructors 创建一个任意对象(可能调用底层类型的构造函数)
make_any() 创建一个任意对象(传递值来初始化它)
destructor 销毁任何对象
= 分配一个新值
emplace() 分配一个类型为 T 的新值
reset() 销毁任何值(使对象为空)
has_value() 返回对象是否有值
type() 将当前类型作为 std::type_info 对象返回
any_cast() 使用当前值作为类型 T 的值(其他类型除外)
swap() 在两个对象之间交换值
构造

默认情况下, std::any 初始化为空。

std::any a1; // a1 为空

如果一个值被传递给初始化,它的衰减类型被用作包含值的类型:

std::any a2 = 42; // a2 包含 int 类型的值
std::any a3 = "hello"; // a2 包含 const char* 类型的值

要保存与初始值类型不同的类型,您必须使用 in_place_type 标签:

std::any a4{std::in_place_type<long>, 42};
std::any a5{std::in_place_type<std::string>, "hello"};

甚至传递给 in_place_type 的类型也会衰减。 以下声明包含一个 const char*:

std::any a5b{std::in_place_type<const char[6]>, "hello"};

要通过多个参数初始化可选对象,您必须创建对象或添加 std::in_place_type 作为第一个参数(无法推断包含的类型):

std::any a6{std::complex{3.0, 4.0}};
std::any a7{std::in_place_type<std::complex<double>>, 3.0, 4.0};

你甚至可以传递一个初始化列表,后跟其他参数:

// initialize a std::any with a set with lambda as sorting criterion:
auto sc = [] (int x, int y) {
    return std::abs(x) < std::abs(y);
};
std::any a8{std::in_place_type<std::set<int,decltype(sc)>>,
            {4, 8, -7, -2, 0, 5},
            sc};

请注意,还有一个便利函数 make_any<>(),可用于单个或多个参数(不需要 in_place_type 参数)。 您始终必须明确指定初始化类型(如果仅传递一个参数,则不会推导出):

auto a10 = std::make_any<float>(3.0);
auto a11 = std::make_any<std::string>("hello");
auto a13 = std::make_any<std::complex<double>>(3.0, 4.0);
auto a14 = std::make_any<std::set<int,decltype(sc)>>({4, 8, -7, -2, 0, 5},
                                                     sc);
更改值

存在相应的赋值和 emplace() 操作。 例如:

std::any a;
a = 42; // a 包含 int 类型的值
a = "hello"; // a 包含 const char* 类型的值
a.emplace{std::in_place_type<std::string>, "hello"};
// a 包含 std::string 类型的值
a.emplace{std::in_place_type<std::complex<double>>, 4.4, 5.5};
// a 包含 std::complex<double> 类型的值
访问值

要访问包含的值,您必须使用 std::any_cast<> 将其转换为它的类型。 要将值转换为字符串,您有几个选项:

std::any_cast<std::string>(a) // 生成值的副本
std::any_cast<std::string&>(a); // 通过引用写入值
std::any_cast<const std::string&>(a); // 引用读访问

在这里,如果转换失败,则抛出 std::bad_any_cast 异常。 如果删除了顶级引用的传递类型具有相同的类型 ID,则该类型适合。 如果转换失败,您可以传递一个地址来获取 nullptr,因为当前类型不适合:

std::any_cast<std::string>(&a) // 通过指针写访问
std::any_cast<const std::string>(&a); // 通过指针读取访问

请注意,此处转换为引用会导致运行时错误:

std::any_cast<std::string&>(&a); // RUN-TIME ERROR
移动语义

std::any 还支持移动语义。 但是,请注意,仅具有复制语义的类型才支持移动语义。 也就是说,不支持将仅移动类型作为包含值类型。

处理移动语义的最佳方式可能并不明显。 所以,这里是你应该怎么做:

std::string s("hello, world!");
std::any a;
a = std::move(s); // move s into a
s = std::move(std::any_cast<string&>(a)); // 将 a 中的分配字符串移动到 s

像往常一样,对于移出对象,在最后一次调用之后,a 的包含值是未指定的。 因此,只要不假设包含的字符串值具有哪个值,就可以将 a 用作字符串。 以下语句的输出可能是空字符串,而不是“NIL”:

std::cout << (a.has_value() ? std::any_cast<std::string>(a) : std::string("NIL"));

注意:

s = std::any_cast<string>(std::move(a));

也可以,但需要额外的动作。

直接转换为右值引用不会编译:

s = std::any_cast<std::string&&>(a); // compile-time error

请注意,而不是调用

a = std::move(s); // 将 s 移动到 a

以下可能并不总是有效(尽管它是 C++ 标准中的一个示例):

std::any_cast<string&>(a) = std::move(s); // OOPS: a 必须持有一个字符串

这仅在 a 已经包含 std::string 类型的值时才有效。 如果不是,则在我们移动分配新值之前,演员表会抛出一个 std::bad_any_cast 异常。

17.3 后记

2006 年,Kevlin Henney 和 Beman Dawes 在 https://wg21.link/n1939 中首次提出任何对象,将 Boost.Any 作为参考实现。 正如 Beman Dawes、Kevlin Henney 和 Daniel Krugler 在 https://wg21.link/n3804 中提出的,该课程被采纳为图书馆基础知识 TS 的一部分。

正如 Beman Dawes 和 Alisdair Meredith 在 https://wg21.link/p0220r1 中提出的,该类与 C++17 的其他组件一起采用。

Vicente J. Botet Escriba 将 API 与 std::variant<> 和 std::optional<> 与 https://wg21.link/p0032r3 进行了协调。 Jonathan Wakely 使用 https://wg21.link/p0504r0 修复了 in_place 标记类型的行为。

18 std::byte

程序将数据保存在内存中。 对于 std::byte,C++17 为它引入了一种类型,它确实代表了内存元素字节的“自然”类型。 与 char 或 int 等类型的主要区别在于,这种类型不能(容易)(ab)用作整数值或字符类型。 对于数字计算或字符序列不是目标的情况,这会带来更多的类型安全性。

唯一支持的“计算”操作是按位运算符。

18.1 使用 std::byte

以下示例演示了 std::byte 的核心功能:

#include <cstddef> // for std::byte
std::byte b1{0x3F};
std::byte b2{0b11110000};
std::byte b3[4] {b1, b2, std::byte{1}}; // 4 bytes (last is 0)
if (b1 == b3[0]) {
    b1 <<= 1;
}
std::cout << std::to_integer<int>(b1) << '\n'; // outputs: 126

在这里,我们定义了两个具有两个不同初始值的字节。 b2 使用自 C++14 以来可用的两个功能进行初始化:

  • 前缀 0b 可以定义二进制文字。
  • 数字分隔符 ’ 允许使数字文字在源代码中更具可读性(它可以放在数字文字的任意两位之间)。

请注意,列表初始化(使用花括号)是您可以直接初始化 std::byte 对象的单个值的唯一方法。 所有其他形式不编译:

std::byte b1{42}; // ok(对于自 C++17 以来具有固定基础类型的所有枚举)
std::byte b2(42); // ERROR
std::byte b3 = 42; // ERROR
std::byte b4 = {42}; // ERROR

这是 std::byte 被实现为枚举类型这一事实的直接结果,使用可以用整数值初始化作用域枚举的新方式。 也没有隐式转换,因此您必须使用显式转换的整数文字来初始化字节数组:

std::byte b5[] {1}; // ERROR
std::byte b6[] {std::byte{1}}; // OK

在没有任何初始化的情况下,堆栈上的对象的 std::byte 的值是未定义的:

std::byte b; // 未定义的值

像往常一样(除了原子),您可以通过列表初始化强制将所有位设置为零的初始化:

std::byte b{}; // 与b{0}一样 

std::to_integer<>() 提供将字节对象用作整数值(包括 bool 和 char)的能力。 如果没有转换,输出运算符将无法编译。 请注意,因为它是一个模板,您甚至需要使用 std:: 完全限定的转换

std::cout << b1; // ERROR
std::cout << to_integer<int>(b1); // ERROR (ADL 在这里不起作用)
std::cout << std::to_integer<int>(b1); // OK

这样的转换对于使用 std::byte 作为布尔值也是必要的。 例如:

if (b2) ... // ERROR
if (b2 != std::byte{0}) ... // OK
if (to_integer<bool>(b2)) ... // ERROR (ADL 在这里不起作用)
if (std::to_integer<bool>(b2)) ... // OK

因为 std::byte 被定义为枚举类型,底层类型为 unsigned char,所以 std::byte 的大小始终为 1:

std::cout << sizeof(b); // always 1

位数取决于 unsigned char 类型的位数,您可以通过标准数字限制找到:

std::cout << std::numeric_limits<unsigned char>::digits; // std::byte 的位数

大多数时候是 8,但有些平台并非如此。

18.2 std::byte 类型和操作

本节详细介绍 std::byte 的类型和操作。

18.2.1 std::byte 类型

在头文件 中,C++ 标准库定义类型 std::byte 如下:

namespace std {
    enum class byte : unsigned char {
    };
}

也就是说,std::byte 只不过是一个范围枚举类型,其中定义了一些补充的按位运算符:

namespace std {
    ...
    template<typename IntType>
    constexpr byte operator<< (byte b, IntType shift) noexcept;
    template<typename IntType>
    constexpr byte& operator<<= (byte& b, IntType shift) noexcept;
    template<typename IntType>
    constexpr byte operator>> (byte b, IntType shift) noexcept;
    template<typename IntType>
    constexpr byte& operator>>= (byte& b, IntType shift) noexcept;
    constexpr byte& operator|= (byte& l, byte r) noexcept;
    constexpr byte operator| (byte l, byte r) noexcept;
    constexpr byte& operator&= (byte& l, byte r) noexcept;
    constexpr byte operator& (byte l, byte r) noexcept;
    constexpr byte& operator^= (byte& l, byte r) noexcept;
    constexpr byte operator^ (byte l, byte r) noexcept;
    constexpr byte operator~ (byte b) noexcept;
    template<typename IntType>
    constexpr IntType to_integer (byte b) noexcept;
}
18.2.2 std::byte 操作

表 std::byte 操作列出了为 std::byte 提供的所有操作。

Operation 作用
constructors 创建一个字节对象(使用默认构造函数未定义的值)
destructor 销毁一个字节对象(什么都不做)
= 分配一个新值
==, !=, <, <=, >, >= 比较字节对象
`«, », , &, ^, ~`
`«=, »=, =, &=, ^=`
to_integer() 将字节对象转换为整型 T
sizeof() Yields 1
转换为整型

通过使用 to_integer<>(),您可以将 std::byte 转换为任何基本的整数类型(bool、字符类型或整数类型)。 例如,这是将 std::byte 与数值进行比较或在条件中使用它所必需的:

if (b2) ... // ERROR
if (b2 != std::byte{0}) ... // OK
if (to_integer<bool>(b2)) ... // ERROR (ADL 在这里不起作用)
if (std::to_integer<bool>(b2)) ... // OK

另一个使用示例是 std::byte I/O to_integer<>() 使用从 unsigned char 到目标类型的静态转换规则。 例如:

std::byte ff{0xFF};
std::cout << std::to_integer<unsigned int>(ff); // 255
std::cout << std::to_integer<int>(ff); // also 255 (没有负值)
std::cout << static_cast<int>(std::to_integer<signed char>(ff)); // -1
带有 std::byte 的 I/O

没有为 std::byte 定义输入和输出运算符,因此您必须将它们转换为整数值:

std::byte b;
...
std::cout << std::to_integer<int>(b); // 将值打印为十进制值
std::cout << std::hex << std::to_integer<int>(b); // 将值打印为十六进制值

通过使用 std::bitset<>,您还可以将值输出为二进制值(位序列):

#include <bitset>
#include <limits>
using ByteBitset = std::bitset<std::numeric_limits<unsigned char>::digits>;
std::cout << ByteBitset{std::to_integer<unsigned char>(b1)};

using 声明定义了一个 bitset 类型,其位数与 std::byte 一样,然后我们创建并输出这样一个用字节的整数类型初始化的对象。 您还可以使用它将 std::byte 的二进制表示形式写入字符串:

std::string s = ByteBitset{std::to_integer<unsigned char>(b1)}.to_string();

输入方式类似:只需将值读取为整数、字符串或位集值并进行转换。 例如,您可以编写一个输入运算符,从二进制表示中读取一个字节,如下所示:

std::istream& operator>> (std::istream& strm, std::byte& b)
{
    // 读入 bitset:
    std::bitset<std::numeric_limits<unsigned char>::digits> bs;
    strm >> bs;
    // 没有失败,转换为 std::byte:
    if (! std::cin.fail()) {
        b = static_cast<std::byte>(bs.to_ulong()); // OK
    }
    return strm;
}

请注意,我们必须使用 static_cast<>() 将 bitset 转换为 unsigned long 转换为 std::byte。 列表初始化不起作用,因为转换范围缩小:

b = std::byte{bs.to_ulong()}; // ERROR: 缩小

而且我们没有其他的初始化方式.

18.3 后记

std::byte 最初由 Neil MacIntosh 提出,传入 https://wg21.link/p0298r0。 最终接受的措辞由 Neil MacIntosh 在 https://wg21.link/p0298r3 中制定。

19 String Views

在 C++17 中,C++ 标准库采用了一个特殊的字符串类,它允许我们处理像字符串这样的字符序列,而无需为它们分配内存:std::string_view。 也就是说,std::string_view 对象引用外部字符序列而不拥有它们。 也就是说,对象可以被认为是对字符序列的引用。

string_view

使用这样的字符串视图既便宜又快速(按值传递 string_view 总是很便宜)。 然而,它也有潜在的危险,因为与原始指针类似,程序员需要确保在使用 string_view 时引用的字符序列仍然有效)。

19.1 与 std::string 的区别

与 std::string 相比,std::string_view 对象具有以下属性:

  • 底层字符序列是只读的。 没有允许修改字符的操作。 您只能分配新值、交换值以及删除开头或结尾的字符。

  • 不保证字符序列以空值结尾。 因此,字符串视图不是以空结尾的字节流 (NTBS)。

  • 该值可以是 nullptr,例如在使用默认构造函数初始化字符串视图后由 data() 返回。

  • 没有分配器支持。 由于可能的 nullptr 值和可能缺少的 null 终止符,您应该始终在通过 operator[] 或 data() 访问字符之前使用 size() (除非您知道得更好)。

19.2 使用 String Views

字符串视图有两个主要应用:

  1. 您可能已经使用字符序列或字符串分配或映射数据,并希望在不分配更多内存的情况下使用这些数据。典型的例子是使用内存映射文件或处理大文本中的子字符串。

  2. 您想提高接收字符串的函数/操作的性能,只是直接以只读方式处理它们,不需要尾随的空终止符。 这种情况的一种特殊形式可能是将字符串文字处理为具有类似于字符串的 API 的对象:

static constexpr std::string_view hello{"hello world"};

第一个示例通常意味着通常只传递字符串视图,而编程逻辑必须确保底层字符序列保持有效(即,映射的文件内容未被取消映射)。在任何时候,您都可以使用字符串视图来初始化或将它们的值分配给 std::string。

但请注意,使用字符串视图就像“更好的字符串”一样。这可能会导致更差的性能和严重的运行时错误。因此,请仔细阅读以下小节。

19.3 使用与字符串相似的字符串视图

第一个示例,使用像只读字符串一样的 string_view,是一个打印带有作为字符串视图传递的前缀的元素集合的函数:

#include <string_view>
template<typename T>
void printElems(const T& coll, std::string_view prefix = std::string_view{})
{
    for (const auto& elem : coll) {
        if (prefix.data()) { // 检查 nullptr
            std::cout << prefix << ' ';
        }
        std::cout << elem << '\n';
    }
}

在这里,仅通过声明函数将采用 std::string_view,与采用 std::string 的函数相比,我们可以节省分配堆内存的调用。 详细信息取决于是否传递了短字符串以及是否使用了短字符串优化 (SSO)。 例如,如果我们将函数声明如下:

template<typename T>
void printElems(const T& coll, const std::string& prefix = std::string{});

我们传递一个字符串文字,调用会创建一个临时字符串,它将分配内存,除非字符串很短并且使用了短字符串优化。 通过使用字符串视图,不需要分配,因为字符串视图只引用字符串文字。

但是,请注意,在使用字符串视图的任何未知值之前,必须根据 nullptr 检查 data()。

另一个示例,使用像只读字符串一样的 string_view,是 std::optional<> 的 asInt() 示例的改进版本,它是为字符串参数声明的:

lib/asint.cpp

#include <optional>
#include <string_view>
#include <charconv> // 对于 from_chars()
#include <iostream>
// 如果可能,将字符串转换为 int:
std::optional<int> asInt(std::string_view sv)
{
    int val;
    // 将字符序列读入 int:
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data()+sv.size(),
                                     val);
    // 如果我们有错误代码,则不返回任何值:
    if (ec != std::errc{}) {
        return std::nullopt;
    }
    return val;
}
int main()
{
    for (auto s : {"42", " 077", "hello", "0x33"} ) {
        // 如果可能,尝试将 s 转换为 int 并打印结果:
        std::optional<int> oi = asInt(s);
        if (oi) {
            std::cout << "convert '" << s << "' to int: " << *oi << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

现在, asInt() 按值获取字符串视图。但是,这会产生重大影响。首先,使用 std::stoi() 创建整数不再有意义,因为 stoi() 接受一个字符串,而从字符串视图创建一个字符串是一项相对昂贵的操作。

相反,我们将字符串视图的字符范围传递给新的标准库函数 std::from_chars()。它需要一对原始字符指针来转换字符的开头和结尾。请注意,这意味着我们可以跳过对空字符串视图的任何特殊处理,其中 data() 为 nullptr 且 size() 为 0,因为从 nullptr 到 nullptr+0 的范围是有效的空范围(对于添加的任何指针类型0 受支持且无效)。

std_from_chars() 返回一个 std::from_chars_result,它是一个具有两个成员的结构,一个指向未处理的第一个字符的指针 ptr 和一个 std::errc ec,其中 std::errc 表示没有错误。因此,在使用返回值的 ec 成员(使用结构化绑定)初始化 ec 后,如果转换失败,则以下检查返回 nullopt:

if (ec != std::errc{}) {
    return std::nullopt;
}

在对子字符串进行排序时,使用字符串视图还可以显着提高性能。

19.3.1 被认为是有害的字符串视图

通常,诸如智能指针之类的“智能对象”被认为比相应的语言功能更安全(或至少不更危险)。 因此,给人的印象可能是字符串视图(一种字符串引用)更安全,或者至少与使用字符串引用一样安全。 但不幸的是,事实并非如此。 字符串视图实际上比字符串引用或智能指针更危险。 它们的行为更像原始字符指针。

不要将字符串分配给字符串视图

考虑我们声明一个返回新字符串的函数:

std::string retString();

使用返回值通常很安全:

  • 将其分配给使用 auto 声明的字符串或对象是安全的(但可以移动,这通常是可以的,但没有最佳性能):

    auto std::string s1 = retString(); // safe
    
  • 如果可能的话,将返回值分配给字符串引用是非常安全的,只要我们在本地使用该对象,因为引用会将返回值的生命周期延长到其生命周期的末尾:

    std::string& s2 = retString(); // 编译时错误(缺少常量)
    const std::string& s3 = retString(); // s3 延长返回字符串的生命周期
    std::cout << s3 << '\n'; // OK
    auto&& s4 = retString(); // s4 延长返回字符串的生命周期
    std::cout << 4 << '\n'; // OK
    

对于字符串视图,没有给出这种安全性。 它既不复制也不延长返回值的生命周期:

std::string_view sv = retString(); // sv 不会延长返回字符串的生命周期
std::cout << sv << '\n'; // 运行时错误:返回的字符串被破坏

在这里,返回的字符串在第一条语句的末尾被破坏,因此从字符串视图 sv 中引用它是一个致命的运行时错误,导致未定义的行为。 问题与调用时相同:

const char* p = retString().c_str();

或者:

auto p = retString().c_str();

出于这个原因,您还应该非常小心地返回一个字符串视图:

// 非常危险:
std::string_view substring(const std::string&, std::size_t idx = 0);
// 因为:
auto sub = substring("very nice", 5); // 将视图返回到传递的临时字符串
// 但调用后临时字符串被破坏
std::cout << sub << '\n'; // 运行时错误:tmp 字符串已被破坏
不要将字符串视图返回给字符串

尤其是让字符串成员的 getter 返回字符串视图是一种非常危险的设计。 因此,您不应执行以下操作:

class Person {
    std::string name;
public:
    Person (std::string n) : name{std::move(n)} {
    }
    std::string_view getName() const { // don’t do this
        return name;
    }
};

因为,同样,以下结果将成为致命的运行时错误,导致未定义的行为:

Person createPerson();
auto n = createPerson().getName(); // OOPS:删除临时字符串
std::cout << "name: " << n << '\n'; // 致命的运行时错误

如果 getName() 按值或按引用返回字符串,这又是一个问题。

函数模板应该使用返回类型 auto

请注意,很容易意外地将返回的字符串分配给字符串视图。 例如,考虑两个单独看起来非常有用的函数的定义:

// 为返回字符串的字符串视图定义 +:
std::string operator+ (std::string_view sv1, std::string_view sv2) {
    return std::string(sv1) + std::string(sv2);
}
// 泛型串联:
template<typename T>
T concat (const T& x, const T& y) {
    return x + y;
}

但是,再次将它们一起使用可能很容易导致致命的运行时错误:

std::string_view hi = "hi";
auto xy = concat(hi, hi); // xy 是 std::string_view
std::cout << xy << '\n'; // 致命的运行时错误:引用的字符串被破坏

这样的代码很容易被意外编写。 这里真正的问题是 concat() 的返回类型。 如果声明它的返回类型由编译器推断,上面的示例将 xy 初始化为 std::string:

// 改进的通用连接:
template<typename T>
auto concat (const T& x, const T& y) {
    return x + y;
}

此外,在调用链中使用字符串视图会适得其反,因为在调用链中或在其末尾需要字符串。 例如,如果您使用以下构造函数定义类 Person:

class Person {
    std::string name;
public:
    Person (std::string_view n) : name{n} {
    }
    ...
};

传递您仍然需要的字符串文字或字符串很好:

Person p1{"Jim"}; // 没有性能开销
std::string s = "Joe";
Person p2{s}; // 没有性能开销

但是在字符串中移动变得不必要的昂贵,因为传递的字符串首先被隐式转换为字符串视图,然后用于创建一个新的字符串再次分配内存:

Person p3{std::move(s)}; // 性能开销:移动损坏

不要在这里处理 std::string_view 。 按值取参数并将其移动到成员仍然是最好的解决方案。 因此,构造函数和 getter 应该如下所示:

class Person {
    std::string name;
public:
    Person (std::string n) : name{std::move(n)} {
    }
    std::string getName() const {
        return name;
    }
};
字符串视图的安全使用总结

总而言之,请谨慎使用 std::string_view,这意味着您还应该更改 您编程的一般风格:

  • 不要在 API 中使用将参数传递给字符串的字符串视图。 – 不要从字符串视图参数初始化字符串成员。 – 字符串视图链的末尾没有字符串。
  • 不要返回字符串视图。 – 除非它只是一个转发的输入参数,或者您通过例如相应地命名函数来发出危险信号。
  • 出于这个原因,函数模板永远不应该返回传递的泛型参数的类型 T。 – 改为返回 auto。
  • 永远不要使用返回值来初始化字符串视图。
  • 出于这个原因,不要将返回泛型类型的函数模板的返回值分配给 auto。 – 这意味着,AAA(几乎总是自动)模式被字符串视图打破。 如果这些规则太复杂或难以遵循,请不要使用 std::string_view (除非您知道自己在做什么)。

19.4 String View 类型和操作

本节详细介绍字符串视图的类型和操作。

19.4.1 具体字符串视图类型

在头文件 <string_view> 中,C++ 标准库提供了一些类 basic_string_view<> 的特化:

  • 类 std::string_view 是该模板的预定义特化,用于 char 类型的字符:
namespace std {
    using string_view = basic_string_view<char>;
}
  • 对于使用更广泛字符集(例如 Unicode 或某些亚洲字符集)的字符串,预定义了其他三种类型:
namespace std {
    using u16string_view = basic_string_view<char16_t>;
    using u32string_view = basic_string_view<char32_t>;
    using wstring_view = basic_string_view<wchar_t>;
}

在以下部分中,这些类型的字符串视图之间没有区别。用法和问题是相同的,因为所有字符串视图类都具有相同的接口。因此,“字符串视图”表示任何字符串视图类型:string_view、u16string_view、u32string_view 和 wstring_view。本书中的示例通常使用 string_view 类型,因为欧洲和英美环境是软件开发的常见环境。

19.4.2 字符串视图操作

表字符串视图操作列出了为字符串视图提供的所有操作。 除了 remove_prefix() 和 remove_suffix() 之外,还为 std::strings 提供了字符串视图的所有操作。 但是,保证可能略有不同,因为对于字符串视图, data() 返回的值可能是 nullptr 并且缺少保证以空终止符结束序列。

操作 作用
constructors 创建或复制字符串视图
destructor 销毁字符串视图
= 分配一个新值
swap() 在两个字符串视图之间交换值
==, !=, <, <=, >, >=, compare() 比较字符串视图
empty() 返回字符串视图是否为空
size(), length() 返回字符数
max_size() 返回最大可能的字符数
[], at() 访问一个字符
front(), back() 访问第一个或最后一个字符
« 将值写入流
copy() 将内容复制或写入字符数组
data() 将值作为 nullptr 或常量字符返回,数组(注意:没有终止空字符)
find functions 搜索某个子字符串或字符
begin(), end() 提供正常的迭代器支持
cbegin(), cend() 提供常量迭代器支持
rbegin(), rend() 提供反向迭代器支持
crbegin(), crend() 提供常量反向迭代器支持
substr() 返回某个子字符串
remove_prefix() 删除前导字符
remove_suffix() 删除尾随字符
hash<> 计算哈希值的函数对象类型
构造

您可以使用默认构造函数创建字符串视图,作为副本,从原始字符数组(以 null 终止或具有指定长度),从 std::string 或作为带有后缀 sv 的文字。 但是,请注意以下几点:

  • 使用默认构造函数创建的字符串视图将 nullptr 作为 data()。 因此,没有有效的 operator[] 调用。

    std::string_view sv;
    auto p = sv.data(); // 产生 nullptr
    std::cout << sv[0]; // ERROR: 没有有效字符
    
  • 当通过空终止字节流初始化字符串视图时,结果大小是不带 ‘\0’ 的字符数,并且使用终止空字符的索引是无效的:

    std::string_view sv{"hello"};
    std::cout << sv; // OK
    std::cout << sv.size(); // 5
    std::cout << sv.at(5); // 抛出 std::out_of_range 异常
    std::cout << sv[5]; // 未定义的行为,但在这里它通常有效
    std::cout << sv.data(); // 未定义的行为,但在这里它通常有效
    

    最后两个调用是形式上未定义的行为。 因此,它们不能保证工作,尽管在这种情况下您可以假设在最后一个字符之后有空终止符。 您可以通过传递包括空终止符在内的字符数来初始化具有空终止符作为其值的一部分的字符串视图:

    std::string_view sv{"hello", 6}; // NOTE: 6 to include ’\0’
    std::cout << sv.size(); // 6
    std::cout << sv.at(5); // OK, 打印  ’\0’的值
    std::cout << sv[5]; // OK, 打印 '\0' 的值
    std::cout << sv.data(); // OK
    
  • 要从 std::string 创建字符串视图,std::string 类中提供了隐式转换运算符。 同样,在最后一个字符之后有空终止符,通常保证字符串,不保证字符串视图存在:

    std::string s = "hello";
    std::cout << s.size(); // 5
    std::cout << s.at(5); // OK, 打印 '\0' 的值
    std::cout << s[5]; // OK, 打印 '\0' 的值
    std::string_view sv{s};
    std::cout << sv.size(); // 5
    std::cout << sv.at(5); // 抛出 std::out_of_range 异常
    std::cout << sv[5]; // 未定义的行为,但在这里它通常有效
    std::cout << sv.data(); // 未定义的行为,但在这里它通常有效
    
  • 由于为后缀 sv 定义了文字运算符,因此您还可以创建一个字符串视图,如下所示:

    using namespace std::literals;
    auto s = "hello"sv;
    

这里的关键点是,通常你不应该期望空终止字符并且在访问字符之前总是使用 size() (除非你知道关于值的具体事情)。

作为一种解决方法,您可以将 ‘\0’ 作为字符串视图的一部分,但您不应该使用字符串视图作为空终止字符串,而空终止符不是它的一部分,即使空终止符就在后面。

散列

C++ 标准库保证字符串和字符串视图的哈希值是相等的。

修改字符串视图

只提供了几个操作来修改字符串视图:

  • 您可以分配一个新值或交换两个字符串视图的值:

    std::string_view sv1 = "hey";
    std::string_view sv2 = "world";
    sv1.swap(sv2);
    sv2 = sv1;
    
  • 您可以跳过前导或尾随字符(即,将开头移动到第一个字符后面的字符或将结尾移动到最后一个字符之前的字符)。

    std::string_view sv = "I like my kindergarten";
    sv.remove_prefix(2);
    sv.remove_suffix(8);
    std::cout << sv; // prints: like my kind
    

​ 请注意,不支持 operator+。 因此:

std::string_view sv1 = "hello";
std::string_view sv2 = "world";
auto s1 = sv1 + sv2; // ERROR

其中一个操作数必须是字符串:

auto s2 = std::string(sv1) + sv2; // OK

请注意,没有隐式转换为字符串,因为这是一项昂贵的操作,因为它可能会分配内存。 因此,只能进行显式转换。

19.4.3 其他类型的字符串视图支持

原则上,可以传递字符串的每个地方也传递字符串视图是有意义的,期望接收者何时需要将该值以空值终止(例如,通过将值传递给字符串的 C 函数)。

​ 但是,到目前为止,我们只添加了对最重要的地方的支持:

  • 字符串可以在有用的地方使用或与字符串视图结合使用。 您可以从中创建一个字符串(构造函数是显式的)、分配、追加、插入、替换、比较或通过传递字符串视图来查找子字符串。 还有从字符串到字符串视图的隐式转换。
  • 你可以将一个字符串视图传递给std::quoted,它可以打印出其带引号的值。比如说:
using namespace std::literals;
auto s = R""(some\value) "sv; // 原始字符串视图
std::cout << std::quoted(s); // 输出。"some\value"
  • 你可以用字符串视图初始化、扩展或比较文件系统路径。

但是,例如,在C++标准库的regex组件中还没有对字符串视图的支持。标准库中,还没有对字符串视图的支持。

19.5 使用 String Views in API’s

字符串视图很便宜,每个std::string都可以作为一个字符串视图使用。所以,似乎std::string_view是处理字符串参数的更好的类型。嗯,细节很重要… 首先,使用std::string_view只有在使用该参数的函数具有以下条件时才有意义 以下的限制条件时,使用std::string_view才有意义。

  • 它不期望在结尾处有一个空的终结符。例如,当把参数作为一个单一的const char*传递给C函数时,情况就不是这样了。

  • 它尊重所传递参数的生命周期。通常这意味着接收函数只使用传递的值,直到它结束。

  • 调用函数不应该处理底层字符的所有者(比如删除它,改变它的值,或者释放它的内存)。

  • 它可以将nullptr作为值来处理。 请注意,如果你同时为std::string和std::string_view的函数重载,可能会出现歧义错误。std::string_view。

    void foo(const std::string&);
    void foo(std::string_view);
    foo("hello"); // ERROR: 模糊的
    
19.5.1 使用用于初始化字符串的字符串视图

看起来字符串视图的一个简单而有用的应用是在初始化字符串时将其声明为参数类型 当初始化一个字符串时。但是要注意!

考虑用 “老办法 “来初始化一个字符串成员。

class Person {
    std::string name;
public:
    Person (const std::string& n) : name(n) {
    }
    ...
};

这个构造函数有其缺点。用一个字符串字头初始化一个人,会产生一个不必要的拷贝,这可能会导致对堆内存的不必要的请求。比如说:

Person p("Aprettylong NonSSO Name");

首先调用 std::string 构造函数来创建临时参数 n,因为请求了 std::string 类型的引用。 如果字符串很长或没有启用短字符串优化,这意味着为字符串值分配堆内存。 即使使用移动语义,临时字符串也会被复制以初始化成员名称,这意味着再次分配内存。 您只能通过添加更多构造函数重载或引入模板构造函数来避免这种开销,这可能会导致其他问题。 相反,如果我们使用字符串视图,性能会更好:

class Person {
    std::string name;
public:
    Person (std::string_view n) : name(n) {
    }
    ...
};

现在,一个临时的字符串视图n被创建,它根本不分配内存,因为字符串视图只引用了字符串字面的字符。只有name的初始化为成员name分配了一次内存。

然而,有一个问题:如果你传递一个临时字符串或用std::move()标记的字符串,该字符串被转换为字符串视图的类型(这很便宜),然后字符串视图被用来为新字符串分配内存(这很昂贵)。换句话说。使用字符串视图会禁用移动语义,除非你为它提供一个额外的重载。

对于如何初始化带有字符串成员的对象,仍然有明确的建议。以 字符串的值和移动。

class Person {
    std::string name;
public:
    Person (std::string n) : name(std::move(n)) {
    }
    ...
};

无论如何,我们必须创建一个字符串。因此,尽快创建它可以使我们在传递参数的那一刻就能从所有可能的优化中受益。当我们拥有它时,我们只需移动,这是一个廉价的操作。

如果我们通过一个返回临时字符串的辅助函数来初始化这个字符串:

std::string newName()
{
    ...;
    return std::string{...};
}
Person p{newName()};

强制性的复制消除将推迟新字符串的物化,直到该值被传递给构造函数。在那里我们有一个名为n的字符串,这样我们就有了一个有位置的对象(一个glvalue)。 然后这个对象的值被移动到初始化成员名。 这个例子再次证明了。

  • 字符串视图并不是一个更好的取用字符串的接口。
  • 事实上,字符串视图只应该在调用链中使用,在那里它们永远不必作为字符串使用
19.5.2 使用字符串视图而不是字符串

还有其他通过字符串视图替换字符串的可能。但还是要小心。 例如,用下面的代码代替:

// 将时间点(带前缀)转换为字符串:
std::string toString (const std::string& prefix, const std::chrono::system_clock::time_point& tp)
{
    // 转换为日历时间:
    auto rawtime = std::chrono::system_clock::to_time_t(tp);
    std::string ts = std::ctime(&rawtime); // 注意:不是线程安全的
    ts.resize(ts.size()-1); // 跳过尾随换行符
    return prefix + ts;
}

您可以实现以下内容:

std::string toString (std::string_view prefix,
                      const std::chrono::system_clock::time_point& tp)
{
    auto rawtime = std::chrono::system_clock::to_time_t(tp);
    std::string_view ts = std::ctime(&rawtime); // 注意:不是线程安全的
    ts.remove_suffix(1); // 跳过尾随换行符
    return std::string(prefix) + ts; // 不幸的是还没有运算符 +
}

除了通过值获取前缀的传递字符串值作为 std::string_view 的优化之外,我们还可以在内部使用字符串视图。 但只是因为 ctime() 返回的 C 字符串在一段时间内有效(直到下一次调用 ctime() 或 asctime() 才有效)。 请注意,我们可以从字符串中删除尾随的换行符,但是我们不能通过简单地调用 operator+ 来连接两个字符串视图。 相反,我们必须将其中一个操作数转换为 std::string (不幸的是,这可能会不必要地分配额外的内存)。

19.6 后记

除了通过值获取前缀的传递字符串值作为 std::string_view 的优化之外,我们还可以在内部使用字符串视图。但只是因为ctime()返回的C字符串有一段时间有效(一直有效到下一个有引用语义的字符串类是由Jeffrey Yasskin在https://wg21.link/n3334提出的(使用名称string_ref ). 该课程被 Jeffrey Yasskin 在 https://wg21.link/n3921 中提出的图书馆基础知识 TS 中采用。 正如 Beman Dawes 和 Alisdair Meredith 在 https://wg21.link/p0220r1 中提出的,该类与 C++17 的其他组件一起采用。 Marshall Clow 在 https://wg21.link/p0254r2https://wg21.link/p0403r1 以及 Nicolai Josuttis 在 https://wg21.link/p0392r0 中添加了一些用于更好集成的修改。 Daniel Krugler 的其他修复在 https://wg21.link/lwg2946 中(这可能是针对 C++17 的缺陷)。调用 ctime() 或 asctime())。请注意,我们可以从字符串中删除尾随的换行符,但是我们不能通过简单地调用 operator+ 来连接两个字符串视图。相反,我们必须将其中一个操作数转换为 std::string (不幸的是,这可能会不必要地分配额外的内存)。

20 文件系统库

在C++17中,Boost.filesystem库最终被采纳为C++标准库。通过这样做,该库根据新的语言特性进行了调整,与库的其他部分更加一致,进行了清理,并对一些缺失的部分进行了扩展(比如计算文件系统路径之间相对路径的操作)。

20.1 基本例子

让我们从一些基本的例子开始。

20.1.1 打印一个通过文件系统路径的属性

以下程序允许我们使用传递的字符串作为文件系统路径来根据文件类型打印补丁的某些方面:

filesystem/checkpath1.cpp

#include <iostream>
#include <filesystem>
int main(int argc, char* argv[])
{
    if (argc < 2) {
        std::cout << "Usage: " << argv[0] << " <path> \n";
        return EXIT_FAILURE;
    }
    std::filesystem::path p{argv[1]}; // p 表示文件系统路径(可能不存在)
    if (is_regular_file(p)) { // 路径 p 是常规文件吗?
        std::cout << p << " exists with " << file_size(p) << " bytes\n";
    }
    else if (is_directory(p)) { // 路径 p 是目录吗?
        std::cout << p << " is a directory containing:\n";
        for (auto& e : std::filesystem::directory_iterator{p}) {
            std::cout << " " << e.path() << '\n';
        }
    }
    else if (exists(p)) { // 路径 p 真的存在吗?
        std::cout << p << " is a special file\n";
    }
    else {
        std::cout << "path " << p << " does not exist\n";
    }
}

我们首先将任何传递的命令行参数转换为文件系统路径:

std::filesystem::path p{argv[1]}; // p 表示文件系统路径(可能不存在)

然后,我们执行以下检查:

  • 如果路径代表一个现有的常规文件,我们打印它的大小:

    if (is_regular_file(p)) { // 路径 p 是普通文件吗?
        std::cout << p << " exists with " << file_size(p) << " bytes\n";
    }
    

    调用这个程序如下:

    checkpath checkpath.cpp
    

    将输出如下内容:

    "checkpath.cpp" exists with 907 bytes
    

    请注意,路径的输出运算符会自动写入引用的路径名(在双引号内,反斜杠被另一个反斜杠转义,这是 Windows 路径的一个问题)。

  • 如果文件系统路径作为目录存在,我们遍历目录中的文件并打印路径:

    if (is_directory(p)) { // 路径 p 是目录吗?
        std::cout << p << " is a directory containing:\n";
        for (auto& e : std::filesystem::directory_iterator(p)) {
            std::cout << " " << e.path() << '\n';
        }
    }
    

这里我们使用了 directory_iterator,它提供了 begin() 和 end(),我们可以使用基于范围的 for 循环遍历 directory_entry 元素。 在这种情况下,我们使用 directory_entry 成员函数 path(),它产生条目的文件系统路径。 调用这个程序如下:

checkpath .

将输出如下内容:

"." is a directory containing:
"./checkpath.cpp"
"./checkpath.exe"
...
  • 最后,我们检查传递的文件系统路径是否存在:

    if (!exists(p)) { // 路径 p 真的存在吗?
        ...
    }
    
Windows下的路径处理

在 Windows 下,默认情况下路径被引用的事实是一个问题,因为通常的目录分隔符反斜杠总是被转义并写入两次。 因此,在 Windows 下调用该程序如下:

checkpath C:\

将输出如下内容:

"C:\\" is a directory containing:
...
"C:\\Users"
"C:\\Windows"

写入引用的路径可确保写入的文件名可以读入程序,以便您取回原始文件名。 但是,对于标准输出,这通常是不可接受的。

出于这个原因,在 Windows 下运行良好的可移植版本应该避免使用成员函数 string() 将引用的路径写入标准输出:

filesystem/checkpath2.cpp

#include <iostream>
#include <filesystem>
int main(int argc, char* argv[])
{
    if (argc < 2) {
        std::cout << "Usage: " << argv[0] << " <path> \n";
        return EXIT_FAILURE;
    }
    std::filesystem::path p{argv[1]}; // p 表示文件系统路径(可能不存在)
    if (is_regular_file(p)) { // 路径 p 是普通文件吗?
        std::cout << '"' << p << "\" exists with " << file_size(p) << " bytes\n";
    }
    else if (is_directory(p)) { // 路径 p 是目录吗?
        std::cout << '"' << p << "\" is a directory containing:\n";
        for (auto& e : std::filesystem::directory_iterator{p}) {
            std::cout << " \"" << e.path().string() << "\"\n";
        }
    }
    else if (exists(p)) { // 路径 p 真的存在吗?
        std::cout << '"' << p << "\" is a special file\n";
    }
    else {
        std::cout << "path \"" << p << "\" does not exist\n";
    }
}

现在,在 Windows 下调用这个程序如下:

checkpath C:\

将输出如下内容:

"C:\" is a directory containing:
...
"C:\Users"
"C:\Windows"

提供了其他转换以使用通用字符串格式或将字符串转换为本机编码。

20.1.2 切换文件系统类型

我们仍然可以对之前的程序进行一些修改和改进,如下所示:

filesystem/checkpath3.cpp

#include <iostream>
#include <filesystem>
int main(int argc, char* argv[])
{
    if (argc < 2) {
        std::cout << "Usage: " << argv[0] << " <path> \n";
        return EXIT_FAILURE;
    }
    namespace fs = std::filesystem;
    switch (fs::path p{argv[1]}; status(p).type()) {
        case fs::file_type::not_found:
            std::cout << "path \"" << p.string() << "\" does not exist\n";
            break;
        case fs::file_type::regular:
            std::cout << '"' << p.string() << "\" exists with "
                << file_size(p) << " bytes\n";
            break;
        case fs::file_type::directory:
            std::cout << '"' << p.string() << "\" is a directory containing:\n";
            for (auto& e : std::filesystem::directory_iterator{p}) {
                std::cout << " " << e.path().string() << '\n';
            }
            break;
        default:
            std::cout << '"' << p.string() << "\" is a special file\n";
            break;
    }
}
命名空间 fs

首先,我们做一些非常常见的事情:将 fs 定义为命名空间 std::filesystem 的快捷方式:

namespace fs = std::filesystem;

使用这个命名空间,我们初始化,例如,switch 语句中的路径 p:

fs::path p{argv[1]};

switch 语句是带有初始化的新 switch 的应用,我们在其中初始化路径并为其类型提供不同的情况:

switch (fs::path p{argv[1]}; status(p).type()) {
        ...
}

表达式 status(p).type() 创建一个 file_status,type() 为此创建一个 file_type。 这样我们就可以直接处理不同的类型,而不是像 is_regular_file()、is_directory() 等一连串的调用。 提供类型是在多个步骤中有意提供的,这样如果我们对状态信息不感兴趣,我们就不必支付操作系统调用的代价。

另请注意,特定于实现的 file_type 可能存在。 例如,Windows 提供了特殊的文件类型连接。 但是使用它不是便携式的。

20.1.3 创建不同类型的文件

在仅对文件系统进行读取访问之后,现在让我们给出第一个修改它的示例。 以下程序在子目录 tmp 中创建不同类型的文件:

filesystem/createfiles.cpp

#include <iostream>
#include <fstream>
#include <filesystem>
#include <cstdlib>
int main ()
{
    namespace fs = std::filesystem;
    try {
        // 创建目录 tmp/test/(如果它们还不存在):
        fs::path testDir{"tmp/test"};
        create_directories(testDir);
        // 创建数据文件 tmp/test/data.txt:
        auto testFile = testDir / "data.txt";
        std::ofstream dataFile{testFile};
        if (!dataFile) {
            std::cerr << "OOPS, can't open \"" << testFile.string() << "\"\n";
            std::exit(EXIT_FAILURE); // 退出程序失败
        }
        dataFile << "The answer is 42\n";
        // 创建从 tmp/slink/ 到 tmp/test/ 的符号链接:
        create_directory_symlink("test", testDir.parent_path() / "slink");
        // 递归列出所有文件(也遵循符号链接)
        std::cout << fs::current_path().string() << ":\n";
        auto iterOpts{fs::directory_options::follow_directory_symlink};
        for (const auto& e : fs::recursive_directory_iterator(".", iterOpts)) {
            std::cout << " " << e.path().lexically_normal().string() << '\n';
        }
    }
    catch (fs::filesystem_error& e) {
        std::cerr << "EXCEPTION: " << e.what() << '\n';
        std::cerr << " path1: \"" << e.path1().string() << "\"\n";
    }
}

让我们一步一步地完成这个程序。

命名空间 fs

首先,我们做一些非常常见的事情:将 fs 定义为命名空间 std::filesystem 的快捷方式:

namespace fs = std::filesystem;

例如,我们使用这个命名空间初始化临时文件的基本子目录的路径:

fs::path testDir{"tmp/test"};
创建目录

然后我们尝试创建子目录:

create_directories(testDir);

通过使用 create_directories() 我们创建整个传递路径的所有缺失目录(还有 create_directory() 仅在现有目录内创建目录)。

如果目录已经存在,则执行此调用不是错误。 但是,任何其他问题都是错误并引发相应的异常。

如果 testDir 已经存在,create_directories() 返回 false。 因此,您也可以调用:

if (!create_directories(testDir)) {
    std::cout << "\"" << testDir.string() << "\" already exists\n";
}

但是,请注意,如果 testDir 存在但不是目录,这也不是错误。 因此,返回 true 并不意味着在调用之后有一个具有请求名称的目录。 我们可以检查一下,但在这种情况下,这是间接涉及的,因为下一次在目录中创建文件的调用将失败。 但是,错误消息可能会令人困惑。 为了获得更好的错误消息,您可能需要检查之后是否真的存在目录。

创建常规文件

然后我们创建一个包含一些内容的新文件 /tmp/test/data.txt:

auto testFile = testDir / "data.txt";
std::ofstream dataFile(testFile);
if (!dataFile) {
    std::cerr << "OOPS, can't open \"" << testFile.string() << "\"\n";
    std::exit(EXIT_FAILURE); // 退出程序失败
}
dataFile << "The answer is 42\n";

这里我们使用运算符 / 来扩展路径,然后我们将其作为参数传递给文件流的构造函数。 如您所见,常规文件的创建仍然只能使用现有的 I/O 流库来完成。 但是,为构造函数提供了一个新的重载,以便能够直接传递文件系统路径。 请注意,您仍应始终检查创建/打开文件是否成功。 很多事情都可能在这里出错(见下文)。

创建符号链接

下一条语句尝试创建一个引用目录 tmp/test 的符号链接 tmp/slink:

create_directory_symlink("test", testDir.parent_path() / "slink");

请注意,第一个参数定义了创建链接视图的路径。 因此,您必须通过“test”而不是“tmp/test”才能有效地从 tmp/slink 链接到 tmp/test。 如果你调用:

std::filesystem::create_directory_symlink("tmp/test", "tmp/slink");

您将有效地创建 tmp/slink 作为指向 tmp/tmp/test 的符号链接。

请注意,通常调用 create_symlink() 而不是 create_directory_symlink() 也可以,但是某些操作系统对目录的符号链接有特殊处理,或者当它们知道文件是目录时执行得更好,因此如果您应该使用 create_directory_symlink() 知道符号链接是指一个目录。

目录递归

最后,我们递归地列出当前目录:

auto iterOpts = fs::directory_options::follow_directory_symlink;
for (auto& e : fs::recursive_directory_iterator(".", iterOpts)) {
    std::cout << " " << e.path().lexically_normal().string() << '\n';
}

因为我们使用递归目录迭代器并传递选项以跟随符号链接,follow_directory_symlink,我们应该在基于 POSIX 的系统上获得如下输出:

# /home/nico:
...
tmp
tmp/slink
tmp/slink/data.txt
tmp/test
tmp/test/data.txt
...

在 Windows 系统上的输出如下所示:

# C:/Users/nico:
...
tmp
tmp\slink
tmp\slink\data.txt
tmp\test
tmp\test\data.txt
...

请注意,我们在打印所有目录条目的路径时使用 lexically_normal()。 如果我们跳过它,目录条目的路径将包含一个带有初始化迭代器的目录的前缀。 因此,只打印循环内的路径:

auto iterOpts = fs::directory_options::follow_directory_symlink;
for (auto& e : fs::recursive_directory_iterator(".", iterOpts)) {
    std::cout << " " << e.path() << '\n';
}

将在基于 POSIX 的系统下输出:

# all files:
...
"./testdir"
"./testdir/data.txt"
"./tmp"
"./tmp/test"
"./tmp/test/data.txt"

在 Windows 上,输出将是:

# all files:
...
".\\testdir"
".\\testdir\\data.txt"
".\\tmp"
".\\tmp\\test"
".\\tmp\\test\\data.txt"

因此,通过调用 lexically_normal() 我们产生了规范化的路径,它确实删除了当前目录的前导点。 如前所述,通过调用 string() 我们避免了每个路径都被引用,这对于基于 POSIX 的系统来说是可以的(只是将名称放在双引号中),但在 Windows 系统上看起来非常令人惊讶(因为每个反斜杠 被另一个反斜杠转义)。

错误处理

文件系统是麻烦的根源。 由于使用了错误的字符、没有必要的权限或其他进程可能会在您处理文件系统时修改文件系统,您可能无法执行操作。 因此,根据平台和权限,该程序可能会出现一些问题。

对于那些没有被返回值覆盖的情况(这里是目录已经存在的情况),我们捕获相应的异常并打印一般消息和其中的第一个路径:

try {
    ...
}
catch (fs::filesystem_error& e) {
    std::cerr << "EXCEPTION: " << e.what() << '\n';
    std::cerr << " path1: \"" << e.path1().string() << "\"\n";
}

例如,如果我们无法创建目录,则可能会打印出如下消息:

EXCEPTION: filesystem error: cannot create directory: [tmp/test]
path1: "tmp/test"

或者,如果我们无法创建符号链接,例如因为它已经存在,您会收到类似以下消息:

EXCEPTION: create_directory_symlink: Can’t create a file when it already
exists: "tmp\test\data.txt", "testdir"
path1: "tmp\test\data.txt"

如前所述,当目录已经作为常规文件存在时,在目录中创建新文件的尝试将失败。因此,不要忘记检查打开文件的状态。默认情况下,用于读取和写入常规文件的 I/O Stream 库不会将错误作为异常处理。

无论如何请注意,多用户/多进程操作系统中的情况随时可能发生变化。因此,您创建的目录甚至可能在您创建后被删除、重命名或替换为常规文件。因此,根本不可能通过找出当前情况来确保未来请求的有效性。出于这个原因,它通常是尝试做你想做的事情(即创建目录、打开文件)并处理异常和错误或验证检查预期行为的最佳方法。

但是,有时尝试对文件系统做一些事情可能会奏效,但不是您想的那样。例如,如果您想在特定目录中创建文件并且已经存在指向另一个目录的符号链接,则该文件会在意外位置创建或覆盖。这可能没问题(用户可能有充分的理由在预期目录的位置创建符号链接)。但是,如果您想检测这种情况,则必须在创建文件之前检查文件是否存在(这比您最初想象的要复杂一些)。

但同样:不能保证文件系统检查的结果在您处理它们时仍然有效。

20.1.4 使用并行算法处理文件系统的问题

有关使用并行算法累积目录树中所有常规文件大小的另一个示例,请参见 dirsize.cpp。

20.2 原则和术语

在讨论文件系统库的细节之前,我们必须介绍一些设计原则和术语。 这是必要的,因为该标准涵盖了不同的操作系统并将它们映射到一个通用 API。

20.2.1 一般可移植性声明

C++ 标准不仅标准化了所有可能的操作系统对其文件系统的共同点。 在许多情况下,它遵循 POSIX 标准,而 C++ 标准要求尽可能地遵循 POSIX。 只要它是合理的,行为应该仍然存在,但有一些限制。 如果不可能有合理的行为,则实现应报告错误。 此类错误的可能示例是:

  • 字符用于不支持的文件名
  • 创建了不受支持的文件系统元素(例如,符号链接) 特定文件系统的差异仍然可能很重要:
  • 区分大小写: “hello.txt”和“Hello.txt”和“hello.TXT”可能指同一个(Windows)或三个不同的文件(基于POSIX)。
  • 绝对路径与相对路径: 在某些系统上,“/bin”是绝对路径(基于 POSIX),而在其他系统上则不是(Windows)。
20.2.2 命名空间

文件系统库在 std 中有自己的子命名空间文件系统。 为其引入快捷方式 fs 是一个非常常见的约定:

namespace fs = std::filesystem;

例如,这允许使用 fs::current_path() 而不是 std::filesystem::current_path()。 本章的进一步代码示例通常会使用 fs 作为相应的快捷方式。 请注意,不限定文件系统调用有时会导致意外行为。

20.2.3 路径

文件系统库的关键元素是路径。它是一个名称,表示文件系统中文件的(潜在)位置。它由一个可选的根名称、一个可选的根目录和一系列由目录分隔符分隔的文件名组成。路径可以是相对的(因此文件位置取决于当前工作目录)或绝对的。 不同的格式是可能的:

  • 通用格式,可移植

  • 一种原生格式,特定于底层文件系统 在基于 POSIX 的操作系统上,通用格式和本机格式之间没有区别。 在 Windows 上,通用格式 /tmp/test.txt 是除 \tmp\test.txt 之外的有效原生格式,它也受支持(因此,/tmp/test.txt 和 \tmp\test.txt 是相同的路径)。在 OpenVMS 上,相应的原生格式可能是 [tmp]test.txt。 存在特殊文件名:

  • “.”代表当前目录

  • “..” 代表父目录 通用路径格式如下:

    [rootname] [rootdir] [relativepath]
    

在哪里:

  • 可选的根名称是特定于实现的(例如,在 POSIX 系统上可以是 //host,在 Windows 系统上可以是 C:)
  • 可选的根目录是目录分隔符
  • 相对路径是由目录分隔符分隔的一系列文件名

根据定义,目录分隔符由一个或多个“/”或特定于实现的首选目录分隔符组成。

可移植通用路径的示例是:

//host1/bin/hello.txt
.
tmp/
/a/b//../c

请注意,最后一个路径与 /a/c 指向相同的位置,并且在 POSIX 系统上是绝对的,但在 Windows 系统上是相对的(因为缺少驱动器/分区)。

另一方面,诸如 C:/bin 之类的路径在 Windows 系统上是绝对路径(“C”驱动器/分区上的根目录“bin”),但在 POSIX 上是相对路径(目录中的子目录“bin”) “C:”)。

在 Windows 系统上,反斜杠是实现特定的目录分隔符,因此上面的路径也可以通过使用反斜杠作为首选目录分隔符来编写:

\\host1\bin\hello.txt
.
tmp\
\a\b\..\c

文件系统库提供了在本地格式和通用格式之间转换路径的功能。 路径可能是空的。 这意味着没有定义路径。 这不一定与“.”相同。 它的含义取决于上下文。

20.2.4 规范化

路径可能已经或可以被规范化。 在规范化路径中:

  • 文件名仅由一个首选目录分隔符分隔。
  • 文件名“.”除非整个路径只是“.”,否则不使用。 (代表当前目录)。
  • 文件名不包含“..”文件名(我们不会先向下然后再向上),除非它们位于相对路径的开头。
  • 如果尾部的文件名是一个名称不是”… “或”… “的目录,路径才以目录分隔符结束。

请注意,规范化仍然意味着以目录分隔符结尾的文件名与不以分隔符结尾的文件名不同。原因是在某些操作系统上,当知道路径是目录时,行为会有所不同(例如,带有尾随分隔符的符号链接可能会被解析)。

路径规范化的表效果列出了一些在 POSIX 和 Windows 系统上进行规范化的示例。再次注意,在 POSIX 系统上,C:bar 和 C: are 只是文件名,并没有像在 Windows 上那样指定分区的特殊含义。

路径 POSIX 标准化 windows 标准化
foo/.///bar/../ foo/ foo\
//host/../foo.txt //host/foo.txt \host\foo.txt
./f/../.f/ .f/ .f\
C:bar/../ . C:
C:/bar/.. C:/ C:\
C:\bar.. C:\bar.. C:\
/./../data.txt /data.txt \data.txt
././ . .

请注意,在基于POSIX的系统上进行规范化处理时,路径C:\bar...保持不变。原因是在那里反斜杠不是目录分隔符,所以整个路径只是一个文件名,其中有一个冒号、两个反斜杠和两个点作为其名称的一部分。 文件系统为词法规范化(不考虑文件系统)和依赖文件系统的规范化都提供了功能。

20.2.5 成员函数与独立的函数

文件系统库提供了几个函数,它们既可以是成员函数也可以是独立函数。一般的做法是:

  • 成员函数很便宜。原因是它们是纯词法操作,不考虑实际的文件系统,所以不需要调用操作系统。

    例如:

    mypath.is_absolute() // 检查路径是绝对的还是相对的
    
  • 独立的函数是昂贵的,因为它们通常考虑到实际的文件系统,所以不需要调用操作系统。

    例子:

    equivalent(path1, path2); // 如果两个路径都指向同一个文件,则为true
    

有时,文件系统库甚至提供了相同的功能,既在词法上操作,又考虑到了实际的文件系统。

std::filesystem::path fromP, toP;
...
toP.lexically_relative(fromP); // 产生从 fromP 到 toP 的词法路径
relative(toP, fromP); // 产生从 fromP 到 toP 的实际路径

由于参数依赖性查找(ADL),在调用独立的文件系统函数和一个参数具有文件系统的特定类型时,你通常不需要指定完整的命名空间std::filesystem。只有在使用其他类型的隐式转换时,你才需要限定 调用。比如说:

create_directory(std::filesystem::path{"tmpdir"}); // OK
remove(std::filesystem::path{"tmpdir"}); // OK
std::filesystem::create_directory("tmpdir"); // OK
std::filesystem::remove("tmpdir"); // OK
create_directory("tmpdir"); // ERROR
remove("tmpdir"); // OOPS: 调用 C 函数 remove()

请注意,最后一个调用通常会编译,但会找到C函数remove(),它也会删除一个指定的文件,但在Windows下不会删除空目录。

20.2.6 错误处理

正如第二个例子所讨论的,文件系统是一个错误的来源。你必须考虑到必要的文件可能不存在,文件操作不被允许,或者操作违反了资源限制。此外,当程序运行时,其他进程可能会创建、修改或删除文件,因此,即使提前检查也不能保证没有错误。

问题是,原则上你无法确保下一个文件系统操作会成功。任何检查的结果在你处理它时可能不再有效。因此,通常最好的方法是执行一个或多个文件系统操作,并处理由此产生的异常或错误。

还要注意的是,当用普通文件进行读写时,I/O流库默认不会抛出错误。它将任何操作转换为无操作。因此,我们建议至少检查一下文件是否能成功打开。

因为处理异常并不总是合适的(比如当你想直接对一个失败的文件系统调用做出反应时),文件系统库在处理以下问题时使用了混合方法 文件系统时,采用混合方法:

  • 默认情况下,文件系统错误被作为异常处理。
  • 但是,如果你有或者想要,你可以在本地处理特定的错误。 这是通过文件系统操作实现的,通常每个操作都有两个重载。
  1. 默认情况下(没有额外的错误处理参数),操作在出错时抛出一个filesystem_error异常。
  2. 通过传递一个额外的输出参数,你可以在出错时得到一个错误代码。 请注意,在后一种情况下,你可能仍然有特殊的返回值,标志着一个特定的错误没有被作为异常处理。
使用 filesystem_error 异常

例如,你可以尝试创建一个目录,如下所示:

if (!create_directory(p)) { // 错误异常(除非路径存在)
    std::cout << p << " already exists\n"; // 路径存在
}

这里,没有传递错误代码参数,所以错误通常会引发一个异常。但是请注意,路径已经存在的特殊情况(是否是一个目录并不重要),将通过返回false来处理。因此,一个异常是由于其他问题引起的,比如缺少创建目录的权限,无效的路径p,或者违反文件系统资源(比如超过路径长度 限制)。 像这样的代码应该直接或间接地包含在一个try-catch子句中,它可以处理std::filesystem::filesystem_error类型的异常:

try {
    ...;
    if (!create_directory(p)) { // 错误异常(除非路径存在)
        std::cout << p << " already exists\n"; // 路径存在
    }
    ...;
}
catch (const std::filesystem::filesystem_error& e) { // 源自 std::exception
    std::cout << "EXCEPTION: " << e.what() << '\n';
    std::cout << " path: " << e.path1() << '\n';
}

正如你所看到的,文件系统异常提供了通常的标准异常API,通过what()产生一个特定于实现的错误信息。然而,它还提供了path1(),如果涉及到一个路径,甚至是 path2(),如果涉及到第二个路径。

使用 error_code 参数

使用error_code参数 另一种调用函数创建目录的方法如下。

std::error_code ec;
create_directory(p, ec); // 出错时设置错误代码
if (ec) { // 如果设置了错误代码(由于错误)
    std::cout << "ERROR: " << ec.message() << "\n";
}

之后,我们还可以针对特定的错误代码进行检查。

if (ec == std::errc::read_only_file_system) { // if specific error code set
    std::cout << "ERROR: " << p << " is read-only/n";
}

注意,在这种情况下,我们仍然可以检查create_directory()的返回值。

std::error_code ec;
if (!create_directory(p, ec)) { // 出错时设置错误代码
    std::cout << "can't create directory " << p << "\n"; // any error occurred
    std::cout << "ERROR:" << ec.message() << "\n";
}

然而,并不是所有的文件系统操作都提供这种能力(因为它们在正常情况下返回一些值正常情况下)。 在C++11中引入了error_code类型,包括一个可移植错误条件的列表,如 如std::errc::read_only_filesystem。在POSIX系统中,这些映射为errno值

20.2.7 文件类型

不同的操作系统支持不同的文件类型。标准文件系统库考虑到了这一点。原则上,有一个枚举类型file_type,它被标准化为有以下值:

namespace std::filesystem {
    enum class file_type {
        regular, directory, symlink,
        block, character, fifo, socket,
        ...;
        none, not_found, unknown,
    };
}

表file_type Values列出了这些值的含义。 平台可能会提供额外的文件类型值,但这是不可移植的。例如:

Windows提供了文件类型值junction,它用于NTFS文件系统的NTFS结点(也称为软链接)。它们被用作位于同一台计算机的不同本地卷上的目录的链接。在同一台计算机上的不同卷上的目录的链接。

意义
regular 常规文件
directory 目录文件
symlink 符号连接
character 字符专用文件
block 块特殊文件
fifo FIFO或管道文件
socket 套接字文件
额外的执行定义的文件类型
none 文件类型未知(尚)
unknown 文件存在但无法确定类型
not_found 表示未找到该文件的伪类型

除了常规文件和目录之外,最常见的其他类型是符号链接,它是一种指向另一个文件系统位置的文件的类型。在那个位置可能有一个文件,也可能没有。请注意,某些操作系统和/或文件系统(例如 FAT 文件系统)根本不支持符号链接。某些操作系统仅对常规文件支持它们。请注意,在 Windows 上,您需要特殊权限才能创建符号链接,例如,您可以使用 mklink 命令执行此操作。 字符特殊文件、块特殊文件、FIFO 和套接字来自 UNIX 文件系统。目前,所有四种类型都不能与 Visual C++ 一起使用。 如您所见,对于文件不存在或其文件类型未知或无法检测的情况,存在特殊值。在本章的其余部分中,我使用两个通用类别来表示几种文件类型:

  • 其他文件:具有除常规文件、目录和符号链接之外的任何文件类型的文件。库函数 is_other() 匹配该术语。
  • 特殊文件:具有以下任何文件类型的文件:字符特殊文件、块特殊文件、FIFO 和套接字。 特殊文件类型加上实现定义的文件类型共同构成了其他文件类型。

20.3 路径操作

为了处理文件系统,有很多操作可以调用。处理文件系统的一个关键类型是std::filesystem::path,它可以作为一个文件的绝对或相对路径,这个文件可能存在,也可能不存在(尚未存在)。

你可以创建路径,检查它们,修改它们,以及比较它们。因为这些操作通常不考虑文件系统(关心现有文件、符号链接等),所以它们的调用很便宜。因此,它们通常是成员函数(如果它们不是构造函数或 操作符)。

20.3.1 创建路径

表Path Creation列出了创建新路径对象的方法。

调用 作用
path(string) 从一个字符串创建路径
path(beg,end) 从一个范围创建路径
u8path(u8string) 从一个UTF-8字符串中创建路径
current_path() 产生当前工作目录的路径
temp_directory_path() 产生临时文件的路径

注意,current_path()和temp_directory_path()都是比较昂贵的操作,因为它们是基于操作系统的调用。通过传递一个参数,current_path()也可以用来修改当前工作目录。 通过u8path(),你可以使用所有UTF-8字符创建可移植的路径。比如说:

std::filesystem::path{u8path(u8"K\u00F6ln"); // ”Koln” (Cologne native) ¨
...
// 从返回的UTF-8字符串中创建目录:
std::string utf8String = readUTF8String(...);
create_directory(std::filesystem::u8path(utf8String));
20.3.2 路径检查

表Path Inspection列出了你可以调用的检查路径p的函数。注意,这些操作不考虑文件系统,因此是路径的成员函数。 每个路径都是绝对或相对的。如果它没有根目录,它就是相对的(根名称是可能的;例如,C:hello.txt在Windows下是一个相对路径)。 has_…()函数检查没有has_的相应函数是否产生一个空路径。 请注意以下几点。

  • 如果根元素或目录分隔符是路径的一部分,总是有一个父路径。如果路径只由根元素组成(即相对路径是空的),parent_path()得到的是相同的路径。也就是说,例如,”/“的父路径是”/"。只有像 “hello.txt “这样的纯文件名的父路径是空的。
调用 作用
p.empty() 产生路径是否为空
p.is_absolute() 产生一个路径是否是绝对的
p.is_relative() 产生是否是相对路径
p.has_filename() 产生路径是否既不是目录也不是根名的问题
p.has_stem() 与has_filename()相同(因为任何文件名都有一个干)。
p.has_extension() 产生路径是否有扩展名
p.has_root_name() yields 路径是否有根名
p.has_root_directory() yields 路径是否有根目录
p.has_root_path() yields 路径是否有根名或根目录
p.has_parent_path() 产生路径是否有一个父路径
p.has_relative_path() 产生路径是否不只由根元素组成
p.filename() 产生文件名(或空路径)。
p.stem() 产生不带扩展名的文件名(或空路径)
p.extension() 产生扩展名(或空路径)
p.root_name() 产生根名(或空路径)
p.root_directory() 产生根目录(或空路径)
p.root_path() 产生根元素(或空路径)
p.parent_path() 产生父路径(或空路径)
p.relative_path() 产生没有根元素的路径(或空路径)
p.begin() 路径迭代的开始
p.end() 路径迭代结束
  • 如果一个路径有一个文件名,它也总是有一个茎。
  • 空路径是一个相对路径(对于除is_empty()和is_relative()之外的所有其他操作,产生false或空路径)。 这些操作的结果可能取决于操作系统。例如,路径C:/hello.txt
  • 在Unix系统上
    • 是相对的
    • 没有根元素(既没有根名称也没有根目录),因为C:是一个文件名。
    • 有父路径C:
    • 具有相对路径C:/hello.txt
  • 在Windows系统上
    • 是绝对的
    • 有根名称C:和根目录/
    • 没有父路径
    • 有相对路径 hello.txt
路径迭代

你可以对一个路径进行迭代,产生路径中的元素:根名(如果有),根目录(如果有),以及所有的文件名。如果路径以目录分隔符结束,最后一个元素是一个空文件名。 迭代器是一个双向的迭代器,这样你就可以使用—-。迭代器引用的值又是路径类型的。然而,两个迭代器在同一路径上迭代时,即使它们引用了相同的元素,也可能不会引用相同的路径对象。 比如说:

void printPath(const std::filesystem::path& p)
{
    std::cout << "path elements of " << p.string << ":\n";
    for (auto pos = p.begin(); pos != p.end(); ++pos) {
        std::filesystem::path elem = *pos;
        std::cout << " " << elem;
    }
    std::cout << '\n';
}

如果这个函数被调用如下:

printPath("../sub/file.txt");
printPath("/usr/tmp/test/dir/");
printPath("C:\\usr\\tmp\\test\\dir\\");

在基于POSIX的系统上的输出将是:

path elements of "../sub/file.txt":
".." "sub" "file.txt"
path elements of "/usr/tmp/test/dir/":
"/" "usr" "tmp" "test" "dir" ""
path elements of "C:\\usr\\tmp\\test\\dir\\":
"C:\\usr\\tmp\\test\\dir\\"

注意,最后一个路径只是一个文件名,因为在基于POSIX的系统下,C:既不是有效的根名,反斜杠也不是有效的目录分隔符。 在Windows系统上的输出将是:

path elements of "../sub/file.txt":
".." "sub" "file.txt"
path elements of "/usr/tmp/test/dir/":
"/" "usr" "tmp" "test" "dir" ""
path elements of "C:\\usr\\tmp\\test\\dir\\":
"C:" "\\" "usr" "tmp" "test" "dir" ""

要检查一个路径p是否以目录分隔符结束,你可以实现:

if (!p.empty() && (--p.end())->empty()) {
    std::cout << p << " has a trailing separator\n";
}
20.3.3 路径I/O和转换

表中路径I/O和转换列出了读取或写入以及产生转换后的路径的操作。 这些功能没有考虑到实际的文件系统。如果你必须处理符号链接重要的路径,你可能想使用与文件系统相关的路径转换。

调用 作用
strm « p 把路径的值写成引号字符串
strm » p 将路径的值读成带引号的字符串
p.string() 得到的路径是std::string
p.wstring() 得到的路径是std::wstring
p.u8string() 产生路径的UTF-8字符串,类型为std::u8string
p.u16string() 产生路径的UTF-16字符串,类型为std::u16string
p.u32string() 产生路径的UTF-32字符串,类型为std::u32string
p.string<…>() 得到的路径是std::basic_string<…>。
p.lexically_normal() 产生 p 作为标准化路径
p.lexically_relative(p2) 产生从p2到p的路径(如果没有则为空路)。
p.lexically_proximate(p2) 产生从 p2 到 p 的路径(如果没有,则为 p)

lexically_…()函数返回一个新的路径,而其他转换函数产生一个相应的字符串类型。这些函数都没有修改它们所调用的路径。 例如,下面的代码:

std::filesystem::path p{"/dir/./sub//sub1/../sub2"};
std::cout << "path: " << p << '\n';
std::cout << "string(): " << p.string() << '\n';
std::wcout << "wstring(): " << p.wstring() << '\n';
std::cout << "lexically_normal(): " << p.lexically_normal() << '\n';

对前三行有相同的输出:

path: "/dir/./sub//sub1/../sub2"
string(): /dir/./sub//sub1/../sub2
wstring(): /dir/./sub//sub1/../sub2

但最后一行的输出取决于目录分隔符。在基于POSIX的系统中,它是:

lexically_normal(): "/dir/sub/sub2"

而在Windows上,它是:

lexically_normal(): "\\dir\\sub\\sub2"
路径I/O

首先,请注意,I/O操作符是以带引号的字符串形式写入和读取路径。你必须把它们转换为字符串,以便在写它们时不加引号:

std::filesystem::path file{"test.txt"}
std::cout << file << '\n'; // writes: "test.txt"
std::cout << file.string() << '\n'; // writes: test.txt

在Windows上,这有更糟糕的效果。下面的代码:

std::filesystem::path tmp{"C:\\Windows\\Temp"};
std::cout << tmp << '\n';
std::cout << tmp.string() << '\n';
std::cout << '"' << tmp.string() << "\"\n";

有以下输出:

"C:\\Windows\\Temp"
C:\Windows\Temp
"C:\Windows\Temp"

注意,读取文件名支持两种形式(带前导 “的引号和不带引号)。因此,所有打印出来的形式将被正确地读回,使用标准输入操作符的路径:

std::filesystem::path tmp;
std::cin >> tmp; // 正确读取带引号和不带引号的路径
正常化

当你处理可移植代码时,malization可能有更多令人惊讶的结果。比如说:

td::filesystem::path p2{"//dir\\subdir/subsubdir\\/./\\"};
std::cout << "p2: " << p2 << '\n';
std::cout << "lexically_normal(): " << p2.lexically_normal() << '\n';

在Windows系统上有以下可能的预期输出:

p2: "//host\\dir/sub\\/./\\"
lexically_normal(): "\\\\host\\dir\\sub\\"

然而,在基于POSIX的系统上,输出变成了:

p2: "//host\\dir/sub\\/./\\"
lexically_normal(): "/host\\dir/sub\\/\\"

原因是在基于POSIX的系统中,反斜杠不是目录分隔符,也不是根名的有效字符,所以我们有一个绝对路径,有三个文件名host\dir、sub\和\。在基于POSIX的系统中,没有办法检测到反斜杠是一个可能的目录分隔符(在这种情况下,generic_string()和make_preferred()都没有帮助)。 因此,对于可移植代码来说,在处理路径时,你应该始终使用通用路径格式。 尽管如此,在迭代当前目录时,使用lexically_normal()函数去掉前面的点也是一个好办法。

相对路径

lexically_relative()和lexically_proximate()都可以被调用来计算相对于 路径之间的相对路径。唯一的区别是在没有路径的情况下的行为,只有在一个路径是相对的,另一个是绝对的或者根名称不同的情况下才会发生。在这种情况下。

  • p.lexically_relative(p2)如果没有从p2到p的相对路径,则产生空路径。

  • p.lexically_proximate(p2)如果没有从p2到p的相对路径,则产生p。 由于这两个操作都是词法操作,实际的文件系统(可能有符号链接)和current_path()都没有被考虑在内。如果两个路径相等,则相对路径为”."。 例子:

    fs::path{"/a/d"}.lexically_relative("/a/b/c") // "../../d"
    fs::path{"/a/b/c"}.lexically_relative("/a/d") // "../b/c"
    fs::path{"/a/b"}.lexically_relative("/a/b") // "."
    fs::path{"/a/b"}.lexically_relative("/a/b/") // "."
    fs::path{"/a/b"}.lexically_relative("/a/b\\") // "."
    fs::path{"/a/b"}.lexically_relative("/a/d/../c") // "../b
    fs::path{"a/d/../b"}.lexically_relative("a/c") // "../d/../b"
    fs::path{"a//d/..//b"}.lexically_relative("a/c") // "../d/../b"
    

    在Windows系统上,我们有:

    fs::path{"C:/a/b"}.lexically_relative("c:/c/d") ; // ""
    fs::path{"C:/a/b"}.lexically_relative("D:/c/d") ; // ""
    fs::path{"C:/a/b"}.lexically_proximate("D:/c/d") ; // "C:/a/b"
    
转换为字符串

使用u8string(),你可以将路径作为UTF-8字符串使用,这也是当今存储数据的常用格式。存储数据的常用格式。比如说;

// 将路径存储为UTF-8字符串:
std::vector<std::string> utf8paths; // std::u8string with C++20
for (const auto& entry : fs::directory_iterator(p)) {
    utf8paths.push_back(entry.path().u8string());
}

请注意,u8string()的返回值可能会在C++20中从std::string变为std::u8string(新的UTF-8字符串类型,与char8_t一起在https://wg21.link/p0482,用于UTF-8字符)。 成员模板string<>()可以用来转换为一个特殊的字符串类型,例如一个不区分大小写的字符串类型:

struct ignoreCaseTraits : public std::char_traits<char> {
    // 不敏感地比较两个字符的情况:
    static bool eq(const char& c1, const char& c2) {
        return std::toupper(c1) == std::toupper(c2);
    }
    static bool lt(const char& c1, const char& c2) {
        return std::toupper(c1) < std::toupper(c2);
    }
    // 比较s1和s2的多达n个字符:
    static int compare(const char* s1, const char* s2, std::size_t n);
    // 检索字符c在s中的位置:
    static const char* find(const char* s, std::size_t n, const char& c);
};
// 为这类字符串定义一个特殊的类型:
using icstring = std::basic_string<char, ignoreCaseTraits>;
std::filesystem::path p{"/dir\\subdir/subsubdir\\/./\\"};
icstring s2 = p.string<char,ignoreCaseTraits>();

还需要注意的是,你不应该使用同样提供的函数c_str(),因为它可以转换为本地字符串格式,可能是一个wchar_t,这样你就必须使用,例如 std::wcout而不是std::cout来将其写入流中。

20.3.4 本地格式和通用格式之间的转换

在本地和通用格式之间的转换表列出了在通用路径格式和实际平台的特定实现格式之间的转换操作。

这些函数在基于POSIX的系统中应该没有影响,因为本地路径格式和通用路径格式之间没有区别。在其他平台上调用这些函数可能会有影响:

  • generic…() path 函数产生转换为具有通用格式的相应字符串格式的路径,

    调用 作用
    p.generic_string() 得到的路径是一个通用的std::string
    p.generic_wstring() 得到的路径是一个通用的std::wstring
    p.generic_u8string() 得到的路径是一个通用的std::u8string
    p.generic_u16string() 得到的路径是一个通用的std::u16string
    p.generic_u32string() 得到的路径是一个通用的std::u32string
    p.generic_string<…>() 得到的路径是一个通用的std::basic_string<…>
    p.native() 产生本地格式的路径,类型为path::string_type
    conversionToNativeString 隐式转换为本地字符串类型
    p.c_str() 产生作为本地字符串格式的字符序列的路径
    p.make_preferred() 用本地格式替换p中的目录分隔符,并得到修改后的p
    • native()产生转换为本地字符串编码的路径,它由std::filesystem::path::string_type类型定义。在Windows下,这个类型是std::wstring类型,所以你必须使用std::wcout而不是std::cout来直接将其写入标准输出流。新的重载允许我们将本地字符串传递给文件流的新重载。
    • c_str()做了同样的事情,但产生的结果是一个空尾的字符序列。注意,使用这个函数也是不可移植的,因为在Windows上用std::cout打印序列并不能产生正确的输出。你必须在那里使用std::wcout。
    • make_preferred()用本地目录分隔符替换了除根名之外的任何目录分隔符。注意,这是唯一一个修改它所调用的路径的函数。因此,严格来说属于下一节修改路径的函数,但由于它处理的是本地格式的转换,所以也在这里列出。

    例如,在Windows下,下面的代码。

    std::filesystem::path p{"/dir\\subdir/subsubdir\\/./\\"};
    std::cout << "p: " << p << '\n';
    std::cout << "string(): " << p.string() << '\n';
    std::wcout << "wstring(): " << p.wstring() << '\n';
    std::cout << "lexically_normal(): " << p.lexically_normal() << '\n';
    std::cout << "generic_string(): " << p.generic_string() << '\n';
    std::wcout << "generic_wstring(): " << p.generic_wstring() << '\n';
    // 因为它是Windows,而且本地字符串类型是wstring。:
    std::wcout << "native(): " << p.native() << '\n'; // Windows!
    std::wcout << "c_str(): " << p.c_str() << '\n';
    std::cout << "make_preferred(): " << p.make_preferred() << '\n';
    std::cout << "p: " << p << '\n';
    

    有以下输出:

    p: "/dir\\subdir/subsubdir\\/./\\"
    string(): /dir\subdir/subsubdir\/./\
    wstring(): /dir\subdir/subsubdir\/./\
    lexically_normal(): "\\dir\\subdir\\subsubdir\\"
    generic_string(): /dir/subdir/subsubdir//.//
    generic_wstring(): /dir/subdir/subsubdir//.//
    native(): /dir\subdir/subsubdir\/./\
    c_str(): /dir\subdir/subsubdir\/./\
    make_preferred(): "\\dir\\subdir\\subsubdir\\\\.\\\\"
    p: "\\dir\\subdir\\subsubdir\\\\.\\\\"
    

    再次注意:

    • 本地字符串类型是不可移植的。在Windows上它是一个wstring,在基于POSIX的系统上它是一个字符串,所以你必须使用cout而不是wcout来打印native()和c_str()的结果。使用wcout只对wstring()和generic_wstring()的返回值具有可移植性。generic_wstring()的返回值。
    • 只有make_preferred()的调用会修改它所调用的路径。所有其他的调用使p 不受影响。
20.3.5 路径修改

表Path Modifications列出了允许我们直接修改路径的操作。 +=和concat()只是在路径上添加新的字符,而/、/=和append()则是在路径上添加一个子 路径,并用当前目录的分隔符分隔:

std::filesystem::path p{"myfile"};
p += ".git"; // p: myfile.git
p /= ".git"; // p: myfile.git/.git
p.concat("1"); // p: myfile.git/git1
p.append("1"); // p: myfile.git/git1/1
std::cout << p << '\n';
std::cout << p / p << '\n';

在基于POSIX的系统上,输出是:

"myfile.git/.git1/1"
"myfile.git/.git1/1/myfile.git/.git1/1"

在Windows系统上,输出为:

"myfile.git\\.git1\\1"
"myfile.git\\.git1\\1\\myfile.git\\.git1\\1"

注意,追加一个绝对子路径意味着替换现有的路径。例如,在:

namespace fs = std::filesystem;
auto p1 = fs::path("/usr") / "tmp"; // path is /usr/tmp or /usr\tmp
auto p2 = fs::path("/usr/") / "tmp"; // path is /usr/tmp
auto p3 = fs::path("/usr") / "/tmp"; // path is /tmp
调用 作用
p = p2 指定一个新的路径
p = sv 指定一个字符串(视图)作为新的路径
p.assign(p2) 指定一个新的路径
p.assign(sv) 将一个字符串(视图)作为一个新的路径。
p.assign(beg, end) 将范围内的元素从开始到结束分配给路径
p1 / p2 将p2作为p1的子路径连接起来,产生路径。
p /= sub 将子路径作为子路径附加到路径p上
p.append(sub) 将子路径作为子路径附加到路径p上
p.append(beg, end) 将范围内从开始到结束的元素作为子路径附加到路径p
p += str 将str的字符附加到路径p中
p.concat(sub) 将str的字符附加到路径p中
p.concat(beg, end) 将范围内从beg到end的元素追加到路径p中。
p.remove_filename() 从路径中删除尾部的文件名
p.replace_filename(repl) 替换尾部的文件名(如果有的话)
p.replace_extension() 删除任何尾部的文件名扩展名
p.replace_extension(repl) 替换尾部文件名的扩展名(如果有)。
p.clear() 使路径为空
p.swap(p2) 交换两个路径的值
swap(p1, p2) 交换两个路径的值
p.make_preferred() 用本地格式替换p中的目录分隔符,并产生修改后的p
auto p4 = fs::path("/usr/") / "/tmp"; // path is /tmp

我们有4个路径,分别指向两个不同的文件。

  • p1和p2相等,指向文件/usr/tmp(注意,在Windows下它们相等,p1是/usr/tmp)。

  • p3和p4是相等的,指的是文件/tmp,因为附加了一个绝对路径。 对于根元素来说,是否分配了一个新元素也很重要。例如,在Windows下

    我们有:

    auto p1 = fs::path("usr") / "C:/tmp"; // path is C:/tmp
    auto p2 = fs::path("usr") / "C:"; // path is C:
    auto p3 = fs::path("C:") / ""; // path is C:
    auto p4 = fs::path("C:usr") / "/tmp"; // path is C:/tmp
    auto p5 = fs::path("C:usr") / "C:tmp"; // path is C:usr\tmp
    auto p6 = fs::path("C:usr") / "c:tmp"; // path is c:tmp
    auto p7 = fs::path("C:usr") / "D:tmp"; // path is D:tmp
    

    函数make_preferred()将路径中的目录分隔符转换为本地格式。 比如说:

    std::filesystem::path p{"//server/dir//subdir///file.txt"};
    p.make_preferred();
    std::cout << p << '\n';
    

    写在基于POSIX的平台上:

    "//server/dir/subdir/file.txt"
    

    在Windows上,输出结果如下:

    "\\\\server\\dir\\\\subdir\\\\\\file.txt"
    

    请注意,前导根名不会被修改,因为它必须由两个斜线或反斜线组成。 还要注意的是,在基于POSIX的系统上,这个函数不能将反斜线转换为斜线,因为反斜线不被认为是目录分隔符。 replace_extension() 替换、添加或删除一个扩展名。

    • 如果文件有一个扩展名,它被替换

    • 如果文件没有扩展名,将添加新的扩展名。

    • 如果你跳过新的扩展名或者新的扩展名是空的,任何现有的扩展名都会被删除。 你是否在替换处放置一个前导点并不重要。该函数确保 例如:

      fs::path{"file.txt"}.replace_extension("tmp") // file.tmp
      fs::path{"file.txt"}.replace_extension(".tmp") // file.tmp
      fs::path{"file.txt"}.replace_extension("") // file
      fs::path{"file.txt"}.replace_extension() // file
      fs::path{"dir"}.replace_extension("tmp") // dir.tmp
      fs::path{".git"}.replace_extension("tmp") // .git.tmp
      

      注意,作为 “纯扩展名 “的文件名(如.git)不算是扩展名。

20.3.6 路径比较

表路径比较列出了你可以用来比较两个不同路径的操作。 请注意,大多数比较不考虑文件系统,这意味着它们只进行词法操作,这很便宜,但可能会导致令人惊讶的返回值。

  • 使用==、!=和compare(),下列路径都是不同的。

    tmp1/f
    ./tmp1/f
    tmp1/./f
    tmp1/tmp11/../f
    
    调用 作用
    p1 == p2 产生两个路径是否相等
    p1 != p2 得出两条路径是否不相等
    p1 < p2 产生一个路径是否小于另一个路径的结果
    p1 <= p2 产生一条路径是否小于或等于另一条路径
    p1 >= p2 产生一条路径是否大于或等于另一条路径
    p1 > p2 产生一条路径是否大于另一条路径
    p.compare(p2) 得出p2是否小于、等于或大于p的结论
    p.compare(sv) 产生p2是否小于、等于或大于字符串(view)的结果,sv转换为路径
    equivalent(p1, p2) 考虑到文件系统的昂贵的路径比较
    • 只检测指定目录分隔符的不同格式。因此,下列路径都是相同的(只要反斜线是有效的目录分隔符)。

      tmp1/f
      /tmp1//f
      /tmp1\f
      tmp1/\/f
      

    只有当你为每个路径调用lexically_normal()时,上面所有的路径才是相等的(只要反斜杠是有效的目录分隔符)。比如说:

    std::filesystem::path p1{"tmp1/f"};
    std::filesystem::path p2{"./tmp1/f"};
    p1 == p2 // true
    p1.compare(p2) // not 0
    p1.lexically_normal() == p2.lexically_normal() // true
    p1.lexically_normal().compare(p2.lexically_normal()) // 0
    

    如果你想把文件系统考虑进去,以便正确处理符号链接,你可以使用equivalent()。然而,请注意,这个函数要求两个路径都代表现有的文件。因此,一个尽可能准确地比较路径的通用方法(但没有最好的性能) 是这样的:

    bool pathsAreEqual(const std::filesystem::path& p1,
                       const std::filesystem::path& p2)
    {
        return exists(p1) && exists(p2) ? equivalent(p1, p2)
            : p1.lexically_normal() == p2.lexically_normal();
    }
    
20.3.7 其他路径操作

表中其他路径操作列出了尚未列出的其余路径操作。

调用 作用
p.hash_value() 产生一个路径的哈希值

注意,只有相等的路径才有相同的哈希值。也就是说,以下的路径产生不同的 哈希值:

tmp1/f
./tmp1/f
tmp1/./f
tmp1/tmp11/../f

出于这个原因,你可能想在将路径放入哈希表之前将其规范化。

20.4 文件系统操作

本节涵盖了考虑到当前文件系统的更昂贵的文件系统操作。 因为这些操作通常要考虑到文件系统(照顾现有文件、符号链接等),所以它们比纯路径操作要昂贵。 因此,它们通常是独立的函数。

20.4.1 文件属性
  1. 有几个属性你可以得到关于一个给定路径后面的文件。首先,表 “文件类型的操作 “列出了你可以调用的函数,以检查由路径p指定的文件是否存在以及它的整体类型(如果有的话)。注意,这些操作确实考虑到了文件系统,因此是独立的函数。
  2. 文件系统类型的函数与相应的file_type值相匹配。然而,请注意,这些函数(除了is_symlink())遵循符号链接。也就是说,对于一个目录的符号链接,is_symlink()和is_directory()都会产生true。
  3. 还要注意的是,根据其他文件类型的定义,对于所有检查特殊文件(没有普通文件、没有目录、没有符号链接)的is_other()也会产生真值。
  4. 对于特定实现的文件类型,没有特定的便利函数,因此对它们来说 只有is_other()为真(如果我们有一个符号链接到这样的文件,则is_symlink()为真)。你可以 使用文件状态API来检查这些特定的类型。
  5. 为了不跟踪符号链接,使用symlink_status()并为返回的 file_status,正如接下来讨论的exists()。
调用 作用
exists(p) 产生是否有文件可以打开的结果
is_symlink(p) 产生文件p是否存在并且是一个符号链接
is_regular_file(p) 产生文件p是否存在并且是一个普通文件
is_directory(p) 产生文件p是否存在并且是一个目录
is_other(p) 产生文件是否存在 p 并且既不是常规也不是目录也不是符号链接
is_block_file(p) 产生文件p是否存在并且是一个块状的特殊文件
is_character_file(p) 产生文件p是否存在并且是一个特殊字符的文件
is_fifo(p) 产生文件 p 是否存在,并且是 FIFO 或管道文件
is_socket(p) 产生文件p是否存在并且是一个套接字文件
检查文件是否存在

exists()回答的问题是,是否有一个有效的文件可以打开。因此,正如刚才讨论的,它遵循符号链接。所以,如果有一个符号链接到一个不存在的文件,它就会产生错误。 因此,像这样的代码不会像预期的那样工作:

// 如果还没有做,就创建一个符号链接到文件:
if (!exists(p)) { // OOPS:检查p所指的文件是否不存在
    std::filesystem::create_symlink(file, p);
}

如果p已经作为一个不存在的文件的符号链接存在,它将尝试在已经存在符号链接的位置创建符号链接,并引发一个相应的异常。

因为多用户/多进程文件系统中的情况可能随时发生变化,通常最好的办法是尝试执行一个操作,并在操作失败时处理错误。因此,我们可以简单地调用操作并处理相应的异常或处理作为附加参数传递的错误代码。

然而,有时你需要检查一个文件是否存在(在执行文件系统操作之前)。例如,如果你想在一个特定的位置创建一个文件,而那里已经有一个符号链接,那么这个文件就会在一个可能意想不到的位置被创建或被覆盖。在这种情况下,你 在这种情况下,你应该检查文件是否存在,方法如下:

if (!exists(symlink_status(p))) { // OK: 检查p是否还不存在(作为符号链接)。
    ...
}

这里我们使用symlink_status(),它产生的是不跟随符号链接的状态,来检查 检查在p的位置是否存在任何文件。

其他文件属性

文件属性的表操作列出了几个独立的函数来检查额外的文件属性。

调用 作用
is_empty(p) 产生文件是否为空
file_size(p) 产生文件的大小
hard_link_count(p) 产生硬链接的数量
last_write_time(p) 产生最后一次写到一个文件的时间点

请注意,一个路径是否为空和一个路径所指定的文件是否为是空的:

p.empty() // 如果路径p是空的,则为真(廉价操作)。
is_empty(p) // 如果路径p上的文件是空的,则为真(文件系统操作)

file_size(p)返回文件p的大小,如果它作为常规文件存在的话(就像POSIX函数stat()的成员st_size一样)。对于所有其他的文件,其结果是执行定义的,不可移植。 hard_link_count(p) 返回一个文件在文件系统中存在的次数。通常这个数字是1,但在某些文件系统中,同一个文件可以存在于文件系统的不同位置(即有不同的路径)。这与符号链接不同,在符号链接中,一个文件指的是另一个文件。在这里,我们有一个具有不同路径的文件,可以直接访问它。只有当最后一个硬链接被删除时,文件本身才会被删除。文件本身也会被删除。

处理最后一次修改

last_write_time(p)返回文件最后一次修改或写入的时间点。返回类型是标准Chrono库中用于时间点的特殊time_point类型:

namespace std::filesystem {
    using file_time_type = chrono::time_point<trivialClock>;
}

时钟类型trivialClock是一个特定实现的时钟类型,反映了文件时间值的分辨率和范围。例如,你可以按以下方式使用它:

void printFileTime(const std::filesystem::path& p)
{
    auto filetime = last_write_time(p);
    auto diff = std::filesystem::file_time_type:🕰:now() - filetime;
    std::cout << p << " is "
        << std::chrono::duration_cast<std::chrono::seconds>(diff).count()
        << " Seconds old.\n";
}

这可能会输出:

"fileattr.cpp" is 4 Seconds old.

而不是

std::filesystem::file_time_type:🕰:now()

在这个例子中,你也可以这样写:

decltype(filetime):🕰:now()

请注意,文件系统时间点使用的时钟并不保证是标准的system_clock。由于这个原因,目前还没有标准化的支持将文件系统的时间点转换为time_t类型,以便在字符串或输出中使用它作为绝对时间。下面的函数 “大致 “地将任何时钟的时间点转换为time_t对象:

template<typename TimePoint>
std::time_t toTimeT(TimePoint tp)
{
    using system_clock = std::chrono::system_clock;
    return system_clock::to_time_t(system_clock::now()
                                   + (tp - decltype(tp):🕰:now()));
}

诀窍是计算出文件系统时间点相对于现在的持续时间,然后把这个差值加到系统时钟的当前时间上。这个函数并不精确,因为两个时钟可能有不同的分辨率,而且我们在稍微不同的时间调用now()两次。然而,在一般情况下 一般来说,这样做效果很好。 例如,对于一个路径p,我们可以调用:

auto ftime = last_write_time(p);
std::time_t t = toTimeT(ftime);
// 转换为日历时间(包括跳过尾部换行)。:
std::string ts = ctime(&t);
ts.resize(ts.size()-1);
std::cout << "last access of " << p << ": " << ts << '\n';

其中可能会打印:

last access of "fileattr.exe": Sun Jun 24 10:41:12 2018

为了以我们想要的方式格式化一个字符串,我们可以调用:

std::time_t t = toTimeT(ftime);
char mbstr[100];
if (std::strftime(mbstr, sizeof(mbstr), "last access: %B %d, %Y at %H:%M\n",
                  std::localtime(&t))) {
    std::cout << mbstr;
}

这可能会输出:

last access: June 24, 2018 at 10:41

将任何文件系统的时间点转换为字符串的一个有用的辅助工具是:

filesystem/ftimeAsString.hpp

#include <string>
#include <chrono>
#include <filesystem>
std::string asString(const std::filesystem::file_time_type& ft)
{
    using system_clock = std::chrono::system_clock;
    auto t = system_clock::to_time_t(system_clock::now()
                                     + (ft - std::filesystem::file_time_type:🕰:now()));
    // 转换为日历时间(包括跳过尾部的新行)。
    std::string ts = ctime(&t);
    ts.resize(ts.size()-1);
    return ts;
}

注意,ctime()和strftime()不是线程安全的,不能同时调用。 请参阅修改现有文件,了解修改最后一次写入权限的相应API。

20.4.2 文件状态

为了避免文件系统的访问,有一个特殊的类型file_status,可以用来保持和修改缓存的文件类型和权限。 缓存的文件类型和权限。这个状态可以在询问特定路径的文件状态时被设置。

  • 当询问特定路径的文件状态时,如表 “文件状态的操作 “中所列。

  • 当在一个目录上迭代时

    调用 作用
    status(p) 产生文件p的file_status(包括符号链接)
    symlink_status(p) 读取p的文件状态(不遵循符号链接)

不同的是,如果路径p在符号链接中解析,status()跟随链接并打印那里的文件属性(状态可能是没有文件),而symlink_status(p)打印的是符号链接本身的状态。 表file_status Operations列出了对file_status对象fs的可能调用。

调用 作用
exists(fs) 产生一个文件是否存在。
is_regular_file(fs) 产生文件是否存在并且是一个常规文件
is_directory(fs) 产生文件是否存在并且是一个目录。
is_symlink(fs) 产生文件是否存在并且是一个符号链接。
is_other(fs) 产生文件是否存在,并且既不是普通文件也不是目录也不是象征性链接
is_character_file(fs) 产生文件是否存在并且是一个特殊字符的文件。
is_block_file(fs) 产生文件是否存在并且是一个块状特殊文件。
is_fifo(fs) 产生文件是否存在并且是FIFO或管道文件
is_socket(fs) 产生文件是否存在并且是一个套接字。
fs.type() 产生文件的文件类型。
fs.permissions() 产生文件的权限。

状态操作的一个好处是,你可以为同一个文件保存多个操作系统的调用。同一个文件。例如,不使用

if (!is_directory(path)) {
    if (is_character_file(path) || is_block_file(path)) {
        ...
    }
    ...
}

你最好执行:

auto pathStatus{status(path)};
if (!is_directory(pathStatus)) {
    if (is_character_file(pathStatus) || is_block_file(pathStatus)) {
        ...
    }
    ...
}

另一个关键的好处是,通过使用symlink_status(),你可以在不遵循任何符号链接的情况下检查路径的状态。例如,这有助于检查某个特定路径上是否存在任何文件。 因为这些文件状态不使用操作系统,所以不提供返回错误代码的重载。提供。

路径参数的exists()和is_…()函数是调用和检查文件状态的type()的捷径。比如说:

is_regular_file(mypath)

快捷方式:

is_regular_file(status(mypath))

捷径,它是

status(mypath).type() == file_type::regular
20.4.3 权限

处理文件权限的模式是从UNIX/POSIX世界中采用的。有一些位来表示对文件所有者、同一组的成员或所有其他人的阅读、写入和/或执行/搜索访问。此外,还有 “执行时设置用户ID”、“执行时设置组ID “和粘性位(或其他与系统有关的含义)的特殊位。 表Permission Bits列出了在命名空间std::filesystem中定义的bitmask范围枚举类型perms的值,它代表一个或多个权限位。

枚举 八进制 POSIX 意义
none 0 未设置权限
owner_read 0400 S_IRUSR 对所有者的阅读权限
owner_write 0200 S_IWUSR 对所有者的写入权限
owner_exec 0100 S_IXUSR 所有者的执行/搜索权限
owner_all 0700 S_IRWXU 所有者的所有权限
group_read 040 S_IRGRP 组的读取权限
group_write 020 S_IWGRP 组的写入权限
group_exec 010 S_IXGRP 组的执行/搜索权限
group_all 070 S_IRWXG 群组的所有权限
others_read 04 S_IROTH 所有其他人的读取权限
others_write 02 S_IWOTH 所有其他人的写入权限
others_exec 01 S_IXOTH 所有其他人的执行/搜索权限
others_all 07 S_IRWXO 所有其他人的所有权限
all 0777 所有人的所有权限
set_uid 04000 S_ISUID 在执行时设置用户 ID
set_gid 02000 S_ISGID 在执行时设置组 ID
sticky_bit 01000 S_ISVTX 依赖于操作系统
mask 07000 所有可能位掩码
unkonwn 0xFFFF 权限未知

你可以询问当前的权限,结果是检查返回的perms对象的位。 为了组合标志,你必须使用位操作符。比如说:

// 如果可写:
if ((fileStatus.permissions()
     & (fs::perms::owner_write | fs::perms::group_write
        | fs::perms::others_write))
    != fs::perms::none) {
    ...
}

初始化比特掩码的一个更短的方法(但可能不太容易读懂)是直接使用相应的八进制值和放松枚举的初始化:

// 如果可写:
if ((fileStatus.permissions() & fs::perms{0222}) != fs::perms::none) {
    ...
}

注意,在将结果与特定的值进行比较之前,你必须将&表达式放在括号里。还要注意的是,你不能跳过比较,因为对于比特掩码范围的枚举类型,没有隐含的转换为bool。 再比如,要把一个文件的权限转换为UNIX ls -l命令中的字符串,你可以使用下面的辅助函数:

filesystem/permAsString.hpp

#include <string>
#include <chrono>
#include <filesystem>
std::string asString(const std::filesystem::perms& pm)
{
    using perms = std::filesystem::perms;
    std::string s;
    s.resize(9);
    s[0] = (pm & perms::owner_read) != perms::none ? 'r' : '-';
    s[1] = (pm & perms::owner_write) != perms::none ? 'w' : '-';
    s[2] = (pm & perms::owner_exec) != perms::none ? 'x' : '-';
    s[3] = (pm & perms::group_read) != perms::none ? 'r' : '-';
    s[4] = (pm & perms::group_write) != perms::none ? 'w' : '-';
    s[5] = (pm & perms::group_exec) != perms::none ? 'x' : '-';
    s[6] = (pm & perms::others_read) != perms::none ? 'r' : '-';
    s[7] = (pm & perms::others_write) != perms::none ? 'w' : '-';
    s[8] = (pm & perms::others_exec) != perms::none ? 'x' : '-';
    return s;
}

这允许你打印一个文件的权限,作为标准ostream命令的一部分:

std::cout << "permissions: " << asString(status(mypath).permissions())
    << '\n';

对于一个拥有所有者所有权限和所有其他人的读/执行权限的文件,可能的输出结果是:

permissions: rwxr-xr-x

然而,请注意,Windows的ACL(访问控制列表)方法并不真正适合这个方案。由于这个原因,在使用Visual C++时,可写文件总是设置了所有的读、写和执行位(即使它们不是可执行文件),带有只读标志的文件总是设置了所有的读和可执行位。这也影响了可移植地修改权限的API。

20.4.4 文件系统的修改

你也可以通过创建和删除文件或修改现有文件来修改文件系统。

创建和删除文件

创建和删除文件表列出了路径 p 创建和删除文件的操作。

调用 作用
create_directory(p) 创建一个目录
create_directory(p, attrPath) 创建一个具有attrPath属性的目录。
create_directories(p) 创建一个目录和上面的所有目录,还不存在的目录
create_hard_link(old, new) 创建另一个文件系统的条目,以取代现有的文件old
create_symlink(to, new) 创建一个符号链接,从新文件到新文件。
create_directory_symlink(to, new) 创建一个符号链接,从新的目录连接到
copy(from, to) 复制一个任何类型的文件
copy(from, to, options) 复制一个带有选项的任何类型的文件
copy_file(from, to) 复制一个文件(但不是目录或符号链接)
copy_file(from, to, options) 拷贝一个带有选项的文件
copy_symlink(from, to) 复制一个符号链接(to指的是from指的地方)。
remove(p) remove(p) 删除一个文件或空目录
remove_all(p) 删除p和其子树中的所有文件(如果有的话)任何)。

没有创建普通文件的功能。这是由I/O流标准库涵盖的。 例如,下面的语句创建一个新的空文件(如果它还不存在):

std::ofstream{"log.txt"};

创建一个或多个目录的函数会返回是否创建了一个新目录。 因此,查找已经存在的目录不会出错。 但是,在那里找到不是目录的文件也不是错误。 因此,在 create_directory() 或 create_directories() 返回 false 之后,您不知道是否已经存在请求的目录或其他内容。 当然,如果您之后对该文件执行特定于目录的操作并获得异常,您会发现它可能没问题(因为处理这个罕见的问题可能不值得付出努力)。 但是,如果您想要更正错误消息或出于其他原因必须确保确实有一个目录,您必须执行以下操作:

if (!create_directory(myPath) && !is_directory(myPath)) {
    std::cerr << "OOPS, \"" << myPath.string() << "\" is already something else\n";
    ... // 处理这个错误
}

copy…() 函数不适用于特殊文件类型。 默认情况下,它们:

  • 如果现有文件被覆盖,则报告错误

  • 不要递归操作

  • 按照符号链接 这个默认值可以被参数 options 覆盖,它具有位掩码范围的枚举类型 copy_options,定义在命名空间 std::filesystem 中。 表复制选项。 列出可能的值。

    复制选项 作用
    none 默认值(值 0)
    skip_existing 跳过覆盖现有文件
    overwrite_existing 覆盖现有文件
    update_existing 如果新文件较新,则覆盖现有文件
    recursive 递归复制子目录及其内容
    copy_symlinks 将符号链接复制为符号链接
    skip_symlinks 忽略符号链接
    directories_only 仅复制目录
    create_hard_links 创建额外的硬链接而不是文件副本
    create_symlinks 创建符号链接而不是文件副本(源路径必须是绝对路径,除非目标路径在当前目录中)

    rename() 可以处理任何类型的文件,包括目录和符号链接。 对于符号链接,链接被重命名,而不是它所指的位置。 请注意, rename() 需要包含文件名的完整新路径才能将其移动到不同的目录:

    // move "tmp/sub/x" to "tmp/x":
    std::filesystem::rename("tmp/sub/x", "top"); // ERROR
    std::filesystem::rename("tmp/sub/x", "top/x"); // OK
    

    last_write_time() 使用处理上次修改中描述的时间点格式。 例如:

    // 创建文件p(更新最后的文件访问)。:
    last_write_time(p, std::filesystem::file_time_type:🕰:now());
    

    permissions() 使用权限中描述的权限 API 格式。 可选模式是位掩码枚举类型 perm_options,在命名空间 std::filesystem 中定义。 它一方面允许在替换、添加和删除之间进行选择,另一方面允许使用 nofollow 来修改符号链接而不是它们所引用的文件的权限。 例如:

    // 删除组的写访问权限和其他人的任何访问权限:
    permissions(mypath,
                std::filesystem::perms::group_write
                | std::filesystem::perms::others_all,
                std::filesystem::perm_options::remove);
    

    再次注意,Windows 由于其 ACL 权限概念仅支持两种模式:

    • 读取、写入和执行/搜索所有 (rwxrwxrwx)

    • 读取、执行/搜索所有(r-xr-xr-x) 要在这两种模式之间进行可移植的切换,您必须同时启用或禁用所有三个写入标志(一个接一个地删除不起作用):

      // 启用/禁用写访问的可移植值:
      auto allWrite = std::filesystem::perms::owner_write
      | std::filesystem::perms::group_write
      | std::filesystem::perms::others_write;
      // 可移植地删除写入权限:
      permissions(file, allWrite, std::filesystem::perm_options::remove);
      

      初始化allWrite的一个更短的方法(但可能不那么好读)(使用放松的枚举初始化)如下。:

      std::filesystem::perms allWrite{0222};
      

      resize_file() 可用于减小或扩展常规文件的大小:例如:

      // 使文件为空:
      resize_file(file, 0);
      
20.4.5 符号链接和依赖文件系统的路径转换

表中文件系统路径转换列出了处理文件路径的操作,其中考虑到了文件系统。如果你需要处理符号链接,这一点尤其重要。对于不考虑文件系统的廉价路径转换,请参见纯路径转换。 注意,这些调用对文件是否必须存在、是否规范化以及是否遵循符号链接的处理方式不同。表中的文件系统路径转换属性给出了这些函数的要求和执行情况的概述。 下面的函数演示了大多数这些操作的用法和效果(在处理符号链接时):

filesystem/symlink.hpp

调用 作用
read_symlink(symlink) 产生现有符号链接引用的文件
absolute(p) 产生现有的 p 作为绝对路径(不遵循符号链接)
canonical(p) 产生现有的 p 作为绝对路径(遵循符号链接)
weakly_canonical(p) 产生 p 作为绝对路径(遵循符号链接)
relative(p) 产生从当前目录到 p 的相对(或空)路径
relative(p, base) 产生从 base 到 p 的相对(或空)路径
proximate(p) 产生从当前目录到 p 的相对(或绝对)路径
proximate(p, base) 产生从 base 到 p 的相对(或绝对)路径
调用 必须存在 规范化 遵循符号链接
read_symlink() yes yes once
absolute() no yes no
canonical() yes yes all
weakly_canonical() no yes all
relative() no yes all
proximate() no yes all
#include <filesystem>
#include <iostream>
void testSymLink(std::filesystem::path top)
{
    top = absolute(top); // 在我们更改当前路径时使用绝对路径
    create_directory(top); // 确保 top 存在
    current_path(top); // 这样我们就可以将目录更改为它
    std::cout << std::filesystem::current_path() << '\n'; // 打印top路径
    // 定义我们的子目录(不创建它们):
    std::filesystem::path px{top / "a/x"};
    std::filesystem::path py{top / "a/y"};
    std::filesystem::path ps{top / "a/s"};
    // 打印一些相对路径(对于不存在的文件):
    std::cout << px.relative_path() << '\n'; // 相对路径,从top
    std::cout << px.lexically_relative(py) << '\n'; // 从 py 到 px: "../x"
    std::cout << relative(px, py) << '\n'; // 从 py 到 px: "../x"
    std::cout << relative(px) << '\n'; // 从curr.path到px : "a/x"
    std::cout << px.lexically_relative(ps) << '\n'; // 从 ps 到 px: "../x"
    std::cout << relative(px, ps) << '\n'; // 从 ps 到 px: "../x"
    // 现在创建所有子目录和符号链接:
    create_directories(px);
    create_directories(py);
    if (!is_symlink(ps)) {
        create_directory_symlink(top, ps);
    }
    std::cout << "ps: " << ps << '\n'
        << " -> " << read_symlink(ps) << '\n';
    // 并查看词法和文件系统相关之间的区别:
    std::cout << px.lexically_relative(ps) << '\n'; // 从 ps 到 px: "../x"
    std::cout << relative(px, ps) << '\n'; // 从 ps 到 px: "a/x"
}

注意,我们首先将可能的相对路径转换为绝对路径,因为否则改变当前路径会影响路径变量的位置。 relative_path()和lexically_relative()是廉价的路径成员函数,没有考虑到实际的文件系统。因此,它们忽略了符号链接。独立的函数relative() 将文件系统考虑在内。只要我们还没有文件,它的作用就像lexically_relative()。但在创建符号链接ps(top/a/s)之后,它就会跟随符号链接,并给出不同的结果。 在POSIX系统中,从”/tmp “调用该函数,参数为 “top”,其输出结果如下:

"/tmp/sub"
"tmp/sub/a/x"
"../x"
"../x"
"a/x"
"../x"
"../x"
ps: "/tmp/sub/a/s" -> "/tmp/sub"
"../x"
"a/x"

在Windows系统中,从 “C:/temp “调用该函数,参数为 “top”,输出结果为如下所示:

"C:\\temp\\top"
"temp\\top\\a/x"
"..\\x"
"..\\x"
"a\\x"
"..\\x"
"..\\x"
ps: "C:\\temp\\top\\a/s" -> "C:\\temp\\top"
"..\\x"
"a\\x"

请再次注意,你需要管理员权限才能在Windows上创建符号链接。

20.4.6 其他文件系统操作

表 “其他操作 “列出了尚未提及的其他文件系统操作。

调用 作用
equivalent(p1, p2) 得出 p1 和 p2 是否引用同一个文件
space(p) 产生有关路径 p 处可用磁盘空间的信息
current_path(p) 将当前工作目录的路径设置为 p

在关于路径比较的章节中讨论了equivalent()函数。 space()的返回值是以下结构:

namespace std::filesystem {
    struct space_info {
        uintmax_t capacity;
        uintmax_t free;
        uintmax_t available;
    };
}

因此,使用结构化绑定可以打印 root 的可用磁盘空间,如下所示:

auto [cap, _, avail] = std::filesystem::space("/");
std::cout << std::fixed << std::precision(2)
    << avail/1.0e6 << " of " << cap/1.0e6 << " MB available\n\n";

输出可能是例如:

43019.82 of 150365.79 MB available

为路径参数调用的 current_path() 会修改整个程序的当前工作目录(因此,它适用于所有线程)。 离开范围时,您可以通过以下方式切换到另一个工作目录并恢复旧目录:

auto current{std::filesystem::current_path()};
try {
    std::filesystem::current_path(subdir);
    ...;
}
catch (...) {
    std::filesystem::current_path(current);
    throw;
}
std::filesystem::current_path(subdir);

20.5 遍历目录

文件系统库的一个关键应用是遍历目录或文件系统(子)树的所有文件。 最方便的方法是使用基于范围的 for 循环。 您可以遍历目录中的所有文件:

for (const auto& e : std::filesystem::directory_iterator(dir)) {
   std::cout << e.path() << '\n';
}

或递归遍历文件系统(子)树中的所有文件:

for (const auto& e : std::filesystem::recursive_directory_iterator(dir)) {
    std::cout << e.path() << '\n';
}

传递的参数 dir 可以是路径或任何可隐式转换为路径的内容(尤其是所有形式的字符串); 请注意, e.path() 产生的文件名包括迭代开始的目录。

因此,如果我们遍历“.” 文件名 file.txt 变为 ./file.txt 或 .\file.txt。

此外,此路径被引用到流中,因此此文件名的输出变为“./file.txt”或“.\file.txt”。 因此,正如之前在初始示例中所讨论的,以下循环更便于移植:

for (const auto& e : std::filesystem::directory_iterator(dir)) {
    std::cout << e.path().lexically_normal().string() << '\n';
}

要在当前目录上进行迭代,你应该传递”. “作为当前目录而不是”"。传递一个空的路径在Windows上是可行的,但不能移植。

范围的目录迭代器

你可以将一个迭代器传递给一个基于范围的for循环,这可能看起来令人惊讶,因为你通常需要一个范围。

诀窍在于,directory_iterator和recursive_directory_iterator都是提供begin()和end()的全局重载的类。

  • begin()产生迭代器本身。
  • end()产生结束迭代器,你也可以用默认的构造函数来创建它。

出于这个原因,你也可以按以下方式进行迭代。

std::filesystem::directory_iterator di{p};
for (auto pos = begin(di); pos != end(di); ++pos) {
    std::cout << pos->path() << '\n';
}

或者如下:

for (std::filesystem::directory_iterator pos{p};
     pos != std::filesystem::directory_iterator{};
     ++pos) {
    std::cout << pos->path() << '\n';
}
目录迭代器选项

遍历目录时,您可以传递 directory_options 类型的值,这些值列在表 Directory Iterator Options 中。 该类型是位掩码范围的枚举类型,在命名空间 std::filesystem 中定义。

目录选项 作用
none 默认(值 0)
follow_directory_symlink 跟随符号链接(而不是跳过它们)
skip_permission_denied 跳过权限被拒绝的目录

默认情况下,不跟踪符号链接,并跳过你不允许迭代的目录。使用 skip_permission_denied 遍历一个被拒绝的目录时,会产生一个异常。 createfiles.cpp显示了follow_directory_symlink的一个应用:

20.5.1 目录条目

目录迭代器所迭代的元素是std::filesystem::directory_entry类型的。因此,如果一个目录迭代器是有效的,operator*()就会产生该类型。这意味着,基于范围的for循环的正确类型如下。

for (const std::filesystem::directory_entry& e
     : std::filesystem::directory_iterator(p)) {
    std::cout << e.path() << '\n';
}

目录条目既包含路径对象,也包含额外的属性,如硬链接计数、文件状态、文件大小、最后写入时间、是否是符号链接,以及如果是的话,它指向哪里。

注意,这些迭代器是输入迭代器。原因是在一个目录上迭代可能导致不同的结果,因为在任何时候目录条目都可能改变。在并行算法中使用目录迭代器时,必须考虑到这一点。

表中目录条目操作列出了你可以为一个目录条目e调用的操作,它们或多或少是你可以调用的查询文件属性、获取文件状态检查权限和比较路径的操作。

调用 作用
e.path() 产生当前条目的文件系统路径
e.exists() 产生文件是否存在
e.is_regular_file() 产生文件是否存在并且是一个普通文件
e.is_directory() 产生文件是否存在并且是一个目录
e.is_symlink() 得出文件是否存在并且是一个符号链接
e.is_other() 产生文件是否存在,并且既不是常规文件也不是目录也不是象征性链接
e.is_block_file() 产生文件是否存在并且是一个块状特殊文件
e.is_character_file() 产生文件是否存在并且是一个特殊字符的文件。
e.is_fifo() 产生文件是否存在,并且是FIFO或管道文件
e.is_socket() 产生文件是否存在并且是一个套接字。
e.file_size() 产生文件的大小。
e.hard_link_count() 产生硬链接的数量
e.last_write_time() 产生最后一次写到文件的时间点
e.status() 产生文件的状态p
e.symlink_status() 产生文件的状态(在符号链接之后) p
e1 == e2 产生两个入口的路径是否相等
e1 != e2 产生两个入口路径是否不相等
e1 < e2 产生是否一个条目路径小于另一个条目路径的结果
e1 <= e2 产生一个条目路径是否比另一个条目路径小或相等
e1 >= e2 产生一个条目路径是否大于或等于另一个条目路径
e1 > e2 产生一个条目路径是否大于另一个条目路径。
e.assign(p) 用p替换e的路径并更新所有条目属性
e.replace_filename(p) 用p替换e的当前路径的文件名并更新所有条目属性
e.refresh() 更新此条目的所有缓存属性

assign()和replace_filename()调用相应的修改路径操作,但不修改底层文件系统中的文件。

目录条目缓存

我们鼓励实现者缓存这些额外的文件属性,以避免在使用条目时对文件系统的额外访问。然而,实现并不要求缓存数据,这意味着这些通常很便宜的操作可能会变得更昂贵。 因为所有的值通常都被缓存了,这些调用通常是廉价的,因此是成员函数。

for (const auto& e : std::filesystem::directory_iterator{"."})
{
    auto t = e.last_write_time(); // 通常便宜
    ...
}

无论是否有缓存,在一个多用户或多进程的操作系统中,所有这些迭代可能产生不再有效的文件数据。文件内容和大小可能会改变,文件可能被删除或替换(因此,甚至文件类型也可能改变),权限可能被修改。 在这种情况下,你可以要求刷新一个目录项所持有的数据:

for (const auto& e : std::filesystem::directory_iterator{"."})
{
    ...; // 数据数据变旧
    e.refresh(); // 刷新文件的缓存数据
    if (e.exists()) {
        auto t = e.last_write_time();
        ...;
    }
}

另外,你可能总是问当前的情况:

for (const auto& e : std::filesystem::directory_iterator{"."})
{
    ...; // 数据数据变旧
    if (exists(e.path())) {
        auto t = last_write_time(e.path());
        ...
    }
}

20.6 后记

文件系统库在Beman Dawes的领导下作为一个Boost库开发了多年。在2014年,它第一次成为一个正式的测试标准,即文件系统技术规范(见https://wg21.link/n4100)。 随着https://wg21.link/p0218r0,文件系统技术规范被采纳为 由Beman Dawes提出的标准库。对计算相对路径的支持被添加到 由Beman Dawes, Nicolai Josuttis和Jamie Allsop在https://wg21.link/p0219r1。由Beman Dawes在https://wg21.link/p0317r1 中提议增加了几个小的修正。Nicolai Josuttis在https://wg21.link/p0392r0,Jason Liu和Hubert Tong在https: //wg21.link/p0430r2,特别是文件系统小组的成员(Beman Dawes, S. Davis Herring, Nicolai Josuttis, Jason Liu, Billy O’Neal, P.J. Plauger, and Jonathan Wakely)在https://wg21.link/p0492r2。

Licensed under CC BY-NC-SA 4.0