C++如何实现容器的Copy/Move/Swap方法
C++如何实现容器的Copy/Move/Swap方法
1、引言
目前网上有很多关于如何编写C++容器的教程,比如各种“手写STL”之类的文章和视频,但是这些教程中的容器一般都不包括allocator,比如:
template <typename T>
class MyVector { ... };
然而我们知道标准库的容器都是有一个Allocator的模板参数:
template <typename T, typename Allocator>
class MyVector { ... };
当容器包含了Allocator之后,某些函数的实现就会发生相应的变化,比如Copy/Move/Swap函数。本文将讲解一个带有Allocator的容器该如何实现以上方法。同时为了尽可能的简洁,本文将不涉及异常处理。
2、容器的构成
任何容器都可以被看成是由两部分构成,实现部分(impl)和内存分配器(allocator)。前者和该容器涉及的数据结构本身挂钩,后者则和内存的分配相关。
3、实现对比
3.1、一个普通的vector
首先我们来看一个不带有allocator的vector可能的实现(笔者测试过ChatGpt可以写出来)。
template <typename T>
class MyVector {
struct Impl {
T* start = nullptr; // begin指向的位置
T* finish = nullptr; // begin + size指向的位置
T* end_of_capacity = nullptr; // begin + capacity指向的位置
};
Impl impl;
// 拷贝构造函数一般是(指针)深拷贝,需要将other中的元素逐一拷贝
MyVector(const MyVector& other) {
// 申请内存
impl.start = allocate_memory(other.capacity());
// 逐一拷贝各元素
copy_elements_from_other(other);
// 更新字段
impl.finish = impl.start + other.size();
impl.end_of_capacity = impl.start + other.capacity();
}
// 拷贝构造函数和拷贝赋值函数是类似的
MyVector& operator=(const MyVector& other) {
if (this != std::addressof(other))
{
// 我们可以直接构造一个临时的vector然后交换一下即可,这样写起来代码很少,直接复用拷贝构造函数
MyVector temp(other);
swap(*this, temp);
}
return *this;
}
/*
移动构造函数一般是(指针)浅拷贝。引入移动语义Motivation如下:
Move semantics is mostly about performance optimization: the ability to move an expensive
object from one address in memory to another, while pilfering resources of the source
in order to construct the target with minimum expense.
具体细节可以参见 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm#Introduction
*/
MyVector(MyVector&& other) noexcept(...) {
// 这里直接复用other即可, noexcept对于某些函数还是很重要的,这里仅仅是提醒读者,具体实现不作涉及
impl = std::exchange(other.impl, Impl());
}
// 原理同上
MyVector& operator=(MyVector&& other) noexcept(...) {
if (this != std::addressof(other))
{
MyVector temp(std::move(other));
swap(*this, temp);
}
return *this;
}
void swap(MyVector& other) noexcept(...) {
if (this != std::addressof(other)) {
// 直接swap三个指针字段即可
std::swap(impl, other.impl);
}
}
};
至此一个简单vector涉及的Copy/Move/Swap函数已经全部实现完毕,但是当我们加入allocator之后,上面的许多实现都需要相应地改变。
3.2、allocator职责
allocator一般有4个职责,分别是申请/释放内存,构造/销毁对象,一般配合std::allocator_traits一起使用,所以我们通过std::allocator_traits来讲解这些职责。
[[nodiscard]] static constexpr pointer allocate(Alloc& a, size_type n);
// Calls a.allocate(n)
申请内存,返回指针。第一个参数是allocator,第二个参数是需要申请的元素的数量。注意标准并没有规定这个pointer一定是原始指针,它可以是某种fancy pointer。
static constexpr void deallocate(Alloc& a, pointer p, size_type n);
// Calls a.deallocate(n)
释放内存。第一个参数是allocator,第二个参数是我们需要释放内存的地址,其应该是通过调用allocate而获得的,第三个参数是需要释放的元素的数量。
template< class T, class... Args >
static constexpr void construct(Alloc& a, T* p, Args&&... args);
// Calls a.construct(p, std::forward<Args>(args)...)
在地址p上构造元素。第一个参数是allocator,第二个参数是该元素的地址,剩余的参数将用于构造元素。这个函数有默认实现,当用户自定义的allocator不提供construct时调用std::construct_at(p, std::forward<Args>(args)...)。
template< class T >
static constexpr void destroy(Alloc& a, T* p);
// Calls a.destroy(p)
销毁地址p上的元素。第一个参数是allocator,第二个参数是该元素的地址。这个函数有默认实现,当用户自定义的allocator不提供destroy时调用std::destroy_at(p)。
到此为止allocator的四个职责已经讲解完毕了,
3.3、一个带有allocator的vector
接下来我们将修改3.1中的定义,让我们的vector支持allocator。
template <typename T, typename Allocator = std::allocator<T>>
class MyVector {
struct Impl {
T* start = nullptr; // begin指向的位置
T* finish = nullptr; // begin + size指向的位置
T* end_of_capacity = nullptr; // begin + capacity指向的位置
};
Impl impl;
Allocator alloc; // [[no_unique_address]]
using traits = std::allocator_traits<Allocator>;
// 我们额外提供一些带有allocator的构造函数,这些构造函数中的Allocator都是const&的形式,这是标准规定的。
MyVector(const MyVector& other, const Allocator& a) : alloc(a) {
// 同上
}
MyVector(MyVector&& other, const Allocator& a)
: impl(std::move(other.impl)), alloc(a) {
if (a == other.alloc) {
// 同上
} else {
// 需要使用alloc重新申请内存,然后将other的元素move到该容器中
assign_from(
std::make_move_iterator(other.begin()),
std::make_move_iterator(other.end())
);
}
}
};
对于拷贝构造函数,我们依然是按照之前的写法进行深拷贝。不同的是我们首先通过select_on_container_copy_construction来获取other的alloc。
MyVector(const MyVector& other)
: alloc(traits::select_on_container_copy_construction(other.alloc)) {
// 实现同上
}
这里是标准规定的写法,我们截取ISO/IEC 14882部分内容:
Copy constructors for these container types obtain an allocator by calling allocator_traits<allocator_type>::select_on_container_copy_construction on the allocator belonging to the container being copied.
然后是移动构造函数。移动构造函数并直接移动alloc即可。
MyVector(MyVector&& other) noexcept(...)
: alloc(std::move(alloc)) {
// 实现同上
}
这里依旧截取ISO/IEC 14882部分内容:
Move constructors obtain an allocator by move construction from the allocator belonging to the container being moved. Such move construction of the allocator shall not exit via an exception.
到目前位置都没有什么大的变化。接下来是赋值相关的函数,这里会引入一些其他的成员类型,分别是
- propagate_on_container_copy_assignment => allocator是否可以拷贝赋值,即alloc = other.alloc ?
- propagate_on_container_move_assignment => allocator是否可以移动赋值,即alloc = std::move(other.alloc) ?
- propagate_on_container_swap => allocator是否可以交换,即std::swap(alloc, other.alloc) ?
- is_always_equal => allocator是否永远相等(相等表示other.alloc可以释放this->alloc申请的内存)
当propagate_on_container_copy_assignment为true的时候,使用other.alloc替换当前的alloc,对应下面的[2]。同时,如果当前的alloc和other.alloc不相等,那么我们需要使用当前的alloc将元素全部销毁并释放内存,因为当调用[2]之后, 该容器的alloc已经被替换了,而被替换之后的alloc也许不能够继续操作之前的已经申请的内存,此时会导致之前的元素无法销毁,内存无法释放。
MyVector& operator=(const MyVector& other) {
if (this != std::addressof(other)) {
if (traits::propagate_on_container_copy_assignment::value) {
// 当alloc != other.alloc的时候,other.alloc 是不能够释放alloc申请的
// 内存的,所以此时我们要在alloc被赋值之前先使用alloc将元素销毁并释放元素。
if (alloc != other.alloc) {
clear_and_deallocate_memory(); // [1]
}
// 替换alloc
alloc = other.alloc; // [2]
}
// 用other中的元素替换当前容器中的元素,这里可能存在复用内存的情况,比如
// this->capacity() > other.capacity()时可以直接消除所有元素然后在
// 原来的内存上构造元素,所以[1]是必要的。
assign_from(other.cbegin(), other.cend()); // [3]
}
return *this;
}
// 我们可以尝试简化一下这个过程,缺点是swap应该是合理的,可以使用一个assert去阻止不合理的swap
MyVector& operator=(const MyVector& other) {
if (this != std::addressof(other)) {
MyVector temp(other, traits::propagate_on_container_copy_assignment::value
? other.alloc
: alloc);
swap(temp);
}
return *this;
}
propagate_on_container_move_assignment为true则支持分配器的容器将在移动赋值上复制其存储的分配器。当然,如果当前的alloc和other.alloc是相等的,alloc的移动可以被省略。
MyVector& operator=(MyVector&& other) noexcept(...) {
if (this != std::addressof(other)) {
if (traits::propagate_on_container_move_assignment::value) {
clear_and_deallocate_memory();
alloc = std::move(other.alloc); // [4]
impl = std::exchange(other.impl, Impl());
} else if (alloc == other.alloc) {
// 当alloc == other.alloc的时候,alloc可以释放other.alloc的内存,此时直接
// 使用当前的alloc即可。
clear_and_deallocate_memory();
impl = std::exchange(other.impl, Impl());
} else {
// 当前的alloc无法完成move这一过程,此时容器只能逐元素移动
assign_from(
std::make_move_iterator(other.begin()),
std::make_move_iterator(other.end())
);
}
}
return *this;
}
// 我们可以尝试简化一下这个过程,缺点是swap应该是合理的,可以使用一个assert去阻止不合理的swap
MyVector& operator=(MyVector&& other) noexcept(...) {
if (this != std::addressof(other)) {
MyVector temp = traits::propagate_on_container_move_assignment::value
? MyVector(std::move(other))
: MyVector(std::move(other), alloc);
swap(temp);
}
return *this;
}
对于[4],CppCon上使用了move,但是在MSVC的文档中写的是“复制”。这里我们还是按照CppCon来。
propagate_on_container_swap为true,则支持分配器的容器将在交换上交换其存储的分配器。
void swap(MyVector& other) noexcept(...) {
if (this != std::addressof(other)) {
if (traits::propagate_on_container_swap::value) {
std::swap(impl, other.impl);
std::swap(alloc, other.alloc);
} else if (alloc == other.alloc) {
std::swap(impl, other.impl);
} else {
assert(false && "This is undefined behavior.");
}
}
}
// 我们大胆假设Allocator是规范的,那么就不会出现上述的UB选项,至此我们
// 可以将swap简化到如下形式
void swap(MyVector& other) noexcept(...) {
std::swap(impl, other.impl);
if constexpr (traits::propagate_on_container_swap::value) {
std::swap(alloc, other.alloc);
}
}
最后是is_always_equal,当其是true时,内存分配器永远相等,即
bool operator==(const Alloc& lhs, const Alloc& rhs) {
return true;
}
一般用于noexcept的判定之中。
至此位置,我们已经完成了一个带有allocator的vector的Copy/Move/Swap函数,上面的部分地方也可以使用if-constexpr来进行优化。
这里展示一下标准库为我们提供的allocator是怎么定义如上成员类型的:
#include <memory>
#include <memory_resource>
int main(int argc, char const *argv[])
{
// 我们这里假设T为int,注意用户是不应该特化allocator的。
// 由于这4个成员类型只可能是true_type/false_type,我们直接使用static_assert测试即可。
// -------------------- std::allocator --------------------------------
using Allocator = std::allocator<int>;
using AllocTraits = std::allocator_traits<Allocator>;
// std::allocator是一个空类,
// 采用::operator new申请内存,::operator delete释放内存。
static_assert(std::is_empty_v<Allocator>);
static_assert(!AllocTraits::propagate_on_container_copy_assignment());
// https://www.appsloveworld.com/cplus/100/205/why-does-stdallocator-require-propagate-on-container-move-assignment-to-be-true
static_assert(AllocTraits::propagate_on_container_move_assignment());
static_assert(!AllocTraits::propagate_on_container_swap());
static_assert(AllocTraits::is_always_equal());
// ---------------- std::pmr::polymorphic_allocator -------------------
using PmrAllocator = std::pmr::polymorphic_allocator<int>;
using PmrAllocTraits = std::allocator_traits<PmrAllocator>;
// std::pmr::polymorphic_allocator内部有一个memory_resource*,
// 内存方面的操作都是由该字段完成。
static_assert(!PmrAllocTraits::propagate_on_container_copy_assignment());
static_assert(!PmrAllocTraits::propagate_on_container_move_assignment());
static_assert(!PmrAllocTraits::propagate_on_container_swap());
static_assert(!PmrAllocTraits::is_always_equal());
return 0;
}
4、总结
至此为止关于带有Allocator的容器的Copy/Move/Swap内容已经全部讲解完毕。我们来简单总结一下几个特殊的成员类型:
- select_on_container_copy_construction出现在拷贝构造函数中,用于初始化alloc。
- propagate_on_container_copy_assignment出现在拷贝赋值函数当中,如果为true则代表当前的alloc可以被other.alloc赋值。
- propagate_on_container_move_assignment出现在移动赋值函数当中,如果为true则代表当前的alloc可以由std::move(other.alloc)得到。
- propagate_on_container_swap出现在swap中,如果为true则代表当前的alloc可以和other.alloc交换,即std::swap(alloc, other.alloc)是合法的。
- is_always_equal主要用于优化。
实际上这些值还是要取决于用户具体的allocator该如何设计。如何设计一个合理的allocator也是一个难题。从代码模板来看,propagate_on_container_XXX都设计成true_type可能会好一点。
5、拓展
接下来我们再补充一些编写带有allocator的容器可能需要的知识,所有内容均来自这里。
5.1、allocator实例表达式的含义
假设
- T是一个无cv限定的对象类型
- A是allocator
- a,a1和a2等是A的实例
- B是由A rebind而来
- b是B的实例
对以下表达式的要求分别是:
- 关系表达式
a1 == a2 // [1]
a1 != a2 // [2] 等价于!(a1 == a2)
- 当且仅当a1申请的内存空间可以被a2释放的时候返回true。
- [1-2]不会抛出异常
- 建立自反(reflexive)、对称(symmetric)、传递关系(transitive)。
简单解释一下,上述第三点要求就相当于必须满足
- a1 == a1
- if (a1 == a2) then a2 == a1
- if (a1 == a2 and a2 == a3) then a1 == a3
- 声明
A a1(a); // [1]
A a1 == a; // [2]
A a(b) // [3]
A a1(std::move(a)) // [4]
A a1 = std::move(a) // [5]
A a(std::move(b)) // [6]
- [1-6]不会抛出异常
- [1-2]调用拷贝构造函数,且调用之后满足a1 == a。
- [3]用b去初始化a,满足B(a) == b且A(b) == a。
- [4-5]构造完a1以后a不能够发生改变且满足a1 == a。
- [6]构造的a必须等价于A(b)
从[4-6]看出来allocator的移动和拷贝应该是没什么区别的,标准库提供的两个allocator也是没有移动构造函数的。
解释一下[3]:
假设某个容器使用单链表作为底层的数据结构:
template <typename T, typename Allocator = std::allocator<T>>
class LinkList {
struct ListNode {
T value;
ListNode* next;
};
};
传入的Allocator类型是std::allocator<T>,但是实际上容器需要的类型是std::allocator<ListNode>。而[3]则是保证了我们可以通过如下方式获得真正需要的allocator。
std::allocator<int> alloc;
std::allocator<ListNode> node_alloc(alloc);
// 申请一个node
std::allocator_traits<std::allocator<ListNode>>::allocate(node_alloc, 1);
这些要求使得编写一个符合标准的allocator是比较困难的,一个最简单的方式是直接继承std::pmr::memory_resource然后使用std::pmr::polymorphic_allocator,不过memory_resource内部的虚函数会带来额外的开销,如果allocator的设计不能够弥补这部分开销,那么可能会弄巧成拙。如果读者想找一些allocator的实现来学习,也许可以看看这里,最下方有作者的Github。
5.2 实现一个带有allocator的单链表
最后我们通过实现一个简单的带有allocator的单链表来结束本文。
5.2.1 结点和数据成员定义
struct ListNodeBase {
ListNodeBase* next = nullptr;
};
struct ListNode : ListNodeBase {
T value; // [1] alignas(T) unsigned char value[sizeof(T)]
};
struct ListImpl {
size_t size = 0;
ListNodeBase header;
};
如果我们将ListNode作为头结点,那么我们需要将value字段改成备注中的形式以保证默认构造函数始终是合法的。
5.2.2 拷贝构造和拷贝赋值函数
LinkList(const LinkList& other)
: LinkList(other, node_alloc_traits::select_on_container_copy_construction(other.alloc))
{ }
LinkList& operator=(const LinkList& other) {
constexpr auto POCCA = typename alloc_traits::propagate_on_container_copy_assignment();
if (this != std::addressof(other)) {
clear();
if constexpr (POCCA)
alloc = other.alloc;
assign_from_another_list(other);
}
return *this;
}
对于链表这种node-based容器,我们直接清除内部的元素并归还内存即可。
5.2.3 移动构造和移动赋值函数
LinkList(LinkList&& other) noexcept(NoException)
: impl(std::exchange(other.impl, ListImpl())), alloc(std::move(other.alloc)) {
assert(other.is_empty_list());
}
LinkList& operator=(LinkList&& other) noexcept(NoException) {
constexpr auto POCMA = typename alloc_traits::propagate_on_container_move_assignment();
if (this != std::addressof(other)) {
clear();
if constexpr (POCMA) {
// Same as LinkList(LinkList&& other)
alloc = std::move(other.alloc);
impl = std::exchange(other.impl, ListImpl());
} else {
// Same as LinkList(LinkList&& other, const Allocator& a)
if (alloc == other.alloc) {
impl = std::exchange(other.impl, ListImpl());
} else {
move_from_another_list(other);
other.clear();
}
}
}
assert(other.is_empty_list() && "We always making other empty.");
return *this;
}
我们始终让LinkList在被move之后处于一种初始的状态,以保证其析构函数可以正确执行,且符合“对象被move之后就空了”这一直觉。用户后续可以把他当成一个初始的空的LinkList继续使用,当然这是极其不推荐的。
5.2.3 Swap函数
对于Swap函数,我们假设UB是不存在的。
void swap(LinkList& other) noexcept(NoException) {
std::swap(impl, other.impl);
if constexpr (typename alloc_traits::propagate_on_container_swap())
std::swap(alloc, other.alloc);
}
5.2.4、全部代码
由于代码较多,不在本文中展示,你可以点击这里。
参考文献
[1] https://github.com/abseil/abseil-cpp
[2] https://en.cppreference.com/w/