Back

第五部分-专业工具

第五部分 专业工具

这部分介绍了普通应用程序程序员通常不必知道的新语言和库特性。 它可能涵盖基础库、特定模式或特殊环境中的程序员的工具。

27 多态的内存资源 (PMR)

自C++98以来,标准库就支持配置类分配其内部(堆)内存的方式的能力。由于这个原因,标准库中几乎所有分配内存的类型都有一个分配器参数。因此,你可以配置容器、字符串和其他类型分配其内部内存的方式,如果它们需要比堆栈上分配的空间更多的话。 分配这种内存的默认方式是从堆中分配。但是有不同的理由来修改这个默认行为:

  • 你可以使用你自己的方式分配内存,以减少系统调用的数量。
  • 你可以确保分配的内存位于彼此的旁边,以从CPU缓存中获益。
  • 你可以把容器和它们的元素放在可供多进程使用的共享内存中。
  • 你甚至可以重定向这些堆内存调用,以使用早期在堆栈上分配的内存。因此,可以有性能和功能方面的原因。

然而,在C++17之前,使用分配器(右)在很多方面都是既棘手又笨拙的(由于一些缺陷,太过复杂,以及与后向兼容的修改)。 现在,C++17为预定义和用户定义的内存分配方式提供了一个相当容易使用的方法,它可以用于标准类型和用户定义的类型。 基于这个原因,本章将讨论:

  • 使用标准库提供的标准内存资源
  • 定义自定义内存资源
  • 为自定义类型提供内存资源支持

如果没有Pablo Halpern、Arthur O’Dwyer、David Sankel和Jonathan Wakely的大力帮助,本章就不可能完成。一些视频解释了这里提供的功能:

  • 分配器:Pablo Halpern 的好零件
  • C++17 的 std::pmr 有代价 David Sankel
  • 分配器是 Arthur O’Dwyer 的堆句柄

27.1 使用标准内存资源

本节介绍了标准内存资源以及如何使用它们。

27.1.1 激励性的例子

让我们首先比较一下没有和有标准内存资源的内存消耗。

为容器和字符串分配内存

假设在你的程序中,你有一个由一些字符串组成的向量,你用相当长的方式初始化这些字符串字符串:

pmr/pmr0.cpp

#include <iostream>
#include <string>
#include <vector>
#include "../lang/tracknew.hpp"
int main()
{
    TrackNew::reset();
    std::vector<std::string> coll;
    for (int i=0; i < 1000; ++i) {
        coll.emplace_back("just a non-SSO string");
    }
    TrackNew::status();
}

注意,我们使用一个类来跟踪内存分配的数量,该类跟踪所有用以下循环执行的::new调用:

std::vector<std::string> coll;
for (int i=0; i < 1000; ++i) {
    coll.emplace_back("just a non-SSO string");
}

有很多的分配,因为 vector 内部使用内存来存储元素。此外,字符串元素本身可能会在堆上分配内存以保存其当前值(由于经常实现小字符串优化,这通常只在字符串超过15个字符时发生)。 该程序的输出可能类似于以下内容。

1018 allocations for 134,730 bytes

这将意味着为每个元素分配一次,加上 vector 内部的18次分配,因为它18次分配(更多)内存来容纳其元素。 这样的行为可能变得很关键,因为内存(重新)分配需要时间,在某些情况下(如嵌入式系统),分配堆内存可能是个问题。 我们可以要求 vector 在前面预留足够的内存,但一般来说,你无法避免重新分配,除非你知道前面要处理的数据量。如果你不知道到底要处理多少数据,你总是要在避免重新分配和不浪费太多内存之间找到一个折中点。而你至少需要1001个分配(一个分配用于保存向量中的元素,一个用于不使用小字符串优化的每个字符串)。

不为容器分配内存

通过使用多态分配器,我们可以轻松地改善这种情况。首先,我们可以使用std::pmr::vector,让向量在堆栈中分配其内存。 pmr/pmr1.cpp

#include <iostream>
#include <string>
#include <vector>
#include <array>
#include <cstdlib> // for std::byte
#include <memory_resource>
#include "../lang/tracknew.hpp"
int main()
{
    TrackNew::reset();
    // 在堆栈上分配一些内存:
    std::array<std::byte, 200000> buf;
    // 并将其用作vector的初始内存池:
    std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};
    std::pmr::vector<std::string> coll{&pool};
    for (int i=0; i < 1000; ++i) {
        coll.emplace_back("just a non-SSO string");
    }
    TrackNew::status();
}

首先,我们使用新类型 std::byte 在堆栈上分配我们自己的内存:

// 在堆栈上分配一些内存:
std::array<std::byte, 200000> buf;

除了 std::byte 你也可以只使用 char。 然后,我们用这个内存初始化一个 monotonic_buffer_resource,传递它的地址和它的大小:

std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};

最后,我们使用一个std::pmr::vector,它占用了所有分配的内存资源:

std::pmr::vector<std::string> coll{&pool};

这个声明只是以下的一个快捷方式:

std::vector<std::string,
std::pmr::polymorphic_allocator<std::string>> coll{&pool};

也就是说,我们声明向量使用多态分配器,它可以在运行时在不同的内存资源之间切换。monotonic_buffer_resource类派生于memory_resource类,因此可以作为多态分配器的内存资源。因此,通过传递我们的内存资源的地址,我们确保向量使用我们的内存资源作为多态分配器。 如果我们测量这个程序的分配的内存,输出结果可能是。 32000字节的1000次分配 矢量的18次分配不再是在堆上进行的。相反,我们初始化的缓冲区buf被使用。 如果预先分配的200000字节的内存不够用,向量仍然会在堆上分配更多的内存。发生这种情况,是因为monotonic_memory_resource使用了默认的分配器,它用new来分配内存,作为退路。

完全不分配内存

我们甚至可以通过定义std::pmr::vector的元素类型来避免使用堆内存 的元素类型为std::pmr::string:

pmr/pmr2.cpp

#include <iostream>
#include <string>
#include <vector>
#include <array>
#include <cstdlib> // for std::byte
#include <memory_resource>
#include "../lang/tracknew.hpp"
int main()
{
    TrackNew::reset();
    // 在堆栈中分配一些内存:
    std::array<std::byte, 200'000> buf;
    // 并将其作为vector及其字符串的初始内存池。:
    std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};
    std::pmr::vector<std::pmr::string> coll{&pool};
    for (int i=0; i < 1000; ++i) {
        coll.emplace_back("just a non-SSO string");
    }
    TrackNew::status();
}

由于以下的矢量定义:

std::pmr::vector<std::pmr::string> coll{&pool};

程序的输出成为:

0 allocations for 0 bytes

原因是,默认情况下,pmr向量试图将其分配器传播给其元素。当元素不使用多态分配器时,这是不成功的,如std::string类型的情况。然而,通过使用std::pmr::string类型,它是一个使用多态分配器的字符串,传播工作正常。 同样,只有当缓冲区中没有更多的内存时,新的内存才会由堆上的池分配。例如,这种情况可能发生在以下修改中:

for (int i=0; i < 50000; ++i) {
    coll.emplace_back("just a non-SSO string");
}

当输出可能突然变得:

8 allocations for 14777448 bytes
重复使用内存池

我们甚至可以重复使用我们的堆栈内存池。比如说:

pmr/pmr3.cpp

#include <iostream>
#include <string>
#include <vector>
#include <array>
#include <cstdlib> // for std::byte
#include <memory_resource>
#include "../lang/tracknew.hpp"
int main()
{
    // 在堆栈上分配一些内存:
    std::array<std::byte, 200000> buf;
    for (int num : {1000, 2000, 500, 2000, 3000, 50000, 1000}) {
        std::cout << "-- check with " << num << " elements:\n";
        TrackNew::reset();
        std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()};
        std::pmr::vector<std::pmr::string> coll{&pool};
        for (int i=0; i < num; ++i) {
            coll.emplace_back("just a non-SSO string");
        }
        TrackNew::status();
    }
}

这里,在分配了堆栈上的200,000字节后,我们再次使用这块内存,为vector及其元素初始化一个新的资源池:

输出可能变为:

-- check with 1000 elements:
0 allocations for 0 bytes
-- check with 2000 elements:
1 allocations for 300000 bytes
-- check with 500 elements:
0 allocations for 0 bytes
-- check with 2000 elements:
1 allocations for 300000 bytes
-- check with 3000 elements:
2 allocations for 750000 bytes
-- check with 50000 elements:
8 allocations for 14777448 bytes
-- check with 1000 elements:
0 allocations for 0 bytes

每次200000字节足够的时候,我们不需要额外的分配(在这里,它是最多1000个元素的情况)。这200000字节被使用,当内存池被销毁时,可用于下一次迭代。 每次内存被超过时,内存池会在堆上分配额外的内存,当内存池被销毁时,这些内存会被删除。 这样你就可以很容易地对内存池进行编程,你只需分配一次内存(无论是在堆栈还是在堆上),并在每个新任务(服务请求、事件、要处理的数据文件等)中重复使用它。等等)。 我们将在后面讨论更复杂的内存池的例子。

27.1.2 标准内存资源

为了支持多态分配器,C++标准库提供了表标准内存资源中列出的内存。

内存资源 行为
new_delete_resource() 产生一个指向内存资源的指针,调用new和删除
synchronized_pool_resource 类来创建内存资源,碎片少,线程安全。
unsynchronized_pool_resource 创建零散的内存资源的类,不是线程安全的
monotonic_buffer_resource 创建内存资源的类,该类永远不会取消分配,可以选择使用一个传递的缓冲区,不是线程安全的
null_memory_resource() 产生一个指向内存资源的指针,每次分配都会失败

