前段时间小狐狸写了一点 Rust,最近因为要使用 Qt,回去继续写 C++,就很想在 C++ 里面使用 Rust 的许多常用的类型工具。在 Rust 中 Option<&T> 可以很方便地传递一个可能为空的引用,写得糙的话可以替代普通指针,并且避免空指针造成的错误。C++ 17 引入了一个名为 optional 的标准库容器,就有着与 Rust 的 Option 类似的功能。但由于 C++ 本身的一些局限,想用它伪造一个指针的替代物其实它远比想象得更复杂。
不过很多同学会问,既然 C++ 本身是有空指针的,为什么我们需要强行用 std::optional 来替代指针呢?为什么不能直接返回引用呢?这里我们看一个例子:
#include <vector>
#include <string>
#include <iostream>
using namespace std;
class BlackBox{
public:
void insert(const string& item){
container.push_back(item);
}
string& get_ref(int i){
return container[i];
}
private:
vector<string> container;
};
int main(){
BlackBox box;
box.insert("Hello");
box.insert("world");
// 输出 Hello
cout << box.get_ref(0) << endl;
box.get_ref(0) = "Hi";
// 输出修改后的 Hi
cout << box.get_ref(0) << endl;
// 崩溃或者 undefined behavior
cout << box.get_ref(2) << endl;
}
这里我们用一个名为 BlackBox 的类封装了一个 vector,并且可以通过 get_ref() 来得到某一项元素的引用。之所以选择引用而不是指针,是因为引用并不能直接创建,只能指向一个由其他对象管理的内存空间,因此可以避免一部分指针的问题(当然 Rust 的引用还能够进行所有权和生命周期检查,更为安全,在 C++ 里面我们就将就一下好了)。返回的引用也能更自然地进行赋值等操作。
然而这里有一个问题:对于最后我们如果试图获取一个不存在的元素的引用,应当返回什么?根据 vector 的文档,访问一个不存在的元素是未定义的行为,这显然是不好的。因此我们希望用 optional 将其包装,如果这个东西不存在,就返回空,而由调用者进行检查。首先我们尝试不返回引用,而是返回一个值:
#include <optional>
#include <vector>
#include <string>
#include <iostream>
using namespace std;
class BlackBox{
public:
void insert(const string& item){
container.push_back(item);
}
optional<string> get(int i){
if (i >=0 && i < container.size()) {
return optional<string>(container[i]);
} else {
return nullopt;
}
}
private:
vector<string> container;
};
int main(){
BlackBox box;
box.insert("Hello");
box.insert("world");
// 输出 Hello
cout << box.get(0).value_or("None") << endl;
// 输出 None
cout << box.get(2).value_or("None") << endl;
}
到这一步一切都很正常,第一个输出指令成功输出了 Hello,而第二个则输出了 None。现在让我们尝试换成返回引用:
class BlackBox{
public:
void insert(const string& item){
container.push_back(item);
}
optional<string&> get(int i){
if (i >=0 && i < container.size()) {
return optional<string>(container[i]);
} else {
return nullopt;
}
}
private:
vector<string> container;
};
仅仅将 optional<string> 换成 optional <string&>,就会产生一大堆编译错误,让人摸不着头脑。究其根本原因是 C++ 标准库容器一般而言只能保存可复制、可用于赋值的类型,而引用并不满足这个要求。为此我们需要使用一个名为 reference_wrapper 的东西将引用转化成一个普通对象进行操作。而要从 reference_wrapper 中获取引用,则要调用它的 get() 方法:
#include <optional>
#include <functional>
#include <vector>
#include <string>
#include <iostream>
using namespace std;
class BlackBox{
public:
void insert(const string& item){
container.push_back(item);
}
optional<reference_wrapper<string>> get(int i){
if (i >=0 && i < container.size()) {
return optional<reference_wrapper<string>>(container[i]);
} else {
return nullopt;
}
}
private:
vector<string> container;
};
int main(){
BlackBox box;
box.insert("Hello");
box.insert("world");
auto ref_opt = box.get(0);
if (ref_opt.has_value()) {
// 从 optional 中获取 reference_wrapper<string>
auto ref = ref_opt.value();
// ref.get() 的返回值是 string& 类型,因此可以输出、修改
// 这一步输出 Hello
cout << ref.get() << endl;
ref.get() = "Hi";
} else {
cout << "None" << endl;
}
ref_opt = box.get(0);
if (ref_opt.has_value()) {
// 这里输出 Hi
cout << ref_opt.value().get() << endl;
}
// 这里 ref_opt 得到的是 nullopt
ref_opt = box.get(2);
cout << (ref_opt.has_value() ? ref_opt.value().get() : "None") << endl;
}
整个程序变复杂了许多,然而通过两层包装,我们得到了一个安全的返回引用的方式,除了生命周期检查,基本和 Rust 的 Option<&mut T> 等价,唯一不同的是有一个 get() 方法的开销,但这个可以让编译器内联优化抹平,也不是什么问题。
好奇的同学可能会问,reference_wrapper 究竟是什么魔法,能把引用包装成可以操作访问的东西的?在 GNU C++ 标准库的实现中,它其实是保存了引用所对应的指针,所以搞到最后操作的还是指针,只是通过一个封装来规避危险的操作而已。