optional, expected, variant的比较

从C++17开始,C++标准中陆续引入了一些表示“替代”意义的数据类型。包括C++17中引入的std::optionalstd::variant,以及C++23引入的std::expected。当然,C++17中还引入了std::any,这个和我们讨论的内容距离有点远,就不讨论了。

提到这个问题,是因为在项目中遇到一个设计选择问题,我该如何设计函数的返回值体系,如何让代码看起来更自然易懂,流程更清晰明确。

这里就还牵涉到另一个数据类型,在boost::Leaf,我们会放到一起讨论。

首先看std::optional,它代表一个“可以为空”的值。这里的“空”,从逻辑意义上,表示的是一个没有意义,或者无效的值,最典型的是当表示指针时的空指针。在之前,我们都是使用某个具有特殊意义的值来表示。例如用0表示空指针,用-1表示无效的取值(当合理的值范围为自然数时)等。但是这种方式并不直观,有时也不好用——比如,取值范围为整个有理数区间时,你可能会为找一个“合法的无效值”而头痛。甚至,最简单的,到底是0表示成功还是非0表示成功,就够令人头大的了。而optional则相反,它具有明确的语义区分,同时又不会占用额外的资源。

std::optional是在C++17中被正式引入的,之前boost::optional已经被用了很久了。在其他语言中,也已经有了类似的东西,例如在Jave中的Optional<T>,在Haskell中的Data.Maybe,在Rust中的std::option等。可以说,std::optional的引入是顺应了民意和历史潮流的。

  • 当有一个明确的,合法的null值的时候。比如说,一个可选的参数,一个允许是无效结果的函数返回值,一个可以延迟计算的结果,等等。在以前的时候,我们通常会用一个单独的标志,或者某个合法值范围之外的特定值来标识,而现在,则可以使用含义明确的optional来表示。

使用optional作为返回值好不好,如果这个函数没有错误场景,那么还是可以的。但是如果这个函数涉及到返回结果,又要有错误码,就不怎么合适了。因为optional在本质上只能返回“一个值”,它只能表示“返回值无效”,而没有空间来表述为什么无效。使用optional作为返回值,就会丢失错误原因,这是良好的开发所尽量要避免的事情——我可以暂时不处理,但是在接口上,我必须要有。

在C++17中还引入了std::variant类型。这是一个包含了若干个可替代类型的数据类型,在一个时刻只能是其中的一个类型。例如,std::variant<int,string>表示一个它的取值可以是int,也可以是std::string,但是当它被赋值的那一刻就确定了,你不能将一个int赋值给它,然后又要以std::strinig来访问它。相对于C的union,它提供了后者所不具备的类型安全性(当然,在一些特殊场景下,这反而是限制)。

在内部,std::variant通常是用union来保存数据,同时还有一个标志位来指明它使用的是哪种类型。从某种意义上说,std::variant更适合用于作为某种运行期多态来使用,以配合std::visitor使用。用它来表达返回值和错误码,不是说不行,或者说在功能上完全可以,但主要的问题是语义上太分散了,它甚至某种程度上还不如optional,后者好歹还有hasValue()这种明确的语义,而variant来说,各个可能的取值的地位是等价的。换句话说,它是一种“type-erased”的。

std::variant的基本使用,就和std::tuple一样,是很不好用的,语法很笨拙。可能唯一可入眼的是和std::visitor配合使用,然后,就是overload的用法。本文主要是想说返回值和错误处理,就不展开了。

我们更期望的返回值,应该是这样一种语义的表达:

我期望它应该是某种值,但是可能未必会如我所料,而我需要知道为什么

关于expected,大概可能好像是Scott Mayer在某次演讲中提出的概念,因为孤陋寡闻的缘故,到20年前后才听说这东西。后来有诸多实践,并最终被纳入C++23标准中。它是专门用来表达“错误”的概念的。它的使用也和std::optional很接近,方法名字和含义也都很相似,并且也都提供了相同的monad形式的函数。

但是另一方面,这也是一个专用性很强的接口:它就是专门用于定义处理返回结果的,并不是适合作为数据成员的表达——optionalvariant才是更适合的东西。

如果是C++23之前,可以使用这个替代物:tl_expect。它的库位于:github: https://github.com/TartanLlama/expected.git。

在能使用expect之前,我曾经用过optional来保存错误码,用nullopt表示操作成功;还曾经用std::pair<int,QString>来存储过返回值,用QString::isEmpty()来判断是否是错误。这些行为都不具备一致性的语义,都是adhoc的行为——这才是真正的问题——软件中缺乏一种统一的,被所有人一致认可的语义和行为规范,会给代码的维护带来很大的麻烦。

更糟糕的一种常见的场景是,在一开始的时候开发人员是顾不上错误处理的,可能简单的返回int就拉到了。等到代码调试通过了,有责任心的会想起来要把错误检查加进去,没有的就等着出问题了再说。到时候各种补丁乱七八糟打上去…。而expected最大的好处,我认为就在这里。从一开始就把语义和规范明确下来了。你可以一开始不写,但是以后补上去的时候,不会造成大的影响。