new_delete_resource()和null_memory_resource()是返回全局内存资源指针的函数,它被定义为一个单子。其他三个内存资源是类,你必须创建对象并将这些对象的指针传递给多态分配器。一些使用例子:

std::pmr::string s1{"my string", std::pmr::new_delete_resource()};
std::pmr::synchronized_pool_resource pool1;
std::pmr::string s2{"my string", &pool1};
std::pmr::monotonic_buffer_resource pool2{...};
std::pmr::string s3{"my string", &pool2};

一般来说,内存资源是作为指针传递的。由于这个原因,重要的是你要确保这些指针所指的资源对象在最后一次调用去分配之前一直存在(如果你移动对象并且内存资源可以互换,这可能比你预期的要晚)。

默认的内存资源

多态分配器有一个默认的内存资源,如果没有传递其他的内存资源,就会使用它。表中默认内存资源的操作列出了为其定义的操作。

内存资源 行为
get_default_resource() 产生一个指向当前默认内存资源的指针
set_default_resource(memresPtr) 设置默认的内存资源(传递一个指针),并且产生一个指向前一个的指针

你可以用std::pmr::get_default_resource()来获取当前的默认资源,你可以通过它来初始化一个多态分配器。你可以用std::pmr::set_default_resource()全局性地设置一个不同的默认内存资源。这个资源在任何范围内都作为默认资源使用,直到下一次调用std::pmr::set_default_resource()。比如说

static std::pmr::synchronized_pool_resource myPool;
// 设置myPool为新的默认内存资源。
std::pmr::memory_resource* old = std::pmr::set_default_resource&myPool)。
    ...;
// 恢复旧的默认内存资源为默认值。
std::pmr::set_default_resource(old)

如果你在程序中创建了一个自定义的内存资源,并将其作为默认资源使用,那么在main()中作为静态对象先创建它,这是一个好办法。

int main()
{
    static std::pmr::synchronized_pool_resource myPool;
    ...
}

或者,提供一个全局函数,将你的资源作为静态对象返回。

memory_resource* myResource()
{
    static std::pmr::synchronized_pool_resource myPool;
    return &myPool;
}

返回类型 memory_resource 是所有内存资源的基类。 请注意,以前的默认资源在被替换时可能仍然被使用。除非你知道(并确保)情况并非如此,例如,这意味着没有使用该资源创建静态对象,否则你应该让你的资源尽可能长地存活(同样,最好是在main()的开头创建,以便它最后被销毁)。

27.1.3 标准内存资源的详细介绍

让我们详细讨论一下不同的标准内存资源。

new_delete_resource()

new_delete_resource()是默认的内存资源。它是由get_default_resource()返回的。 返回,除非你通过调用set_default_resource()定义了一个不同的默认内存资源。 它处理分配,就像使用默认分配器时的处理方式。

  • 每次分配都调用new
  • 每一次去分配都调用delete

然而,请注意,具有这种内存资源的多态分配器不能与默认分配器互换,因为它们只是具有不同的类型。由于这个原因

std::string s{"my string with some value"}
std::pmr::string ps{std::move(s), std::pmr::new_delete_resource()}; // copies

将不会移动(将分配给s的内存传递给ps)。相反,s的内存将被复制到ps的新内存中,用new分配。

(un)synchronized_pool_resource

synchronized_pool_resource和unsynchronized_pool_resource是内存资源的类,它们试图将所有内存定位在彼此附近。因此,它们迫使内存的碎片化很少。 不同的是,synchronized_pool_resource是线程安全的(这需要花费更多的性能),而unsynchronized_pool_resource则不是。因此,如果你知道这个池子的内存只由一个线程处理(或者(去)分配是同步的),你应该选择unsynchronized_pool_resource。 这两个类仍然使用底层内存资源来实际执行分配和去分配。它们只是作为一个封装器,确保这些分配能更好地被集群起来。因此:

std::pmr::synchronized_pool_resource myPool

std::pmr::synchronized_pool_resource myPool{std::pmr::get_default_resource()}

此外,当池子被销毁时,它们会删除所有的内存。 这些池子的一个主要应用是确保基于节点的容器中的元素位于彼此的旁边。这也可能大大增加了容器的性能,因为这样CPU缓存就会把元素装在一起的缓存行中。其效果是,当你访问一个元素后,访问其他元素就变得非常快,因为它们已经在缓存中了。 然而,你应该衡量一下,因为这取决于内存资源的实现。例如,如果内存资源使用mutex来同步内存访问,性能可能会明显变差。 让我们用一个简单的例子来看看这个效果。下面的程序创建了一个地图,它将积分值映射到字符串。

pmr/pmrsync0.cpp

#include <iostream>
#include <string>
#include <map>
int main()
{
    std::map<long, std::string> coll;
    for (int i=0; i<10; ++i) {
        std::string s{"Customer" + std::to_string(i)};
        coll.emplace(i, s);
    }
    // print element distances:
    for (const auto& elem : coll) {
        static long long lastVal = 0;
        long long val = reinterpret_cast<long long>(&elem);
        std::cout << "diff: " << (val-lastVal) << '\n';
        lastVal = val;
    }
}

这个数据结构是一个平衡的二叉树,每个节点都执行自己的分配来存储一个元素。因此,对每个元素进行分配,这些分配默认当前在堆上分配内存(使用标准默认分配器)。 为了看清效果,程序打印了元素地址之间的距离,同时迭代了 它们之间的距离。例如,一个输出可能看起来如下:

diff: 1777277585312
diff: -320
diff: 60816
diff: 1120
diff: -400
diff: 80
diff: -2080
diff: -1120
diff: 2720
diff: -3040

这些元素不是彼此相邻的。我们有60,000字节的距离,10个元素的大小约为24字节。如果在元素的分配之间分配了其他的内存,这种碎片化就会变得更严重。 现在让我们用多态分配器运行这个程序,使用synchronized_pool_resource:

pmr/pmrsync1.cpp

#include <iostream>
#include <string>
#include <map>
#include <memory_resource>
int main()
{
    std::pmr::synchronized_pool_resource pool;
    std::pmr::map<long, std::pmr::string> coll{&pool};
    for (int i=0; i<10; ++i) {
        std::string s{"Customer" + std::to_string(i)};
        coll.emplace(i, s);
    }
    // 打印元素距离:
    for (const auto& elem : coll) {
        static long long lastVal = 0;
        long long val = reinterpret_cast<long long>(&elem);
        std::cout << "diff: " << (val-lastVal) << '\n';
        lastVal = val;
    }
}

正如你所看到的,我们简单地创建了资源并将其作为参数传递给容器的构造函数:

std::pmr::synchronized_pool_resource pool;
std::pmr::map<long, std::pmr::string> coll{&pool};

例如,现在的输出看起来如下:

diff: 2548552461600
diff: 128
diff: 128
diff: 105216
diff: 128
diff: 128
diff: 128
diff: 128
diff: 128
diff: 128

正如你所看到的,这些元素现在都位于彼此附近。但是,它们仍然没有位于一个内存块中。当内存池发现第一块内存不足以容纳所有的元素时,它为更多的元素分配更多的内存。因此,我们分配的内存越多,内存块就越大,这样就有更多的元素被放在彼此附近。这个算法的细节是由实现定义的。

当然,这个输出是特殊的,因为我们按照元素在容器内的排序来创建它们。因此,在实践中,如果你用随机值创建对象,这些元素将不会一个接一个地顺序定位(在不同的内存块中)。然而,它们仍然位于彼此之间,这对于处理这个容器中的元素时的良好性能是很重要的。

还请注意,我们不看元素值的内存是如何安排的。在这里,通常小字符串的优化导致对元素没有分配内存。但是一旦我们放大了字符串值,池子也会尝试将这些字符串放在一起。请注意,池为不同的分配大小管理不同的内存块。也就是说,在一般情况下,元素之间是相互定位的,相同字符串大小的元素的字符串值是相互靠近的。

monotonic_buffer_resource

monotonic_buffer_resource类也提供了将所有内存放在大块内存中的能力。然而,它还有另外两种能力。

  • 你可以传递一个缓冲区来作为内存使用。这一点,特别是可以在堆栈上分配内存。
  • 内存资源永远不会被取消分配,直到整个资源被取消分配。也就是说,它也试图避免碎片化。而且,它的速度超快,因为去分配是一个无操作的过程,你跳过了追踪去分配的内存以便进一步使用。每当有分配内存的请求时,它只是返回下一块空闲的内存,直到所有的内存都用完。

请注意,对象仍然是被销毁的。只有它们的内存没有被释放。如果你删除了对象,这通常会删除它们的内存,但删除并没有影响。 如果你没有删除对象,或者你有足够的内存可以浪费(不重复使用之前被其他对象使用的内存),你应该更喜欢这种资源。 我们已经在第一个激励性的例子中看到了monotonic_buffer_resource的应用,我们把在堆栈中分配的内存传递给了池。

std::array<std::byte, 200000> buf
std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size()}

你也可以使用这个池子让任何内存资源跳过去分配(可以选择传递一个初始大小)。默认情况下,这将适用于默认的内存资源,默认是new_delete_resource()。就是说,用

//使用默认的内存资源,但只要池子还在,就跳过去分配。
{
    std::pmr::monotonic_buffer_resource pool
        std::pmr::vector<std::pmr::string> coll{&pool};
    for (int i=0; i < 100; ++i) {
        coll.emplace_back("just a non-SSO string")
    }
    coll.clear(); // 销毁但不去分配
} // 取消分配所有分配的内存

