正确使用对象数组的类型调整和擦除

Correctly using type-punning and erasure for array of objects

提问人:Oersted 提问时间:8/25/2023 最后编辑:Jan SchultkeOersted 更新时间:9/14/2023 访问量:122

问:

我的目标是拥有一个用于存储对象数组的内存池非模板类。 同一内存池对象必须可重用于不同的数组(不同大小、不同类型和/或对齐方式)。

我已经发布了一系列问题,但它们可能过于关注有关可能实现的技术细节,而此实现可能不是正确的:

带着这个问题,我将重点关注“什么”。
我想有一个带有这个伪代码 API 的内存池类(和一个使用示例):

// type-punning reusable buffer for arrays
// holds a non-typed buffer (actually a char*) that can be used to store any
// types, according to user needs
struct Buffer {
    // start of storage address
    char* p = nullptr;

    // adding whatever method and variable required to make it work
    // ...

    // Creates an adequate storage (if needed) to store an array of N object of
    // type T and default-construct them returns a pointer to the first element
    // of this array
    template <typename T>
    T* DefaultAllocate(const size_t N);
    // Ends lifetime of the currently stored array of objects, if any, leaving
    // the storage reusable for another array of possibly different type and
    // size
    // Make it non-template if possible
    // Make it optional if possible (by calling it automatically in
    // DefaultAllocate if needed)
    template <typename T>
    void Deallocate() {}
    // Releasing all ressources (storage and objects)
    ~Buffer() {}
};

int main() {
    constexpr std::size_t N0 = 7;
    constexpr std::size_t N1 = 3;
    Buffer B;
    std::cout << "Test on SomeClass\n";
    SomeClass* psc = B.DefaultAllocate<SomeClass>(N0);
    psc[0] = somevalue0;
    *(psc + 1) = somevalue1;
    psc[2] = somevalue2;
    std::cout << psc[0] << '\n';
    std::cout << psc[1] << '\n';
    std::cout << *(psc + 2) << '\n';
    std::cout << "Test on SomeOtherClass\n";
    // reallocating, possibly using existing storage, for a different type and
    // size
    SomeOtherClass* posc = B.DefaultAllocate<SomeOtherClass>(N1);
    std::cout << posc[0] << '\n';
    std::cout << posc[1] << '\n';
    std::cout << posc[2] << '\n';
    return 0;
}

编译器资源管理器中的可编辑版本

应该如何实现此类以避免 UB、内存泄漏,让指针算术在类型化指针(由“DefaultAllocate”返回的指针)上有效并正确对齐?

我期待 C++14 个带有技术参考和解释的答案(什么确保了 UB 的缺失、指针算术的有效性,...)。

但我也对如何在更现代的版本中做到这一点感兴趣(特别是因为已经发生了一些根本性的变化,导致在某些特定情况下需要)。std::launder

注意:在使用 std::aligned_alloc 对对象数组进行类型双关时,一种非常有趣的技术(为了帮助数据擦除,已经提出了使用 和 lambda)。std::function

C++ 语言-律师 指针-算术严格 别名类型 -双关语

评论