如果是使用MSVC,想使用std::expected,要记得打开C++标准到preview版本。
如果是Qt,除了要设置CONFIG += c++2a外,还要保证你的编译器是MSVC2022,如果是2019的话,好像是不行的。你需要删掉2019,然后在2022时安装2019的支持,然后强制Qt改用2022版本的编译器。或者,像我一样对玩工具黑魔法完全没兴趣的,索性把Qt和VC全删了后重新安装😊

构造

下面只会写一些我认为重要的地方。基本的使用去看手册就可以了。
在我看来,optionalexpected最大的差别在于,未初始化的optionalnull,而未初始化的expected不是error。
其实,这一点也不是什么大问题,对大多数人来说,先定义一个optionalexpected,再在后面给它赋值的概率应该是少之又少的。就我的使用看,最多的场景其实就是当右值立即返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 注意不一样的地方
std::optional<int> o1; // has_value()返回false
std::expected<int, std::string> e1; // has_value()返回true!

// optional有make_optional来构造值,expected没有. 这个语义上很合理
auto o2 = std::make_optional(12);
std::optional<int> o3{ 12 };
tl::expected<int, double> e2{ 12 };

// tl::expected有tl::make_unexpected来构造错误值, std::expected没有. 而optional不需要这种, 直接赋值就可以
std::optional<int> o4{ std::nullopt };
std::expected<int, string> e1{ std::unexpected("error") };
std::expected<int, string> e2{ std::unexpect, "error" };
tl::expected<int, string> e3 = tl::make_unexpected("error");

// 大家都支持in_place操作, 真正能想到这点收益的人, 其实也不需要看这篇文章😊
std::optional<std::pair<int, double>> o5{ std::in_place, 3, 5.2 };
std::expected<std::pair<int, double>, int> e5{ std::in_place, 3, 5.2 };
std::expected<vector<int>, string> e6{ std::in_place, {2,3,4} };

// optional 和 expected都不支持包含引用,但是可以用refreence_wrapper
// 这个高级场景,基本上不写库就用不到
int v = 10;
std::expected<std::reference_wrapper<int>, int> e7{ std::ref(v) };
std::optional<std::reference_wrapper<int>> o7{ std::ref(v) };

Monadic接口

在C++23中,std::optionalstd::expected都提供了相同形式的monadic接口:

  • and_then()
  • transform(),在tl::expected中是map()
  • or_else()
  • transform_error()std::optional没有,因为没有意义。在tl::expected中是map_error()

关于什么是Monadic,这是另一个话题了。

初次接触的人可能会比较迷惑,为什么会有and_thentransform两个接口,他们的行为似乎没区别?以std::optional为例,我们看一下下面的例子就知道了:

1
2
3
4
5
6
7
8
9
10
11
std::optional<int> f1(int a){  return a * a; }
int f2(int a){ return a * a; }

int main()
{
std::optional<int> o1{ 12 };
auto ov1 = o1.and_then(f1);
cout << "v1=" << ov1.value() << endl;
auto ov2 = o1.transform(f2);
cout << "v2=" << ov2.value() << endl;
}

我们定义了两个函数,f1返回的是optional<int>f2返回的是int,在and_then()里面调用的是f1(),而transform()调用的是f2()。这是and_then()transform()最明显的不同:

  • and_then()里面的函数要求返回值是std::optional<U>
  • transform()要求的函数的返回值是U,而transform()会将返回值封装成std::optional<U>返回。
    我们实际上是要根据自己已有的函数的形式来选择是使用and_then()还是transform()的。

下面是一个很简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
std::expected<int, string> f1(int v)
{
cout << "calling f1" << endl;
if (v > 10) {
return v * 2;
}
else
{
return std::unexpected{ "value too small!" };
}
}

std::expected<double, string> f2(int v)
{
std::cout << "calling f2" << endl;
if (v < 30)
{
return v / 2;
}
else
{
return std::unexpected{ "value too large!" };
}
}

std::expected<double, string> f3(string s)
{
cout << "calling f3: "
<< "error occured: " << s << endl;
return std::unexpected(s);
}

int main()
{
auto result = f1(5)
.and_then(f2)
.or_else(f3);
cout << "finished: ";
}

它的打印输出:

1
2
3
calling f1 with v=5
calling f3: error occured: value too small!
finished:

使用Monadic来优化错误处理流程

在我们之前的扫描软件的scan函数中,是利用宏来简化分支处理的。

我们定义了一系列的工作步骤:

1
2
3
4
5
6
bool doPrepare(...);
bool doPreview(...);
bool verifyOptions(...);
bool verifyLowScanRange(...);
bool doLowScan(...);
bool doHighScan(...);