带有循环的内部块将不时为向量及其元素分配内存。由于我们使用的是一个池子,分配的内存被组合成块状。例如,这可能导致14次分配。通过首先调用coll.reserve(100),这通常会变成只有两个分配。 正如所写的那样,只要池子存在,就不做任何取消分配。因此,如果向量的创建和使用是在一个循环中完成的,池子分配的内存会不断增加。 monotonic_buffer_resource也允许我们传递一个初始大小,然后它使用其第一次分配的最小大小(当第一次内存请求发生时进行)。此外,你可以定义它使用哪个内存资源来执行分配。这使得我们可以通过连锁内存资源来提供更复杂的内存资源。 考虑一下下面的例子:

{
    // 分配大块内存(从10k开始),而不去分配:
    std::pmr::monotonic_buffer_resource keepAllocatedPool{10000};
    std::pmr::synchronized_pool_resource pool{&keepAllocatedPool};
    for (int j=0; j < 100; ++j) {
        std::pmr::vector<std::pmr::string> coll{&pool};
        for (int i=0; i < 100; ++i) {
            coll.emplace_back("just a non-SSO string");
        }
    } // 去分配的数据被送回池中,但没有解除分配。
    // 到目前为止,没有任何东西被释放
} // 释放所有分配的内存

通过这段代码,我们首先为我们所有的内存创建一个池,只要它活着就永远不会去分配,初始化时开始分配10000字节(使用默认的内存资源分配)。

std::pmr::monotonic_buffer_resource keepAllocatedPool{10000}

然后我们创建另一个池,使用这个非去分配的池来分配内存块。

std::pmr::synchronized_pool_resource pool{&keepAllocatedPool}

综合的效果是,我们的所有内存都有一个池子,它从10000字节开始分配,如果有必要,可以分配更多的内存,而且几乎没有碎片,可以被所有使用这个池子的pmr对象使用。 当keepAllocatedPool超出范围时,所分配的内存(可能是最初的10000字节加上一些更大的内存块的额外分配)都将被释放。

这里到底发生了什么,将在后面我们扩展这个例子以追踪这个嵌套池的所有分配时进行演示。

null_memory_resource()

null_memory_resource()处理分配的方式是每次分配都会抛出一个bad_alloc异常。 最重要的应用是确保使用堆栈上分配的内存的内存池不会突然在堆上分配内存,如果它需要更多的话。 考虑一下下面的例子:

pmr/pmrnull.cpp

#include <iostream>
#include <string>
#include <unordered_map>
#include <array>
#include <cstddef> // for std::byte
#include <memory_resource>
int main()
{
    // 使用堆栈上的内存而不在堆上进行回退:
    std::array<std::byte, 200000> buf;
    std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size(),
                                             std::pmr::null_memory_resource()};
    // 并分配太多内存:
    std::pmr::unordered_map<long, std::pmr::string> coll {&pool};
    try {
        for (int i=0; i<buf.size(); ++i) {
            std::string s{"Customer" + std::to_string(i)};
            coll.emplace(i, s);
        }
    }
    catch (const std::bad_alloc& e) {
        std::cerr << "BAD ALLOC EXCEPTION: " << e.what() << '\n';
    }
    std::cout << "size: " << coll.size() << '\n';
}

我们在堆栈中分配内存,并将其传递给单调的缓冲区作为内存资源:

std::array<std::byte, 200000> buf;
std::pmr::monotonic_buffer_resource pool{buf.data(), buf.size(),
                                         std::pmr::null_memory_resource()};

通过传递null_memory_resource()作为后备内存资源,我们确保任何分配更多内存的尝试都会抛出一个异常,而不是在堆上分配内存。 其结果是,程序迟早会结束,例如,输出结果如下:

BAD ALLOC EXCEPTION: bad allocation
size: 2048

当堆内存分配不是一种选择时,这有助于获得合理的反馈,而不是碰上你必须避免的行为。

27.2 定义自定义内存资源

你可以提供你自定义的内存资源。为此,你只需要

  • 派生自std::pmr::memory_resource
  • 实现私有成员
    • do_allocate() 来分配内存
    • do_deallocate()来删除内存
    • do_is_equal()定义你的类型是否以及何时可以与另一个内存资源对象交换分配的内存

下面是一个完整的例子,它仅仅允许我们跟踪任何 其他的内存资源:

pmr/tracker.hpp

#include <iostream>
#include <string>
#include <memory_resource>
class Tracker : public std::pmr::memory_resource
{
    private:
    std::pmr::memory_resource* upstream; // 包装的内存资源
    std::string prefix{};
    public:
    // 我们包装传递的或默认的资源:
    explicit Tracker(std::pmr::memory_resource* us
                     = std::pmr::get_default_resource())
        : upstream{us} {
        }
    explicit Tracker(std::string p,
                     std::pmr::memory_resource* us
                     = std::pmr::get_default_resource())
        : prefix{std::move(p)}, upstream{us} {
        }
    private:
    void* do_allocate(size_t bytes, size_t alignment) override {
        std::cout << prefix << "allocate " << bytes << " Bytes\n";
        void* ret = upstream->allocate(bytes, alignment);
        return ret;
    }
    void do_deallocate(void* ptr, size_t bytes, size_t alignment) override {
        std::cout << prefix << "deallocate " << bytes << " Bytes\n";
        upstream->deallocate(ptr, bytes, alignment);
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept
        override {
        // same object?:
        if (this == &other) return true;
        // 相同的类型和前缀以及相等的上游?
        auto op = dynamic_cast<const Tracker*>(&other);
        return op != nullptr && op->prefix == prefix
            && upstream->is_equal(other);
    }
};

像通常的智能内存资源一样,我们支持传递另一个内存资源(通常称为上游)来包裹它或将其作为后备资源。此外,我们还可以传递一个可选的前缀。在每次分配和去分配时,我们都会用可选的前缀来追踪这个调用。 我们唯一需要实现的其他函数是do_is_equal(),它定义了两个分配器何时可以互换(也就是说,一个多态内存资源对象是否以及何时可以去分配另一个分配的内存)。在这种情况下,我们简单地说,只要前缀相同,任何这种类型的对象都可以取消从任何其他这种类型的对象分配的内存:

bool do_is_equal(const std::pmr::memory_resource& other) const noexcept
    override {
    // same object?:
    if (this == &other) return true;
    // 相同的类型和前缀以及相等的上游?:
    auto op = dynamic_cast<const Tracker*>(&other);
    return op != nullptr && op->prefix == prefix
        && upstream->is_equal(other);
}

第一个比较的存在只是为了跳过其他更昂贵的与下限的比较。如果我们不使用相同的跟踪器,我们要求另一个内存资源也是具有相同前缀的跟踪器(相同意义上的相同)和可交换的底层内存资源。否则,如果我们使用不同底层内存资源的跟踪器,应用程序会认为从完全不同的内存资源中分配的内存是可以取消的。 让我们使用这个跟踪器来理解之前演示的嵌套池的行为,以分配大块的内存而不去分配:

pmr/tracker.cpp

#include "tracker.hpp"
#include <iostream>
#include <string>
#include <vector>
#include <memory_resource>
int main()
{
    {
        // 追踪分配内存块(从10k开始)而不去分配。:
        Tracker track1{"keeppool:"};
        std::pmr::monotonic_buffer_resource keeppool{10000, &track1};
        {
            Tracker track2{" syncpool:", &keeppool};
            std::pmr::synchronized_pool_resource pool{&track2};
            for (int j=0; j < 100; ++j) {
                std::pmr::vector<std::pmr::string> coll{&pool};
                coll.reserve(100);
                for (int i=0; i < 100; ++i) {
                    coll.emplace_back("just a non-SSO string");
                }
                if (j==2) std::cout << "--- third iteration done\n";
            } // 去分配的数据被送回池子里,但没有去分配。
            // 到目前为止,没有分配到任何东西
            std::cout << "--- leave scope of pool\n";
        }
        std::cout << "--- leave scope of keeppool\n";
    } // 删除所有已分配的内存
}

输出可能如下所示:

syncpool:allocate 48 Bytes
keeppool:allocate 10000 Bytes
syncpool:allocate 16440 Bytes
keeppool:allocate 16464 Bytes
syncpool:allocate 96 Bytes
keeppool:allocate 24696 Bytes
syncpool:deallocate 48 Bytes
syncpool:allocate 312 Bytes
syncpool:allocate 568 Bytes
syncpool:allocate 1080 Bytes
syncpool:allocate 2104 Bytes
syncpool:allocate 4152 Bytes
syncpool:deallocate 312 Bytes
syncpool:deallocate 568 Bytes
syncpool:deallocate 1080 Bytes
syncpool:deallocate 2104 Bytes
syncpool:allocate 8248 Bytes
syncpool:deallocate 4152 Bytes
--- third iteration done
--- leave scope of pool
syncpool:deallocate 8248 Bytes
syncpool:deallocate 16440 Bytes
syncpool:deallocate 96 Bytes
--- leave scope of keeppool
keeppool:deallocate 24696 Bytes
keeppool:deallocate 16464 Bytes
keeppool:deallocate 10000 Bytes

输出显示了以下情况。

