CMU 15-445 projects
课程Slides&Notes
15-445 & 数据库内核 - 知乎 (zhihu.com)
Project #0 - C++ Primer
完整代码
//===----------------------------------------------------------------------===//
//
// BusTub
//
// p0_trie.h
//
// Identification: src/include/primer/p0_trie.h
//
// Copyright (c) 2015-2022, Carnegie Mellon University Database Group
//
//===----------------------------------------------------------------------===//
#pragma once
#include <memory>
#include <stack>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <utility>
#include <vector>
#include "common/exception.h"
#include "common/rwlatch.h"
namespace bustub {
/**
* TrieNode is a generic container for any node in Trie.
*/
class TrieNode {
public:
explicit TrieNode(char key_char) {
key_char_ = key_char;
is_end_ = false;
children_.clear();
}
TrieNode(TrieNode &&other_trie_node) noexcept {
key_char_ = other_trie_node.key_char_;
is_end_ = other_trie_node.is_end_;
children_.swap(other_trie_node.children_);
}
virtual ~TrieNode() = default;
bool HasChild(char key_char) const { return children_.find(key_char) != children_.end(); }
bool HasChildren() const { return !children_.empty(); }
bool IsEndNode() const { return is_end_; }
char GetKeyChar() const { return key_char_; }
std::unique_ptr<TrieNode> *InsertChildNode(char key_char, std::unique_ptr<TrieNode> &&child) {
if (HasChild(key_char) || key_char != child->key_char_) {
return nullptr;
}
children_[key_char] = std::forward<std::unique_ptr<TrieNode>>(child);
return &children_[key_char];
}
std::unique_ptr<TrieNode> *GetChildNode(char key_char) {
auto node = children_.find(key_char);
if (node != children_.end()) {
return &(node->second);
}
return nullptr;
}
void RemoveChildNode(char key_char) {
auto node = children_.find(key_char);
if (node != children_.end()) {
children_.erase(key_char);
}
}
void SetEndNode(bool is_end) { is_end_ = is_end; }
protected:
char key_char_;
bool is_end_{false};
std::unordered_map<char, std::unique_ptr<TrieNode>> children_;
};
template <typename T>
class TrieNodeWithValue : public TrieNode {
private:
T value_;
public:
TrieNodeWithValue(TrieNode &&trieNode, T value) : TrieNode(std::forward<TrieNode>(trieNode)) {
value_ = value;
SetEndNode(true);
}
TrieNodeWithValue(char key_char, T value) : TrieNode(key_char) {
value_ = value;
SetEndNode(true);
}
~TrieNodeWithValue() override = default;
T GetValue() const { return value_; }
};
class Trie {
private:
/* Root node of the trie */
std::unique_ptr<TrieNode> root_;
/* Read-write lock for the trie */
ReaderWriterLatch latch_;
public:
Trie() {
// auto *root = new TrieNode('\0');
root_ = std::make_unique<TrieNode>('\0');//make_unique不是传TrieNode*,而是直接调用TrieNode的构造函数了
}
template <typename T>
bool Insert(const std::string &key, T value) {
if (key.empty()) {
return false;
}
latch_.WLock();
auto c = key.begin();
auto pre_child = &root_;
while (c != key.end()) {
auto cur = c++;
if (c == key.end()) {
break;
}
if (!pre_child->get()->HasChild(*cur)) {
pre_child = (*pre_child)->InsertChildNode(*cur, std::make_unique<TrieNode>(*cur));//调用对应拷贝构造
} else {
pre_child = (**pre_child).GetChildNode(*cur);
}
}
c--;
auto end_node = pre_child->get()->GetChildNode(*c);
if (end_node != nullptr && end_node->get()->IsEndNode()) {
latch_.WUnlock();
return false;
}
if (end_node != nullptr) {
auto new_node = new TrieNodeWithValue(std::move(**end_node), value);
end_node->reset(new_node);
latch_.WUnlock();
return true;
}
pre_child = pre_child->get()->InsertChildNode(*c, std::make_unique<TrieNode>(*c));//调用对应拷贝构造
auto new_node = new TrieNodeWithValue(std::move(**pre_child), value);
pre_child->reset(new_node);
latch_.WUnlock();
return true;
}
bool Remove(const std::string &key) {
if (key.empty()) {
return false;
}
latch_.WLock();
auto previous = &root_;
std::vector<std::unique_ptr<TrieNode> *> vec;
for (auto cur : key) {
auto temp = (*previous)->GetChildNode(cur);
if (temp == nullptr) {
latch_.WUnlock();
return false;
}
vec.emplace_back(previous);
previous = temp;
}
previous->get()->SetEndNode(false);
while (!vec.empty() && !previous->get()->IsEndNode() && !previous->get()->HasChildren()) {
auto tmp = vec[vec.size() - 1];
tmp->get()->RemoveChildNode((**previous).GetKeyChar());
vec.pop_back();
previous = tmp;
}
latch_.WUnlock();
return true;
}
template <typename T>
T GetValue(const std::string &key, bool *success) {
T ret{};
if (key.empty()) {
*success = false;
return ret;
}
latch_.RLock();
auto pre = &root_;
for (auto ch : key) {
auto next = pre->get()->GetChildNode(ch);
if (next == nullptr) {
*success = false;
latch_.RUnlock();
return ret;
}
pre = next;
}
auto newnode = dynamic_cast<TrieNodeWithValue<T> *>(pre->get());
if (newnode == nullptr) {
*success = false;
// ret = {};
} else {
*success = true;
ret = newnode->GetValue();
}
latch_.RUnlock();
return ret;
}
};
} // namespace bustub
C++语法参考链接
-
#define INTERGE int; unsigned INTERGE n; //没问题 typedef int INTERGE; unsigned INTERGE n; //错误,不能在 INTERGE 前面添加 unsigned,因为INTERGE是编译时被创建的一个新的类型,并非文本替换
-
C++11 unique_ptr智能指针详解 (biancheng.net) 现在已经有
make_unique<>
了 -
(51条消息) 智能指针make_unique 与make_shared 的知识介绍_aFakeProgramer的博客-CSDN博客
-
https://www.zhihu.com/question/363686723/answer/2590214399
TrieNode
的移动构造函数
注意:由于unique_ptr的特性,我们经常使用一个指针来指向智能指针!!!这样就可以防止对unique_ptr的复制了
这里对复合unique_ptr<TrieNode>
的unordered_map
进行拷贝构造时候,需要注意智能指针的特性,对外层容器使用std::move
或者使用unique_ptr<TrieNode>
和unordered_map
都包含的swap()
方法
C++ 11中的所有容器都实现了
move
语义,move
只是转移了资源的控制权,本质上是将左值强制转化为右值使用,以用于移动拷贝或赋值,避免对含有资源的对象发生无谓的拷贝
Reference :[c++11]我理解的右值引用、移动语义和完美转发
c++中引入了
右值引用
和移动语义
,可以避免无谓的复制,提高程序性能。有点难理解,于是花时间整理一下自己的理解。左值、右值
C++
中所有的值都必然属于左值、右值二者之一。左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。很难得到左值和右值的真正定义,但是有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值。左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。
看见书上又将右值分为将亡值和纯右值。纯右值就是
c++98
标准中右值的概念,如非引用返回的函数返回的临时变量值;一些运算表达式,如1+2产生的临时变量;不跟对象关联的字面量值,如2,‘c’,true,“hello”;这些值都不能够被取地址。而将亡值则是
c++11
新增的和右值引用相关的表达式,这样的表达式通常时将要移动的对象、T&&
函数返回值、std::move()
函数的返回值等,不懂将亡值和纯右值的区别其实没关系,统一看作右值即可,不影响使用。
示例:
int i=0;// i是左值, 0是右值 class A { public: int a; }; A getTemp() { return A(); } A a = getTemp(); // a是左值 getTemp()的返回值是右值(临时变量)
左值引用、右值引用
c++98
中的引用很常见了,就是给变量取了个别名,在c++11
中,因为增加了右值引用(rvalue reference)的概念,所以c++98
中的引用都称为了左值引用(lvalue reference)。int a = 10; int& refA = a; // refA是a的别名, 修改refA就是修改a, a是左值,左移是左值引用 int& b = 1; //编译错误! 1是右值,不能够使用左值引用
c++11
中的右值引用使用的符号是&&
,如int&& a = 1; //实质上就是将不具名(匿名)变量取了个别名 int b = 1; int && c = b; //编译错误! 不能将一个左值复制给一个右值引用 class A { public: int a; }; A getTemp() { return A(); } A && a = getTemp(); //getTemp()的返回值是右值(临时变量)
getTemp()
返回的右值本来在表达式语句结束后,其生命也就该终结了(因为是临时变量),而通过右值引用,该右值又重获新生,其生命期将与右值引用类型变量a
的生命期一样,只要a
还活着,该右值临时变量将会一直存活下去。实际上就是给那个临时变量取了个名字。注意:这里
a
的类型是右值引用类型(int &&
),但是如果从左值和右值的角度区分它,它实际上是个左值。因为可以对它取地址,而且它还有名字,是一个已经命名的右值。所以,左值引用只能绑定左值,右值引用只能绑定右值,如果绑定的不对,编译就会失败。但是,常量左值引用却是个奇葩,它可以算是一个“万能”的引用类型,它可以绑定非常量左值、常量左值、右值,而且在绑定右值的时候,常量左值引用还可以像右值引用一样将右值的生命期延长,缺点是,只能读不能改。
const int & a = 1; //常量左值引用绑定 右值, 不会报错 class A { public: int a; }; A getTemp() { return A(); } const A & a = getTemp(); //不会报错 而 A& a 会报错
事实上,很多情况下我们用来常量左值引用的这个功能却没有意识到,如下面的例子:
#include <iostream> using namespace std; class Copyable { public: Copyable(){} Copyable(const Copyable &o) { cout << "Copied" << endl; } }; Copyable ReturnRvalue() { return Copyable(); //返回一个临时对象 } void AcceptVal(Copyable a) { } void AcceptRef(const Copyable& a) { } int main() { cout << "pass by value: " << endl; AcceptVal(ReturnRvalue()); // 应该调用两次拷贝构造函数 cout << "pass by reference: " << endl; AcceptRef(ReturnRvalue()); //应该只调用一次拷贝构造函数 }
当我敲完上面的例子并运行后,发现结果和我想象的完全不一样!期望中
AcceptVal(ReturnRvalue())
需要调用两次拷贝构造函数,一次在ReturnRvalue()
函数中,构造好了Copyable
对象,返回的时候会调用拷贝构造函数生成一个临时对象,在调用AcceptVal()
时,又会将这个对象拷贝给函数的局部变量a
,一共调用了两次拷贝构造函数。而AcceptRef()
的不同在于形参是常量左值引用,它能够接收一个右值,而且不需要拷贝。而实际的结果是,不管哪种方式,一次拷贝构造函数都没有调用!
这是由于编译器默认开启了返回值优化(RVO/NRVO, RVO, Return Value Optimization 返回值优化,或者NRVO, Named Return Value Optimization)。编译器很聪明,发现在
ReturnRvalue
内部生成了一个对象,返回之后还需要生成一个临时对象调用拷贝构造函数,很麻烦,所以直接优化成了1个对象对象,避免拷贝,而这个临时变量又被赋值给了函数的形参,还是没必要,所以最后这三个变量都用一个变量替代了,不需要调用拷贝构造函数。虽然各大厂家的编译器都已经都有了这个优化,但是这并不是
c++
标准规定的,而且不是所有的返回值都能够被优化,而这篇文章的主要讲的右值引用,移动语义可以解决编译器无法解决的问题。为了更好的观察结果,可以在编译的时候加上
-fno-elide-constructors
选项(关闭返回值优化)。// g++ test.cpp -o test -fno-elide-constructors pass by value: Copied Copied //可以看到确实调用了两次拷贝构造函数 pass by reference: Copied
上面这个例子本意是想说明常量左值引用能够绑定一个右值,可以减少一次拷贝(使用非常量的左值引用会编译失败),但是顺便讲到了编译器的返回值优化。。编译器还是干了很多事情的,很有用,但不能过于依赖,因为你也不确定它什么时候优化了什么时候没优化。
总结一下,其中
T
是一个具体类型:
- 左值引用, 使用
T&
, 只能绑定左值- 右值引用, 使用
T&&
, 只能绑定右值- 常量左值, 使用
const T&
, 既可以绑定左值又可以绑定右值- 已命名的右值引用,编译器会认为是个左值
- 编译器有返回值优化,但不要过于依赖
移动构造和移动赋值
回顾一下如何用c++实现一个字符串类
MyString
,MyString
内部管理一个C语言的char *
数组,这个时候一般都需要实现拷贝构造函数和拷贝赋值函数,因为默认的拷贝是浅拷贝,而指针这种资源不能共享,不然一个析构了,另一个也就完蛋了。具体代码如下:
#include <iostream> #include <cstring> #include <vector> using namespace std; class MyString { public: static size_t CCtor; //统计调用拷贝构造函数的次数 // static size_t CCtor; //统计调用拷贝构造函数的次数 public: // 构造函数 MyString(const char* cstr=0){ if (cstr) { m_data = new char[strlen(cstr)+1]; strcpy(m_data, cstr); } else { m_data = new char[1]; *m_data = '\0'; } } // 拷贝构造函数 MyString(const MyString& str) { CCtor ++; m_data = new char[ strlen(str.m_data) + 1 ]; strcpy(m_data, str.m_data); } // 拷贝赋值函数 =号重载 MyString& operator=(const MyString& str){ if (this == &str) // 避免自我赋值!! return *this; delete[] m_data; m_data = new char[ strlen(str.m_data) + 1 ]; strcpy(m_data, str.m_data); return *this; } ~MyString() { delete[] m_data; } char* get_c_str() const { return m_data; } private: char* m_data; }; size_t MyString::CCtor = 0; int main() { vector<MyString> vecStr; vecStr.reserve(1000); //先分配好1000个空间,不这么做,调用的次数可能远大于1000,因为vector的内存空间是连续的,如果一次次调用push_back()开新空间,那么需要频繁拷贝移动原空间的元素,而reserve()只是分配相应内存空间,并没有构建实际对象,只有在push_back操作时才会在vecStr已经分配的空间中创建对象 for(int i=0;i<1000;i++){ vecStr.push_back(MyString("hello")); } cout << MyString::CCtor << endl; }
代码看起来挺不错,却发现执行了
1000
次拷贝构造函数,如果MyString("hello")
构造出来的字符串本来就很长,构造一遍就很耗时了,最后却还要拷贝一遍,而MyString("hello")
只是临时对象,拷贝完就没什么用了,这就造成了没有意义的资源申请和释放操作,如果能够直接使用临时对象已经申请的资源,既能节省资源,又能节省资源申请和释放的时间。而C++11
新增加的移动语义就能够做到这一点。要实现移动语义就必须增加两个函数:移动构造函数和移动赋值构造函数。
#include <iostream> #include <cstring> #include <vector> using namespace std; class MyString { public: static size_t CCtor; //统计调用拷贝构造函数的次数 static size_t MCtor; //统计调用移动构造函数的次数 static size_t CAsgn; //统计调用拷贝赋值函数的次数 static size_t MAsgn; //统计调用移动赋值函数的次数 public: // 构造函数 MyString(const char* cstr=0){ if (cstr) { m_data = new char[strlen(cstr)+1]; strcpy(m_data, cstr); } else { m_data = new char[1]; *m_data = '\0'; } } // 拷贝构造函数 MyString(const MyString& str) { CCtor ++; m_data = new char[ strlen(str.m_data) + 1 ]; strcpy(m_data, str.m_data); } // 移动构造函数 MyString(MyString&& str) noexcept :m_data(str.m_data) { MCtor ++; str.m_data = nullptr; //不再指向之前的资源了 } // 拷贝赋值函数 =号重载 MyString& operator=(const MyString& str){ CAsgn ++; if (this == &str) // 避免自我赋值!! return *this; delete[] m_data; m_data = new char[ strlen(str.m_data) + 1 ]; strcpy(m_data, str.m_data); return *this; } // 移动赋值函数 =号重载 MyString& operator=(MyString&& str) noexcept{ MAsgn ++; if (this == &str) // 避免自我赋值!! return *this; delete[] m_data; m_data = str.m_data; str.m_data = nullptr; //不再指向之前的资源了 return *this; } ~MyString() { delete[] m_data; } char* get_c_str() const { return m_data; } private: char* m_data; }; size_t MyString::CCtor = 0; size_t MyString::MCtor = 0; size_t MyString::CAsgn = 0; size_t MyString::MAsgn = 0; int main() { vector<MyString> vecStr; vecStr.reserve(1000); //先分配好1000个空间 for(int i=0;i<1000;i++){ vecStr.push_back(MyString("hello")); } cout << "CCtor = " << MyString::CCtor << endl; cout << "MCtor = " << MyString::MCtor << endl; cout << "CAsgn = " << MyString::CAsgn << endl; cout << "MAsgn = " << MyString::MAsgn << endl; } /* 结果 CCtor = 0 MCtor = 1000 CAsgn = 0 MAsgn = 0 */
可以看到,移动构造函数与拷贝构造函数的区别是,拷贝构造的参数是
const MyString& str
,是常量左值引用,而移动构造的参数是MyString&& str
,是右值引用,而MyString("hello")
是个临时对象,是个右值,优先进入移动构造函数而不是拷贝构造函数。而移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间,将要拷贝的对象复制过来,而是"偷"了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr
,这一步很重要,如果不将别人的指针修改为空,那么临时对象析构的时候就会释放掉这个资源,"偷"也白偷了。下面这张图可以解释copy和move的区别。不用奇怪为什么可以抢别人的资源,临时对象的资源不好好利用也是浪费,因为生命周期本来就是很短,在你执行完这个表达式之后,它就毁灭了,充分利用资源,才能很高效。
对于一个左值,肯定是调用拷贝构造函数了,但是有些左值是局部变量,生命周期也很短,能不能也移动而不是拷贝呢?
C++11
为了解决这个问题,提供了std::move()
方法来将左值转换为右值,从而方便应用移动语义。我觉得它其实就是告诉编译器,虽然我是一个左值,但是不要对我用拷贝构造函数,而是用移动构造函数吧。。。int main() { vector<MyString> vecStr; vecStr.reserve(1000); //先分配好1000个空间 for(int i=0;i<1000;i++){ MyString tmp("hello"); vecStr.push_back(tmp); //调用的是拷贝构造函数 } cout << "CCtor = " << MyString::CCtor << endl; cout << "MCtor = " << MyString::MCtor << endl; cout << "CAsgn = " << MyString::CAsgn << endl; cout << "MAsgn = " << MyString::MAsgn << endl; cout << endl; MyString::CCtor = 0; MyString::MCtor = 0; MyString::CAsgn = 0; MyString::MAsgn = 0; vector<MyString> vecStr2; vecStr2.reserve(1000); //先分配好1000个空间 for(int i=0;i<1000;i++){ MyString tmp("hello"); vecStr2.push_back(std::move(tmp)); //调用的是移动构造函数 } cout << "CCtor = " << MyString::CCtor << endl; cout << "MCtor = " << MyString::MCtor << endl; cout << "CAsgn = " << MyString::CAsgn << endl; cout << "MAsgn = " << MyString::MAsgn << endl; } /* 运行结果 CCtor = 1000 MCtor = 0 CAsgn = 0 MAsgn = 0 CCtor = 0 MCtor = 1000 CAsgn = 0 MAsgn = 0 */
下面再举几个例子:
MyString str1("hello"); //调用构造函数 MyString str2("world"); //调用构造函数 MyString str3(str1); //调用拷贝构造函数 MyString str4(std::move(str1)); // 调用移动构造函数、 // cout << str1.get_c_str() << endl; // 此时str1的内部指针已经失效了!不要使用 //注意:虽然str1中的m_dat已经称为了空,但是str1这个对象还活着,知道出了它的作用域才会析构!而不是move完了立刻析构 MyString str5; str5 = str2; //调用拷贝赋值函数 MyString str6; str6 = std::move(str2); // str2的内容也失效了,不要再使用
需要注意一下几点:
str6 = std::move(str2)
,虽然将str2
的资源给了str6
,但是str2
并没有立刻析构,只有在str2
离开了自己的作用域的时候才会析构,所以,如果继续使用str2
的m_data
变量,可能会发生意想不到的错误。- 如果我们没有提供移动构造函数,只提供了拷贝构造函数,
std::move()
会失效但是不会发生错误,因为编译器找不到移动构造函数就去寻找拷贝构造函数,也这是拷贝构造函数的参数是const T&
常量左值引用的原因!c++11中
的所有容器都实现了move
语义,move
只是转移了资源的控制权,本质上是将左值强制转化为右值使用,以用于移动拷贝或赋值,避免对含有资源的对象发生无谓的拷贝。move
对于拥有如内存、文件句柄等资源的成员的对象有效,如果是一些基本类型,如int和char[10]数组等,如果使用move,仍会发生拷贝(因为没有对应的移动构造函数),所以说move
对含有资源的对象说更有意义。universal references(通用引用)
当右值引用和模板结合的时候,就复杂了。
T&&
并不一定表示右值引用,它可能是个左值引用又可能是个右值引用。例如:template<typename T> void f( T&& param){ } f(10); //10是右值 int x = 10; // f(x); //x是左值
如果上面的函数模板表示的是右值引用的话,肯定是不能传递左值的,但是事实却是可以。这里的
&&
是一个未定义的引用类型,称为universal references
,它必须被初始化,它是左值引用还是右值引用却决于它的初始化,如果它被一个左值初始化,它就是一个左值引用;如果被一个右值初始化,它就是一个右值引用。注意:只有当发生自动类型推断时(如函数模板的类型自动推导,或auto关键字),
&&
才是一个universal references
。例如:
template<typename T> void f( T&& param); //这里T的类型需要推导,所以&&是一个 universal references template<typename T> class Test { Test(Test&& rhs); //Test是一个特定的类型,不需要类型推导,所以&&表示右值引用 }; void f(Test&& param); //右值引用 //复杂一点 template<typename T> void f(std::vector<T>&& param); //在调用这个函数之前,这个vector<T>中的推断类型 //已经确定了,所以调用f函数的时候没有类型推断了,所以是 右值引用 template<typename T> void f(const T&& param); //右值引用 // universal references仅仅发生在 T&& 下面,任何一点附加条件都会使之失效
所以最终还是要看
T
被推导成什么类型,如果T
被推导成了string
,那么T&&
就是string&&
,是个右值引用,如果T
被推导为string&
,就会发生类似string& &&
的情况,对于这种情况,c++11
增加了引用折叠的规则,总结如下:
- 所有的右值引用叠加到右值引用上仍然使一个右值引用。
- 所有的其他引用类型之间的叠加都将变成左值引用。
如上面的
T& &&
其实就被折叠成了个string &
,是一个左值引用。#include <iostream> #include <type_traits> #include <string> using namespace std; template<typename T> void f(T&& param){ if (std::is_same<string, T>::value) std::cout << "string" << std::endl; else if (std::is_same<string&, T>::value) std::cout << "string&" << std::endl; else if (std::is_same<string&&, T>::value) std::cout << "string&&" << std::endl; else if (std::is_same<int, T>::value) std::cout << "int" << std::endl; else if (std::is_same<int&, T>::value) std::cout << "int&" << std::endl; else if (std::is_same<int&&, T>::value) std::cout << "int&&" << std::endl; else std::cout << "unknown" << std::endl; } int main() { int x = 1; f(1); // 参数是右值 T推导成了int, 所以是int&& param, 右值引用 f(x); // 参数是左值 T推导成了int&, 所以是int&&& param, 折叠成 int&,左值引用 int && a = 2; f(a); //虽然a是右值引用,但它还是一个左值, T推导成了int& string str = "hello"; f(str); //参数是左值 T推导成了string& f(string("hello")); //参数是右值, T推导成了string f(std::move(str));//参数是右值, T推导成了string }
所以,归纳一下, 传递左值进去,就是左值引用,传递右值进去,就是右值引用。如它的名字,这种类型确实很"通用",下面要讲的完美转发,就利用了这个特性。
完美转发
所谓转发,就是通过一个函数将参数继续转交给另一个函数进行处理,原参数可能是右值,可能是左值,如果还能继续保持参数的原有特征,那么它就是完美的。
void process(int& i){ cout << "process(int&):" << i << endl; } void process(int&& i){ cout << "process(int&&):" << i << endl; } void myforward(int&& i){ cout << "myforward(int&&):" << i << endl; process(i); } int main() { int a = 0; process(a); //a被视为左值 process(int&):0 process(1); //1被视为右值 process(int&&):1 process(move(a)); //强制将a由左值改为右值 process(int&&):0 myforward(2); //右值经过forward函数转交给process函数,却称为了一个左值, //原因是该右值有了名字 所以是 process(int&):2 myforward(move(a)); // 同上,在转发的时候右值变成了左值 process(int&):0 // forward(a) // 错误用法,右值引用不接受左值 }
上面的例子就是不完美转发,而c++中提供了一个
std::forward()
模板函数解决这个问题。将上面的myforward()
函数简单改写一下:void myforward(int&& i){ cout << "myforward(int&&):" << i << endl; process(std::forward<int>(i)); } myforward(2); // process(int&&):2
上面修改过后还是不完美转发,
myforward()
函数能够将右值转发过去,但是并不能够转发左值,解决办法就是借助universal references
通用引用类型和std::forward()
模板函数共同实现完美转发。例子如下:#include <iostream> #include <cstring> #include <vector> using namespace std; void RunCode(int &&m) { cout << "rvalue ref" << endl; } void RunCode(int &m) { cout << "lvalue ref" << endl; } void RunCode(const int &&m) { cout << "const rvalue ref" << endl; } void RunCode(const int &m) { cout << "const lvalue ref" << endl; } // 这里利用了universal references,如果写T&,就不支持传入右值,而写T&&,既能支持左值,又能支持右值 template<typename T> void perfectForward(T && t) { RunCode(forward<T> (t)); } template<typename T> void notPerfectForward(T && t) { RunCode(t); } int main() { int a = 0; int b = 0; const int c = 0; const int d = 0; notPerfectForward(a); // lvalue ref notPerfectForward(move(b)); // lvalue ref notPerfectForward(c); // const lvalue ref notPerfectForward(move(d)); // const lvalue ref cout << endl; perfectForward(a); // lvalue ref perfectForward(move(b)); // rvalue ref perfectForward(c); // const lvalue ref perfectForward(move(d)); // const rvalue ref }
上面的代码测试结果表明,在
universal references
和std::forward
的合作下,能够完美的转发这4种类型。emplace_back减少内存拷贝和移动
我们之前使用
vector
一般都喜欢用push_back()
,由上文可知容易发生无谓的拷贝,解决办法是为自己的类增加移动拷贝和赋值函数,但其实还有更简单的办法!就是使用emplace_back()
替换push_back()
,如下面的例子:#include <iostream> #include <cstring> #include <vector> using namespace std; class A { public: A(int i){ // cout << "A()" << endl; str = to_string(i); } ~A(){} A(const A& other): str(other.str){ cout << "A&" << endl; } public: string str; }; int main() { vector<A> vec; vec.reserve(10); for(int i=0;i<10;i++){ vec.push_back(A(i)); //调用了10次拷贝构造函数 // vec.emplace_back(i); //一次拷贝构造函数都没有调用过 } for(int i=0;i<10;i++) cout << vec[i].str << endl; }
可以看到效果是明显的,虽然没有测试时间,但是确实可以减少拷贝。
emplace_back()
可以原地直接通过构造函数的参数构造对象,但前提是要有对应的构造函数。对于
map
和set
,可以使用emplace()
。基本上emplace_back()
对应push_bakc()
,emplce()
对应insert()
。移动语义对
swap()
函数的影响也很大,之前实现swap可能需要三次内存拷贝,而有了移动语义后,就可以实现高性能的交换函数了。template <typename T> void swap(T& a, T& b) { T tmp(std::move(a)); a = std::move(b); b = std::move(tmp); }
如果T是可移动的,那么整个操作会很高效,如果不可移动,那么就和普通的交换函数是一样的,不会发生什么错误,很安全。
总结
- 由两种值类型,左值和右值。
- 有三种引用类型,左值引用、右值引用和通用引用。左值引用只能绑定左值,右值引用只能绑定右值,通用引用由初始化时绑定的值的类型确定。
- 左值和右值是独立于他们的类型的,右值引用可能是左值可能是右值,如果这个右值引用已经被命名了,他就是左值。
- 引用折叠规则:所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用折叠都为左值引用。当
T&&
为模板参数时,输入左值,它将变成左值引用,输入右值则变成具名的右值应用。- 移动语义可以减少无谓的内存拷贝,要想实现移动语义,需要实现移动构造函数和移动赋值函数。
std::move()
将一个左值转换成一个右值,强制使用移动拷贝和赋值函数,这个函数本身并没有对这个左值什么特殊操作。std::forward()
和universal references
通用引用共同实现完美转发。- 用
empalce_back()
替换push_back()
增加性能。
std::unordered_map<char, std::unique_ptr<TrieNode>> children_;
TrieNode(TrieNode &&other_trie_node) noexcept {
key_char_ = other_trie_node.key_char_;
is_end_ = other_trie_node.is_end_;
children_.swap(other_trie_node.children_);//children_ = std::move(other_trie_node.children_);
}
子类TrieNodeWithValue
转发构造函数
对于TrieNode
继承派生出的子类TrieNodeWithValue
。TrieNode(trieNode)
不会调用基类 TrieNode
的移动构造函数。应该使用成员初始化列表将 trieNode
转发(forward)给基类构造函数进行移动构造。
TrieNodeWithValue
是 TrieNode
的子类,构造子类时,如果没有手动在子类的构造函数中构造父类,就会调用父类的默认构造函数,而代码中父类是没有默认构造函数的,所以需要手动在子类中构造父类。
需要使用 std::forward<TrieNode>
转发右值引用。
template <typename T>
class TrieNodeWithValue : public TrieNode {
private:
/* Value held by this trie node. */
T value_;
public:
/**
* TODO(P0): Add implementation
*
* @brief Construct a new TrieNodeWithValue object from a TrieNode object and specify its value.
* This is used when a non-terminal TrieNode is converted to terminal TrieNodeWithValue.
*
* The children_ map of TrieNode should be moved to the new TrieNodeWithValue object.
* Since it contains unique pointers, the first parameter is a rvalue reference.
*
* You should:
* 1) invoke TrieNode's move constructor to move data from TrieNode to
* TrieNodeWithValue.
* 2) set value_ member variable of this node to parameter `value`.
* 3) set is_end_ to true
*
* @param trieNode TrieNode whose data is to be moved to TrieNodeWithValue
* @param value
*/
TrieNodeWithValue(TrieNode &&trieNode, T value) : TrieNode(std::move(trieNode)) {
value_ = value;
SetEndNode(true);
}//由于右值引用是左值,而转发需要保持原传入参数的特性,因此这里需要std::move将trieNode转化为右值,或者用std::forward
//错误写法
TrieNodeWithValue(TrieNode &&trieNode, T value) {
TrieNode(trieNode);
value_ = value;
is_end_ = true;
}
auto关键字
auto
关键字的用法,以及如何初始化一个已经声明的unique_ptr
智能指针:首先用一个普通指针(auto
)来在堆上new
相应对象,然后通过智能指针的reset()
方法获得该对象的唯一控制权
reset(p)
:其中p
表示一个普通指针,如果p
为nullptr
,unique_ptr
也变成空指针;反之,则该函数会释放当前unique_ptr
指针指向的堆内存(如果有),然后获取p
所指堆内存的所有权(然后p
变为nullptr
)。
class Trie {
private:
/* Root node of the trie */
std::unique_ptr<TrieNode> root_;
/* Read-write lock for the trie */
ReaderWriterLatch latch_;
public:
/**
* TODO(P0): Add implementation
*
* @brief Construct a new Trie object. Initialize the root node with '\0'
* character.
*/
//上下效果好像一样,都能通过测试
Trie() {
auto *root = new TrieNode('\0');
root_.reset(root);
}
Trie() {
auto root = new TrieNode('\0');
root_.reset(root);
}
Trie的实现,初始构造一个参数为'\0'
的TrieNode根节点
class Trie {
private:
/* Root node of the trie */
std::unique_ptr<TrieNode> root_;
/* Read-write lock for the trie */
ReaderWriterLatch latch_;
public:
/**
* TODO(P0): Add implementation
*
* @brief Construct a new Trie object. Initialize the root node with '\0'
* character.
*/
Trie() {
auto *root = new TrieNode('\0');
root_.reset(root);
}
这里也可以用make_unique
直接构造
root_ = make_unique<TrieNode>('\0');
关键实现:Insert()
首先注意到这句话
If the key already exists, return false. Duplicated keys are not allowed and you should never overwrite value of an existing key.
那么提出问题:如何在一个字典树中判断一个string key是否已经存在?
很简单,逐位比较即可,如果中间存在不一样的字符位,那么必然会产生InsertChildNode
的过程,而从第一个不一样的(即在原Trie中不存在)的字符开始,该字符串接下来的每一个字符都会是InsertChildNode
注意 When you reach the ending character of a key 要单独处理,此时只要
auto end_node = pre_child->get()->GetChildNode(*cur);
end_node != nullptr;
即说明当前待插入的key
字符串已经在书中全部存在,有可能是其他串的前缀,也有可能就是这个一模一样的串,即前缀匹配
那么这时只需要看是否endNode就能判断情况2/3
而
end_node == nullptr
说明Trie中必与当前key
不能匹配,情况1
比如你需要插入“apple”,而当前Trie中已经有串"apple"或者“appletree”,那么当cur
指向key.end()-1
也就是’e’的迭代器时,循环结束,而此时**GetChildNode()
返回必不为nullptr
**,因为完全匹配了
反之原因就是上文加粗部分,如果串中任意位置有不匹配的部分,比如你需要插入"apple",而Trie中存在“abple”,从第二个字符’b’开始不匹配,循环体随即执行pre_child = pre_child->get()->InsertChildNode(*cur, std::make_unique<TrieNode>(*cur))
,开辟了一个符号为’p’新的树枝,然后pre_child
进入了这新的树枝,导致接下来"ple"部分在’p’树枝上都是不存在的,需要一直InsertChildNode
同理判断最后一个节点的上一个节点是否有该节点儿子时,如果有则说明完全前缀匹配了,如果没有表示该串不匹配
注意一些细节:
-
考虑到节点不容易进行复制,我们可以直接新建节点或转移节点,使用reset函数进行更新
-
auto new_node = new TrieNodeWithValue(std::move(**end_node), value);
其中移动构造将
end_node
下所有堆上资源也即children_
移动给new_node
,因此不用担心变更为TrieNodeWithValue
后断链,丢失子女链接 -
end_node->reset(new_node);
其中
end_node
是unique_pre<TrieNode>*
类型,内含指针是TrieNode*
的,而new_node
是子类指针TrieNodewithvalue*
的,但是可以直接用unique_ptr.reset
方法,记住这一特性:包含父类指针的
unique_ptr
可以直接对子类指针使用reset()
重写内存 -
上下合在一起看构建新的
TrieNodeWithValue
节点逻辑,即在父亲节点构造/已存在对应普通儿子节点TrieNode
后,赋值给end_node
(此时父亲节点的children_中已经存在了end_node
的值,这个值是不变的,来保证不断链),然后将其资源移动给新构造的TrieNodewithvalue
,再将end_node.reset()
获得资源,整个过程下来父亲节点的children中end_node的值是不变的,只是转移了资源和类属性注意是
end_node
调用reset()
方法,夺回资源,因此需要先进入孩子节点end_node
,保证不断链pre_child/end_node值是不变的,只对其所指向的地址中存放的unique_ptr中的资源进行操作
-
make_unique<type>
获得一次性的右值unique_ptr
用于构造函数 -
unique_ptr.get()
获得其中包含的原指针,而**end_node
是获得其中指针指向的TreeNode
对象,区分二者其中
auto end_node = pre_child->get()->GetChildNode(*cur);
等价于
auto end_node = *(pre_child)->GetChildNode(*cur);
/**
* TODO(P0): Add implementation
*
* @brief Insert key-value pair into the trie.
*
* If the key is an empty string, return false immediately.
*
* If the key already exists, return false. Duplicated keys are not allowed and
* you should never overwrite value of an existing key.
*
* When you reach the ending character of a key:
* 1. If TrieNode with this ending character does not exist, create new TrieNodeWithValue
* and add it to parent node's children_ map.
* 2. If the terminal node is a TrieNode, then convert it into TrieNodeWithValue by
* invoking the appropriate constructor.
* 3. If it is already a TrieNodeWithValue,
* then insertion fails and returns false. Do not overwrite existing data with new data.
*
* You can quickly check whether a TrieNode pointer holds TrieNode or TrieNodeWithValue
* by checking the is_end_ flag. If is_end_ == false, then it points to TrieNode. If
* is_end_ == true, it points to TrieNodeWithValue.
*
* @param key Key used to traverse the trie and find the correct node
* @param value Value to be inserted
* @return True if insertion succeeds, false if the key already exists
*/
template <typename T>
bool Insert(const std::string &key, T value) {
if (key.empty()) {
return false;
}
latch_.WLock();
auto endstr = key.end()-1;
auto cur = key.begin();//cur作为字符串逐位比较的迭代器
auto pre_child = &root_;//pre_child作为Trie迭代器,从根开始与字符串逐位比较,看当前cur代表字符是否在children_中存在
while (cur != endstr) {//字符串的结尾字符,需要单独处理,其余中间字符要么Insert要么直接往后迭代
if (!pre_child->get()->HasChild(*cur)) {//unique_ptr.get()获得原指针,这里也就是TreeNode*
pre_child = pre_child->get()->InsertChildNode(*cur, std::make_unique<TrieNode>(*cur));
} else {
pre_child = pre_child->get()->GetChildNode(*cur);
}
cur++;
}
//When you reach the ending character of a key:
auto end_node = pre_child->get()->GetChildNode(*cur);
//If it is already a TrieNodeWithValue, then insertion fails and returns false. Do not overwrite existing data with new data.
if (end_node != nullptr && end_node->get()->IsEndNode()) {//看是否endNode就能判断情况2/3
latch_.WUnlock();
return false;
}
if (end_node != nullptr) {//情况2,需要把原有的普通TrieNode转换为新的endNode也即TrieNodewithvalue
auto new_node = new TrieNodeWithValue(std::move(**end_node), value);//这里new_node类型为TrieNodeWithValue*
end_node->reset(new_node);//这里注意细节,父类初始化的unique_ptr直接对继承类指针调用reset()
latch_.WUnlock();
return true;
}//整个过程下来父亲节点的children_中end_node的值是不变的,只是转移了资源和类属性
pre_child = pre_child->get()->InsertChildNode(*cur, std::make_unique<TrieNode>(*cur));//这里同理,需要先进入创建的孩子节点,再进行节点转换,保证不断链
auto new_node = new TrieNodeWithValue(std::move(**pre_child), value);
pre_child->reset(new_node);//pre_child值是不变的,只对其所指向的地址中存放的unique_ptr中的资源进行操作
latch_.WUnlock();
return true;
}
节点转换部分也可以这么写,
注意过程中是对curr
也就是unique_ptr*
进行操作,而父亲节点中children_
存储的是unique_ptr
,能更好看出防止断链而操作指针的思想
(*curr) = std::make_unique<TrieNodeWithValue<T>>(std::move(*(*curr)), value);
其中
-
上面代码的
end_node
和这里的curr
同为unique_ptr*
类型,可解引用方式而言,这里是*curr
而上文为end_node->
,为什么是这样?->
运算符用于访问指针所指对象的成员,而*
运算符用于解引用指针,以访问它所指向的对象- 因为这里直接将
unique_ptr
看作整个对象进行赋值,因此直接*
取对象,而上文将unique_ptr
看作进行操作的类对象,因此用->
调用类函数reset()
*p->mem
等价于(*p).mem
-
注意模板类作为签名的写法:
TrieNodeWithValue<T>
,需要用尖括号填入template <typename T>
中的T
-
make_unique
函数实现如下template<typename T, typename... Ts> std::unique_ptr<T> make_unique(Ts&&... params) { return std::unique_ptr<T>(new T(std::forward<Ts>(params)...)); }
make_unique
完美传递了参数给对象的构造函数,从一个原始指针构造出一个std::unique_ptr
,返回创建的std::unique_ptr
Remove()
用栈保留遍历的路径,用(这里面的节点都是用unique_ptr*的形式表示的)std:tuple
记录 字符 与其 父节点 的映射关系
改成直接用数组当栈,记录父亲节点
/**
* TODO(P0): Add implementation
*
* @brief Remove key value pair from the trie.
* This function should also remove nodes that are no longer part of another
* key. If key is empty or not found, return false.
*
* You should:
* 1) Find the terminal node for the given key.
* 2) If this terminal node does not have any children, remove it from its
* parent's children_ map.
* 3) Recursively remove nodes that have no children and are not terminal node
* of another key.
*
* @param key Key used to traverse the trie and find the correct node
* @return True if the key exists and is removed, false otherwise
*/
bool Remove(const std::string &key) {
if (key.empty()) {
return false;
}
latch_.WLock();
auto previous = &root_;
std::vector<std::unique_ptr<TrieNode> *> vec;
for (auto cur : key) {
auto temp = (*previous)->GetChildNode(cur);
if (temp == nullptr) {
latch_.WUnlock();
return false;
}
vec.emplace_back(previous);
previous = temp;
}
previous->get()->SetEndNode(false);//如果这个串在字典树内,先把尾节点设置成非endnode,无论其有无儿子
//然后把没有孩子,并且非尾巴节点删除
while (!vec.empty() && !previous->get()->IsEndNode() && !previous->get()->HasChildren()) {
auto tmp = vec[vec.size() - 1];
tmp->get()->RemoveChildNode((**previous).GetKeyChar());
vec.pop_back();
previous = tmp;
}
latch_.WUnlock();
return true;
}
递归版本
remove_innner
的返回值表示传入节点是否可以删除,可以删除的条件是该节点无子节点且非尾节点。函数内判断当前节点的子节点是否可以删除,并返回当前节点是否可以删除。出口是递归到了传入 key 的尾节点,取消尾节点标记,并返回是否可以删除。该函数调用前还需要判断一下 key 是否存在。
bool remove_inner(const std::string &key, size_t i, std::unique_ptr<TrieNode> *curr, bool *success) {
if(curr == nullptr) return false;
if (i == key.size()) {
*success = true; // Remove 的返回值,表示成功删除
(*curr)->SetEndNode(false);
return !(*curr)->HasChildren() && !(*curr)->IsEndNode();
}
bool can_remove = remove_inner(key, i + 1, (*curr)->GetChildNode(key[i]), success);
if (can_remove) {
(*curr)->RemoveChildNode(key[i]);
}
return !(*curr)->HasChildren() && !(*curr)->IsEndNode();
}
GetValue()
-
返回值,题中有不返回值的情况,但是函数为模板类,必须返回T类型的初始化变量,以下三种方式等价
return {}; return T{}; return T(); //https://blog.csdn.net/zqxf123456789/article/details/107128067 //https://blog.csdn.net/u012011079/article/details/114080919
-
用
dynamic_cast<模板类>
后判断是否空指针,用来判断是否指定的模板类型,看注释写即可
/**
* TODO(P0): Add implementation
*
* @brief Get the corresponding value of type T given its key.
* If key is empty, set success to false.
* If key does not exist in trie, set success to false.
* If the given type T is not the same as the value type stored in TrieNodeWithValue
* (ie. GetValue<int> is called but terminal node holds std::string),
* set success to false.
*
* To check whether the two types are the same, dynamic_cast
* the terminal TrieNode to TrieNodeWithValue<T>. If the casted result
* is not nullptr, then type T is the correct type.
*
* @param key Key used to traverse the trie and find the correct node
* @param success Whether GetValue is successful or not
* @return Value of type T if type matches
*/
template <typename T>
T GetValue(const std::string &key, bool *success) {
T ret;
if (key.empty()) {
*success = false;
return {};//return T{};
}
latch_.RLock();
auto pre = &root_;
for (auto ch : key) {
auto next = pre->get()->GetChildNode(ch);
if (next == nullptr) {
*success = false;
latch_.RUnlock();
return {};
}
pre = next;
}
auto newnode = dynamic_cast<TrieNodeWithValue<T> *>(pre->get());
if (newnode == nullptr) {
*success = false;
ret = {};
} else {
*success = true;
ret = newnode->GetValue();
}
latch_.RUnlock();
return ret;
}
Project #1 - Buffer Pool
(54条消息) 【CMU15-445数据库】bustub Project #1:Buffer Pool_bustub project1_Altair_Alpha_的博客-CSDN博客
Task #1 - Extendible Hash Table
参考链接
-
Extendible Hashing (Dynamic approach to DBMS) - GeeksforGeeks
-
CMU15445 2022 Project1-Buffer Pool Manager攻略 - 知乎 (zhihu.com)
-
(51条消息) 智能指针make_unique 与make_shared 的知识介绍_aFakeProgramer的博客-CSDN博客
-
(54条消息) 《Effective Modern C++》学习笔记 - Item 42: 考虑使用emplace代替插入_emplace重复元素_Altair_Alpha_的博客-CSDN博客
完整代码
//===----------------------------------------------------------------------===//
//
// BusTub
//
// extendible_hash_table.cpp
//
// Identification: src/container/hash/extendible_hash_table.cpp
//
// Copyright (c) 2022, Carnegie Mellon University Database Group
//
//===----------------------------------------------------------------------===//
#include <cassert>
#include <cstdlib>
#include <functional>
#include <list>
#include <utility>
#include "container/hash/extendible_hash_table.h"
#include "storage/page/page.h"
namespace bustub {
template <typename K, typename V>
ExtendibleHashTable<K, V>::ExtendibleHashTable(size_t bucket_size)
: global_depth_(1), bucket_size_(bucket_size), num_buckets_(2) {
dir_.push_back(std::make_shared<Bucket>(bucket_size, 1));
dir_.push_back(std::make_shared<Bucket>(bucket_size, 1));
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::IndexOf(const K &key) -> size_t {
int mask = (1 << global_depth_) - 1;
return std::hash<K>()(key) & mask;
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetGlobalDepth() const -> int {
std::scoped_lock<std::mutex> lock(latch_);
return GetGlobalDepthInternal();
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetGlobalDepthInternal() const -> int {
return global_depth_;
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetLocalDepth(int dir_index) const -> int {
std::scoped_lock<std::mutex> lock(latch_);
return GetLocalDepthInternal(dir_index);
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetLocalDepthInternal(int dir_index) const -> int {
return dir_[dir_index]->GetDepth();
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetNumBuckets() const -> int {
std::scoped_lock<std::mutex> lock(latch_);
return GetNumBucketsInternal();
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::GetNumBucketsInternal() const -> int {
return num_buckets_;
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Find(const K &key, V &value) -> bool {
std::scoped_lock<std::mutex> lock(latch_);
auto index = IndexOf(key);
auto target_bucket = dir_[index];
return target_bucket->Find(key, value);
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Remove(const K &key) -> bool {
std::scoped_lock<std::mutex> lock(latch_);
auto index = IndexOf(key);
auto target_bucket = dir_[index];
return target_bucket->Remove(key);
}
template <typename K, typename V>
void ExtendibleHashTable<K, V>::Insert(const K &key, const V &value) {
std::scoped_lock<std::mutex> lock(latch_);
while (dir_[IndexOf(key)]->IsFull()) {//看对应的桶满了没
auto index = IndexOf(key);
auto target_bucket = dir_[index];
if (target_bucket->GetDepth() == global_depth_) {
global_depth_++;
auto oldcapacity = dir_.size();
// double the size of the directory.
dir_.resize(oldcapacity << 1); //vector直接用resize()
for (size_t i = 0; i < oldcapacity; i++) {
dir_[i + oldcapacity] = dir_[i];
}
}
// Increment the local depth of the bucket.
auto bucket_0 = std::make_shared<Bucket>(bucket_size_, target_bucket->GetDepth() + 1);
auto bucket_1 = std::make_shared<Bucket>(bucket_size_, target_bucket->GetDepth() + 1);
size_t mask = 1 << target_bucket->GetDepth();//只有这一步是用到桶的本地深度的,也就是桶自身分裂,rehash其中元素的时候,需要用桶的深度来确定 rehash位
//下面是两个与mask做&的for循环,第一个根据上面得出来的hash位,rehash旧桶中的元素的key入两个新桶中,后面一个是对「目录下标指向的桶」重新分配,本质上其实是同一件事情,都是对新分配的标志位hash位进行的判断
// Split the bucket and redistribute directory pointers & the kv pairs in the bucket.
for (const auto &item : target_bucket->GetItems()) {
if ((std::hash<K>()(item.first) & mask) == 0U) {
bucket_0->Insert(item.first, item.second);
} else {
bucket_1->Insert(item.first, item.second);
}
}
num_buckets_++;
// cautious size_t,做位运算需要用unsigned数,即size_t
for (size_t i = 0; i < dir_.size(); ++i) {
if (dir_[i] == target_bucket) {
dir_[i] = ((i & mask) == 0U) ? bucket_0 : bucket_1;
}
}
}//while end
auto target_bucket = dir_[IndexOf(key)];
for (auto &item : target_bucket->GetItems()) {
if (item.first == key) {
item.second = value;
return;
}
}
target_bucket->Insert(key, value);
}
//===--------------------------------------------------------------------===//
// Bucket
//===--------------------------------------------------------------------===//
template <typename K, typename V>
ExtendibleHashTable<K, V>::Bucket::Bucket(size_t array_size, int depth) : size_(array_size), depth_(depth) {}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Find(const K &key, V &value) -> bool {
for(const auto &[k,v]:list_){//遍历键值对时使用 structured binding,避免还要写 pair.first 和 pair.second
//https://blog.csdn.net/hubing_hust/article/details/128667994
if(key == k){
value = v;
return true;
}
return false;
}
/*return std::any_of(list_.begin(), list_.end(), [&key, &value](const auto &item) {
if (item.first == key) {
value = item.second;
return true;
}
return false;
});*/
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Remove(const K &key) -> bool {
return std::any_of(list_.begin(), list_.end(), [&key, this](const auto &item) {
if (item.first == key) {
this->list_.remove(item);
return true;
}
return false;
});
}
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Insert(const K &key, const V &value) -> bool {
if (IsFull()) {
return false;
}
list_.emplace_back(key, value); // list_.push_back(std::make_pair(key,value));
return true;
}
template class ExtendibleHashTable<page_id_t, Page *>;
template class ExtendibleHashTable<Page *, std::list<Page *>::iterator>;
template class ExtendibleHashTable<int, int>;
// test purpose
template class ExtendibleHashTable<int, std::string>;
template class ExtendibleHashTable<int, std::list<int>::iterator>;
} // namespace bustub
设计思路
拉链法实现的哈希表当某个哈希值对应的(即存在于同一个“桶”中,Bucket)元素特别多时,查找的时间复杂度由 O ( 1 ) 退化为遍历的 O ( n ) 。举个极端的例子,如果哈希函数是不管输入是什么都映射为 0,那么就和在第 0 位存储一个链表无异。如何设计散布更加均匀的哈希函数是优化的另一个方向,而另一种方法是当检测到某个桶中的元素过多时对表进行扩展。扩展最简单的做法是直接将哈希表的长度(桶数)翻倍,再将哈希函数的值域由 [ 0 , n ) 改为 [ 0 , 2 n ) (这点很容易实现,因为大部分哈希函数本身就是先算出某个值,然后对 n 取余数),然后对所有存储的元素重新算一次哈希值分布到不同的桶中。
这种方法的缺点很明显:如果哈希表中已经存储了大量的元素,因为要对所有元素重算哈希值,扩展的过程会有巨大的计算量,导致一次突发的大延迟。实际上,进行扩展时,可能仅仅是某一个桶出现了拉链很长的状况,其它桶的余量还很充足。于是,出现了可扩展哈希表(Extendible Hash Table)的方案,其将哈希得到的下标与桶改为非一对一映射,并引入全局深度(Global Depth)和局部深度(Local Depth)的概念,实现扩展时只需对达到容量的那一个桶进行分裂,解决了以上问题。
- 注意stl中的vector作为目录结构,list作为目录项中存放的链表结构,也就是实际上的桶,而链表中使用pair存储键值对
互斥锁
见上面参考链接3,对桶的任何读取操作(包括GetGlobalDepth()
,GetLocalDepth()
,GetLocalDepth()
这三个读取了私有成员而没读取桶的?)都要加上
std::scoped_lock<std::mutex> lock(latch_);
push_back
和emplace_back
[《Effective Modern C++》学习笔记 - Item 42: 考虑使用emplace代替插入_Altair_Alpha_的博客-CSDN博客]
注意两者区别,push_back
需要插入一个完整对象,而emplace_back
只需要传参数,自动调用内部对象构造函数,不需要自己手动构造对象
//std::list<std::pair<K, V>> list_;
auto Insert(const K &key, const V &value) -> bool {
if (IsFull()) return false;
list_.push_back(std::make_pair(key, value)); // list_.emplace_back(key,value);
return true;
}
同样效果的还有shared_ptr<>
与make_shared<>
,unique_ptr<>
…
push_back
方法会将一个已经存在的对象复制一份并添加到容器中,因此需要先创建一个对象然后再将其添加到容器中。
emplace_back
方法则是在容器的末尾直接构造一个新对象,而不是先创建一个对象再将其添加到容器中。也就是说,它在容器的末尾直接构造一个新元素,不需要先创建一个对象再将其添加到容器中,因此可以更加高效。需要注意的是,
emplace_back
方法只能使用对象的构造函数来构造新的元素,因此我们需要保证构造函数的正确性。
ExtendibleHashTable
类的构造函数
按照定义
template <typename K, typename V>
ExtendibleHashTable<K, V>::ExtendibleHashTable(size_t bucket_size)
: global_depth_(1), bucket_size_(bucket_size), num_buckets_(2) {
dir_.push_back(std::make_shared<Bucket>(bucket_size, 1));
dir_.push_back(std::make_shared<Bucket>(bucket_size, 1));
}
这里为什么不能写成
dir_.emplace_back(bucket_size, 1);
dir_
是一个std::vector<std::shared_ptr<Bucket>>
类型的容器,其中存储的元素是std::shared_ptr<Bucket>
类型的智能指针,而不是直接存储Bucket
对象。因此,如果直接使用dir_.emplace_back(bucket_size, 1)
来添加元素,则会产生编译错误,因为std::shared_ptr<Bucket>
类没有相应的构造函数来接受bucket_size
和1
这两个参数。
在这种情况下,应该使用std::make_shared
函数来创建一个新的std::shared_ptr<Bucket>
对象,并将其添加到容器中。std::make_shared
函数可以接受任意数量的参数,并将它们传递给Bucket
类的构造函数来创建一个新的对象,然后返回一个指向该对象的智能指针。因此,正确的写法是使用std::make_shared
函数来创建新的std::shared_ptr<Bucket>
对象,如下所示:
dir_.emplace_back(std::make_shared<Bucket>(bucket_size, 1));
std::any_of()
代码格式检查要求使用std::any_of()
来实现桶对象的内置函数,比如
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Remove(const K &key) -> bool {
return std::any_of(list_.begin(), list_.end(), [&key, this](const auto &item) {
if (item.first == key) {
this->list_.remove(item);
return true;
}
return false;
});
}
其中std::any_of()
是一个STL算法,用于判断一个容器中是否存在满足指定条件的元素。它接受三个参数:容器开始和结束迭代器以及一个谓词(可调用对象),用于指定要匹配的条件
list_.begin()
和 list_.end()
分别是 list_
容器的开始和结束迭代器。[&key,this](const auto& item) { ... }
是一个lambda表达式,它将 key
作为捕获值,并检查键是否等于 key
。std::any_of()
将迭代容器中的每个元素,并应用提供的lambda表达式,返回一个 bool
值,表示是否有满足条件的元素。
要在类成员函数体中使用 Lambda 表达式,请将
this
指针传递给 capture 子句,以提供对封闭类的成员函数和数据成员的访问权限。
关于lambda表达式见参考链接
已更新,不用std::any_of()
也可以
为什么迭代器应该使用
auto item = list_.begin();
,而不是auto &item = list_.begin();
因为您在遍历过程中尝试删除元素,而这会导致迭代器失效。您在使用
list_.remove(*item)
删除元素后,item
迭代器已经无效,因此在继续使用它会导致未定义行为。为了正确地删除元素,您可以使用
list_.erase(item)
,这会删除当前迭代器指向的元素并返回下一个有效的迭代器。这样就不会出现迭代器失效的问题
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Find(const K &key, V &value) -> bool {
for (const auto &item : list_) {
if (item.first == key) {
value = item.second;
return true;
}
}
return false;
}
//这里使用迭代器代替kv遍历即可,auto item= list_.begin()不能写作auto &item= list_.begin(),因为这里对item做了更改?
template <typename K, typename V>
auto ExtendibleHashTable<K, V>::Bucket::Remove(const K &key) -> bool {
for (auto item = list_.begin(); item != list_.end(); item++) {
if ((*item).first == key) {
list_.remove(*item);
return true;
}
}
return false;
}
哈希映射与IndexOf()
对于任意类型K
,需要将其映射为二进制,进而取后global_depth_
位,然后在索引表(目录)中找到对应桶
使用std::hash<K>()
方法,chatgpt解释如下
std::hash<K>()
是一个函数对象,用于对类型K
进行哈希计算。通过调用std::hash<K>()
可以获取到这个函数对象的一个实例。当我们调用
std::hash<K>()(key)
时,会将键key
作为参数传递给哈希函数对象,该对象对其进行哈希计算,并返回一个「无符号整数」类型的哈希值。
mask
是一个掩码,它是一个二进制全 1 的数,用于限制哈希值的范围。通过使用位与运算符&
将哈希值与掩码相与,可以得到哈希值在 0 到mask
之间的一个数,该数可以用作哈希表中的桶索引。例如,当
mask
的值为0b111
时,它的二进制表示为 111,即 3,因此哈希值相与mask
后只会保留哈希值的低 3 位,得到一个范围在 0 到 3 的整数。这就保证了哈希值可以被映射到哈希表中的某个桶。
也就是说,IndexOf()
中的取掩码过程中
int mask = (1 << global_depth_) - 1;
因为全局深度global_depth_
是与目录大小相关的,global_depth_
为几,就说明在哈希对应索引时需要比对二进制的后几位,因此将1左移global_depth_
后减1就得到 后global_depth_
位 全为1的二进制数,因此mask
必然覆盖到整个目录的索引 下标 取值范围
/**
* @brief For the given key, return the entry index in the directory where the key hashes to.
* @param key The key to be hashed.
* @return The entry index in the directory.
*/
//肯定能哈希到目录中对应索引的下标
auto IndexOf(const K &key) -> size_t {
int mask = (1 << global_depth_) - 1;
return std::hash<K>()(key) & mask;
}
所以Find()
、Insert()
、Remove
三个函数过程如下,先哈希找到对应的目录索引,然后对该索引shared_ptr<bucket>
所指向的桶做对应操作
std::scoped_lock<std::mutex> lock(latch_);
auto index = IndexOf(key);
auto target_bucket = dir_[index];
ops...//return target_bucket->Find(key, value); 直接调用内定义类Bucket的内置插入函数了
同时注意到该函数返回值是size_t
类型的,这是因为 在位运算中使用的常量一般都是无符号数,而size_t
即为unsigned类型的
Insert()
-
根据上面
IndexOf()
的实现效果,因此外层大循环这里是while
,即使内部在上一轮循环中已经对索引目录扩容了,这一轮再次调用IndexOf()
一样能实现 从新扩容的目录中哈希到对应索引 的效果,因为每一轮的global_depth_
都是动态变化的因为全局深度
global_depth_
是与目录大小相关的,global_depth_
为 几 ,就说明在哈希对应索引时需要比对二进制的 后几位 ,因此将1左移global_depth_
后减1就得到 后global_depth_
位 全为1的二进制数,因此mask
必然覆盖到整个目录的索引 下标 取值范围至于为什么要用
while
,假如一个桶分裂后且depth_
增加后,kv对 再分配过程中 全部分到一个桶,那么这个桶还是满的,就需要再次经历这个过程 -
global_depth_
代表的是kv对在索引目录中 找下标 的哈希二进制位数,而桶的depth_
代表的是桶内kv对分布的哈希二进制位数,因此可能多个下标索引指向同一个桶,因为二者不等 -
为什么是将目录大小加倍?因为多出来了表示索引的一位,有0/1两种可能,即*2
-
// double the size of the directory. dir_.resize(oldcapacity << 1); for (int i = 0; i < oldcapacity; i++) { dir_[i + oldcapacity] = dir_[i]; }
resize()
来重新分配数组大小,而新建的所有桶,一开始都是跟前面oldcapacity
对应的索引,因为 后global_depth_
位 是相同的,此时global_depth_
加一代表 在哈希 下标 找索引时要多看前面一位了,而每个桶的深度代表这个桶内的元素后几位是相同的。而oldcapacity
又是 2^旧的global_depth_
这里要重点理解 目录表的元素数量和
global_depth_
的关系 以及global_depth_
的二进制哈希意义 -
这里的
0U
中的U
表示是一个无符号整数类型,这是为了避免一些编译器对于有符号数进行一些意料之外的转换,比如截断或者符号位扩展等。因为在位运算中使用的常量一般都是无符号数,因此将
0
后面加上U
是一种良好的习惯。做位运算需要用unsigned,因此这里
i
声明为size_t
-
这里遍历使用引用
&
以及const
,防止拷贝开销以及引用造成的可能修改,这两个放在一起使用 -
在C++中,
size()
的返回值为size_t
,因此这里i
声明为size_t
,否则后面比较部分会警告 -
为什么前面
IndexOf()
中掩码是 后global_depth_
位全为1,这里是 第depth_
位为1?因为前者是索引目录中哈希得到下标,需要比对后
global_depth_
位,而这里的逻辑是桶满了,需要对桶里面的元素以及指向桶的索引指针作再分配,是根据
depth_
新增加的那一位是1还是0来再分配的,而第depth_
位后面的所有位都是一样的,这样才会一开始被分在一个桶里面 -
因为是
shared_ptr
,因此不用手动释放,将指针分配到两个新桶里面以后,旧的桶就自动被释放了
/**
*
* TODO(P1): Add implementation
*
* @brief Insert the given key-value pair into the hash table.
* If a key already exists, the value should be updated.
* If the bucket is full and can't be inserted, do the following steps before retrying:
* 1. If the local depth of the bucket is equal to the global depth,
* increment the global depth and double the size of the directory.
* 2. Increment the local depth of the bucket.
* 3. Split the bucket and redistribute directory pointers & the kv pairs in the bucket.
*
* @param key The key to be inserted.
* @param value The value to be inserted.
*/
void Insert(const K &key, const V &value) override {
std::scoped_lock<std::mutex> lock(latch_);
while (dir_[IndexOf(key)]->IsFull()) {//1
auto index = IndexOf(key);
auto target_bucket = dir_[index];
if (target_bucket->GetDepth() == global_depth_) {
global_depth_++;
auto oldcapacity = dir_.size();
// double the size of the directory.
dir_.resize(oldcapacity << 1);//3
for (size_t i = 0; i < oldcapacity; i++) {//4,7
dir_[i + oldcapacity] = dir_[i];
}
}
// Increment the local depth of the bucket.
auto bucket_0 = std::make_shared<Bucket>(bucket_size_, target_bucket->GetDepth() + 1);
auto bucket_1 = std::make_shared<Bucket>(bucket_size_, target_bucket->GetDepth() + 1);
size_t mask = 1 << target_bucket->GetDepth();//8
// Split the bucket and redistribute the kv pairs in the bucket.
for (const auto &item : target_bucket->GetItems()) {//6
if ((std::hash<K>()(item.first) & mask) == 0U) {//5
bucket_0.Insert(item.first, item.second);
} else {
bucket_1.Insert(item.first, item.second);
}
}
num_buckets_++;
// cautious size_t,redistribute directory pointers to the splited bucket
for (size_t i = 0; i < dir_.size(); ++i) {//7
if (dir_[i] == target_bucket) {
dir_[i] = ((i & mask) == 0U) ? bucket_0 : bucket_1;
}
}
}//9
//retry inserting
auto target_bucket = dir_[IndexOf(key)];
for (auto &item : target_bucket->GetItems()) {
if (item.first == key) {
item.second = value;
return;
}
}
target_bucket.Insert(key, value);
}
Task #2 - LRU-K Replacement Policy
参考链接
-
(52条消息) 缓存替换策略:LRU-K算法详解及其C++实现 CMU15-445 Project#1_AntiO2的博客-CSDN博客
-
(54条消息) 【CMU15-445数据库】bustub Project #1:Buffer Pool_bustub project1_Altair_Alpha_的博客-CSDN博客
完整代码
//===----------------------------------------------------------------------===//
//
// BusTub
//
// lru_k_replacer.cpp
//
// Identification: src/buffer/lru_k_replacer.cpp
//
// Copyright (c) 2015-2022, Carnegie Mellon University Database Group
//
//===----------------------------------------------------------------------===//
#include "buffer/lru_k_replacer.h"
#include <cstddef>
#include <exception>
#include <mutex>
namespace bustub {
LRUKReplacer::LRUKReplacer(size_t num_frames, size_t k) : replacer_size_(num_frames), k_(k) {}
auto LRUKReplacer::Evict(frame_id_t *frame_id) -> bool {
std::scoped_lock<std::mutex> lock(latch_);
if (curr_size_ == 0U) {//size_t curr_size_{0};
return false;
}
for (auto i = history_list_.rbegin(); i != history_list_.rend(); i++) {
auto frame = *i; // be cautious the erase method will modify the iterator
if (is_evictable_[frame]) {
*frame_id = frame;
curr_size_--;
access_num_[frame] = 0;
is_evictable_[frame] = false;
history_list_.erase(hashmap_[frame]); // need positive iterator instead of history_list_.erase(i);
//history_list_.remove(frame); std::list 中 remove()和erase()是等效的
hashmap_.erase(frame);
return true;
}
}
for (auto i = cache_list_.rbegin(); i != cache_list_.rend(); i++) {
auto frame = *i;
if (is_evictable_[frame]) {
*frame_id = frame;
curr_size_--;
access_num_[frame] = 0;
is_evictable_[frame] = false;
cache_list_.erase(hashmap_[frame]); // need positive iterator instead of history_list_.erase(i);
//cache_list_.remove(frame);
hashmap_.erase(frame);
return true;
}
}
return false;
}
void LRUKReplacer::RecordAccess(frame_id_t frame_id) {
std::scoped_lock<std::mutex> lock(latch_);
if (frame_id > static_cast<int>(replacer_size_)) { // size_t to int cast
throw std::exception();
}
size_t num = ++access_num_[frame_id];
if (num == 1) {
history_list_.push_front(frame_id);
hashmap_[frame_id] = history_list_.begin();
} else if (num == k_) {
history_list_.remove(frame_id);//history_list_.erase(hashmap_[frame_id]);
cache_list_.push_front(frame_id);
hashmap_[frame_id] = cache_list_.begin();
} else if (num > k_) {
cache_list_.remove(frame_id);//cache_list_.erase(hashmap_[frame_id]);
cache_list_.push_front(frame_id);
hashmap_[frame_id] = cache_list_.begin();
}
//这里隐含了history_list_的FIFO调度算法,也即目标块访问到,但是access_num_没达到k_次时,什么也不做,就实现了FIFO
}
void LRUKReplacer::SetEvictable(frame_id_t frame_id, bool set_evictable) {
std::scoped_lock<std::mutex> lock(latch_);
if (frame_id > static_cast<int>(replacer_size_)) { // size_t to int cast
throw std::exception();
}
if (hashmap_.find(frame_id) == hashmap_.end()) {//这里的写法与access_num_[frame_id] == 0是等效的,因为access_num_是只增不减的,唯一清零的时候就是页面被Evict()或者Remove()的时候,而这两个时候hashmap_中的映射关系也会被清空
return;
}
if (!is_evictable_[frame_id] && set_evictable) {
curr_size_++;
} else if (is_evictable_[frame_id] && !set_evictable) {
curr_size_--;
}
is_evictable_[frame_id] = set_evictable;
}
void LRUKReplacer::Remove(frame_id_t frame_id) {
std::scoped_lock<std::mutex> lock(latch_);
if (frame_id > static_cast<int>(replacer_size_)) { // size_t to int cast
throw std::exception();
}
//这里要先判断访问数量(或者是否在hashmap_中),再判断是否可移除,顺序调转就过不了测试
size_t num = access_num_[frame_id];
if (num == 0) {
return;
}
if (!is_evictable_[frame_id]) {
throw std::exception();
}
if (num < k_) {
history_list_.remove(frame_id);
} else {
cache_list_.remove(frame_id);
}
curr_size_--;
hashmap_.erase(frame_id);
access_num_[frame_id] = 0;
is_evictable_[frame_id] = false;
}
auto LRUKReplacer::Size() -> size_t {
std::scoped_lock<std::mutex> lock(latch_);
return curr_size_;
}
} // namespace bustub
整体思路与私有成员设计
为了要在 O ( 1 ) O(1) O(1)时间内完成找到LRU块,用时间戳肯定是不行的,一个一个比对时间戳的复杂度为 O ( N ) O(N) O(N)
因此用双向链表(队列)代替时间戳,这样只要按照使用顺序维护队列,就可以 O ( 1 ) O(1) O(1)获得排在队尾的LRU了
同时为了在 O ( 1 ) O(1) O(1)时间内根据key定位任意块(链表无法随机访问),需要用一个哈希表来映射key与块本身(或者value)
这样就完成了 O ( 1 ) O(1) O(1)时间定位任意块,从而任意块的访问可以化为将其移动至队头,以及 O ( 1 ) O(1) O(1)时间从队列中取出LRU块的操作
LRU-K的优化思路见上面链接1,3
即设置两条队列,访问次数未达到k的块存放在普通队列中,达到k的块放在真正的LRU缓存队列中,普通队列使用FIFO淘汰算法,缓存队列用LRU淘汰算法
如果要淘汰块,优先淘汰普通队列的块
同时还要两个哈希表记录每个块访问次数和是否可驱逐
一个哈希表映射块号与块本身,此处注意的点是:用指向该块的 指针或者迭代器 来表示块本身,leetcode那道题也是这么设计的,对于两个缓存队列来说只需要一个映射哈希表即可,原因如下
std::list
的iterator
不会因其它元素的插入移动删除操作而失效。这个iterator
记录的位置既可能在hist_list
,也可能在cache_list
中(因为它们都是std::list<frame_id_t>
,所以iterator
类型也是相同的可以通用
private:
[[maybe_unused]] size_t current_timestamp_{0};//不用时间戳
size_t curr_size_{0};//题目规定了这个是当前Replacer中可以驱逐的块数量
size_t replacer_size_;//题目规定的块号的上限
size_t k_;
std::mutex latch_;
std::unordered_map<frame_id_t, std::list<frame_id_t>::iterator> hashmap_;//值是迭代器,相当于指向块的指针
std::unordered_map<frame_id_t, bool> is_evictable_;
std::unordered_map<frame_id_t, int> access_num_;
//两条缓存队列
std::list<frame_id_t> history_list_;
std::list<frame_id_t> cache_list_;
加锁
注意只要是访问私有成员的函数,都要加互斥锁,用到了mutex中的std::scoped_lock
//即使只是读取curr_size_
auto LRUKReplacer::Size() -> size_t {
std::scoped_lock<std::mutex> lock(latch_);
return curr_size_;
}
Evict()
-
一开始写作这样
history_list_.erase(i);
报错,因为
erase()
参数需要正向迭代器,而i
为反向迭代器 -
如果像下文第一个for循环那样写,会导致段错误
以测试用例为例子,在vscode的debug:watch栏中设置观察
*i
当前
history_list_
和hashmap_
如下,frame_id
:1因为被访问了两次,在cache_list_
中第一轮的
Evict()
中,*i
为2,history_list_.erase(hashmap_[*i])
执行完之后,*i
变成了3因而下一句
hashmap_.erase(*i)
就将哈希表中页号3的映射关系删除了,而并没有删除页号2到这里还没有报错,但是下一轮
Evict()
可以看到此时的
*i
仍然为3,但是hashmap_
中已经没有key为3的元素了,但是还存在key为2的元素,此时再步进下去就会报段错误因为
i
是std::list history_list_
的反向迭代器,在其中的操作是正确的,但是这个结果放到std::unordered_map<> hash_map_
身上就变成错误的了至于为什么
erase()
后i
就自动指向下一个元素了呢?记得每次改完代码后跑测试,无论是debug还是跑完整测试,都要新生成测试文件(
make extendible_hash_table_test -j$(nproc)
),不然一直是旧的结果网上找不到对应的资料,都是关于正向迭代器的资料,我测试了一下,把
i
改为了正向迭代器,并且std::list::erase()
之后,*i
仍然为5,并没有像反向迭代器似的指向下一个值4但是别以为这个迭代器仍然存在,只是保存了值而已,因此再执行下一句
hashmap_.erase(*i)
访问迭代器就报错了,因为此时的i
已经不存在了在执行以后,
i
所指的对象已经被销毁,所以再对i
进行操作是非法的,*i
会报错==30140==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000000c80 at pc 0x0000004ead4a bp 0x7fffffffab90 sp 0x7fffffffab88
这里参考以下文章
回到刚才,我猜测因为是反向迭代器与正向迭代器性质不同,因此
std::list::erase()
了i
对应的正向迭代器后,反向迭代器i
就自动指向下一个元素了(相当于正向迭代器的手动i++
),而正向迭代器的操作参见文章因此这里访问元素不能依赖迭代器
*i
,在每次循环开头时就保存迭代器对应页号,用页号操作页面
auto LRUKReplacer::Evict(frame_id_t *frame_id) -> bool {
std::scoped_lock<std::mutex> lock(latch_);
if (curr_size_ == 0U) {
return false;
}
//这是错的
for (auto i = history_list_.rbegin(); i != history_list_.rend(); i++) {
// auto frame = *i; // be cautious the erase method will modify the iterator to the next one
if (is_evictable_[*i]) {
*frame_id = *i;
curr_size_--;
access_num_[*i] = 0;
is_evictable_[*i] = false;
history_list_.erase(hashmap_[*i]); //1 need positive iterator instead of history_list_.erase(i);
hashmap_.erase(*i);//2 在这一步之后,下一轮的Evict()调用会报错,这个bug是这两条语句一起作用的效果
return true;
}
}
//这是对的
for (auto i = cache_list_.rbegin(); i != cache_list_.rend(); i++) {
auto frame = *i;//用页号访问
if (is_evictable_[frame]) {
*frame_id = frame;
curr_size_--;
access_num_[frame] = 0;
is_evictable_[frame] = false;
cache_list_.erase(hashmap_[frame]); // need positive iterator instead of history_list_.erase(i);
hashmap_.erase(frame);
return true;
}
}
return false;
}
Task #3 - Buffer Pool Manager Instance
参考链接
【CMU15-445数据库】bustub Project #1:Buffer Pool
完整代码
//===----------------------------------------------------------------------===//
//
// BusTub
//
// buffer_pool_manager_instance.cpp
//
// Identification: src/buffer/buffer_pool_manager.cpp
//
// Copyright (c) 2015-2021, Carnegie Mellon University Database Group
//
//===----------------------------------------------------------------------===//
#include "buffer/buffer_pool_manager_instance.h"
#include <cstddef>
#include "common/config.h"
#include "common/exception.h"
#include "common/macros.h"
namespace bustub {
BufferPoolManagerInstance::BufferPoolManagerInstance(size_t pool_size, DiskManager *disk_manager, size_t replacer_k,
LogManager *log_manager)
: pool_size_(pool_size), disk_manager_(disk_manager), log_manager_(log_manager) {
// we allocate a consecutive memory space for the buffer pool
pages_ = new Page[pool_size_];
page_table_ = new ExtendibleHashTable<page_id_t, frame_id_t>(bucket_size_);
replacer_ = new LRUKReplacer(pool_size, replacer_k);
// Initially, every page is in the free list.
for (size_t i = 0; i < pool_size_; ++i) {
free_list_.emplace_back(static_cast<int>(i));
}
}
BufferPoolManagerInstance::~BufferPoolManagerInstance() {
delete[] pages_;
delete page_table_;
delete replacer_;
}
auto BufferPoolManagerInstance::GetAvailableFrame(frame_id_t *outcomeframeid) -> bool {
frame_id_t frame_id;
if (!free_list_.empty()) {
frame_id = free_list_.front();
free_list_.pop_front();
*outcomeframeid = frame_id;
return true;
}
if (replacer_->Evict(&frame_id)) {
if (pages_[frame_id].is_dirty_) {
disk_manager_->WritePage(pages_[frame_id].page_id_, pages_[frame_id].data_);
pages_[frame_id].is_dirty_ = false;
}
page_table_->Remove(pages_[frame_id].page_id_);
*outcomeframeid = frame_id;
return true;
}
return false;
}
auto BufferPoolManagerInstance::NewPgImp(page_id_t *page_id) -> Page * {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (GetAvailableFrame(&frame_id)) {
*page_id = AllocatePage();
pages_[frame_id].page_id_ = *page_id;
pages_[frame_id].pin_count_ = 1;
pages_[frame_id].ResetMemory();
page_table_->Insert(*page_id, frame_id);
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
page_id = nullptr;
return nullptr;
}
auto BufferPoolManagerInstance::FetchPgImp(page_id_t page_id) -> Page * {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (page_table_->Find(page_id, frame_id)) {
pages_[frame_id].pin_count_++;
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
if (GetAvailableFrame(&frame_id)) {
page_table_->Insert(page_id, frame_id);
pages_[frame_id].page_id_ = page_id;
pages_[frame_id].pin_count_ = 1;
pages_[frame_id].ResetMemory();
disk_manager_->ReadPage(page_id, pages_[frame_id].GetData());
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
return nullptr;
}
auto BufferPoolManagerInstance::UnpinPgImp(page_id_t page_id, bool is_dirty) -> bool {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (!page_table_->Find(page_id, frame_id) || pages_[frame_id].pin_count_ == 0) {
return false;
}
if (--pages_[frame_id].pin_count_ == 0) {
replacer_->SetEvictable(frame_id, true);
}
if (!pages_[frame_id].is_dirty_) {
pages_[frame_id].is_dirty_ = is_dirty;
}
return true;
}
auto BufferPoolManagerInstance::FlushPgImp(page_id_t page_id) -> bool {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (!page_table_->Find(page_id, frame_id)) {
return false;
}
disk_manager_->WritePage(page_id, pages_[frame_id].GetData());
pages_[frame_id].is_dirty_ = false;
return true;
}
void BufferPoolManagerInstance::FlushAllPgsImp() {
std::scoped_lock lock(latch_);
for (size_t i = 0; i < pool_size_; i++) {
if (pages_[i].page_id_ != INVALID_PAGE_ID) {
disk_manager_->WritePage(pages_[i].page_id_, pages_[i].GetData());
pages_[i].is_dirty_ = false;
}
}
}
auto BufferPoolManagerInstance::DeletePgImp(page_id_t page_id) -> bool {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (!page_table_->Find(page_id, frame_id)) {
return true;
}
if (pages_[frame_id].pin_count_ != 0) {
return false;
}
if (pages_[frame_id].is_dirty_) {
disk_manager_->WritePage(pages_[frame_id].page_id_, pages_[frame_id].data_);
pages_[frame_id].is_dirty_ = false;
}
pages_[frame_id].ResetMemory();
pages_[frame_id].pin_count_ = 0;
pages_[frame_id].page_id_ = INVALID_PAGE_ID;
page_table_->Remove(page_id);
free_list_.emplace_back(frame_id);
replacer_->Remove(frame_id);
DeallocatePage(page_id);
return true;
}
auto BufferPoolManagerInstance::AllocatePage() -> page_id_t { return next_page_id_++; }
} // namespace bustub
设计思路
bustub 中有 Page 和 Frame 的概念,Page 是承载 4K 大小数据的类,可以通过 DiskManager 从磁盘文件中读写,带有 page_id 编号,is_dirty 标识等信息。Frame 不是一个具体的类,而可以理解为 Buffer Pool Manager中容纳 Page 的槽位,具体来说,
- BPM 中有一个 Page 数组,这个page数组作为实际的buffer pool内存池
- frame_id 就是某个 Page 在该数组中的下标,也即使用哈希表映射page页号与其在数组中下标frame_id的关系,而此哈希表即为页表page table。
- 此处的页表哈希方法就用我们之前实现的ExtendibleHashTable方法,其实看成stl中的unordered_map简单映射功能就行
参考下图:
外界只知道 page_id,向 BPM 查询时,BPM 要确定该 Page 是否存在以及其位置,所以要维护一个 page_id 到 frame_id 的映射,其实现就使用我们刚完成的 ExtendibleHashTable。为区分空闲和占用的 Page,维护一个 free_list,保存空闲的 frame_id。初始状态,所有 Page 都是空闲的。当上层需要取一个 Page 时,如果 Page 已存在于 BP 中,则直接返回;否则需要从磁盘读取到 BP 中。此时优先取空闲的 Page,否则只能从所有已经占用的 Page 中用我们刚完成的 LRUKReplacer 决定踢出某个 Page。
因此总结如下:外界知道page_id,要访问某页面时,buffer pool内部在 哈希表/页表 通过该page_id查询到对应的frame_id,用该frame_id作为下标从实际page数组中取得对应的Page对象,Page对象中包含了实际的存储数据data_
,页号page_id_
,pin_count
,脏位id_dirty_
- 开局的pool_size限制的是Frame的数量,影响到Page数组大小,以及LRU驱逐策略的块数量
实际上bpm统筹的是三个半模块(具体可见下文 分配新页)
- 页表/哈希表
- 页面元数据与标志位
- 驱逐策略
- 其中2中的标志位pin_count_与3中的Evictable状态是对应的(见下文)
- 2中的is_dirty标志位比较特殊,除非手动,只有bpm将页面写回磁盘的时候会重置,其他时间不会有行为改变该标志位
- 剩下的半个模块就是获取Frame以及释放Frame的时候维护的链表
std::list<frame_id_t>
pin_count_
的两个功能
表示当前有几个进程在使用/需要这个页面,每个需求都对应着一个pin_count_
,当进程用完了不需要这个页面了,就unpin
如果一个页面pin_count_
为0,即没有进程需要这个页面了,就可以被驱逐,只不过是懒驱逐,没有空闲Frame的时候再去释放,这个页面实际上在不在buffer pool中已经无所谓了
既控制LRU驱逐策略中页是否evictable,又控制DeletePgImp
方法是否可直接删除该页
本质上是同一件事情,只要不主动调用UnpinPgImp
方法减少页的pin_count_
,那么分配新页的两个方法FetchPgImp
,NewPgImp
都只会将pin_count_
置为1或者增加,那么无论是主动删除还是驱逐策略,都无法去除该页
在代码中,也即pin_count_
的增减,是与replacer_->SetEvictable
方法绑定的
类型别名
//src/include/common/config.h
using frame_id_t = int32_t; // frame id type
using page_id_t = int32_t; // page id type
GetAvailableFrame
因为NewPgImp
和FetchPgImp
都需要得到一个空闲的Frame来安置新的page,而空闲的Frame要么从free_list_ 中获得,要么根据LRU模块的Evict()结果决定驱逐出当前的哪一个Frame,注意这里 replacer_模块只负责驱逐策略部分,不负责实际执行
因此设计一个返回值为bool类型的函数,这样NewPgImp
和FetchPgImp
调用时就能直接知道能不能分配到Frame,而实际的Frame号通过参数列表传出,其实就和replacer_->Evict(&frame_id)
用法一样
驱逐得到空Frame的本质就是从 页表/哈希表 中移除映射关系,实际的数据以及页号更替工作滞后到写入新页信息的时候
auto BufferPoolManagerInstance::GetAvailableFrame(frame_id_t *outcomeframeid) -> bool {
//从free_list_获得空闲的Frame
frame_id_t frame_id;
if (!free_list_.empty()) {
frame_id = free_list_.front();
free_list_.pop_front();
*outcomeframeid = frame_id;
return true;
}
//否则看是否能驱逐某一块,若能驱逐,传入的frame_id中就保存了块号,那么这个if体内就是做实际驱逐的事情
if (replacer_->Evict(&frame_id)) {
if (pages_[frame_id].is_dirty_) {//如果是脏页,在实际驱逐之前就要写回磁盘
disk_manager_->WritePage(pages_[frame_id].page_id_, pages_[frame_id].data_);
pages_[frame_id].is_dirty_ = false;//这里写入脏页后要重置脏位,因为上层函数不会做这个事情,只会重写页号等标志位和实际数据部分
}
page_table_->Remove(pages_[frame_id].page_id_);//驱逐的本质就是从 页表/哈希表 中移除映射关系,其实页中的数据还是原样,但只要页表里面没有页号frame号键值对了,那么这一页就没了
*outcomeframeid = frame_id;
return true;
}
return false;
}
分配新页
- 通过上文的
GetAvailableFrame
分配一个空闲Frame,如果没有直接退出 - 获得页号,其中
NewPgImp
是根据AllocatePage()
自动分配的,也就是每分配一个页号,全局变量next_page_id_
自增FetchPgImp
中页号是外面提供的
- 页表/哈希表中记录页号到Frame号的映射关系,这一步相当于「在buffer pool层面写入了新页」(因为buffer pool与外界的唯一接口就是页表)
- 上面的
AllocatePage()
过程中,驱逐某一页的实际操作也是在从 页表/哈希表 中移除映射关系,而并没有清空元数据**(懒政)**,因为没必要
- 上面的
- 但是获得新页与驱逐得到Frame不同,buffer pool内部还需要做「实际数据以及标志」层面上的更改
- 页中写入新的页号,pin_count_ ,如果该页已经在buffer中,pin_count_++,否则是第一次替换到buffer中,置为1
- 元数据处理
- 对于
NewPgImp
,因为是获取一个新的空页面,因此pages_[frame_id].ResetMemory()
将元数据清空即可 - 对于
FetchPgImp
,如果老页面在buffer中,啥也不用做,如果不在,则需要清空原数据后,调用磁盘接口将新数据读取到data_
中
- 对于
- 最后一步,需要更新驱逐策略,包括更新访问次数,并且被访问到的页设置为pinned(后面会有专门的unpin函数操作)
auto BufferPoolManagerInstance::NewPgImp(page_id_t *page_id) -> Page * {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (GetAvailableFrame(&frame_id)) {
*page_id = AllocatePage();
page_table_->Insert(*page_id, frame_id);//在buffer pool层面写入了新页
//**做「实际数据以及标志」层面上的更改**
pages_[frame_id].page_id_ = *page_id;
pages_[frame_id].pin_count_ = 1;
pages_[frame_id].ResetMemory();
//更新驱逐策略
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
page_id = nullptr;
return nullptr;
}
auto BufferPoolManagerInstance::FetchPgImp(page_id_t page_id) -> Page * {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (page_table_->Find(page_id, frame_id)) {
//**做「实际数据以及标志」层面上的更改**
pages_[frame_id].pin_count_++;
//更新驱逐策略
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
if (GetAvailableFrame(&frame_id)) {
page_table_->Insert(page_id, frame_id);//在buffer pool层面写入了新页
//**做「实际数据以及标志」层面上的更改**
pages_[frame_id].page_id_ = page_id;
pages_[frame_id].pin_count_ = 1;
pages_[frame_id].ResetMemory();
disk_manager_->ReadPage(page_id, pages_[frame_id].GetData());
//更新驱逐策略
replacer_->RecordAccess(frame_id);
replacer_->SetEvictable(frame_id, false);
return &pages_[frame_id];
}
return nullptr;
}
UnpinPgImp
如果只访问页面的话,pin_count_
是只增不减的,因此设置了手动unpin的方法,当pin_count_
为0时候,该页在驱逐策略中自动变为Evictable状态
auto BufferPoolManagerInstance::UnpinPgImp(page_id_t page_id, bool is_dirty) -> bool {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (!page_table_->Find(page_id, frame_id) || pages_[frame_id].pin_count_ == 0) {
return false;
}
if (--pages_[frame_id].pin_count_ == 0) {
replacer_->SetEvictable(frame_id, true);
}
if (!pages_[frame_id].is_dirty_) {//顺带根据参数设置了脏页状态,只能由干净页面变为脏页,反之不可行
pages_[frame_id].is_dirty_ = is_dirty;
}
return true;
}
FlushAllPgsImp()
因为我们自己实现的ExtendibleHashTable
只提供了Find
,Insert
,Remove
三个接口,因此无法用作遍历对象
因此直接在buffer数组中遍历所有Frame,如果有页号,就说明该页是solid的,可以操作
注意page_
是指针数组,不能用基于范围的循环:
因此用size_t
的 Frame号遍历数组
void BufferPoolManagerInstance::FlushAllPgsImp() {
std::scoped_lock lock(latch_);
// pages_ is a pointer-form array, can't use range-for
for(size_t i = 0;i<pool_size_;i++){
if(pages_[i].page_id_!=INVALID_PAGE_ID){
disk_manager_->WritePage(pages_[i].page_id_, pages_[i].GetData());
pages_[i].is_dirty_ = false;
}
}
}
删除页面
auto BufferPoolManagerInstance::DeletePgImp(page_id_t page_id) -> bool {
std::scoped_lock lock(latch_);
frame_id_t frame_id;
if (!page_table_->Find(page_id, frame_id)) {
return true;
}
//pin_count和Evictable是对应的
if (pages_[frame_id].pin_count_ != 0) {
return false;
}
if (pages_[frame_id].is_dirty_) {
disk_manager_->WritePage(pages_[frame_id].page_id_, pages_[frame_id].data_);
pages_[frame_id].is_dirty_ = false;
}
//清除元数据,标志位,记得一定要将page_id设置为无效
pages_[frame_id].ResetMemory();
pages_[frame_id].pin_count_ = 0;
pages_[frame_id].page_id_ = INVALID_PAGE_ID;
//页表,驱逐策略中去除该Frame,空闲链表中加入该Frame
page_table_->Remove(page_id);
free_list_.emplace_back(frame_id);
replacer_->Remove(frame_id);
//释放页号
DeallocatePage(page_id);
return true;
}
Project #2 - B+Tree
参考链接
- B树和B+树的插入、删除图文详解 - nullzx - 博客园 (cnblogs.com)
- 【CMU15-445数据库】bustub Project #2:B+ Tree(上)-CSDN博客
- 从B树、B+树、B*树谈到R 树-CSDN博客
CHECKPOINT #1
设计思路
观察src/include/storage/index/b_plus_tree.h
私有成员
键值的存储,第一个键不算
从相应的testtest/storage/b_plus_tree_insert_test.cpp
文件中可以看出模板是如何实例化的(学会看头文件),此处使用他们自己在src/include/storage/index/generic_key.h
定义的GenericKey
和 GenericComparator
类来实例化泛型,而各类参数如INTERNAL_PAGE_SIZE以及别名等都是在src/include/common/config.h
文件中定义的
//test/storage/b_plus_tree_insert_test.cpp GenericKey<8>,GenericComparator<8>进行实例化部分
BPlusTree<GenericKey<8>, RID, GenericComparator<8>> tree("foo_pk", bpm, comparator, 2, 3);
//src/include/storage/index/b_plus_tree.h
#define BPLUSTREE_TYPE BPlusTree<KeyType, ValueType, KeyComparator>
//src/include/storage/page/b_plus_tree_page.h
#define INDEX_TEMPLATE_ARGUMENTS template <typename KeyType, typename ValueType, typename KeyComparator>
类型转换
reinterpret_cast运算符允许将任意指针转换到其他指针类型,也允许做任意整数类型和任意指针类型之间的转换。转换时,执行的是逐个比特复制的操作。reinterpret中文意为“重新解释; 重新诠释;”。
//src/include/storage/page/page.h
/** The actual data that is stored within a page. */
char data_[BUSTUB_PAGE_SIZE]{};//这里{}是静态数组的初始化
/** @return the actual data contained within this page */
inline auto GetData() -> char * { return data_; }
而我们的代码中,这里直接将Page对象中 字符数组形式表示的 页内存空间 data_
重新转义 为 树页面类 BPlusTreePage*
,也即「同一块内存空间的不同释义」
auto tree_page = reinterpret_cast<BPlusTreePage* >(page->GetData());
static_cast可以用于类层次结构中 基类和子类之间指针或引用 的转换
这里InternalPage
是BPlusTreePage
的子类,因此父类指针转换为子类指针
auto internal_page = static_cast<InternalPage*>(tree_page);
reinterpret_cast和static_cast的比较:
static_cast就是利用C++类型之间的继承关系图和聚合关系图(编译器必须知道),根据一个子对象地址计算另一个子对象的地址。
reinterpret_cast不关心继承关系,直接把数据类型A的地址「解释」成另一个数据类型B的地址。
所以,对于无继承关系的类的转换,static_cast需要进行构造函数的重载,参数必须是要被转换的类的类型。而reinterpret_cast则没有这个限制。
回到这个项目里面的逻辑
内部节点和叶节点对象都不是直接创建出来,而是由一个 Buffer Pool 管理的 「Page 的 data 部分」类型转化而来(所以要用到很少用很暴力的 reinterpret_cast)。因此,节点对象的生命周期也不是由 new 和 delete,而是由我们上节实现的 BufferPoolManager 管理:取一个页面,用 FetchPage;使用结束归还一个页面,用 UnpinPage
Each B+Tree leaf/internal page corresponds to the content (i.e., the data_ part) of a memory page fetched by buffer pool. So every time you try to read or write a leaf/internal page, you need to first fetch the page from buffer pool using its unique page_id, then reinterpret cast to either a leaf or an internal page, and unpin the page after any writing or reading operations.
查找(GetValue)
给定一个键 x
,查找其是否在 B+ 树中存在。实现逻辑是先找到键可能在的叶节点,然后扫描一遍叶节点的内容确定是否存在,其中重点是前者。
编写一个函数 GetLeafPage
,根据 B+ 树的规则,应该从根节点开始,每次在内部节点中找到
k
i
<
x
<
k
i
+
1
k_i < x < k_i+1
ki<x<ki+1 的位置,然后沿着
v
i
v_i
vi指针继续向下,直到达到叶节点。
逻辑是
- 一个while循环,先从buffer_pool中取出一个普通的Page,然后把Page中的
data_
部分转义(reinterpret)为BPlusTreePage *
- 然后这个被转义的
BPlusTreePage *
对象就可以正常调用BPlusTreePage *
类的函数判断是否为叶子页了 - 如果是叶子页,找到了,返回该页最初的Page形态,而非加工过后的
BPlusTreePage *
形态 - 不是,那么就遍历这个页面所有的键值对,找到
k
i
<
x
<
k
i
+
1
k_i < x < k_i+1
ki<x<ki+1 的位置,然后把这个指针指向的页作为下一页
- 这里的遍历解释见下文插入Insert部分
- 手动控制页对象的生命周期,因为有了FetchPage()新建页对象,那么需要UnPin()来释放页对象
- 下一轮循环
其中
Tips:循环时找内部节点中第一个比
x
大的键,取其左侧的值即可(想象一个数组,每个数组下标对应的值是其指针,而k[0]
无效,这使得第一个key有了左边的指针,而k[1]
变成了其右指针,同时是第二个key的左指针,这样就实现了key比value少一个,同时每个key都有左右两个value),而这样 不能 探测到x
比所有k
都大的情况,所以要将next_page_id
初始化为最右侧的键
//前两行的宏定义见上文
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetLeafPage(const KeyType &key) -> Page * {
page_id_t next_page_id = root_page_id_;
while (true) {
//先通过buffer_pool取出一个page//as long as there is a FetchPage(), there must be a corresponding Unpin() afterwards
Page *page = buffer_pool_manager_->FetchPage(next_page_id);
//根据上面所说的,把Page中的data_部分转义(reinterpret)为BPlusTreePage *
auto tree_page = reinterpret_cast<BPlusTreePage *>(page->GetData());
//然后就可以正常调用BPlusTreePage *类的函数判断是否为叶子页了
if (tree_page->IsLeafPage()) {
return page;
}
//如果不是,static_cast用于父子类指针之间的转换
auto internal_page = static_cast<InternalPage *>(tree_page);
//将 `next_page_id` 初始化为最右侧的键
next_page_id = internal_page->ValueAt(internal_page->GetSize() - 1);
//从int i =1开始,省略第一个key的空间,也就是KeyAt(0) is neglected
for (int i = 1; i < internal_page->GetSize(); i++) {
if (comparator_(internal_page->KeyAt(i), key) > 0) {//这里使用GenericComparator来实例化模板中的KeyComparator,声明为comparator_,因此观察src/include/storage/index/generic_key.h中的模板类,直接用重载的()来判断大小
next_page_id = internal_page->ValueAt(i - 1);
break;
}
}
//the corresponding UnpinPage() to release this Page, inside this while() loop
buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
}
}
//src/storage/index/b_plus_tree.cpp 为上面代码的实例化补充,这里可见comparator_是被const KeyComparator &comparator初始化的,而KeyComparator被上面的GenericComparator<8>进行实例化
INDEX_TEMPLATE_ARGUMENTS
BPLUSTREE_TYPE::BPlusTree(std::string name, BufferPoolManager *buffer_pool_manager, const KeyComparator &comparator,
int leaf_max_size, int internal_max_size)
: index_name_(std::move(name)),
root_page_id_(INVALID_PAGE_ID),
buffer_pool_manager_(buffer_pool_manager),
comparator_(comparator),
leaf_max_size_(leaf_max_size),
internal_max_size_(internal_max_size) {}
在找到对应叶子页的基础上,GetValue()
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetValue(const KeyType &key, std::vector<ValueType> *result, Transaction *transaction) -> bool {
bool ret = false;
Page *page = GetLeafPage(key);
auto tree_page = reinterpret_cast<LeafPage *>(page->GetData());
//注意这里for循环遍历下标,对数组每个元素的key进行比对找到键值对
for (int i = 0; i < tree_page->GetSize(); i++) {
if (comparator_(tree_page->KeyAt(i), key) == 0) {
result->emplace_back(tree_page->ValueAt(i));
ret = true;
break;
}
}
//这里要Unpin()掉上面GetLeafPage()中Fetch但是没有走完while循环旧返回的page
buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
return ret;
}
插入(Insert)
B+ 树的插入流程为:
-
如果是空树,创建一个叶节点作为根。注意涉及
root_page_id_
更新时都要调用一次已经提供的函数UpdateRootPageId()
,如果是第一次创建传 1 作为参数,更新不用传任何参数,具体看b_plus_tree.cpp
里面的UpdateRootPageId()
函数-
这里对叶节点初始化
leaf_page->SetValueAt(0, value); leaf_page->SetKeyAt(0, key);
见下文的键值对实际存储方式为
std::pair<KeyType, ValueType>
的数组,因此新建**叶子页面(因此下标为0处也是有效的,如果是内部页面,下标从1开始标识key,下标index为0处没有key,只有value)**的第一个键值对的下标index设置为0 -
//注意插入键值对的时候控制size leaf_page->IncreaseSize(1);
用于控制当前页面键值对pair数组的size,与下面的for循环遍历全部键值对中的结束条件对应
//使用「当前页面键值对pair数组的size」作为结束控制条件,遍历整个数组,得到key和value for (int i = 0; i < leaf_page->GetSize(); i++)
-
-
如果不是空树,从根节点向下查找到键值应该所在的叶节点。文档说明了**「不支持重复键」**,所以先扫描一遍叶节点,如果发现键存在则直接返回 false
-
因为这种用键值对pair的数组的存储方式,上面的查找以及下面中的for循环遍历全部键值对找到是否有重复键/找到下一个页面的键,都是从0到当前页面的size遍历所有的下标index,使用
ValueAt()
和KeyAt()
返回对应的键值,根据b_plus_tree_leaf_page.cpp
中发现这两个函数就是返回pair.first/pair.second
-
无法像stl里面的map一样,直接获得key,必须遍历找到key
INDEX_TEMPLATE_ARGUMENTS auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool { //如果是空树 if (IsEmpty()) { //首先从buffer_pool获取一个新的页面,以及对应的页面id,注意这里与FetchPage的区别,后者是指定id获取页面,前者是单纯的返回一个页面 Page *page = buffer_pool_manager_->NewPage(&root_page_id_); UpdateRootPageId(1);//依葫芦画瓢 auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData()); //对这个叶节点做初始化操作,注意SetValueAt()和SetKeyAt()都是要「自己主动」在b_plus_tree_leaf_page.h中加上函数签名的 //同时需要在b_plus_tree_page.h中把private数据成员的「不使用」标志去除 leaf_page->Init(root_page_id_, INVALID_PAGE_ID, leaf_max_size_); leaf_page->SetValueAt(0, value); leaf_page->SetKeyAt(0, key); //这里注意一定要IncreaseSize(1) leaf_page->IncreaseSize(1); leaf_page->SetNextPageId(INVALID_PAGE_ID); //记得bufferpool中,有获取页面就一定有Unpin,同时页面被修改了,dirtybit置为脏 buffer_pool_manager_->UnpinPage(root_page_id_, true); return true; } //因为这里Fetchpage了,需要在接下来的所有分支里面Unpin Page *page = GetLeafPage(key); auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData()); //遍历键值对pair数组,看是否有相同的key,因为不支持重复键 for (int i = 0; i < leaf_page->GetSize(); i++) { if (comparator_(leaf_page->KeyAt(i), key) == 0) { buffer_pool_manager_->UnpinPage(leaf_page->GetPageId(), false);//需要在接下来的所有分支里面Unpin return false; } } return false; }
array_[1]
等价于一个指针,按照一般习惯应该在叶子页面的构造函数中为其 new 出一片大小为max_size_
的空间,作为实际页面的存放空间,但实际上不需要这样做,因为:internal/leaf节点对象使用的是buffer_pool中分配好的固定空间,他们的私有成员
array_
可以控制从该位置开始到 Page 的 data 结束为止的这一段空间注意数据实际存储为
std::pair<KeyType, ValueType>
的数组,使用array_[1]
等价的指针来访问这个数组,使用index作为数组下标,访问pair对中的first second元素,对应的是key和value//b_plus_tree_leaf_page.h/b_plus_tree_internal_page.h #define MappingType std::pair<KeyType, ValueType> ... class BPlusTreeInternalPage : public BPlusTreePage { ... private: // Flexible array member for page data. MappingType array_[1]; } //「自己主动」在b_plus_tree_leaf_page.h中加上函数签名 void SetKeyAt(int index, const KeyType &key); void SetValueAt(int index, const ValueType &value); //b_plus_tree_leaf_page.cpp,注意这里的方式,使用index作为数组下标,访问pair对中的first second元素,对应的是key和value INDEX_TEMPLATE_ARGUMENTS void B_PLUS_TREE_LEAF_PAGE_TYPE::SetKeyAt(int index, const KeyType &key) { array_[index].first = key; } INDEX_TEMPLATE_ARGUMENTS void B_PLUS_TREE_LEAF_PAGE_TYPE::SetValueAt(int index, const ValueType &Value) { array_[index].second = Value; } INDEX_TEMPLATE_ARGUMENTS auto B_PLUS_TREE_LEAF_PAGE_TYPE::KeyAt(int index) const -> KeyType { return array_[index].first; }
-
-
未溢出情况,插入的具体逻辑可以放到
LeafPage
类中做,所以添加一个Insert
函数,找到插入位置,将所有后面的键值对后移一位,再设置。由于array_
是有序的,如果还想提高效率,可以把找插入位置用二分搜索实现。- 注意插入键值对的时候控制页面size
Tips:
comparator_
也要作为参数传入Insert
,否则LeafPage
中无法进行键的比较,也就无法查找//b_plus_tree.cpp 书接上文 INDEX_TEMPLATE_ARGUMENTS auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool { ... ... leaf_page->Insert(key, value, comparator_);//comparator_也要传入 if (leaf_page->GetSize() < leaf_max_size_) {//判断为没有溢出,注意这里的size是「严格小于」max_size的 buffer_pool_manager_->UnpinPage(leaf_page->GetPageId(), false); return true; }
//b_plus_tree_leaf_page.h INDEX_TEMPLATE_ARGUMENTS void B_PLUS_TREE_LEAF_PAGE_TYPE::Insert(const KeyType &key, const ValueType &value, KeyComparator comparator_) { int size = GetSize(); if (size >= GetMaxSize()) { throw std::logic_error("B+ tree leaf is full,please split this page beforehand"); } int insert_pos = 0; //先使用传入的comparator_遍历pair对数组比较key大小,找到第一个大于给定key的下标index,也就是insert_position while (insert_pos < size && comparator_(KeyAt(insert_pos), key) < 0) { insert_pos++; } //从尾部开始倒序将所有后面的键值对后移一位 for (int i = size; i > insert_pos; i--) { array_[i] = array_[i - 1]; } //「注意插入键值对的时候控制页面size」 IncreaseSize(1); SetKeyAt(insert_pos, key); SetValueAt(insert_pos, value); }
}
return false;
}
```
`array_[1]` 等价于一个指针,按照一般习惯应该在叶子页面的构造函数中为其 new 出一片大小为 `max_size_` 的空间,作为实际页面的存放空间,但实际上不需要这样做,因为:
**internal/leaf节点对象使用的是buffer_pool中分配好的固定空间,他们的私有成员`array_` 可以控制从该位置开始到 Page 的 data 结束为止的这一段空间**
**注意数据实际存储为`std::pair<KeyType, ValueType>`的数组,使用`array_[1]` 等价的指针来访问这个数组**,**使用index作为数组下标**,访问pair对中的first second元素,对应的是key和value
```cpp
//b_plus_tree_leaf_page.h/b_plus_tree_internal_page.h
#define MappingType std::pair<KeyType, ValueType>
...
class BPlusTreeInternalPage : public BPlusTreePage {
...
private:
// Flexible array member for page data.
MappingType array_[1];
}
//「自己主动」在b_plus_tree_leaf_page.h中加上函数签名
void SetKeyAt(int index, const KeyType &key);
void SetValueAt(int index, const ValueType &value);
//b_plus_tree_leaf_page.cpp,注意这里的方式,使用index作为数组下标,访问pair对中的first second元素,对应的是key和value
INDEX_TEMPLATE_ARGUMENTS
void B_PLUS_TREE_LEAF_PAGE_TYPE::SetKeyAt(int index, const KeyType &key) { array_[index].first = key; }
INDEX_TEMPLATE_ARGUMENTS
void B_PLUS_TREE_LEAF_PAGE_TYPE::SetValueAt(int index, const ValueType &Value) { array_[index].second = Value; }
INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_LEAF_PAGE_TYPE::KeyAt(int index) const -> KeyType { return array_[index].first; }
```
-
未溢出情况,插入的具体逻辑可以放到
LeafPage
类中做,所以添加一个Insert
函数,找到插入位置,将所有后面的键值对后移一位,再设置。由于array_
是有序的,如果还想提高效率,可以把找插入位置用二分搜索实现。- 注意插入键值对的时候控制页面size
Tips:
comparator_
也要作为参数传入Insert
,否则LeafPage
中无法进行键的比较,也就无法查找//b_plus_tree.cpp 书接上文 INDEX_TEMPLATE_ARGUMENTS auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool { ... ... leaf_page->Insert(key, value, comparator_);//comparator_也要传入 if (leaf_page->GetSize() < leaf_max_size_) {//判断为没有溢出,注意这里的size是「严格小于」max_size的 buffer_pool_manager_->UnpinPage(leaf_page->GetPageId(), false); return true; }
//b_plus_tree_leaf_page.h INDEX_TEMPLATE_ARGUMENTS void B_PLUS_TREE_LEAF_PAGE_TYPE::Insert(const KeyType &key, const ValueType &value, KeyComparator comparator_) { int size = GetSize(); if (size >= GetMaxSize()) { throw std::logic_error("B+ tree leaf is full,please split this page beforehand"); } int insert_pos = 0; //先使用传入的comparator_遍历pair对数组比较key大小,找到第一个大于给定key的下标index,也就是insert_position while (insert_pos < size && comparator_(KeyAt(insert_pos), key) < 0) { insert_pos++; } //从尾部开始倒序将所有后面的键值对后移一位 for (int i = size; i > insert_pos; i--) { array_[i] = array_[i - 1]; } //「注意插入键值对的时候控制页面size」 IncreaseSize(1); SetKeyAt(insert_pos, key); SetValueAt(insert_pos, value); }