如何在void函数中使用Alexandrescu的Expected<T>?

39
我发现了一种非常好的想法,即使用返回值和异常的组合结构——Expected<T>。它克服了传统错误处理方法(异常、错误代码)的许多缺点。
请参见Andrei Alexandrescu在C++中的系统化错误处理演讲其幻灯片
异常和错误代码基本上具有相同的使用场景,适用于返回值和不返回值的函数。另一方面,Expected<T>似乎只针对返回值的函数。
因此,我的问题是:
  • 你们中有人尝试过Expected<T>吗?
  • 你会如何将这个习惯用于不返回任何值(即void函数)的函数?

更新:

我想我应该澄清我的问题。 Expected<void> 特化很有意义,但我更感兴趣的是它如何被使用 - 一致的用法习惯。实现本身是次要的(而且容易)。

例如,Alexandrescu 给出了这个例子(稍作编辑):

string s = readline();
auto x = parseInt(s).get(); // throw on error
auto y = parseInt(s); // won’t throw
if (!y.valid()) {
    // ...
}

这段代码以一种自然的方式“清晰”地流动。我们需要值-我们得到它。然而,使用expected<void>,你必须捕获返回的变量并对其执行某些操作(如.throwIfError()或其他操作),这不够优雅。显然,.get()在void情况下没有意义。
那么,如果您有另一个函数(比如toUpper(s)),它可以就地修改字符串且没有返回值,那么您的代码会是什么样子?

1
std::future<void> 的行为方式相同。用户需要调用 get() 或 wait(),因为 Expected<T>/std::future<T> 传达的不仅仅是一个值。请注意,用户也可能会忘记在 Expected<int>/std::future<int> 上调用 .get() 函数,甚至可能只是想忽略其值。 - Vicente Botet Escriba
对于那些对使用@ipc策略处理引用和void的“expected”实现感兴趣的人,请访问http://tinyurl.com/n48nczk。 - void-pointer
@void-pointer 谢谢,但是 URL 是失效的(404)。 - Alex
1
@Alex 抱歉,我忘记在移动文件后更改链接。这是新链接 - void-pointer
1
@berkus 很抱歉我多次破坏了链接。这是最新的链接:https://github.com/adityaramesh/ccbase/blob/master/include/ccbase/error/expected.hpp。这个链接不会再次破坏,因为我不会重命名存储库:https://github.com/adityaramesh/ccbase。此外,现在的实现支持引用。 - void-pointer
显示剩余5条评论
5个回答

13

你们有人尝试过 Expected在实践中的应用吗?

这很自然,即使在我看到这个讲座之前我就已经在使用了。

你如何将这个习惯用于返回空值的函数(即void函数)?

幻灯片中呈现的形式具有一些微妙的含义:

  • 异常与值绑定。
  • 可以根据需求处理异常。
  • 如果由于某些原因忽略了该值,则会抑制异常。

如果您有expected<void>,则不适用此规则,因为由于没有人对void值感兴趣,异常始终被忽略。我将强制执行此操作,就像在Alexandrescus课程中强制阅读expected<T>一样,使用断言和明确的suppress成员函数。出于重要原因,禁止从析构函数中重新抛出异常,因此必须使用断言来完成。

template <typename T> struct expected;

#ifdef NDEBUG // no asserts
template <> class expected<void> {
  std::exception_ptr spam;
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)) {}
  expected(expected&& o) : spam(std::move(o.spam)) {}
  expected() : spam() {}

  bool valid() const { return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() {}
};
#else // with asserts, check if return value is checked
      // if all assertions do succeed, the other code is also correct
      // note: do NOT write "assert(expected.valid());"
template <> class expected<void> {
  std::exception_ptr spam;
  mutable std::atomic_bool read; // threadsafe
public:
  template <typename E>
  expected(E const& e) : spam(std::make_exception_ptr(e)), read(false) {}
  expected(expected&& o) : spam(std::move(o.spam)), read(o.read.load()) {}
  expected() : spam(), read(false) {}

  bool valid() const { read=true; return !spam; }
  void get() const { if (!valid()) std::rethrow_exception(spam); }
  void suppress() { read=true; }

  ~expected() { assert(read); }
};
#endif

expected<void> calculate(int i)
{
  if (!i) return std::invalid_argument("i must be non-null");
  return {};
}

int main()
{
  calculate(0).suppress(); // suppressing must be explicit
  if (!calculate(1).valid())
    return 1;
  calculate(5); // assert fails
}