  • 随着一个对象的第一次分配,syncpool分配了48个字节,这导致keeppool分配其初始的10,000字节。这10,000字节是在堆上分配的,使用的是keeppool初始化时get_default_resource()的资源。
  • 后来的对象会分配和删除内存,这使得syncpool不时地分配更多的内存块,但也会删除内存块。如果syncpool有效地分配了比keeppool所分配的更多的内存,keeppool再次从堆中分配更多的内存。也就是说,只有keeppool的分配成为(相当昂贵的)系统调用。
  • 通过对第三个迭代结束时的额外追踪,你可以看到所有这些分配都发生在外循环的前三个迭代中。然后,(重新)使用的内存量是稳定的。因此,剩下的97次迭代根本就没有从操作系统中分配任何内存。
  • 即使syncpool已经分配了所有的内存,keeppool也没有分配任何内存。
  • 只有当keeppool被销毁时,分配的六块内存才会真正地被调用::delete(或者当keeppool被初始化时用set_default_resource()定义的任何东西)而被取消分配。

如果我们在这个程序中引入第三个跟踪器,我们也可以跟踪对象从syncpool分配和删除内存的时间:

// 跟踪每个调用,同步池中的效果,以及单池中的效果:
Tracker track1{"keeppool:"};
std::pmr::monotonic_buffer_resource keepAllocatedPool{10000, &track1};
Tracker track2{" syncpool:", &keepAllocatedPool};
std::pmr::synchronized_pool_resource syncPool{&track2};
Tracker track3{" objects:", &syncPool};
...
std::pmr::vector<std::pmr::string> coll{&track3};
27.2.1 内存资源的平等性

让我们谈谈 do_is_equal(),该函数定义两个内存资源何时可互换。 这个功能需要比最初看起来更多的思考。 在我们的跟踪器中,我们定义了分配器是可互换的,如果它们都是 Tracker 类型并且使用相同的前缀:

bool do_is_equal(const std::pmr::memory_resource& other) const noexcept
    override {
    // same object?:
    if (this == &other) return true;
    // same type and prefix?:
    auto op = dynamic_cast<const Tracker*>(&other);
    return op != nullptr && op->prefix == prefix;
}

这具有以下效果:

Tracker track1{"track1:"};
Tracker track2{"track2:"};
std::pmr::string s1{"more than 15 chars", &track1}; // 用 track1 分配
std::pmr::string s2{std::move(s1), &track1}; // 移动(相同的跟踪器)
std::pmr::string s3{std::move(s2), &track2}; // 拷贝(不同的前缀)
std::pmr::string s4{std::move(s3)}; // 移动(复制分配器)
std::string s5{std::move(s4)}; // 移动(其他分配器)

也就是说,只有当源码和目的码具有可互换的分配器时,移动才会作为移动执行。对于多态分配类型,当使用移动构造函数时就是这种情况(新对象复制了分配器)。但是如果需要一个不可互换的分配器(如这里的跟踪器有不同的前缀),或者使用不同的分配器类型(如移动到std::string,它使用默认的分配器),内存会被复制。因此,互换性会影响移动的性能。 如果我们让所有Tracker类型的内存资源都可以互换,只检查类型:

bool do_is_equal(const std::pmr::memory_resource& other) const noexcept
    override {
    // 如果所有 Tracker 具有相同的类型,则它们都可以互换:
    return this == &other || dynamic_cast<const Tracker*>(&other) != nullptr;
}

我们会得到以下行为:

Tracker track1{"track1:"};
Tracker track2{"track2:"};
std::pmr::string s1{"more than 15 chars", &track1}; // 用 track1 分配
std::pmr::string s2{std::move(s1), &track1}; // 移动(相同的跟踪器类型)
std::pmr::string s3{std::move(s2), &track2}; // 移动 (same tracker type)
std::pmr::string s4{std::move(s3)}; // 移动 (allocator copied)
std::string s5{std::move(s4)}; // 拷贝 (other allocator)

如您所见,效果是track1分配的内存通过s3传递给s4,两者都使用track2,因此我们得到:

track1:allocate 32 Bytes
track2:deallocate 32 Bytes

如果我们的内存资源不会有不同的状态(即不会有前缀),这将是一个很好的实现,因为这可以提高移动的性能。 所以,使内存资源可互换是值得的,因为较少的移动会转换为拷贝。但你不应该让它们的互换性超过其目的需要。

27.3 为自定义类型提供内存资源支持

在我们介绍了标准内存资源和用户定义的内存资源之后,还有一个问题。我们怎样才能使我们的自定义类型具有多态分配器意识,从而使它们像pmr::string一样作为pmr容器的一个元素,使用其分配器进行分配。

27.3.1 PMR类型的定义

支持多态分配器的方法出奇的简单,只要对所有需要堆内存的数据使用pmr成员就可以了。你必须这样做。

  • 将allocator_type定义为多态分配器的公共成员
  • 为所有构造函数添加重载,使其将分配器作为附加参数(包括复制和移动构造函数)。
  • 让没有分配器参数的初始化构造函数使用allocator_type(如果实现的话,这不适用于复制和移动构造函数)。

下面是第一个例子:

pmr/pmrcustomer.hpp

#include <string>
#include <memory_resource>
// 一个多态分配器感知类型 客户
// - 分配器存储在字符串成员中
class PmrCustomer
{
    private:
    std::pmr::string name; // 也用于存储分配器
    public:
    using allocator_type = std::pmr::polymorphic_allocator<char>;
    // 初始化构造函数 constructor(s):
    PmrCustomer(std::pmr::string n, allocator_type alloc = {})
        : name{std::move(n), alloc} {
        }
    // 使用分配器复制/移动:
    PmrCustomer(const PmrCustomer& c, allocator_type alloc)
        : name{c.name, alloc} {
        }
    PmrCustomer(PmrCustomer&& c, allocator_type alloc)
        : name{std::move(c.name), alloc} {
        }
    // setters/getters:
    void setName(std::pmr::string s) {
        name = std::move(s);
    }
    std::pmr::string getName() const {
        return name;
    }
    std::string getNameAsString() const {
        return std::string{name};
    }
};

首先注意,我们使用一个pmr字符串作为成员。这不仅持有值(这里是名称),还持有当前使用的分配器:

std::pmr::string name; // 也用于存储分配器

然后,我们必须指定这个类型支持多态分配器,这可以通过提供一个相应的allocator_type类型的声明来简单完成:

using allocator_type = std::pmr::polymorphic_allocator<char>;

传递给polymorphic_allocator的类型并不重要(当它被使用时,分配器会被反弹到必要的类型)。例如,你也可以在那里使用std::byte。4 另外,你也可以使用字符串成员的allocator_type:

using allocator_type = decltype(name)::allocator_type;

接下来我们定义通常的构造函数,增加一个可选的分配器参数。

PmrCustomer(std::pmr::string n, allocator_type alloc = {})
    : name{std::move(n), alloc} {
    }

你可以考虑将这样的构造函数声明为显式的。至少如果你有一个默认的构造函数,你应该这样做以避免从分配器到客户的隐式转换。

explicit PmrCustomer(allocator_type alloc = {})
    : name{alloc} {
    }

然后,我们必须提供要求特定分配器的复制和移动操作。这是pmr容器的主要接口,确保其元素使用容器的分配器。

PmrCustomer(const PmrCustomer& c, allocator_type alloc)
    : name{c.name, alloc} {
    }
PmrCustomer(PmrCustomer&& c, allocator_type alloc)
    : name{std::move(c.name), alloc} {
    }

注意,这两个都不是noexcept,因为如果所需的分配器allocator不能互换,即使是move构造函数也可能要复制一个被传递的客户。 最后,我们实现必要的setters和getters,它们通常是。

void setName(std::pmr::string s) {
    name = std::move(s);
}
std::pmr::string getName() const {
    return name;
}

还有一个getter,getNameAsString(),我们提供这个getter来廉价地将名字返回为 std::string。我们将在后面讨论它。目前,你也可以不使用它。

27.3.2 PMR类型的用法

有了上面 PmrCustomer 的定义,我们就可以在 pmr 容器中使用这个类型了。 例如:

pmr/pmrcustomer1.cpp

#include "pmrcustomer.hpp"
#include "tracker.hpp"
#include <vector>
int main()
{
    Tracker tracker;
    std::pmr::vector<PmrCustomer> coll(&tracker);
    coll.reserve(100); // 用跟踪器分配
    PmrCustomer c1{"Peter, Paul & Mary"}; // 用 get_default_resource() 分配
    coll.push_back(c1); // 使用vector分配器(跟踪器)进行分配
    coll.push_back(std::move(c1)); // 副本(分配器不可互换)
    for (const auto& cust : coll) {
        std::cout << cust.getName() << '\n';
    }
}

为了让人们看到所发生的事情,我们使用追踪器来追踪所有的分配和取消分配:

Tracker tracker;
std::pmr::vector<PmrCustomer> coll(&tracker);

当我们为100个元素保留内存时,vector使用我们的跟踪器来分配必要的数据:

coll.reserve(100); //用跟踪器分配

当我们创建消费者时,不使用跟踪器:

PmrCustomer c1{"Peter, Paul & Mary"}; // 用 get_default_resource() 分配

然而,当我们把customer的副本推到vector中时,vector会确保元素也都使用其多态分配器。由于这个原因,PmrCustomer的扩展拷贝构造函数被调用,其第二个参数是vector分配器,这样元素就被初始化了 与跟踪器一起初始化。

std::pmr::vector<PmrCustomer> coll(&tracker);
...
PmrCustomer c1{"Peter, Paul & Mary"}; // 用 get_default_resource() 分配
coll.push_back(c1); // 使用vector分配器(跟踪器)进行分配

如果我们把customer 移到vector中,也会发生同样的情况,因为vector的分配器(跟踪器)和客户的分配器(使用默认资源)是不能互换的:

std::pmr::vector<PmrCustomer> coll(&tracker);
...
PmrCustomer c1{"Peter, Paul & Mary"}; // 用 get_default_resource() 分配
...
coll.push_back(std::move(c1)); // 副本(分配器不可互换)

如果我们还使用跟踪器初始化客户,则移动将起作用:

std::pmr::vector<PmrCustomer> coll(&tracker);
...
PmrCustomer c1{"Peter, Paul & Mary", &tracker}; // 用跟踪器分配
...
coll.push_back(std::move(c1)); // 移动(相同的分配器)

如果我们根本不使用任何跟踪器,情况也是如此:

std::pmr::vector<PmrCustomer> coll; // 使用默认资源分配
...
PmrCustomer c1{"Peter, Paul & Mary"}; // 使用默认资源分配
...
coll.push_back(std::move(c1)); // 移动(相同的分配器)
27.3.3 处理不同的类型

虽然将 PmrCustomer 与 pmr 类型一起使用变得非常好,但我们有一个问题:通常,程序使用 std::string 类型的字符串。 那么我们如何处理同时使用 std::string 和 std::pmr::string 呢? 首先,不同字符串类型之间存在显式但没有隐式转换:

std::string s;
std::pmr::string t1{s}; // OK
std::pmr::string t2 = s; // ERROR
s = t1; // ERROR
s = std::string(t1); // OK

支持显式转换,因为任何字符串都会隐式转换为 std::string_view,它可以显式转换为任何字符串类型。 进一步降低成本,但后者需要分配内存(假设小字符串优化不适用)。 在我们的示例中,这意味着:

std::string s{"Paul Kalkbrenner"};
PmrCustomer c1 = s; // 错误:没有隐式转换
PmrCustomer c2{s}; // 错误:没有隐式转换
PmrCustomer c3{std::pmr::string{s}}; // OK(隐式将 s 转换为 string_view)

我们可能想提供额外的构造函数,但不提供它们的好处是程序员被迫实现昂贵的转换。 此外,如果您为不同的字符串类型(std::string 和 std::pmr::string)重载,则会产生额外的歧义(例如,采用 string_view 或字符串文字),因此需要更多的重载。 无论如何,getter 只能返回一种类型(因为我们不能只重载不同的返回类型)。 因此,我们只能提供一个 getter,它通常应该返回 API 的“本机”类型(这里是 std::pmr::string)。 这意味着,如果我们返回一个 std::pmr::string 并且需要名称为 std::string,我们再次需要显式转换:

PmrCustomer c4{"Mr. Paul Kalkbrenner"}; // OK:使用默认资源分配
std::string s1 = c4.getName(); // 错误:没有隐式转换
std::string s2 = std::string{c4.getName()}; // OOPS:两个分配

这不仅不太方便,这也是一个性能问题,因为在最后一条语句中发生了两次分配:

  • 首先我们为返回值分配内存,然后
  • 那么从 std::pmr::string 类型到 std::string 的转换需要另一个分配。

出于这个原因,提供一个额外的 getNameAsString() 直接创建并返回请求的类型可能是个好主意:

std::string s3 = c4.getNameAsString(); // OK:一次分配

27.4 后记

多态分配器首先由 Pablo Halpern 在 https://wg21.link/n3525 中提出。 这 采用 Pablo Halpern 在 https://wg21.link/n3916 中提出的方法成为图书馆基础 TS 的一部分。 该方法与 Beman Dawes 和 Alisdair Meredith 在 https://wg21.link/p0220r1 中提出的 C++17 的其他组件一起采用。

28 对齐数据上的new和delete

从C++11开始,你可以指定过度对齐的类型,通过使用alignas指定器,拥有比默认对齐方式更大的对齐方式。比如说:

struct alignas(32) MyType32 {
    int i;
    char c;
    std::string s[4];
};
MyType32 val1; // 32 字节对齐
alignas(64) MyType32 val2; // 64 字节对齐

请注意,对齐值必须是 2 的幂,并且指定小于该类型默认对齐的任何值都是错误的。 但是,过度对齐数据的动态/堆分配在 C++11 和 C++14 中未正确处理。 默认情况下,对过度对齐的类型使用 operator new 会忽略请求的对齐方式,因此通常 63 字节对齐的类型可能例如仅 8 字节或 16 字节对齐。 C++17 弥补了这一差距。 新行为的结果是提供了带有对齐参数的新重载,以便能够为过度对齐的数据提供您自己的 operator new 实现。

28.1 使用新的对齐方式

通过使用过度对齐的类型,例如:

struct alignas(32) MyType32 {
    int i;
    char c;
    std::string s[4];
};

一个新的表达式现在可以保证所请求的堆内存是按要求对齐的(前提是 支持过度对齐):

MyType32* p = new MyType32; // 因为 C++17 保证是 32 字节对齐的
...

在C++17之前,请求不保证是32字节对齐的。 像往常一样,没有任何初始化的值,对象是默认初始化的,这意味着可用的构造器被调用,但基本类型的(子)对象有一个未定义的值。由于这个原因,你最好使用带大括号的列表初始化,以确保(子)对象要么有其默认值或0/false/nullptr:

MyType32* p = new MyType32{}; // 对齐和初始化
28.1.1 不同的动态/堆栈内存领域

请注意,对对齐内存的请求可能会导致调用从一个不相连的内存分配机制中获取内存。由于这个原因,对对齐内存的请求可能需要一个特定的相应请求来取消对齐数据。有可能内存是用C11函数aligned_alloc()分配的(现在在C++17中也可用)。在这种情况下,用free()去分配还是可以的,这样与用malloc()分配的内存相比就没有什么区别。 然而,对于平台来说,new和delete的其他实现是允许的,这就导致了必须用不同的内部函数去分配默认对齐的和超对齐的数据的要求。例如,在Windows上通常使用_aligned_malloc(),这就要求使用_aligned_free()作为对应。 与C标准相比,C++标准尊重这种情况,因此在概念上假定有两个互不相干的、不可操作的内存区域,一个用于默认对齐的数据,一个用于超对齐的数据。大多数情况下,编译器知道如何正确地处理这个问题:

std::string* p1 = new std::string; // 使用默认对齐的内存操作
MyType32* p2 = new MyType32; // 使用过度对齐的内存操作
...
delete p1; // 使用默认对齐的内存操作
delete p2; // 使用过度对齐的内存操作

但有时程序员必须做正确的事情,正如我们将在本章的其余部分中看到的那样。

28.1.2 用new表达式传递对齐方式

还有一种方法是为特定的新调用请求特定的过度对齐。比如说:

#include <new> // for align_val_t
...
std::string* p = new(std::align_val_t{64}) std::string; // 64 字节对齐
MyType32* p = new(std::align_val_t{64}) MyType32{}; // 64 字节对齐
...

std::align_val_t 类型在标头 中定义如下:

namespace std {
    enum class align_val_t : size_t {
    };
}

它被规定为现在能够将对齐请求传递给运算符new()的相应实现。请记住,在C++中运算符new()可以用不同的方式实现。

  • 作为一个全局函数(默认提供了不同的重载,可以由程序员替换)。
  • 作为特定类型的实现,可以由程序员提供,并具有比全局重载更高的 优先级高于全局重载。

然而,这是第一个例子,必须特别注意正确处理不同的动态内存领域,因为当用新的表达式指定对齐方式时,编译器不能使用类型来知道是否以及哪种对齐方式被请求。程序员必须指定调用哪个删除操作。 不幸的是,没有一个删除操作符,你可以传递一个额外的参数,你必须直接调用相应的操作符delete(),这意味着你必须知道多个重载中的哪一个被实现。事实上,在这个例子中,对于一个T类型的对象,可以调用以下函数中的一个 类型的对象,可以调用以下函数之一:

void T::operator delete(void* ptr, std::size_t size, std::align_val_t align);
void T::operator delete(void* ptr, std::align_val_t align);
void T::operator delete(void* ptr, std::size_t size);
void T::operator delete(void* ptr);
void ::operator delete(void* ptr, std::size_t size, std::align_val_t align);
void ::operator delete(void* ptr, std::align_val_t align);
void ::operator delete(void* ptr, std::size_t size);
void ::operator delete(void* ptr);

是的,就是这么复杂,我将在后面详细解释。目前,请使用三个选项中的一个。

