optional,expected,variant做返回值的比较
optional
, expected
, variant
的比较
从C++17开始,C++标准中陆续引入了一些表示“替代”意义的数据类型。包括C++17中引入的std::optional
,std::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形式的函数。
但是另一方面,这也是一个专用性很强的接口:它就是专门用于定义处理返回结果的,并不是适合作为数据成员的表达——optional
和variant
才是更适合的东西。
如果是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全删了后重新安装😊
构造
下面只会写一些我认为重要的地方。基本的使用去看手册就可以了。
在我看来,optional
和expected
最大的差别在于,未初始化的optional
是null
,而未初始化的expected
不是error。
其实,这一点也不是什么大问题,对大多数人来说,先定义一个optional
或expected
,再在后面给它赋值的概率应该是少之又少的。就我的使用看,最多的场景其实就是当右值立即返回。
1 | // 注意不一样的地方 |
Monadic接口
在C++23中,std::optional
和std::expected
都提供了相同形式的monadic接口:
and_then()
transform()
,在tl::expected
中是map()
or_else()
transform_error()
,std::optional
没有,因为没有意义。在tl::expected
中是map_error()
关于什么是Monadic,这是另一个话题了。
初次接触的人可能会比较迷惑,为什么会有and_then
和transform
两个接口,他们的行为似乎没区别?以std::optional
为例,我们看一下下面的例子就知道了:
1 | std::optional<int> f1(int a){ return a * a; } |
我们定义了两个函数,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 | std::expected<int, string> f1(int v) |
它的打印输出:
1 | calling f1 with v=5 |
使用Monadic来优化错误处理流程
在我们之前的扫描软件的scan
函数中,是利用宏来简化分支处理的。
我们定义了一系列的工作步骤:
1 | bool doPrepare(...); |
那么,正常的扫描一般会这么写:
1 | bool ScanTask::doScan(...) |
如果是你用的是C/C++之外的语言,都是这么写,没啥可说的。但是如果你是C程序员,你会天然觉得很烦。怎么办?用宏和goto
。
1 |
|
这里还有一个问题,我们只知道失败了,错误原因怎么办?二话不说就弹出来对话框告诉你说有错误,啥错误也不说?
在我们原先的产品实现中,我们最早是让doPreview()
等函数返回整数值的,调用者通过返回值判断错误原因。后来又为ScanTask
类增加了一个_errorString
属性,让doPreview()
等函数在错误发生时将错误信息保存到_errorString
里面,这样外部界面可以通过errorString()
获取到错误描述。但是我们的的确确遇到过忘记了写_errorString
的情况!
如果,使用expected
,我们还可以有另一种写法:
1 | std::expected<int, QString> doPreview(...); |
至于要不要这么改,我觉得纯属个人喜好和团队/产品的编程风格的问题。就我个人而言,虽然觉得很有趣,但是就这个问题本身,觉得没啥意义。但是,至少,就算你不理解Monad的含义(我也很难理解,我也相信绝大多数写代码的人根本无法看懂这个Monadic到底在讲什么),这种写代码的方式还是很容易理解和掌握的。而另一个东西,boost::Leaf
则更令人费解了。
说句题外话,如果你的团队都是8千块钱招过来的,只会从CSDN或Google上面抄代码,这些东西还是算了,不要给自己找麻烦,老老实实写C代码,连宏都不要用,是最安全的。
Boost::Leaf
Boost::Leaf
进入Boost
库应该没有两三年。我知道这个库是有一次看Boost的发布,看到新增这个东西,觉得好奇,进去搂了一眼,看的云里雾里,但是记住了有这么个东西可以用。
了解了expected
之后,再看看它,也就容易懂了。作者认为,expect<T,E>
过于笨重了,因为相对于T
,E
通常是根本就用不到的,只会在错误发生时才用到,而错误发生,相对于正常情况,是一个极小概率事件。所以,作者将其简化为result<T>
,而将E
拿出来,当错误发生时,将其传递到需要它们的错误处理作用域去处理。这样会让result<T>
变得“轻量”,和错误类型解耦。
这一点我是认同的,从
expected
的实现可以看到,它本质上是一个union
加一个bool
,既然是union
,那么就是紧着最大的分配内存。
但是,这样会导致,要使用十分特殊的语法才能支持E
的捕获。这种语法,对于一般的C++程序员,是很违反直觉的。下面先看一个错误处理的例子,有个直观印象:
1 | leaf::result<U> r = leaf::try_handle_some( |
注意了,try_handle_some
是一个函数,后面这一堆是lambda,都是try_handle_some
的参数。它是一个变参模板函数。是不是眼熟?和overload
是不是似曾相识?
一个基本的函数这样写:
1 | leaf::result<QString> f(int v) |
所有的方法都应该返回result<T>
,对应于我们使用expected<T,E>
时的T。
当失败时,用leaf::new_error(...)
或其他的方式返回错误。BOOST_LEAF_AUTO(v,f())
是一个宏,它大致等于
1 | auto r = f(val); |
如果f
是void,就可以用BOOST_LEAF_CHECK(f())
来写。
有事件的话可以去看一下try_handle_some
的源码,虽然看不懂😓,但至少可以理解他说的“错误作用域”的概念,理解一下它的思路就可以了。
我看到网上有的人说Leaf
是不使用异常机制巴拉巴拉的,这个是不准确的。它是使用异常机制的,只是将异常限制在本地了,不将异常向上传递展开才是准确的。