0赞 Jarod42 8/25/2023
不应该只是一个分配器,并在容器中使用/传递它,例如?Bufferstd::vector
0赞 Oersted 8/25/2023
@Jarod42我不确定。首先,我不知道如何实现正确的分配器,首先我需要传递一个非类型化缓冲区(实际上,我应该更新我的 API 以添加一个返回指针的模板函数(有或没有类型检查)。在某种程度上,它可以被看作是一个类,用于数组和可重用的存储。any

答:

1赞 HolyBlackCat 8/25/2023 #1

首先:

  • 如果只想通过解除分配内存块来清理池,则只能接受 的类型。std::is_trivially_destructible

  • 确保正确对齐是微不足道的,这是留给读者的练习。


创建单个对象

T *MakeOne()
{
    return ::new(static_cast<void *>(address)) T{};
}

添加会使它零一些原本未初始化的类型:标量和具有隐式生成的默认构造函数的类,或直接在类体中标记的默认构造函数(对于类,只有未初始化的成员才会归零)。{}=default

添加并确保这始终选择内置的 placement-new,而不是一些用户提供的重载。::static_cast<void *>(...)

在 C++20 中创建数组

T *MakeArray(std::size_t n)
{
    return ::new(static_cast<void *>(address)) T[n]{};
}

在 C++20 之前创建数组

(唯一需要此解决方法的编译器是 MSVC,请参阅 Microsoft C/C++ 语言一致性,并参阅CWG2382 非分配放置的数组分配开销 new)

T *MakeArray(std::size_t n)
{
    for (std::size_t i = 0; i < n; i++)
        ::new(static_cast<void *>(address + i * sizeof(T))) T{};
    // Just remove `launder` if your language standard version doesn't have it.
    return std::launder(reinterpret_cast<T *>(address));
}

std::launder 是必需的,因为您有一个指向包含对象的内存位置的指针,但该指针是以非法方式获取的(标准说它“不指向”您的对象,尽管它具有正确的值),例如当您没有存储 placement-new 返回的值,并且只知道最初传递给它的指针时。

在实践中,缺乏很少会破坏事情(在添加 C++17 之前,如果没有 UB 就无法实现,并且没有人遇到问题)。因此,如果您使用的是 C++14 或更早版本,则可以省略它,事情应该可以正常工作。std::launderstd::vectorlaunder

更多的 UB 可能隐藏在这里,例如在指向原始存储的指针上使用(请参阅此相关问题),但这可以说是标准中的一个缺陷,并且没有编译器强制执行这一点。+

评论

0赞 Jarod42 8/25/2023
“在 C++17 之前没有 UB 就无法实现 std::vector”。事实上,指针算术 () (将)使 UB 成为 UB(但作为 std 库的一部分,规则是不同的)。如果没有,我们可能有一个额外的指针数组来存储放置 new 返回的指针(效率低下但合法)......data()std::vectorstd::launder
0赞 Oersted 8/25/2023
@HolyBlackCat 1- 我不确定如何正确获得正确的对齐方式(我将通过分配额外的 sizeof(T) 字节并计算偏移量来修复任何错位) 2- 您为每个单独的对象使用 placement new,但它是否足以保证指针算术有效。我认为需要一个适当的数组对象,我不知道对象的并列是否实际上构成了 C++ 意义上的数组。3- 为什么我不能在使用非平凡的可破坏类后单独销毁每个对象?待定...
0赞 Oersted 8/25/2023
@HolyBlackCat,在没有正确结束生存期的情况下将新类型的对象重新放置在前一个对象之上(例如,通过破坏,参见 3),这难道不是 UB 吗?我远不是一个语法层,关于类型双关语、混叠的标准对我来说非常令人困惑,...
1赞 Oersted 8/29/2023
注意:我提出了一个关于代码审查的代码,它总结了这个问题和相关问题中收集的许多想法。
1赞 HolyBlackCat 9/14/2023
@JanSchultke 我在这里解决的问题是,数组过去被允许消耗更多的内存,而不是在提供的内存块的开头添加任意元数据(当然,这完全是无稽之谈,只有 MSVC 做到了)。这使得它实际上无法使用,因为人们无法预测您需要分配多少额外的内存(并且人们不想在第一个内存中浪费该内存)。newsizeof(T)*N
-1赞 Boris Radonic 8/25/2023 #2

您可以创建“模板类”并执行如下操作:

    [[nodiscard]] T* allocate(std::size_t n)  noexcept
    {
       
        if (auto p = static_cast<T*>malloc(n * sizeof(T))))
        {
            return p;
        }
        return nullptr;
    }

  
    void deallocate(T* p, std::size_t n) noexcept
    {
        if( nullptr != p )
            free(p);
    }