  1. 不要在新的表达式中直接使用过度对齐。
  2. 提供操作者new()和操作者delete()的实现,使用相同的内存区域(这样调用delete总是可以的)。
  3. 提供与运算符new()相匹配的运算符delete()的特定类型实现,并直接调用它们,而不是使用delete表达式。

注意,你不能使用类型定义或使用声明来代替:

using MyType64 = alignas(64) MyType32; // ERROR
typedef alignas(64) MyType32 MyType64; // ERROR
...
MyType64* p = new MyType64; // 因此不可能

原因是typedef或using声明只是原始类型的一个新名称/别名,这里所要求的是一个不同的类型,遵循不同的对齐规则。 如果你想调用一个对齐的new,获得nullptr作为返回值,而不是抛出 std::bad_alloc,你可以这样做:

// 分配一个 64 字节对齐的字符串(如果没有,则为 nullptr):
std::string* p = new(std::align_val_t{64}, std::nothrow) std::string;
if (p != nullptr) {
    ...
}

28.2 为对齐的内存实现操作符new()

在C++中,当new和delete被调用时,你可以提供你自己的分配和删除内存的实现。这种机制现在也支持传递一个对齐参数。

28.2.1 在C++17之前实现对齐分配

在全局范围内,C++提供了操作符new()和操作符delete()的重载,除非定义了特定类型的实现,否则会使用这些操作符。如果存在这些操作符的特定类型的实现,就会使用它们。请注意,有一个特定类型的操作符new(),就不能使用该类型的任何全局操作符new()实现(同样适用于delete、new[]和 delete[])。

也就是说,每次为类型 T 调用 new 时,都会调用特定类型的 T::operator new() 或(如果不存在)全局 ::operator new() 的相应调用:

auto p = new T; // 尝试调用特定类型的运算符 new()(如果有)
// 如果没有尝试调用全局 ::operator new()

同样地,每次你为一个类型T调用delete时,都会相应地调用特定类型的T::operator delete()或全局的::operator delete()。如果数组被分配/去分配,相应的特定类型或全局操作符operator new和operator delete被调用。 在C++17之前,要求的对齐方式不会自动传递给这些函数,默认机制分配动态内存时不会考虑对齐方式。一个过度对齐的类型总是需要自己实现operator new()和operator delete()才能在动态内存上正确对齐。更糟糕的是,没有可移植的方法来执行对过度对齐的动态内存的请求。 因此,举例来说,你必须定义如下的东西:

lang/alignednew11.hpp

#include <cstddef> // for std::size_t
#include <string>
#if __STDC_VERSION >= 201112L
#include <stdlib.h> // for aligned_alloc()
#else
#include <malloc.h> // for _aligned_malloc() or memalign()
#endif
struct alignas(32) MyType32 {
    int i;
    char c;
    std::string s[4];
    ...;
    static void* operator new (std::size_t size) {
        // 为要求的排列方式分配内存:
        #if __STDC_VERSION >= 201112L
        // use API of C11:
        return aligned_alloc(alignof(MyType32), size);
        #else
        #ifdef _MSC_VER
        // use API of Windows:
        return _aligned_malloc(size, alignof(MyType32));
        #else
        // use API of Linux:
        return memalign(alignof(MyType32), size);
        #endif
        #endif
    }
    static void operator delete (void* p) {
        // 为所要求的对齐方式取消分配内存:
        #ifdef _MSC_VER
        // 使用Windows的特殊API:
        _aligned_free(p);
        #else
        // C11/Linux可以使用通用的free()函数。:
        free(p);
        #endif
    }
    // since C++14:
    static void operator delete (void* p, std::size_t size) {
        MyType32::operator delete(p); // 使用非尺寸删除
    }
    ...;
    // also for arrays (new[] and delete[])
};

注意,从C++14开始,你可以为删除操作符提供一个大小参数。然而,可能会发生尺寸不可用的情况(例如,当处理不完整的类型时),有些情况下,平台可以选择是否向操作符delete()传递一个尺寸参数。出于这个原因,自C++14以来,你应该总是同时替换操作符delete()的无大小和有大小的重载。让一个人调用另一个人通常是可以的。 有了这个定义,下面的代码表现得很正确:

lang/alignednew11.cpp

#include "alignednew11.hpp"
int main()
{
    auto p = new MyType32;
    ...;
    delete p;
}

如所写,从 C++17 开始,您可以跳过执行操作以分配/取消分配对齐数据的开销。 即使没有为您的类型定义 operator new() 和 operator delete(),该示例也能正常工作:

lang/alignednew17.cpp

#include <string>
struct alignas(32) MyType32 {
    int i;
    char c;
    std::string s[4];
    ...
};
int main()
{
    auto p = new MyType32; // 从C++17开始分配32字节对齐的内存
    ...;
    delete p;
}
28.2.2 实现特定类型操作符new()

如果你必须自己实现运算符new()和运算符delete(),现在已经支持超对齐数据。在实践中,特定类型的相应代码 实现的相应代码自C++17以来看起来如下:

lang/alignednew.hpp

#include <cstddef> // for std::size_t
#include <new> // for std::align_val_t
#include <cstdlib> // for malloc(), aligned_alloc(), free()
#include <string>
struct alignas(32) MyType32 {
    int i;
    char c;
    std::string s[4];
    ...;
    static void* operator new (std::size_t size) {
        // 要求默认对齐的数据:
        // 调用默认对其数据:
        std::cout << "MyType32::new() with size " << size << '\n';
        return ::operator new(size);
    }
    static void* operator new (std::size_t size, std::align_val_t align) {
        // 要求过度对齐的数据:
        std::cout << "MyType32::new() with size " << size
            << " and alignment " << static_cast<std::size_t>(align)
            << '\n';
        return ::operator new(size, align);
    }
    static void operator delete (void* p) {
        // 要求默认对齐的数据:
        std::cout << "MyType32::delete() without alignment\n";
        ::operator delete(p);
    }
    static void operator delete (void* p, std::size_t size) {
        MyType32::operator delete(p); // 使用非大小删除
    }
    static void operator delete (void* p, std::align_val_t align) {
        // 要求默认对齐的数据:
        std::cout << "MyType32::delete() with alignment\n";
        ::operator delete(p, align);
    }
    static void operator delete (void* p, std::size_t size,
                                 std::align_val_t align) {
        MyType32::operator delete(p, align); // 使用非大小删除
    }
    // also for arrays (operator new[] and operator delete[])
    ...;
};

原则上,我们只需要额外对齐参数的重载,并调用函数来分配和释放对齐的内存。 最便携的方法是调用为过度对齐(取消)分配提供的全局函数:

static void* operator new (std::size_t size, std::align_val_t align) {
    ...;
    return ::operator new(size, align);
}
...;
static void operator delete (void* p, std::align_val_t align) {
    ...;
    ::operator delete(p);
}

您也可以直接调用 C11 函数进行对齐分配:

static void* operator new (std::size_t size, std::align_val_t align) {
    ...;
    return std::aligned_alloc(static_cast<size_t>(align), size);
}
...;
static void operator delete (void* p, std::align_val_t align) {
    ...;
    std::free(p);
}

然而,由于Windows对aligned_alloc()的问题,在实践中,我们需要特殊的处理方式来进行移植,那么:

static void* operator new (std::size_t size, std::align_val_t align) {
    ...;
    #ifdef _MSC_VER
    // 特定于 Windows 的 API:
    return aligned_malloc(size, static_cast<size_t>(align));
    #else
    // 标准 C++17 API:
    return std::aligned_alloc(static_cast<size_t>(align), size);
    #endif
}
static void operator delete (void* p, std::align_val_t align) {
    ...;
    #ifdef _MSC_VER
    // 特定于 Windows 的 API:
    _aligned_free(p);
    #else
    // 标准 C++17 API:
    std::free(p);
    #endif
}

请注意,所有的分配函数都将对齐参数作为size_t类型,这意味着我们必须使用静态转换来从std::align_val_t类型转换数值。 此外,你可能想用[[nodiscard]]声明运算符new()的重载。属性:

[[nodiscard]] static void* operator new (std::size_t size) {
    ...;
}
[[nodiscard]] static void* operator new (std::size_t size,
                                         std::align_val_t align) {
    ...;
}

直接调用运算符new()(不使用new表达式)是很罕见的,但(正如你在这里看到的)是可能的。有了[[nodiscard]],编译器会检测到,如果调用者忘记使用返回值,这将导致内存泄漏。

operator new() 什么时候调用?

正如所介绍的,我们现在可以有两个重载的操作符new()。

