【问题标题】:How would you use Alexandrescu's Expected<T> with void functions?您将如何将 Alexandrescu 的 Expected<T> 与 void 函数一起使用?
【发布时间】:2013-02-02 02:51:12
【问题描述】:

所以我遇到了这个(恕我直言)非常好的想法,即使用返回值和异常的复合结构 - Expected&lt;T&gt;。它克服了传统错误处理方法(异常、错误代码)的许多缺点。

请参阅Andrei Alexandrescu's talk (Systematic Error Handling in C++)its slides

异常和错误代码的使用场景基本相同,函数返回的和不返回的。另一方面,Expected&lt;T&gt; 似乎只针对返回值的函数。

所以,我的问题是:

  • 你们有没有人在实践中尝试过Expected&lt;T&gt;
  • 如何将这个习惯用法应用于不返回任何内容的函数(即 void 函数)?

更新:

我想我应该澄清一下我的问题。 Expected&lt;void&gt; 专业化是有道理的,但我对它的使用方式更感兴趣——一致的使用习惯。实现本身是次要的(也很容易)。

例如,Alexandrescu 给出了这个例子(稍作修改):

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

这段代码是“干净的”,它只是自然流动。我们需要价值——我们得到它。但是,对于expected&lt;void&gt;,必须捕获返回的变量并对其执行一些操作(如.throwIfError() 或其他东西),这并不那么优雅。显然,.get() 对 void 没有意义。

那么,如果你有另一个函数,比如toUpper(s),它会就地修改字符串并且没有返回值,你的代码会是什么样子?

【问题讨论】:

标签: c++ c++11 error-handling runtime-error


【解决方案1】:

你们有没有试过Expected;在实践中?

很自然,在我看到这个演讲之前我就用过。

您如何将这个习惯用法应用于不返回任何内容的函数(即 void 函数)?

幻灯片中呈现的表格有一些微妙的含义:

  • 异常绑定到值。
  • 异常处理随心所欲。
  • 如果由于某些原因忽略了该值,则会抑制异常。

如果你有expected&lt;void&gt;,这不成立,因为没有人对void 值感兴趣,所以总是忽略异常。我会强制这样做,因为我会强制从 Alexandrescus 类中的 expected&lt;T&gt; 读取,带有断言和显式的 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
}

【讨论】:

  • 如果我理解正确,在尝试链接返回值时断言不会失败吗?例如。预期 calculate2(int i) {return calculate(i*2);}
  • 另外,是否有令人信服的理由反对在#ifndef NDEBUG 中围绕断言?当然,您将在生产中拥有额外的原子 bool,但似乎不值得为此牺牲可读性。
  • @JohnNeuhaus 我认为您的担忧是真实的,尽管您当前的代码不会导致问题浮出水面(在 GCC 和 Clang 下)。补救措施似乎很简单:将 move-constructor 更改为 expected(expected&amp;&amp; o) : spam(std::move(o.spam)), read(o.read.load()) { o.read.store(true); } 的效果。
【解决方案2】:

尽管对于只专注于 C 语言的人来说它可能看起来很新鲜,但对于我们这些喜欢支持 sum 类型的语言的人来说,它不是。

例如,在 Haskell 中你有:

data Maybe a = Nothing | Just a

data Either a b = Left a | Right b

| 读取 or 并且第一个元素(NothingJustLeftRight)只是一个“标签”。本质上,总和类型只是区分联合

在这里,Expected&lt;T&gt; 将类似于:Either T Exception,其特化为 Expected&lt;void&gt;,类似于 Maybe Exception

【讨论】:

  • 我承认我用variant&lt;T, std::exception_ptr&gt;实现了我自己的版本。
  • 谢谢,但我对用户代码的外观更感兴趣 - 使用模式等......虽然我猜它会包括Expected&lt;void&gt;
  • 请注意,Expected 的逻辑与 Maybe Exception 是相反的。当 Maybe Exception 有效时 Expected 无效。
  • 有人在某处讨论过 Haskell 与 C++ 元程序的等价性。值得注意的是,您可以先在 haskell 中编写代码进行调试,然后再转换为模板。 .. 谷歌搜索后,实际上这似乎很流行,检查一下:gergo.erdi.hu/projects/metafun
  • @LucDanton:你能链接到那个吗?
【解决方案3】:

就像 Matthieu M. 所说,这对于 C++ 来说是相对较新的东西,但对于许多函数式语言来说并不是什么新鲜事。

我想在此处添加我的 2 美分:在我看来,可以在“程序与功能”方法中找到部分困难和差异。而且我想用Scala(因为我对Scala和C++都很熟悉,而且我觉得它有一个更接近Expected&lt;T&gt;的设施(Option))来说明这种区别。

在 Scala 中,您有 Option[T],它是 Some(t) 或 None。 特别是,也可以有 Option[Unit],道德上等价于Expected&lt;void&gt;

在 Scala 中,使用模式非常相似,并且围绕 2 个函数构建:isDefined() 和 get()。但它也有一个“map()”函数。

我喜欢将“map”视为“isDefined + get”的功能等价物:

if (opt.isDefined)
   opt.get.doSomething

变成

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

将选项“传播”到结果

我认为,在这里,在这种使用和组合选项的功能风格中,是您问题的答案:

那么,如果你有另一个函数,比如 toUpper(s),它会就地修改字符串并且没有返回值,你的代码会是什么样子?

就我个人而言,我不会就地修改字符串,或者至少我不会返回任何内容。我认为Expected&lt;T&gt; 是一个“功能性”概念,需要一个功能性模式才能正常工作:toUpper(s) 需要返回一个新字符串,或者在修改后返回自身:

auto s = toUpper(s);
s.get(); ...

或者,使用类似 Scala 的地图

val finalS = toUpper(s).map(upperS => upperS.someOtherManipulation)

如果您不想遵循功能性路线,则可以使用 isDefined/valid 并以更程序化的方式编写代码:

auto s = toUpper(s);
if (s.valid())
    ....

如果你遵循这条路线(也许是因为你需要),那么有一个“void vs. unit”的观点:从历史上看,void 不被认为是一种类型,但“无类型”(void foo() 被认为是类似于 Pascal 过程)。单元(在函数式语言中使用)更多地被视为一种类型,意思是“计算”。所以返回一个 Option[Unit] 确实更有意义,被视为“一个可以选择做某事的计算”。而在Expected&lt;void&gt; 中, void 具有类似的含义:当它按预期工作时(没有例外情况),就会结束(不返回任何内容)。至少,IMO!

因此,使用 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 monad 显然赢了……但那是另一回事了……

【讨论】:

  • Expected&lt;T&gt; 有点不同,如果我没听错的话,Option[T] 类似于 C# Nullable 或 C++ boost::optional(建议使用std::optional)。但是Expected 是针对异常而不是可选值。它试图充分利用异常和返回代码。
  • @ixSci 但是看起来非常相似:签名基本相同。不要被“选项”名称所迷惑:最后,两者之间的边界真的很模糊(抛出无效值)。我的目的是用它来完成我对“你如何将这个成语应用于什么都不返回的函数”的回答:我要么不使用它(更好,我不会使用具有(仅)副作用的函数),要么使用如果你真的需要,if-valid/get 对
  • 感谢您指出混淆,我编辑了一个相关位(-> Expected&lt;void&gt; 具有类似的含义:当它按预期工作时(没有例外情况)的计算,刚刚结束(什么都不返回))
  • 不使用只有副作用的函数——这对于例如成员设置函数来说有点困难。基本上每一个修改成员变量的成员函数都是这样的。
  • @Alex 一般来说,我喜欢可以看到一致使用的一种错误处理机制的代码。但是对于Expected&lt;T&gt;,我建议仅将其用于具有实际返回值的函数,并避免使用Expected&lt;void&gt;。在这种情况下,只需抛出(毕竟,在计算结束时,您总是“得到”副作用,并且会抛出无效的Expected&lt;void&gt;
【解决方案4】:

自从观看此视频以来,我一直在思考同样的问题。到目前为止,我还没有找到任何令人信服的论据来支持 Expected,对我来说这看起来很荒谬,而且不利于清晰和清洁。到目前为止,我想出了以下几点:

  • 预期很好,因为它有值或异常,我们没有强制对每个可抛出的函数使用 try{}catch()。所以将它用于每个具有返回值的抛出函数
  • 每个不抛出的函数都应该用noexcept 标记。每一个。
  • 每个不返回任何内容且未标记为 noexcept 的函数都应使用 try{}catch{} 进行包装

如果这些陈述成立,那么我们已经自我记录了易于使用的接口,只有一个缺点:如果不查看实现细节,我们不知道会引发哪些异常。

Expected 给代码带来了一些开销,因为如果你的类实现的内部有一些异常(例如,私有方法的深处),那么你应该在你的接口方法中捕获它并返回 Expected。虽然我认为对于具有返回某些概念的方法来说这是可以容忍的,但我相信它会给设计上没有返回值的方法带来混乱和混乱。此外,对我来说,从不应该返回任何东西的东西中返回东西是很不自然的。

【讨论】:

  • nothrow 对性能非常不利,不应使用。理论上这是个好主意,但是因为它是在运行时而不是编译时强制执行的,这意味着程序员即使在声明 nothrow 之后也可以抛出,因此编译器会生成许多额外的代码来捕获潜在的异常然后终止如果在运行时被捕获。抓住它,你会死。永远不要扔,性能更差。在编译时强制执行之前,请不要在 cmets 中保留。
  • 而且,编写异常安全代码时要考虑到 RAII,并在异常情况下使用异常,而不是尝试捕获所有内容。
  • @Bingo,有没有“对性能超级不利”的教授?实际措施?
  • 什么是 nothrow()?你的意思是一个空的 throw()? throw() 的性能问题应该主要由 C++11 的 noexcept 来解决。
  • 有任何关于表演的数据吗?因为我理解它的方式是相反的,throw() 需要展开堆栈,因此添加了更多代码来确保它,而 noexcept 没有,因此不需要从编译器添加更多代码(或者至少,更少代码)。谁有更多关于什么是什么的信息?^^
【解决方案5】:

应该通过编译器诊断来处理。许多编译器已经根据某些标准库结构的预期用途发出警告诊断。他们应该对忽略 expected&lt;void&gt; 发出警告。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-02-05
    • 1970-01-01
    • 1970-01-01
    • 2022-11-09
    相关资源
    最近更新 更多