C++ 线性分配器和容器边界
C++ Linear Allocator and Container Boundaries
我正在实施自定义 GUI/game 引擎,并已选择实施异构资源管理器系统。因此,在许多情况下都非常需要自定义分配器。
我确定要使用的分配器之一是简单的线性分配器。我的理解是基本的线性分配器是这样实现的(为简洁起见没有适当的错误处理):
struct linear_allocator
{
using value_type = std::byte;
using pointer = value_type*;
using size_type = std::size_t;
explicit linear_allocator(size_type n) noexcept
{
data = static_cast<pointer>(::operator new(n, std::nothrow_t{}));
}
~linear_allocator() noexcept
{
::operator delete(data, std::nothrow_t{});
}
[[nodiscard]] auto allocate(size_type n) noexcept -> pointer
{
auto temp = position;
position += n;
return temp;
}
auto deallocate(pointer p, size_type n) noexcept -> void
{
position = data;
}
private:
pointer data = nullptr;
pointer position = nullptr;
};
我对这个实现的问题在于它的状态。由于这是我代码中对性能极其关键的组件,我担心这不是最佳的,而且很可能容易出错。
因为分配器包含指向初始数据根的指针和位置,因此它包含有关如何遍历内存和数据大小的信息。这意味着我要么需要提供成员数据的访问器,要么在采用此分配器的容器中提供重复的簿记信息。
在我看来,更好的解决方案是让容器拥有状态信息并将分配留给容器。因此,容器将包含所有相关信息,并且没有重复簿记。此外,如果仅在容器的构造函数中进行分配,并且仅在容器的析构函数中进行释放,则可以实现与线性分配器相同的行为。但如果这是真的,分配器似乎不再需要构造函数或析构函数,我还不如利用 std::allocator。如果是这种情况......线性分配器的目的是什么?
我似乎说服自己认为线性分配器是一种反模式。我实际上正在寻找的是某种紧密包装的异构容器,而线性分配器似乎是分配器和容器概念的奇怪合并。存储分类帐的自定义向量(如果堆包含不同大小的对象,则用于索引访问)和数据堆似乎更符合要求。
有人能解释一下分配器和容器之间的界限应该在哪里吗(尤其是在标准库的上下文中)?我假设我的理由有误。
编辑:根据下面 Eugene 的建议,我重写了我的 allocation/container 方案,如下所示(注意,这缺少 copy/move 构造函数,分配等,我只是暂时删除了......但应该在某些时候正确实施)。所提供答案的主要内容是 command_vector 构造函数和析构函数负责分配,而 linear_allocator class 现在是无状态且简单的。代码大致如下所示(我意识到我可以而且应该能够调用 alloc.allocate() 一次而不是在这里分割出 3 个单独的块):
command_vector 容器 class
template<typename key_t, typename alloc_t = linear_allocator<std::byte>>
struct command_vector
{
using key_type = key_t;
using value_type = std::byte;
using pointer = value_type*;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
using allocator = alloc_t;
using iterator = command_packet*;
using const_iterator = const iterator;
using reverse_iterator = std::reverse_iterator<iterator>;
using const_reverse_iterator = std::reverse_iterator<const_iterator>;
explicit command_vector() noexcept :
alloc{},
keys{ alloc.allocate(packet_count) },
packets{ alloc.allocate(packet_count) },
packet_pos{ packets },
heap{ alloc.allocate(heap_size) },
heap_pos{ heap }
{
}
explicit command_vector(size_type max_packets, size_type max_heap) noexcept :
alloc{},
keys{ alloc.allocate(max_packets) },
packets{ alloc.allocate(max_packets) },
packet_pos{ packets },
heap{ alloc.allocate(max_heap) },
heap_pos{ heap }
{
}
constexpr command_vector(key_type* keys, pointer packets, pointer heap) noexcept requires std::is_same_v<alloc_t, null_allocator<value_type>> :
alloc{},
keys{ keys },
packets{ packets },
packet_pos{ packets },
heap{ heap },
heap_pos{ heap }
{
}
constexpr command_vector(const command_vector&) noexcept = delete;
constexpr command_vector(command_vector&&) noexcept = delete;
constexpr auto operator=(const command_vector&) noexcept -> command_vector& = delete;
constexpr auto operator=(command_vector&&) noexcept -> command_vector& = delete;
~command_vector() noexcept
{
if constexpr (!std::is_same_v<allocator, null_allocator<value_type>>)
{
alloc.deallocate(packets);
alloc.deallocate(heap);
}
}
template<typename command_t>
constexpr auto push_back(command_t&& command) noexcept -> void
{
*heap_pos = command;
*packet_pos = make_packet(std::forward<command_t>(command));
heap_pos += sizeof(std::decay_t<command_t>);
++packet_pos;
}
constexpr auto pop_back() noexcept -> void
{
--packet_pos;
heap_pos = static_cast<pointer>(packet_pos->command);
}
constexpr auto clear() noexcept -> void
{
packet_pos = packets;
heap_pos = heap;
}
constexpr auto size() noexcept -> difference_type
{
return packet_pos - packets;
}
constexpr auto begin() -> iterator { return packets; }
constexpr auto end() -> iterator { return packet_pos; }
constexpr auto cbegin() -> const_iterator { return begin(); }
constexpr auto cend() -> const_iterator { return end(); }
constexpr auto rbegin() -> reverse_iterator { return end(); }
constexpr auto rend() -> reverse_iterator { return begin(); }
constexpr auto crbegin() -> const_reverse_iterator { return cend(); }
constexpr auto crend() -> const_reverse_iterator { return cbegin(); }
private:
allocator alloc{};
key_type* keys{};
command_packet* packets{};
command_packet* packet_pos{};
pointer heap{};
pointer heap_pos{};
};
null_allocatorclass
template<typename val_t>
struct null_allocator
{
using value_type = val_t;
using pointer = value_type*;
using size_type = std::size_t;
[[nodiscard]] constexpr auto allocate(size_type n) noexcept -> pointer
{
return nullptr;
}
constexpr auto deallocate(pointer p, size_type n) noexcept -> void
{
}
};
linear_allocator class
template<typename val_t>
struct linear_allocator
{
using value_type = val_t;
using pointer = value_type*;
using size_type = std::size_t;
[[nodiscard]] auto allocate(size_type n) noexcept -> pointer
{
return static_cast<pointer>(::operator new(n, std::nothrow_t{}));
}
auto deallocate(pointer p, size_type n) noexcept -> void
{
::operator delete(p, std::nothrow_t{});
}
};
please explain where the boundaries should be between allocators and
containers are supposed to be
您不应该从分配器开始设计 - 它们是实现细节。从容器开始。选择具有您需要的接口的容器、最频繁操作的低算法复杂性、迭代器失效保证、异常安全保证...
完成后,测量速度。如果程序太慢并且您的容器是基于节点的,您可能可以通过自定义分配器来加速它。当然,要击败通用的 C++ 动态内存机制,您必须了解一些关于您的使用模式的特殊之处。例如,您可能希望一次释放大块内存。
编辑:
容器包含与您的问题相关的所有状态。分配器通常是无状态的——有状态分配器甚至在 C++11 之前都不是标准的。有状态分配器旨在从不同的内存池分配内存,所有状态都与池相关。
您的 linear_allocator
有问题。首先,deallocate()
应该是空的——所有内存都应该在它的 dtor 中释放。其次,它不处理复制——这是真正困难的部分——我不确定正确的语义是什么。尽管我是一位经验丰富的 C++ 程序员,但我不敢自己设计这个分配器。
A custom vector that stores a ledger (for index access if the heap
contains objects of varying sizes) and a heap for data seems to be
more along the lines of what is required.
如果您需要异构数据的容器,那么这听起来不错。例如,您可能需要 std::vector<std::unique_ptr<BaseClass>>
。或者,您可以考虑 std::vector<std::variant<..>>
。当然,您可能需要 std::vector<>
.
以外的容器
我正在实施自定义 GUI/game 引擎,并已选择实施异构资源管理器系统。因此,在许多情况下都非常需要自定义分配器。
我确定要使用的分配器之一是简单的线性分配器。我的理解是基本的线性分配器是这样实现的(为简洁起见没有适当的错误处理):
struct linear_allocator
{
using value_type = std::byte;
using pointer = value_type*;
using size_type = std::size_t;
explicit linear_allocator(size_type n) noexcept
{
data = static_cast<pointer>(::operator new(n, std::nothrow_t{}));
}
~linear_allocator() noexcept
{
::operator delete(data, std::nothrow_t{});
}
[[nodiscard]] auto allocate(size_type n) noexcept -> pointer
{
auto temp = position;
position += n;
return temp;
}
auto deallocate(pointer p, size_type n) noexcept -> void
{
position = data;
}
private:
pointer data = nullptr;
pointer position = nullptr;
};
我对这个实现的问题在于它的状态。由于这是我代码中对性能极其关键的组件,我担心这不是最佳的,而且很可能容易出错。
因为分配器包含指向初始数据根的指针和位置,因此它包含有关如何遍历内存和数据大小的信息。这意味着我要么需要提供成员数据的访问器,要么在采用此分配器的容器中提供重复的簿记信息。
在我看来,更好的解决方案是让容器拥有状态信息并将分配留给容器。因此,容器将包含所有相关信息,并且没有重复簿记。此外,如果仅在容器的构造函数中进行分配,并且仅在容器的析构函数中进行释放,则可以实现与线性分配器相同的行为。但如果这是真的,分配器似乎不再需要构造函数或析构函数,我还不如利用 std::allocator。如果是这种情况......线性分配器的目的是什么?
我似乎说服自己认为线性分配器是一种反模式。我实际上正在寻找的是某种紧密包装的异构容器,而线性分配器似乎是分配器和容器概念的奇怪合并。存储分类帐的自定义向量(如果堆包含不同大小的对象,则用于索引访问)和数据堆似乎更符合要求。
有人能解释一下分配器和容器之间的界限应该在哪里吗(尤其是在标准库的上下文中)?我假设我的理由有误。
编辑:根据下面 Eugene 的建议,我重写了我的 allocation/container 方案,如下所示(注意,这缺少 copy/move 构造函数,分配等,我只是暂时删除了......但应该在某些时候正确实施)。所提供答案的主要内容是 command_vector 构造函数和析构函数负责分配,而 linear_allocator class 现在是无状态且简单的。代码大致如下所示(我意识到我可以而且应该能够调用 alloc.allocate() 一次而不是在这里分割出 3 个单独的块):
command_vector 容器 class
template<typename key_t, typename alloc_t = linear_allocator<std::byte>>
struct command_vector
{
using key_type = key_t;
using value_type = std::byte;
using pointer = value_type*;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
using allocator = alloc_t;
using iterator = command_packet*;
using const_iterator = const iterator;
using reverse_iterator = std::reverse_iterator<iterator>;
using const_reverse_iterator = std::reverse_iterator<const_iterator>;
explicit command_vector() noexcept :
alloc{},
keys{ alloc.allocate(packet_count) },
packets{ alloc.allocate(packet_count) },
packet_pos{ packets },
heap{ alloc.allocate(heap_size) },
heap_pos{ heap }
{
}
explicit command_vector(size_type max_packets, size_type max_heap) noexcept :
alloc{},
keys{ alloc.allocate(max_packets) },
packets{ alloc.allocate(max_packets) },
packet_pos{ packets },
heap{ alloc.allocate(max_heap) },
heap_pos{ heap }
{
}
constexpr command_vector(key_type* keys, pointer packets, pointer heap) noexcept requires std::is_same_v<alloc_t, null_allocator<value_type>> :
alloc{},
keys{ keys },
packets{ packets },
packet_pos{ packets },
heap{ heap },
heap_pos{ heap }
{
}
constexpr command_vector(const command_vector&) noexcept = delete;
constexpr command_vector(command_vector&&) noexcept = delete;
constexpr auto operator=(const command_vector&) noexcept -> command_vector& = delete;
constexpr auto operator=(command_vector&&) noexcept -> command_vector& = delete;
~command_vector() noexcept
{
if constexpr (!std::is_same_v<allocator, null_allocator<value_type>>)
{
alloc.deallocate(packets);
alloc.deallocate(heap);
}
}
template<typename command_t>
constexpr auto push_back(command_t&& command) noexcept -> void
{
*heap_pos = command;
*packet_pos = make_packet(std::forward<command_t>(command));
heap_pos += sizeof(std::decay_t<command_t>);
++packet_pos;
}
constexpr auto pop_back() noexcept -> void
{
--packet_pos;
heap_pos = static_cast<pointer>(packet_pos->command);
}
constexpr auto clear() noexcept -> void
{
packet_pos = packets;
heap_pos = heap;
}
constexpr auto size() noexcept -> difference_type
{
return packet_pos - packets;
}
constexpr auto begin() -> iterator { return packets; }
constexpr auto end() -> iterator { return packet_pos; }
constexpr auto cbegin() -> const_iterator { return begin(); }
constexpr auto cend() -> const_iterator { return end(); }
constexpr auto rbegin() -> reverse_iterator { return end(); }
constexpr auto rend() -> reverse_iterator { return begin(); }
constexpr auto crbegin() -> const_reverse_iterator { return cend(); }
constexpr auto crend() -> const_reverse_iterator { return cbegin(); }
private:
allocator alloc{};
key_type* keys{};
command_packet* packets{};
command_packet* packet_pos{};
pointer heap{};
pointer heap_pos{};
};
null_allocatorclass
template<typename val_t>
struct null_allocator
{
using value_type = val_t;
using pointer = value_type*;
using size_type = std::size_t;
[[nodiscard]] constexpr auto allocate(size_type n) noexcept -> pointer
{
return nullptr;
}
constexpr auto deallocate(pointer p, size_type n) noexcept -> void
{
}
};
linear_allocator class
template<typename val_t>
struct linear_allocator
{
using value_type = val_t;
using pointer = value_type*;
using size_type = std::size_t;
[[nodiscard]] auto allocate(size_type n) noexcept -> pointer
{
return static_cast<pointer>(::operator new(n, std::nothrow_t{}));
}
auto deallocate(pointer p, size_type n) noexcept -> void
{
::operator delete(p, std::nothrow_t{});
}
};
please explain where the boundaries should be between allocators and containers are supposed to be
您不应该从分配器开始设计 - 它们是实现细节。从容器开始。选择具有您需要的接口的容器、最频繁操作的低算法复杂性、迭代器失效保证、异常安全保证...
完成后,测量速度。如果程序太慢并且您的容器是基于节点的,您可能可以通过自定义分配器来加速它。当然,要击败通用的 C++ 动态内存机制,您必须了解一些关于您的使用模式的特殊之处。例如,您可能希望一次释放大块内存。
编辑: 容器包含与您的问题相关的所有状态。分配器通常是无状态的——有状态分配器甚至在 C++11 之前都不是标准的。有状态分配器旨在从不同的内存池分配内存,所有状态都与池相关。
您的 linear_allocator
有问题。首先,deallocate()
应该是空的——所有内存都应该在它的 dtor 中释放。其次,它不处理复制——这是真正困难的部分——我不确定正确的语义是什么。尽管我是一位经验丰富的 C++ 程序员,但我不敢自己设计这个分配器。
A custom vector that stores a ledger (for index access if the heap contains objects of varying sizes) and a heap for data seems to be more along the lines of what is required.
如果您需要异构数据的容器,那么这听起来不错。例如,您可能需要 std::vector<std::unique_ptr<BaseClass>>
。或者,您可以考虑 std::vector<std::variant<..>>
。当然,您可能需要 std::vector<>
.