  • 只有size参数的版本,在C++17之前也被支持,一般是为默认对齐的数据请求提供的。 然而,如果没有提供用于大对齐数据的版本,它也可以作为回退。
  • 带有额外对齐参数的版本,自C++17以来得到了特别的支持,一般是为超对齐数据的请求提供的。

使用哪个重载不一定取决于是否使用了alignas。它取决于特定平台对过对齐数据的定义。 编译器会根据一个一般的对齐值从默认对齐切换到超对齐,这个值 你可以在新的预处理程序常量中找到 stdcpp_default_new_alignment。 也就是说,在任何大于这个常数的对齐方式下,调用new会从试图调用

operator new(std::size_t)

试调用:

operator new(std::size_t, std::align_val_t)

因此,以下代码的输出可能因平台而异:

struct alignas(32) MyType32 {
    ...;
    static void* operator new (std::size_t size) {
        std::cout << "MyType32::new() with size " << size << '\n';
        return ::operator new(size);
    }
    static void* operator new (std::size_t size, std::align_val_t align) {
        std::cout << "MyType32::new() with size " << size
            << " and alignment " << static_cast<std::size_t>(align) << '\n';
        return ::operator new(size, align);
        ::operator delete(p);
    }
    ...;
};
auto p = new MyType32;

如果默认的对齐方式是32(或者更少,并且代码可以编译),表达式new MyType32将调用运算符new()的第一个重载,只有大小参数,所以输出是这样的 像这样:

MyType32::new() with size 128

如果默认对齐方式小于32,将调用两个参数的运算符new()的第二个重载,这样输出就变成了这样:

MyType32::new() with size 128 and alignment 32

如果没有为特定类型的运算符new()提供std::align_val_t重载,没有这个参数的重载将被用作回退。因此,一个只提供C++17之前支持的运算符new()重载的类仍然可以编译并具有相同的行为(注意,对于全局运算符new()来说,情况不是这样的):

struct NonalignedNewOnly {
    ...;
    static void* operator new (std::size_t size) {
        ...;
    }
    ...; // 不operato new(std::size_t, std::align_val_t align)
};
auto p = new NonalignedNewOnly; // OK:使用了操作符new(size_t)。

反之则不然。如果一个类型只提供了带有对齐方式参数的重载,那么任何使用默认对齐方式的new分配存储的尝试都会失败:

struct AlignedNewOnly {
    ...; // no operator new(std::size_t)
    static void* operator new (std::size_t size, std::align_val_t align) {
        return std::aligned_alloc(static_cast<size_t>(align), size);
    }
};
auto p = new AlignedNewOnly; // 错误:没有用于默认对齐的运算符 new()

如果对该类型要求的对齐方式是(小于)默认的对齐方式,这也将是一个错误。

在新的表达式中请求对齐

如果你在new表达式中传递了一个要求的对齐方式,那么传递的对齐方式参数总是被传递,并且必须被操作符new()所支持。事实上,对齐方式参数的处理与你可以传递给新表达式的任何其他额外参数一样。它们被作为附加参数传递给运算符new()。 因此,一个调用,如:

std::string* p = new(std::align_val_t{64}) std::string; // 64 字节对齐

将始终尝试调用:

operator new(std::size_t, std::align_val_t)

一个仅有大小的重载在这里不会作为fallback。 如果你对一个过度对齐的类型有一个特定的对齐请求,其行为就更加有趣了。例如,如果你调用:

MyType32* p = new(std::align_val_t{64}) MyType32{};

并且 MyType32 过度对齐,编译器首先尝试调用:

operator new(std::size_t, std::align_val_t, std::align_val_t)

32作为第二个参数(type的一般过度对齐),64作为第三个参数(要求的特定对齐)。只作为退步:

operator new(std::size_t, std::align_val_t)

被调用,并将64作为请求的特定对齐方式。原则上,你可以为这三个参数提供一个重载,以实现对超对齐类型请求特定对齐时的特定行为。 再次注意,如果你需要为超对齐的数据提供特殊的去分配函数,你必须在新表达式中传递对齐方式时调用正确的去分配函数:

std::string* p1 = new(std::align_val_t{64}) std::string{};
MyType32* p2 = new(std::align_val_t{64}) MyType32{};
...;
::operator delete(p2, std::align_val_t{64}); // !!!
MyType32::operator delete(p1, std::align_val_t{64}); // !!!

这意味着,本例中的新表达式将调用

operator new(std::size_t size, std::align_val_t align);

而 delete 表达式将为默认对齐的数据调用以下两个操作之一:

operator delete(void* ptr, std::align_val_t align);
operator delete(void* ptr, std::size_t size, std::align_val_t align);

以及针对过度对齐数据的以下四种操作之一:

operator delete(void* ptr, std::align_val_t typealign, std::align_val_t align);
operator delete(void* ptr, std::size_t size, std::align_val_t typealign,
                std::align_val_t align);
operator delete(void* ptr, std::align_val_t align);
operator delete(void* ptr, std::size_t size, std::align_val_t align);

28.3 实现全局操作符new()

默认情况下,C++ 平台现在为 operator new() 和 delete() 提供了大量的全局重载(包括相应的数组版本):

void* ::operator new(std::size_t);
void* ::operator new(std::size_t, std::align_val_t);
void* ::operator new(std::size_t, const std::nothrow_t&) noexcept;
void* ::operator new(std::size_t, std::align_val_t,
                     const std::nothrow_t&) noexcept;
void ::operator delete(void*) noexcept;
void ::operator delete(void*, std::size_t) noexcept;
void ::operator delete(void*, std::align_val_t) noexcept;
void ::operator delete(void*, std::size_t, std::align_val_t) noexcept;
void ::operator delete(void*, const std::nothrow_t&) noexcept;
void ::operator delete(void*, std::align_val_t,
                       const std::nothrow_t&) noexcept;
void* ::operator new[](std::size_t);
void* ::operator new[](std::size_t, std::align_val_t);
void* ::operator new[](std::size_t, const std::nothrow_t&) noexcept;
void* ::operator new[](std::size_t, std::align_val_t,
                       const std::nothrow_t&) noexcept;
void ::operator delete[](void*) noexcept;
void ::operator delete[](void*, std::size_t) noexcept;
void ::operator delete[](void*, std::align_val_t) noexcept;
void ::operator delete[](void*, std::size_t, std::align_val_t) noexcept;
void ::operator delete[](void*, const std::nothrow_t&) noexcept;
void ::operator delete[](void*, std::align_val_t,
                         const std::nothrow_t&) noexcept;

如果你想实现自己的内存管理(例如,为了能够调试动态内存调用),你不必全部覆盖它们。只要实现以下基本函数就足够了,因为默认情况下,所有其他函数(包括所有数组版本)都会调用这些基本函数中的一个:

void* ::operator new(std::size_t);
void* ::operator new(std::size_t, std::align_val_t);
void ::operator delete(void*) noexcept;
void ::operator delete(void*, std::size_t) noexcept;
void ::operator delete(void*, std::align_val_t) noexcept;
void ::operator delete(void*, std::size_t, std::align_val_t) noexcept;

原则上,操作者delete()的默认大小版本也只是调用非大小版本。然而,这在将来可能会发生变化,因此要求你同时实现这两个功能(如果你不这样做,有些编译器会发出警告)。

28.3.1 向后的不相容性

请注意,以下程序的行为会随着 C++17 默默地改变:

lang/alignednewincomp.cpp

#include <cstddef> // for std::size_t
#include <cstdlib> // for std::malloc()
#include <cstdio> // for std::printf()
void* operator new (std::size_t size)
{
    std::printf("::new called with size: %zu\n", size);
    return ::std::malloc(size);
}
int main()
{
    struct alignas(64) S {
        int i;
    };
    S* p = new S; // 仅在 C++17 之前调用我们的运算符 new
}

在 C++14 中,为所有新表达式调用全局 ::operator new(size_t) 重载,以便程序始终具有以下输出:

::new called with size: 64

从 C++17 开始,这个程序的行为发生了变化,因为现在默认重载了过度对齐的数据

::operator new(size_t, align_val_t)

在这里调用,没有被替换。 结果,程序将不再输出上面的行。

请注意,此问题仅适用于全局运算符 new()。 如果为 S 定义了特定于类型的运算符 new(),则该运算符仍用作过度对齐数据的后备,以便此类程序的行为与 C++17 之前一样。 另请注意,此处有意使用 printf() 以避免 std::cout 的输出在我们分配内存时分配内存,这可能会导致严重错误(充其量是核心转储)。

28.4 跟踪所有::new的调用

下面的程序演示了如何使用 new 运算符 new() 重载结合内联变量和 [[nodiscard]] 来跟踪 ::new 的所有调用,只需包含此头文件:

lang/tracknew.hpp

#ifndef TRACKNEW_HPP
#define TRACKNEW_HPP
#include <new> // for std::align_val_t
#include <cstdio> // for printf()
#include <cstdlib> // for malloc() and aligned_alloc()
#ifdef _MSC_VER
#include <malloc.h> // for _aligned_malloc() and _aligned_free()
#endif
class TrackNew {
private:
    static inline int numMalloc = 0; // num malloc 调用
    static inline size_t sumSize = 0; // 到目前为止分配的字节数
    static inline bool doTrace = false; // 启用跟踪
    static inline bool inNew = false; // 不要在新的重载中跟踪输出
public:
    static void reset() { //重置新/内存计数器
        numMalloc = 0;
        sumSize = 0;
    }
    static void trace(bool b) { // 启用/禁用跟踪
        doTrace = b;
    }
    // 实施跟踪分配:
    static void* allocate(std::size_t size, std::size_t align,
                          const char* call) {
        // 跟踪和跟踪分配:
        ++numMalloc;
        sumSize += size;
        void* p;
        if (align == 0) {
            p = std::malloc(size);
        }
        else {
            #ifdef _MSC_VER
            p = _aligned_malloc(size, align); // Windows API
            #else
            p = std::aligned_alloc(align, size); // C++17 API
            #endif
        }
        if (doTrace) {
            // 不要在这里使用 std::cout 因为它可能会分配内存
            // 当我们分配内存时(充其量是核心转储)
            printf("#%d %s ", numMalloc, call);
            printf("(%zu bytes, ", size);
            if (align > 0) {
                printf("%zu-bytes aligned) ", align);
            }
            else {
                printf("def-aligned) ");
            }
            printf("=> %p (total: %zu Bytes)\n", (void*)p, sumSize);
        }
        return p;
    }
    static void status() { // 打印当前状态
        printf("%d allocations for %zu bytes\n", numMalloc, sumSize);
    }
};
[[nodiscard]]
void* operator new (std::size_t size) {
    return TrackNew::allocate(size, 0, "::new");
}
[[nodiscard]]
void* operator new (std::size_t size, std::align_val_t align) {
    return TrackNew::allocate(size, static_cast<size_t>(align),
                              "::new aligned");
}
[[nodiscard]]
void* operator new[] (std::size_t size) {
    return TrackNew::allocate(size, 0, "::new[]");
}
[[nodiscard]]
void* operator new[] (std::size_t size, std::align_val_t align) {
    return TrackNew::allocate(size, static_cast<size_t>(align),
                              "::new[] aligned");
}
// 确保释放匹配:
void operator delete (void* p) noexcept {
    std::free(p);
}
void operator delete (void* p, std::size_t) noexcept {
    ::operator delete(p);
}
void operator delete (void* p, std::align_val_t) noexcept {
    #ifdef _MSC_VER
    _aligned_free(p); // Windows API
    #else
    std::free(p); // C++17 API
    #endif
}
void operator delete (void* p, std::size_t,
                      std::align_val_t align) noexcept {
    ::operator delete(p, align);
}
#endif // TRACKNEW_HPP  

考虑在以下 CPP 文件中使用此头文件:

lang/tracknew.cpp

#include "tracknew.hpp"
#include <iostream>
#include <string>
int main()
{
    TrackNew::reset();
    TrackNew::trace(true);
    std::string s = "string value with 26 chars";
    auto p1 = new std::string{"an initial value with even 35 chars"};
    auto p2 = new(std::align_val_t{64}) std::string[4];
    auto p3 = new std::string[4] { "7 chars", "x", "or 11 chars",
                                  "a string value with 28 chars" };
    TrackNew::status();
    ...;
    delete p1;
    delete[] p2;
    delete[] p3;
}

输出取决于何时初始化跟踪以及为其他初始化执行了多少分配。 但它应该包含类似于以下几行的内容:

#1 ::new (27 bytes, def-aligned) => 0x8002ccc0 (total: 27 Bytes)
#2 ::new (24 bytes, def-aligned) => 0x8004cd28 (total: 51 Bytes)
#3 ::new (36 bytes, def-aligned) => 0x8004cd48 (total: 87 Bytes)
#4 ::new[] aligned (100 bytes, 64-bytes aligned) => 0x8004cd80 (total: 187 Bytes)
#5 ::new[] (100 bytes, def-aligned) => 0x8004cde8 (total: 287 Bytes)
#6 ::new (29 bytes, def-aligned) => 0x8004ce50 (total: 316 Bytes)
6 allocations for 316 bytes

第一个输出是,例如,为s的值初始化内存。注意,根据std::string类的分配策略,这个值可能更大。 接下来写的两行是由第二个请求引起的:

auto p1 = new std::string{"an initial value with even 35 chars"};

它为核心字符串对象分配了24个字节,加上36个字节的字符串初始值(同样,这些值可能会有所不同)。 第三次调用要求一个64字节的4个字符串的数组。 最后一个调用再次执行两个分配:一个用于数组,一个用于最后一个字符串的初始值。是的,只为最后一个字符串,因为库的实现通常使用小/短字符串优化(SSO),它将通常不超过15个字符的字符串存储在数据成员中,而不是完全分配堆内存。其他实现可能在这里进行5次分配。

28.5 后记

堆/动态内存分配的对齐由 Clark Nelson 在 https://wg21.link/n3396 中首次提出。 最终接受的措辞由 Clark Nelson 在 https://wg21.link/p0035r4 中制定。

29 其他专业库的改动

对于专家来说,C++标准库还有一些进一步的改进,例如基础库的程序员,本章将介绍这些改进。

29.1 字符序列和数字值之间的低级转换

自 C 以来,将整数值转换为字符序列(反之亦然)一直是一个问题。虽然 C 提供了 sprintf() 和 sscanf(),但 C++ 首先引入了字符串流,但是这需要大量资源。使用 C++11 引入了方便的函数,例如 std::to_string 和 std::stoi() ,它们只接受 std::string 参数。 C++17 引入了具有以下能力的新基本字符串转换函数(引用自最初的提议):

