C++ 17 在 STL 中加入了许多新的"vocabulary types",这些类型时用在不同组件的接口处的。MSCV也在Visual Studio2017中添加了对诸如std::optional
,std::any
和std::variant
的支持。在这篇文章中,我们就来看一下std::optional
解决了哪些问题,以及如何正确使用它。
在程序中,有时我们需要变量来表达“nothing”的意思,比如你怎么写一个函数来“有可能“返回一个值?就好像给我找出一段文字中的第一个偶数,如果有的话,那很好;没有的话,也没什么大不了的——很平凡也很常见的需求。
以前我们常采用的做法是“magic value” :
int find_me_the_even_number();//a return value of -1 means no even number
“magic value”指的是某些预先约定好的特殊值:空string
,0
,1
或者是最大的无符号整型,比如std::string::npos
。这种做法的弊端是不言而喻的,我们会默认这些魔法值是我们不需要的,抑或我们赋予了这些值“有异常”的意义,但这一切都是口头约定,并没有从语法层面加以约束。其次,对于某些类型,我们并不能找到类似的魔法值,或者就算找到后,这些值也不能轻松地被创造出来,这都给我们在使用魔法值时带来风险。
另一种方法是二次询问:变量需要同时绑定一个布尔值,在使用变量前先查询这个bool
变量,看这个值是否存在;如果是,那么进行第二次访问获取这个变量的值。
void maybe_require_an_int(int value = -1, bool do_I_need_int);
void even_better(pair param = std::make_pare(-1, false));
pare maybe_return_an_int();
这个方案也行,但是太丑了,我的师傅告诉我:“看起来很丑的代码八成都有问题“。撇开重复的查询不说,做法在线程上是否安全也有待商榷——如果因为线程的问题访问了那个并不存在的值,要么我们得到一个不确定的数,要么抛出异常,结果都是心碎了一地。
想想看,你的某个类中某个成员,他在类对象被构造时——出于某种原因——还不能被初始化。也就是说,这个类的对象,有选择性的去初始化它的成员。这些成员的初始化要么发生在稍后的某个主动调用,要么发生在接收到某个请求。无论如何,我们都要实现这样的功能,如果按照上边添加bool
值来记录这个成员的初始化情况,那么我们就会得到下面这样“丑陋的代码”。
using T = /*some type*/;
struct S{
bool is_initialized = false;
alignas(T) unsigned char maybe_T[sizeof(T)];
void constructor_the_T(int arg) {
assert(!is_initialized);
new (&maybe_T) T(arg);
is_initialized = true;
}
T& get_the_T() {
assert(is_initialized);
return reinterpret_cast(maybe_T);
}
~S() {
if(is_initialized) {
get_the_T().~T(); // destroy the T
}
}
// 还有一大坨代码
};
上边“还有一大坨代码”的位置就是拟写拷贝/移动构造函数/操作符的位置了,实现这些函数时也都要务必注意:T
有没有被初始化。如果你真的发自内心得觉得上边的代码很恶心的话,那么幸亏你看了这篇文章——你的直觉是对的,我们现在有更好的解决它的方法了。上边的做法无异于徘徊在bug的悬崖边小心翼翼,如履薄冰,躲避着那一个个可能的未知错误。
有些朋友可能会有想法:我能不能用std::unique_ptr
来模拟std::optional
,简要的回答是:功能上满足要求,但是从语义上和性能上我们都不建议这么做;详细的解释请参见我的另一篇回答。
std::optional
C++17 在 STL 中引入了std::optional
,就像std::variant
一样,std::optional
是一个“和类型(sum type)”,也就是说,std::optional
类型的变量要么是一个T
类型的__变量__,要么是一个表示“什么都没有”的__状态__。这个状态也有自己的类型和值:类型是std::nullopt_t
,值为std::nullopt
。看起来是不是很熟悉?没错,概念上它和nullptr
十分相似,区别就是后者是一个关键字罢了。
std::optional
几乎拥有所有我们想要的性质:任何一个T
类型或可以隐式转换成T
类型的变量都可以用来构造它的对象,同样我们也可以用std::nullopt
或默认构造函数来构造它,这个时候我们得到的变量意义是“nothing”罢了。我们使用has_value()
函数来询问std::optional
此时是否有值,如果有的话,我们使用value()
函数来获取他的值。如果std::optional
没有值时我们仍然调用value()
,我们将获得一个std::bad_optional_access
异常报错奖励。需要注意的是,std::optional
在内部存储T
变量,并没有用到动态分配内存,其实说实在的,在C++标准中,动态内存这种行为时时刻刻都是不被建议的。
int main() {
std::string text = "Hello, it's me.";
std::optional opt = firstEvenNumberIn(text);
if (opt.has_value()) {
std::cout << "The first even number is "
<< opt.value()
<< std::endl;
}
}
除了上边的方法之外,std::optional
也提供了一系列和智能指针相似的接口:他可以显式地转化为bool
型变量来显示他此时是否拥有一个有意义的值。指针的解引用操作符*
和->
也被重载,但是在使用前一定要检验变量是否含有值,不加检验的访问将会导致未知的结果。最后,reset()
方法销毁存储在std::optional
中的值,并将其值为空。因此上边的代码也可以改写成:
int main() {
std::string text = "Hello, it's me.";
std::optional opt = firstEvenNumberIn(text);
if (opt) {
std::cout << "The first even number is "
<< *opt
<< std::endl;
}
}
最后,类似于std::make_unique
和std::make_shared
一样,std::make_optional
也可以构造一个T
类型的std::optional
。同样的,使用emplate(Args...)
方法也可以将一个T
类型对象置入一个已经存在的std::optional
对象中。
auto optVec = std::make_optional>(3, 22);
std::set ints{7, 4, 1, 741};
optVec.emplate(std::begin(ints), std::end(ints));
std::copy(optVec->begin(), optVec->end(), std::ostream_iterator(std::out, ", "));
都到这里了,我们就该把文章一开始的类重新写一下了。我们想类中添加一个std::optional
成员,接下来的一切就交给标准库吧——因为std::optional
已经解决了所有有关拷贝和移动构造函数/操作符相关的问题了。
using T = /* some type */;
struct S {
optional maybe_T;
void construct_the_T(int arg) {
// 我们无需处理重复构造所带来的问题,因为
// optional的emplace member会自动销毁所
// 之前存在的对象并构建一个新对象。
maybe_T.emplate(arg);
}
T& get_the_T() {
assert(maybe_T);
return *maybe_T;
// 或者如果你真的对异常情有独钟,你在这里可以写成:
// return maybe_T.value();
}
// ... 接下来的拷贝和构造函数就能保证问题不会处在optional这里啦!...
};
有了std::optional
,我们可以:
std::optional
虽然体量很小,但是却非常强大而有用。下次你再纠结到底该用哪个"magic value"去表达"nothing"的意思时,记得试试std::optional
。