如果我理解正确的话,当尝试链接返回值时,断言不会失败吗?例如:expected<void> calculate2(int i) {return calculate(i*2);} - John Neuhaus
此外,是否有强烈的理由反对只是将assert包含在#ifndef NDEBUG中?尽管你会在生产中拥有额外的原子布尔值,但这似乎不值得为了牺牲可读性。 - John Neuhaus
@JohnNeuhaus 我认为你的担忧是真实存在的,尽管你当前的代码在 GCC 和 Clang 下没有导致问题浮现。不过解决方法似乎很简单:将移动构造函数改为 expected(expected&& o) : spam(std::move(o.spam)), read(o.read.load()) { o.read.store(true); } 的效果即可。 - Yongwei Wu

13

尽管对于那些只关注C语言类似语言的人来说,这可能看起来很新颖,但对于我们中那些尝试过支持和类型的语言的人来说,这并不是新鲜事。

例如,在Haskell中,你有:

data Maybe a = Nothing | Just a

data Either a b = Left a | Right b

在这里,竖线 | 表示或,第一个元素 (Nothing, Just, Left, Right) 只是一个“标签”。实际上,总和类型只是一种辨别式联合
在这里,你可以让 Expected<T> 成为类似于 Either T Exception 的东西,并且专门为 Expected<void> 进行特殊化,它类似于 Maybe Exception

1
我承认我使用了variant<T, std::exception_ptr>实现了自己的版本。 - Luc Danton
1
谢谢,但我更感兴趣的是用户代码的外观 - 使用模式等等...虽然我猜它会包括Expected<void> - Alex
请注意,Expected<void>的逻辑与Maybe Exception相反。当Maybe Exception有效时,Expected<void>无效。 - Vicente Botet Escriba
某个人在某处讨论了Haskell与C ++元程序的等价性。值得注意的是,您可以先用Haskell编写以进行调试,然后再将其翻译为模板。经过一次谷歌搜索,实际上这似乎相当时尚,请查看:http://gergo.erdi.hu/projects/metafun/ - v.oddou
@LucDanton:你能提供链接吗? - einpoklum
@einpoklum 我想我已经解决了这个问题。最近我一直在使用常规的try/catch,或者在只有一个失败原因时使用optional(对于后者我总是非常小心)。 - Luc Danton

5

正如Matthieu M.所说的,这对于C++来说是相对较新的事情,但对许多函数式语言来说却并非新鲜事物。

我想在这里补充我的观点:在我看来,部分困难和差异可以在"过程式vs函数式"方法中找到。我想使用Scala(因为我熟悉Scala和C++,而且我觉得它有一个更接近Expected<T>的工具(Option))来说明这种区别。

在Scala中,你有Option[T],它可以是Some(t)或None。特别地,也可以有Option[Unit],它在道义上等同于Expected<void>

在Scala中,使用模式非常类似,并围绕着2个函数构建:isDefined()和get()。但它还有一个"map()"函数。

我喜欢把"map"看作是"isDefined + get"的函数式等价物:

if (opt.isDefined)
   opt.get.doSomething

变成

val res = opt.map(t => t.doSomething)

"propagating" 选项到结果中
我认为在使用和组合选项的这种函数式风格中,就包含了你问题的答案:
那么如果你有另一个函数toUpper(s),它会就地修改字符串,并且没有返回值,你的代码会是什么样子呢?
个人而言,我不会就地修改字符串,或者至少不会返回空值。我把Expected看作是一个“函数式”的概念,需要一种函数式模式才能很好地工作:toUpper(s)要么返回一个新的字符串,要么在修改后返回它本身。
auto s = toUpper(s);
s.get(); ...

或者,使用类似于Scala的map。
val finalS = toUpper(s).map(upperS => upperS.someOtherManipulation)

如果您不想遵循函数式路线,可以使用isDefined/valid,并以更加过程化的方式编写代码:
auto s = toUpper(s);
if (s.valid())
    ....