  • 没有格式字符串的运行时解析
  • 接口本身不需要动态内存分配
  • 不考虑语言环境
  • 不需要通过函数指针进行间接寻址
  • 防止缓冲区溢出
  • 解析字符串时,错误可与有效数字区分开来
  • 解析字符串时,空格或装饰不会被忽略

除了浮点数之外,此功能还将提供往返保证,即转换为字符序列并转换回原始值的值。

这些函数在头文件 中提供。

29.1.1 使用实例

提供了两个重载函数:

  • std::from_chars() 将给定的字符序列转换为数值。
  • std::to_chars() 将数值转换为给定的字符序列。
from_chars()

std::from_chars() 将给定的字符序列转换为数值。 例如:

#include <charconv>
const char* str = "12 monkeys";
int value;
std::from_chars_result res = std::from_chars(str, str+10,
                                             value);

在成功解析后,值包含解析后的值(本例中为12)。结果值是以下结构:

struct from_chars_result {
    const char* ptr;
    std::errc ec;
};

在调用之后,ptr指的是第一个没有被解析为数字的一部分的字符(或者传递的第二个参数,如果所有的字符都被传递了),ec包含一个std::errc类型的错误条件,如果转换成功,则等于std::errc{}。因此,你可以这样检查结果如下:

if (res.ec != std::errc{}) {
    ... // error handling
}

注意,对于std::errc来说,没有隐式转换为bool,所以你不能像下面这样检查值:

if (res.ec) { // 错误:没有隐式转换为布尔值

或者:

if (!res.ec) { // 错误:没有操作员! 定义

但是,通过使用结构化绑定并且如果使用初始化,您可以编写:

if (auto [ptr, ec] = std::from_chars(str, str+10, value); ec != std::errc{}) {
    ... // error handling
}

另一个例子是解析传递的字符串视图:

to_chars()

std::to_chars() 将数值转换为给定的字符序列。 例如:

#include <charconv>
int value = 42;
char str[10];
std::to_chars_result res = std::to_chars(str, str+9,
                                         value);
*res.ptr = '\0'; // 确保后面有一个尾随空字符

转换成功后,str包含了代表传递值的字符序列(本例中为42),没有尾部空字符。 结果值是以下结构:

struct to_chars_result {
    char* ptr;
    std::errc ec;
};

在调用之后,ptr指的是最后一个写入的字符之后的字符,ec包含一个std::errc类型的错误条件,如果转换成功,则等于std::errc{}。 因此,你可以按以下方式检查结果:

if (res.ec != std::errc{}) {
    ... // 错误处理
}
else {
    process (str, res.ptr - str); //传递字符和长度
}

再次注意,对于std::errc来说,没有隐式转换为bool,所以你不能像下面这样检查值:

if (res.ec) { // 错误:没有隐式转换为布尔值

或者:

if (!res.ec) { // 错误:没有操作员! 定义

由于未写入尾随空终止符,因此您必须确保仅使用写入的字符或添加尾随空字符,如本示例中使用返回值的 ptr 成员所做的那样:

*res.ptr = '\0'; // 确保后面有一个尾随空字符

同样,通过使用结构化绑定,如果使用初始化,您可以编写:

if (auto [ptr, ec] = std::to_chars(str, str+10, value); ec != std::errc{}) {
    ... // 错误处理
}
else {
    process (str, res.ptr - str); // 传递字符和长度
}

请注意,使用现有的std::to_string()函数,这种行为更安全,更容易实现。使用std::to_char()只有在进一步处理直接需要所写的 字符序列。

29.1.2 支持浮点往返运算

如果没有给出精度,to_chars()和from_chars()保证对浮点值的往返支持。这意味着一个转换为字符序列的值在读回时正好是其原始值。不过,这种保证只适用于在同一实现中的写入和读取。 因此,浮点值必须被写成具有最高精度的最细粒度的字符序列。由于这个原因,数值被写入的字符序列可能有很大的尺寸。 请看下面的函数:

lib/charconv.hpp

#include <iostream>
#include <charconv>
#include <cassert>
void d2str2d(double value1)
{
    std::cout << "in: " << value1 << '\n';
    // 转换为字符序列:
    char str[1000];
    std::to_chars_result res1 = std::to_chars(str, str+999,
                                              value1);
    *res1.ptr = '\0'; // 添加尾随空字符
    std::cout << "str: " << str << '\n';
    assert(res1.ec == std::errc{});
    // 从字符序列回读:
    double value2;
    std::from_chars_result res2 = std::from_chars(str, str+999,
                                                  value2);
    std::cout << "out: " << value2 << '\n';
    assert(res2.ec == std::errc{});
    assert(value1 == value2); // should never fail
}

在这里,我们将传递的双精度值转换为字符序列并将其解析回来。 最后的断言再次检查该值是否相同。 下面的程序演示了效果:

lib/charconv.cpp

#include "charconv.hpp"
#include <iostream>
#include <iomanip>
#include <vector>
#include <numeric>
int main()
{
    std::vector<double> coll{0.1, 0.3, 0.00001};
    // 创建两个略有不同的浮点值:
    auto sum1 = std::accumulate(coll.begin(), coll.end(),
                                0.0, std::plus<>());
    auto sum2 = std::accumulate(coll.rbegin(), coll.rend(),
                                0.0, std::plus<>());
    // look the same:
    std::cout << "sum1: " << sum1 << '\n';
    std::cout << "sum1: " << sum2 << '\n';
    // 但不一样:
    std::cout << std::boolalpha << std::setprecision(20);
    std::cout << "equal: " << (sum1==sum2) << '\n'; // false !!
    std::cout << "sum1: " << sum1 << '\n';
    std::cout << "sum1: " << sum2 << '\n';
    std::cout << '\n';
    // 检查往返:
    d2str2d(sum1);
    d2str2d(sum2);
}

我们以不同的顺序累积两个小的浮点序列。 sum1 是从左到右累积的总和,而 sum2 是从右到左累积的总和(使用反向迭代器)。 结果,这些值看起来相同但不是:

sum1: 0.40001
sum1: 0.40001
equal: false
sum1: 0.40001000000000003221
sum1: 0.40000999999999997669

将值传递给 d2str2d() 时,您可以看到这些值存储为具有必要粒度的不同字符序列:

in: 0.40001000000000003221
str: 0.40001000000000003
out: 0.40001000000000003221
in: 0.40000999999999997669
str: 0.40001
out: 0.40000999999999997669

再次注意,粒度(以及字符序列的必要大小)取决于平台。 往返支持适用于所有浮点数,包括 NAN 和 INFINITY。 例如,将 INFINITY 传递给 d2st2d() 应该具有以下效果:

value1: inf
str: inf
value2: inf

但是,请注意,对于 NAN,d2str2d() 中的断言将失败,因为它从不与任何东西进行比较,包括它自己。

29.2 后记

字符序列和数字值之间的低级转换是由Jens Maurer在https://wg21.link/p0067r0 中首次提出的。最终被接受的措辞是由Jens Maurer在https://wg21.link/p0067r5。然而,重要的澄清和新的头文件被指定为Jens Maurer在https://wg21.link/p0682r1,作为针对C++17的缺陷报告。

词汇表