Project with Modern C++ 01: Classes
本文主要关注“如何组织 class”,对 Modern C++中定义 class 时的各种关键字和各种成员函数做一下学习。参考资料如下:
- 项目:
- 书籍:Effective Modern C++
1. Introduction
在通常个人编写一些简单的小软件时,可能鲜有机会“手动地”定义 class,对于各种构造函数,析构函数都是直接使用 C++ 的默认函数。所以我们先给出项目中具体的 class 的代码作为示例:
/**
* @brief An RAII object that grants thread-safe read access to a page of data.
*
* The _only_ way that the BusTub system should interact with the buffer pool's page data is via page guards. Since
* `ReadPageGuard` is an RAII object, the system never has to manually lock and unlock a page's latch.
*
* With `ReadPageGuard`s, there can be multiple threads that share read access to a page's data. However, the existence
* of any `ReadPageGuard` on a page implies that no thread can be mutating the page's data.
*/
class ReadPageGuard {
/** @brief Only the buffer pool manager is allowed to construct a valid `ReadPageGuard.` */
friend class BufferPoolManager;
public:
/**
* @brief The default constructor for a `ReadPageGuard`.
*
* Note that we do not EVER want use a guard that has only been default constructed. The only reason we even define
* this default constructor is to enable placing an "uninitialized" guard on the stack that we can later move assign
* via `=`.
*
* **Use of an uninitialized page guard is undefined behavior.**
*
* In other words, the only way to get a valid `ReadPageGuard` is through the buffer pool manager.
*/
ReadPageGuard() = default;
ReadPageGuard(const ReadPageGuard &) = delete;
auto operator=(const ReadPageGuard &) -> ReadPageGuard & = delete;
ReadPageGuard(ReadPageGuard &&that) noexcept;
auto operator=(ReadPageGuard &&that) noexcept -> ReadPageGuard &;
~ReadPageGuard();
// ...
private:
/** @brief Only the buffer pool manager is allowed to construct a valid `ReadPageGuard.` */
explicit ReadPageGuard(page_id_t page_id, std::shared_ptr<FrameHeader> frame, std::shared_ptr<ArcReplacer> replacer,
std::shared_ptr<std::mutex> bpm_latch, std::shared_ptr<DiskScheduler> disk_scheduler);
// ...
};
正如类名中的“page guard”,在数据库项目中,一个 buffer pool manager(后文简称 bpm) 需要面对多线程访问 buffer pool 中页面的情况,对此 bpm 提供了一个用于多线程访问场景下保护页面数据的 RAII 类型,上层想要对页面进行读和写时,必须通过 bpm 提供的接口,获得一个相应页面的 page guard 类,然后通过这个 page guard 进行数据读或者写。
上面的代码就是一个 write page guard 的部分代码,是一个典型的“手动定义”的例子。里面有很多我之前从未使用过的关键字,包括:friend , delete, explicit, noexcept ;并且涉及到各种特殊成员函数。
2. The Rule of 3/5/0
你可能已经知道“3/5/0 法则”,它涉及到以下特殊成员函数:
- 默认构造函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
在一般情况下,你可能不会显示地声明它们,此时编译器会根据需要自动生成。但是一旦你需要手动定义,此时意味着你的类在管理某种资源(内存、锁等),这时候就需要遵循“3/5/0 法则”
2.1 三法则
如果一个类需要显式定义析构函数、拷贝构造函数或者拷贝赋值运算符中的任何一个,那么你几乎肯定需要同时显式定义这三个。
这是因为,当你析构函数需要手动释放某项资源时,由于编译器自动生成的拷贝只会做浅拷贝,这回导致你复制过的对象,同一块资源被释放两次。因此,你也需要让拷贝构造函数做深拷贝,而不是浅拷贝。
由于有这种情况的存在,哪怕你实际情况可能不是这样,你也要立马根据“三法则”同时对这三个特殊成员函数做思考。
2.2 五法则
随着 C++引入了移动语义,如果你的类需要自定义析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个,那么通常需要把这五个都自定义了。
依旧是举个简单的例子,你把一个对象 A 移动到另一个对象 B 后,当 A 的析构函数调用时,他试图释放资源,但问题是资源已经被移动了,此时导致释放空资源的问题。因此,你需要保证移动操作让你的源对象处于“不持有资源但可以被析构”的状态。
2.3 零法则
如果一个类不需要管理任何资源,就不要写任何特殊成员函数,全部交给编译器自动生成。
此时建议你的成员用标准库中有的 RAII 类型封装资源,例如 std::string, std::vector, std::unique_ptr 等,或者是自动管理资源的对象。这样编译器自动生成的特殊成员变量通常都是正确且高效的。
3. Move Assignment and erase()
以下是我最初编写的移动赋值:
/**
* @brief The move assignment operator for `ReadPageGuard`.
*
* ### Implementation
*
* If you are unfamiliar with move semantics, please familiarize yourself with learning materials online. There are many
* great resources (including articles, Microsoft tutorials, YouTube videos) that explain this in depth.
*
* Make sure you invalidate the other guard; otherwise, you might run into double free problems! For both objects, you
* need to update _at least_ 5 fields each, and for the current object, make sure you release any resources it might be
* holding on to.
*
* @param that The other page guard.
* @return ReadPageGuard& The newly valid `ReadPageGuard`.
*/
auto ReadPageGuard::operator=(ReadPageGuard &&that) noexcept -> ReadPageGuard & {
page_id_ = that.page_id_;
frame_ = std::move(that.frame_);
replacer_ = std::move(that.replacer_);
bpm_latch_ = std::move(that.bpm_latch_);
disk_scheduler_ = std::move(that.disk_scheduler_);
is_valid_ = that.is_valid_;
rlk_ = std::move(that.rlk_);
that.is_valid_ = false;
return *this;
}
void ReadPageGuard::Drop() {
if (!is_valid_) {
return;
}
// 省略
is_valid_ = false;
}
很简单,把 that 中需要 move 的成员都给他 move 过来,然后把 that 的 is_valid_标记为 false,让 that 处于不持有资源,但可析构的状态。但是在测试中我遇到了这么一段代码(下面是 write page guard,但实现和上面示例的 read page guard 类似):
// Create a vector of unique pointers to page guards, which prevents the guards from getting destructed.
std::vector<WritePageGuard> pages;
// 省略
// Scenario: We should be able to create new pages until we fill up the buffer pool.
for (size_t i = 0; i < FRAMES; i++) {
const auto pid = bpm->NewPage();
auto page = bpm->WritePage(pid);
pages.push_back(std::move(page));
}
// Scenario: Drop the first 5 pages to unpin them.
for (size_t i = 0; i < FRAMES / 2; i++) {
const auto pid = pages[0].GetPageId();
EXPECT_EQ(1, bpm->GetPinCount(pid));
pages.erase(pages.begin()); // TODO(wnw231423): this is a test for understanding move assignment
EXPECT_EQ(0, bpm->GetPinCount(pid)); // Test failed here!
}
测试失败了。这里涉及到对 vector 的 erase() 函数的理解问题,erase() 函数的做法是“元素左移,尾部集中销毁”。虽然在 cppreference 只给了很隐晦的说法:
Complexity Linear: the number of calls to the destructor of
Tis the same as the number of elements erased, the assignment operator ofTis called the number of times equal to the number of elements in the vector after the erased elements.
但这里只有“元素左移,尾部集中销毁”这种做法满足条件,因为:
- 如果先析构被 erase 的元素,那么做左移之后,尾部还需要做一次析构
- 不能先析构被 erase 的元素,因为一旦被析构,内存释放,后面元素尝试移动赋值到该元素就会出错。
但我这里想当然地觉得 erase() 函数会调用被 erase 函数的析构函数,导致这里报错了。
因此,这个教训也告诉我,移动赋值也需要释放资源,所以正确的实现应该如下:
auto ReadPageGuard::operator=(ReadPageGuard &&that) noexcept -> ReadPageGuard & {
if (this != &that) {
Drop();
page_id_ = that.page_id_;
frame_ = std::move(that.frame_);
replacer_ = std::move(that.replacer_);
bpm_latch_ = std::move(that.bpm_latch_);
disk_scheduler_ = std::move(that.disk_scheduler_);
is_valid_ = that.is_valid_;
rlk_ = std::move(that.rlk_);
that.is_valid_ = false;
}
4. Keywords
4.1 delete
这里记录两种需要使用 delete 关键字的情景。
情景 一:禁止复制
为了确保一个类不能被复制只能被移动,我们可以把它的拷贝构造函数和拷贝赋值 delete 掉,例如上面提到的 page guard 的例子。
ReadPageGuard(const ReadPageGuard &) = delete;
auto operator=(const ReadPageGuard &) -> ReadPageGuard & = delete;
情景二:禁止构造
为了确保一个类不能够被构造,我们可以把它的构造函数和析构函数 delete 掉。例如下面这个例子,在一个 DBMS 中,上层必须通过 Buffer Pool Manager 获得一个 page guard,再通过 page guard 的 GetData() 接口,获得该页的内存指针。
auto GetData() const -> const char *;
在 DBMS 中,我们的 B+树索引中包括内部结点和叶子结点,继而我们定义了相应的 page 类型,包括 BPlusTreeInternalPage 和 BPlusTreeLeafPage,但我们的 page 只能是通过 buffer pool manager 提供的 page guard 获得的 char * 指针,通过 reinterpret_cast 来转化成 internal page 或者 leaf page, 所以我们不希望该 page 类型能被构造。
// Delete all constructor / destructor to ensure memory safety
BPlusTreePage() = delete;
BPlusTreePage(const BPlusTreePage &other) = delete;
~BPlusTreePage() = delete;
关于 delete 关键字的更多使用技巧可以参考 Effective Modern C++ 的 Item 11 一章。
4.2 Other keywords
然后就是几个关键字的用法,这里结合自己的理解做一下记录:
friend class,结合注释可以看到这里 page guard 是一个完全私有的类,其构造函数是 private 的,并且只属于 bpm,故声明友元类。explicit,用于构造函数不做隐式类型转换,感觉这个是好习惯应该多用,我宁愿在使用构造函数的时候显式地做类型转化,我也不想让构造函数做隐式类型转换。noexcept,用于指示一个函数绝对不会抛出异常,这个肯定也是保证正确的情况下多用,最低也能作为函数 specification 的一部分,但这个说实话让我自己来写我可能很难想到去用这个关键字。可以参考 Effective Modern C++ 的 Item 14 一章。
5. Comparison: Visibility in Rust
依旧是以 bpm 和 page guard 为例,我们尝试一下用 Rust 实现这种关系,从我的个人感觉来说,比较现代的编程语言都淡化了“class”这种东西。所以我们借助 Rust 尝试一下。
目标是这样的,有两个 struct A(bpm)和 B(page guard),A 有公开的 fields/methods 以及私有的 fields/methods,B 也有公开的 fields/methods 以及私有的 fileds/methods,然后要求 B 的构造函数只有 A 能调用。
以下是代码示例,其中具体的 fields 和 method 都只是例子,不代表实际的实现:
// buffer_pool.rs
// 这个文件实现了 buffer_pool module
pub struct Bpm {
pub size: u32, // 公开的field
frames: Vec<Frame>, // 私有的field
}
pub struct PageGuard {
pub size: u32, // 公开的field
frame: Frame, // 私有的field
}
impl Bpm {
pub fn new() -> Self { ... } // 公开的method
pub fn pg(&self) -> PageGuard {
...
let pg = PageGuard::new();
...
} // 公开的method,调用了PageGuard的构造函数
fn private_f(&self) { ... } // 私有的method
}
impl PageGuard {
fn new() -> Self { ... } // 私有的method,构造函数,只有Bpm可以调用
pub fn get_data(&self) -> Data { ... } // 公开的method
fn private_f(&self) { ... } // 私有的method
}
首先,因为 PageGuard 有私有 field,所以外界无法通过类似于 let pg = Pageguard { size: 42, frame: xxx, } 的方式直接构造,然后,由于 Pageguard 没有将 new() 设为 pub,因此外界也无法调用我们自定义的构造函数。最后,bpm 可以调用 PageGuard 的 new(),是因为 Bpm 和 PageGuard 在同一个模块。
这里也可以看出,相比 C++中,一个 class 以外就是外界,在 Rust 中,一个 struct 以外是 module,module 以外才是外界。这也使得 Rust 不需要 C++这种 friend class 的东西,就能实现同样的东西。