如果您按照这条路线(可能是因为需要),则需要注意“void vs. unit”:从历史上看,void并不被视为一种类型,而是“没有类型”(void foo()被认为类似于Pascal过程)。在函数式语言中使用的Unit更多地被视为表示“计算”的类型。因此,返回Option[Unit]更有意义,被视为“可能做了某些事情的计算”。在Expected<void>中,void具有类似的含义:一种计算,当它按预期工作时(没有异常情况),就会结束(不返回任何内容)。至少,在我看来是这样!
因此,使用Expected或Option[Unit]可以被视为可能产生结果的计算,也可能不产生结果。将它们链接在一起会使其变得困难:
auto c1 = doSomething(s); //do something on s, either succeed or fail
if (c1.valid()) {
   auto c2 = doSomethingElse(s); //do something on s, either succeed or fail
   if (c2.valid()) { 
        ...

不是很干净。

在Scala中使用Map可以使代码变得更加简洁。

doSomething(s) //do something on s, either succeed or fail
   .map(_ => doSomethingElse(s) //do something on s, either succeed or fail
   .map(_ => ...)

哪个更好,但仍然远非理想。在这里,Maybe单子显然胜出...但那是另一回事了。


2
Expected<T> 有点不同,如果我理解正确的话,Option[T] 就像 C# 中的 Nullable 或者 C++ 中的 boost::optional(或提出的 std::optional)。但是 Expected 是用于异常而不是可选值。它是对异常和返回码的最佳融合尝试。 - ixSci
@ixSci 看起来非常相似,但是签名大部分都是一样的。不要被“Option”名称所迷惑:最终,两者之间的边界真的很模糊(获取无效值会抛出异常)。我的目的是用它来解决“如何将这个习惯用于返回空值的函数”的问题:我要么不使用它(最好不使用具有(仅)副作用的函数),要么在确实需要时使用if-valid/get对。 - Lorenzo Dematté
感谢指出混淆之处,我编辑了相关部分(-> Expected<void> 假设类似的含义:一种计算,当它按预期工作时(没有异常情况),只是结束(不返回任何内容))。 - Lorenzo Dematté
不使用只有副作用的函数 - 这在某种程度上对于例如成员setter函数来说有点困难。基本上每个修改成员变量的成员函数都是如此。 - Alex
1
总的来说,我喜欢能够看到一种错误处理机制并且使用得一致的代码。但是对于 Expected<T>,我建议仅在具有实际返回值的函数中使用它,并避免使用 Expected<void>。在这种情况下,只需抛出异常即可(毕竟,在计算结束时,您总是会“获取”副作用,并且在无效的 Expected<void> 上进行获取将会抛出异常)。 - Lorenzo Dematté
显示剩余3条评论

2

自从我观看了这个视频后,我一直在思考同样的问题。但到目前为止,我没有找到任何令人信服的理由来支持使用Expected,对我来说,它看起来很荒谬,不够清晰和简洁。我迄今想到的是:

  • Expected很好用,因为它具有值或异常,我们不需要为每个可抛出异常的函数都使用try{}catch()。因此,对于每个具有返回值的可抛出异常的函数,应该使用Expected。
  • 每个不会抛出异常的函数都应该标记为noexcept。每一个。
  • 每个返回值为空且未标记为noexcept的函数都应该被包装在try{}catch{}中。

如果这些陈述是正确的,那么我们就拥有了自我记录易于使用的接口,只有一个缺点:我们不知道可能抛出哪些异常,除非查看实现细节。

Expected会对代码造成一些额外开销,因为如果你的类实现内部(例如在私有方法深处)有一些异常,则必须在接口方法中捕获它并返回Expected。虽然我认为对于那些有返回值的方法来说是可以容忍的,但我认为它会给那些本来不应该返回任何东西的方法带来混乱和杂乱。此外,对我来说,从本来不应该返回任何东西的方法中返回东西是相当不自然的。


1
而且,最好在编写代码时考虑RAII来编写异常安全的代码,并仅在必要时使用异常处理,而不是尝试捕获所有异常。 - Bingo
3
有任何“对性能非常糟糕”的证据吗?有具体的衡量标准吗? - ixSci
@Alex,谢谢,我是指noexcept,但忘记它的实际名称了 :) - ixSci
如果我的记忆没有出错的话,在noexcept函数中使用throw语句是UB(未定义行为)。很难想象编译器作者会为了支持一个UB的“合理”行为而大幅减慢代码,因为他们经常做完全相反的事情。 - Nir Friedman
1
有关性能的任何数据吗?因为我的理解是相反的,throw()需要取消堆栈展开,因此需要添加更多代码来确保它,而noexcept则不需要,因此不需要从编译器中添加更多代码(或者至少少一些)。有人有更多关于这个问题的信息吗?^^ - Julien Lopez
显示剩余4条评论

0

应该使用编译器诊断来处理。许多编译器已经基于某些标准库构造的预期用法发出警告诊断。对于忽略 expected<void>,它们应该发出警告。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接