漫谈C++——C++17新特性之std::optional

C++学习笔记

Posted by Felix Zhang on September 11, 2020

C++17新特性之std::optional

C++ 17 在 STL 中加入了许多新的”vocabulary types”,这些类型时用在不同组件的接口处的。MSCV也在Visual Studio2017中添加了对诸如std::optional, std::anystd::variant的支持。在这篇文章中,我们就来看一下std::optional解决了哪些问题,以及如何正确使用它。

表达“有时存在”

在程序中,有时我们需要变量来表达“nothing”的意思,比如你怎么写一个函数来“有可能“返回一个值?就好像给我找出一段文字中的第一个偶数,如果有的话,那很好;没有的话,也没什么大不了的——很平凡也很常见的需求。

以前我们常采用的做法是“magic value” :

int find_me_the_even_number();//a return value of -1 means no even number

“magic value”指的是某些预先约定好的特殊值:空string01或者是最大的无符号整型,比如std::string::npos。这种做法的弊端是不言而喻的,我们会默认这些魔法值是我们不需要的,抑或我们赋予了这些值“有异常”的意义,但这一切都是口头约定,并没有从语法层面加以约束。其次,对于某些类型,我们并不能找到类似的魔法值,或者就算找到后,这些值也不能轻松地被创造出来,这都给我们在使用魔法值时带来风险。

另一种方法是二次询问:变量需要同时绑定一个布尔值,在使用变量前先查询这个bool变量,看这个值是否存在;如果是,那么进行第二次访问获取这个变量的值。

void maybe_require_an_int(int value = -1, bool do_I_need_int);
void even_better(pair<int, bool> param = std::make_pare(-1, false));
pare<int, bool> 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<T&>(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<T>,就像std::variant一样,std::optional是一个“和类型(sum type)”,也就是说,std::optional<T>类型的变量要么是一个T类型的__变量__,要么是一个表示“什么都没有”的__状态__。这个状态也有自己的类型和值:类型是std::nullopt_t,值为std::nullopt。看起来是不是很熟悉?没错,概念上它和nullptr十分相似,区别就是后者是一个关键字罢了。

std::optional<T>几乎拥有所有我们想要的性质:任何一个T类型或可以隐式转换成T类型的变量都可以用来构造它的对象,同样我们也可以用std::nullopt或默认构造函数来构造它,这个时候我们得到的变量意义是“nothing”罢了。我们使用has_value()函数来询问std::optional此时是否有值,如果有的话,我们使用value()函数来获取他的值。如果std::optional没有值时我们仍然调用value(),我们将获得一个std::bad_optional_access异常报错奖励。需要注意的是,std::optional<T>在内部存储T变量,并没有用到动态分配内存,其实说实在的,在C++标准中,动态内存这种行为时时刻刻都是不被建议的。

int main() {
	std::string text = "Hello, it's me.";
	std::optional<unsigned> 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<unsigned> opt = firstEvenNumberIn(text);
	if (opt) {
		std::cout << "The first even number is  "
					 << *opt
					 << std::endl;
	}

}

最后,类似于std::make_uniquestd::make_shared一样,std::make_optional也可以构造一个T类型的std::optional。同样的,使用emplace(Args...)方法也可以将一个T类型对象置入一个已经存在的std::optional对象中。

auto optVec = std::make_optional<std::vector<int>>(3, 22);
std::set<int> ints{7, 4, 1, 741};
optVec.emplace(std::begin(ints), std::end(ints));
std::copy(optVec->begin(), optVec->end(), std::ostream_iterator<int>(std::out, ", "));

都到这里了,我们就该把文章一开始的类重新写一下了。我们想类中添加一个std::optional<T>成员,接下来的一切就交给标准库吧——因为std::optional<T>已经解决了所有有关拷贝和移动构造函数/操作符相关的问题了。

using T = /* some type */;

struct S {
	optional<T> maybe_T;
	
	void construct_the_T(int arg) {
		// 我们无需处理重复构造所带来的问题,因为
		// optional的emplace member会自动销毁所
		// 之前存在的对象并构建一个新对象。
		maybe_T.emplace(arg);
	}
	
	T& get_the_T() {
		assert(maybe_T);
		return *maybe_T;
		// 或者如果你真的对异常情有独钟,你在这里可以写成:
		// return maybe_T.value();
	}
	
	// ... 接下来的拷贝和构造函数就能保证问题不会处在optional这里啦!...
};

结语

有了std::optional<T>,我们可以:

  • 很轻松的区分到底是有返回值还是没有,而不是用傻傻的”magic value”。
  • 在不采用异常机制的情况下,反映一个 no-value 的 case;毕竟异常机制太兴师动众了,尤其是在不那么“异常”的时刻。
  • 对我们的类进行更好的封装,避免暴露过多的实现接口给调用者,尤其是在这些接口可能导致错误的前提下。

std::optional 虽然体量很小,但是却非常强大而有用。下次你再纠结到底该用哪个”magic value”去表达”nothing”的意思时,记得试试std::optional