那么,正常的扫描一般会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool ScanTask::doScan(...)
{
if(!doPrepare(...))
{
qDebug() << ...;
//... 其他操作
_impl->_device->ReturnSlide();
return false;
}
if(!doPreview(...))
{
qDebug() << ...;
//... 其他操作
_impl->_device->ReturnSlide();
return false;
}
if(!doLowScan(...))
{
qDebug() << ...;
//... 其他操作
return false;
}
}

如果是你用的是C/C++之外的语言,都是这么写,没啥可说的。但是如果你是C程序员,你会天然觉得很烦。怎么办?用宏和goto

1
2
3
4
5
6
7
8
9
10
11
12
#define GOTO_IF_LOG(a,b,c)  if(a){ qDebug(b); goto c;} else{}
bool ScanTask::doScan(...)
{
GOTO_IF_LOG(!doPrepare(...), "...", _errored);
GOTO_IF_LOG(!doPreview(...), "...", _errored);
...
return true;
_errored:
_impl->_device->ReturnSlide();
// 其他统一的退出处理
return false;
}

这里还有一个问题,我们只知道失败了,错误原因怎么办?二话不说就弹出来对话框告诉你说有错误,啥错误也不说?

在我们原先的产品实现中,我们最早是让doPreview()等函数返回整数值的,调用者通过返回值判断错误原因。后来又为ScanTask类增加了一个_errorString属性,让doPreview()等函数在错误发生时将错误信息保存到_errorString里面,这样外部界面可以通过errorString()获取到错误描述。但是我们的的确确遇到过忘记了写_errorString的情况!

如果,使用expected,我们还可以有另一种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
std::expected<int, QString> doPreview(...);
...
std::expected<int, QString> ScanTask::doScan(...)
{
auto result = doPrepare(...)
.and_then([this](){doPreview();})
.and_then([this](){doLowScan();})
...
.or_else([this](){doCleanUp();});

if(result.has_value())
{
...
}
else
{
...
}
return result;
}

至于要不要这么改,我觉得纯属个人喜好和团队/产品的编程风格的问题。就我个人而言,虽然觉得很有趣,但是就这个问题本身,觉得没啥意义。但是,至少,就算你不理解Monad的含义(我也很难理解,我也相信绝大多数写代码的人根本无法看懂这个Monadic到底在讲什么),这种写代码的方式还是很容易理解和掌握的。而另一个东西,boost::Leaf则更令人费解了。

说句题外话,如果你的团队都是8千块钱招过来的,只会从CSDN或Google上面抄代码,这些东西还是算了,不要给自己找麻烦,老老实实写C代码,连宏都不要用,是最安全的。

Boost::Leaf

Boost::Leaf进入Boost库应该没有两三年。我知道这个库是有一次看Boost的发布,看到新增这个东西,觉得好奇,进去搂了一眼,看的云里雾里,但是记住了有这么个东西可以用。

了解了expected之后,再看看它,也就容易懂了。作者认为,expect<T,E>过于笨重了,因为相对于TE通常是根本就用不到的,只会在错误发生时才用到,而错误发生,相对于正常情况,是一个极小概率事件。所以,作者将其简化为result<T>,而将E拿出来,当错误发生时,将其传递到需要它们的错误处理作用域去处理。这样会让result<T>变得“轻量”,和错误类型解耦。

这一点我是认同的,从expected的实现可以看到,它本质上是一个union加一个bool,既然是union,那么就是紧着最大的分配内存。

但是,这样会导致,要使用十分特殊的语法才能支持E的捕获。这种语法,对于一般的C++程序员,是很违反直觉的。下面先看一个错误处理的例子,有个直观印象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
leaf::result<U> r = leaf::try_handle_some(

[]() -> leaf::result<U>
{
BOOST_LEAF_AUTO(v1, f1());
BOOST_LEAF_AUTO(v2, f2());

return g(v1, v2);
},

[]( err1 e ) -> leaf::result<U>
{
if( e == err1::e1 )
.... // Handle err1::e1
else
.... // Handle any other err1 value
} );

注意了,try_handle_some是一个函数,后面这一堆是lambda,都是try_handle_some的参数。它是一个变参模板函数。是不是眼熟?和overload是不是似曾相识?

一个基本的函数这样写:

1
2
3
4
5
6
7
leaf::result<QString> f(int v)
{
if(v<10){
return leaf::new_error(err1::e1);
}
return QString::number(v);
}

所有的方法都应该返回result<T>,对应于我们使用expected<T,E>时的T。
当失败时,用leaf::new_error(...)或其他的方式返回错误。
BOOST_LEAF_AUTO(v,f())是一个宏,它大致等于

1
2
3
4
auto r = f(val);
if(!r){
return r.error();
}

如果f是void,就可以用BOOST_LEAF_CHECK(f())来写。

有事件的话可以去看一下try_handle_some的源码,虽然看不懂😓,但至少可以理解他说的“错误作用域”的概念,理解一下它的思路就可以了。

我看到网上有的人说Leaf是不使用异常机制巴拉巴拉的,这个是不准确的。它是使用异常机制的,只是将异常限制在本地了,不将异常向上传递展开才是准确的。