错误处理注意事项


31

问题:

长期以来,我对这种exceptions机制感到担心,因为我觉得它并不能真正解决应有的问题。

要求:关于该主题的争论很长时间,而且大多数人都在努力比较exceptions和返回错误代码。这绝对不是这里的主题。

尝试定义错误,我同意Bjarne Stroustrup和Herb Sutter的CppCoreGuidelines

错误表示该功能无法实现其广告目的

要求:该exception机制是用于处理错误的语言语义。

要求:对我来说,没有“没有借口”的功能不能完成任务:要么我们错误地定义了前后条件,所以该功能无法确保结果,或者某些特殊情况对于花时间在开发上没有足够重要的意义。一个办法。考虑到IMO,常规代码和错误代码处理之间的区别(在实施之前)是非常主观的。

要求:使用异常指示何时不保留前置条件或后置条件是该exception机制的另一个目的,主要是用于调试目的。我的目标不是这里的用法exceptions

在许多书籍,教程和其他资料中,它们倾向于将错误处理显示为一门相当客观的科学,可以解决这一问题,exceptions而您只需要catch它们具有强大的软件就可以从任何情况下恢复。但是,作为开发人员的几年时间使我从另一种方法来看问题:

  • 程序员倾向于通过抛出异常来简化他们的任务,而这种特殊情况似乎太少而无法仔细实现。典型的情况是:内存不足问题,磁盘已满问题,文件损坏等,这可能就足够了,但并不总是从体系结构级别决定。
  • 程序员往往不会仔细阅读有关库中异常的文档,并且通常不知道函数何时何地抛出。而且,即使他们知道了,他们也并没有真正管理它们。
  • 程序员往往没有足够早地捕获异常,当他们捕获异常时,主要是记录并抛出更多异常。(请参阅第一点)。

这有两个结果:

  1. 经常发生的错误可以在开发的早期发现并进行调试(很好)。
  2. 罕见的异常无法管理,并且会使系统在用户家崩溃(带有一条漂亮的日志消息)。有时会报告错误,甚至不会报告。

考虑到这一点,海事组织错误机制的主要目的应该是:

  1. 在不管理某些特定情况的代码中使可见。
  2. 发生这种情况时,将问题运行时与相关代码(至少是调用方)进行通信。
  3. 提供恢复机制

exception语义作为错误处理机制的主要缺陷是IMO:很容易看到a throw在源代码中的位置,但是绝对不明显,通过查看声明来知道特定功能是否会抛出。这带来了我上面介绍的所有问题。

该语言不会像对语言的其他方面(例如强类型的变量)那样严格执行和检查错误代码

尝试解决方案

为了改善这一点,我开发了一个非常简单的错误处理系统,该系统试图使错误处理的重要性与普通代码相同。

这个想法是:

  • 每个(相关)功能都接收到对success非常轻的对象的引用,并可能将其设置为错误状态。在保存文本错误之前,该对象非常轻。
  • 如果提供的对象已经包含错误,则鼓励函数跳过其任务。
  • 绝对不能覆盖错误。

完整的设计显然会全面考虑每个方面(约10页),以及如何将其应用于OOP。

Success该类的示例:

class Success
{
public:
    enum SuccessStatus
    {
        ok = 0,             // All is fine
        error = 1,          // Any error has been reached
        uninitialized = 2,  // Initialization is required
        finished = 3,       // This object already performed its task and is not useful anymore
        unimplemented = 4,  // This feature is not implemented already
    };

    Success(){}
    Success( const Success& v);
    virtual ~Success() = default;
    virtual Success& operator= (const Success& v);

    // Comparators
    virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
    virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}

    // Retrieve if the status is not "ok"
    virtual bool operator!() const { return status!=ok;}

    // Retrieve if the status is "ok"
    operator bool() const { return status==ok;}

    // Set a new status
    virtual Success& set( SuccessStatus status, std::string msg="");
    virtual void reset();

    virtual std::string toString() const{ return stateStr;}
    virtual SuccessStatus getStatus() const { return status; }
    virtual operator SuccessStatus() const { return status; }

private:
    std::string stateStr;
    SuccessStatus status = Success::ok;
};

用法:

double mySqrt( Success& s, double v)
{
    double result = 0.0;
    if (!s) ; // do nothing
    else if (v<0.0) s.set(Error, "Square root require non-negative input.");
    else result = std::sqrt(v);
    return result;
}

Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;

我在许多(自己的)代码中都使用了它,这迫使程序员(我)对可能的特殊情况以及如何解决它们(好的)进行进一步的思考。但是,它有一个学习曲线,并且不能与现在使用它的代码很好地集成。

问题

我想更好地了解在项目中使用这种范例的含义:

  • 问题的前提是否正确?还是我错过了一些相关的东西?
  • 解决方案是好的构架思想吗?还是价格太高?

编辑:

方法之间的比较:

//Exceptions:

    // Incorrect
    File f = open("text.txt"); // Could throw but nothing tell it! Will crash
    save(f);

    // Correct
    File f;
    try
    {
        f = open("text.txt");
        save(f);
    }
    catch( ... )
    {
        // do something 
    }

//Error code (mixed):

    // Incorrect
    File f = open("text.txt"); //Nothing tell you it may fail! Will crash
    save(f);

    // Correct
    File f = open("text.txt");
    if (f) save(f);

//Error code (pure);

    // Incorrect
    File f;
    open(f, "text.txt"); //Easy to forget the return value! will crash
    save(f);

    //Correct
    File f;
    Error er = open(f, "text.txt");
    if (!er) save(f);

//Success mechanism:

    Success s;
    File f;
    open(s, "text.txt");
    save(s, f); //s cannot be avoided, will never crash.
    if (s) ... //optional. If you created s, you probably don't forget it.

25
赞成“这个问题表明了研究工作;它是有用且明确的”,不是因为我同意:我认为很多想法都被误导了。(详细信息可能在回答中。)
马丁·巴

2
绝对,我理解并同意!该问题的目的是受到批评。问题的分数表示好/不好的问题,而不是OP是正确的。
阿德里安·梅尔

2
如果我理解正确,那么您对异常的主要抱怨就是人们可以忽略异常(在c ++中)而不是处理它们。但是,您的Success构造在设计上存在相同的缺陷。像例外一样,他们只会忽略它。更糟糕的是:它更加冗长,导致级联的回报,您甚至无法将其“捕获”到上游。
dagnelies

3
为什么不只使用monad之类的东西呢?它们使您的错误隐含,但在运行期间不会保持沉默。实际上,查看代码时我想到的第一件事是“ monads,nice”。看看他们。
bash0r

2
我喜欢异常的主要原因是它们使您能够捕获给定代码块中的所有意外错误并一致地对其进行处理。是的,没有充分的理由说明代码不应该执行其任务-“有bug”是一个不好的原因,但是它仍然会发生,并且当它发生时,您需要记录原因并显示一条消息,或者重试。(我有一些代码可以与远程系统进行复杂的,可重新启动的交互;如果远程系统出现故障,我要记录该日志并从头开始重试)
user253751 '17

Answers:


32

错误处理可能是程序中最难的部分。

通常,容易发现存在错误条件是容易的。但是,以一种无法规避的方式发出信号并进行适当处理(请参阅Abrahams的“例外安全级别”)确实很困难。

在C语言中,发信号错误是由返回码完成的,该返回码与您的解决方案同构。

由于这种方法的缺点, C ++引入了异常。也就是说,它仅在呼叫者记得检查错误是否发生并且否则严重失败时才起作用。每当您发现自己说“只要每次...就可以...”时,您就会遇到问题。即使他们关心,人类也不是那么细致。

但是,问题在于异常有其自身的问题。即,不可见/隐藏的控制流程。这样做的目的是:隐藏错误情况,以使代码的逻辑不会被错误处理样板所混淆。它使“快乐路径”更加清晰(而且快!),但代价是使错误路径几乎难以理解。


我发现看看其他语言如何解决这个问题很有趣:

  • Java已经检查了异常(和未检查的异常),
  • Go使用错误代码/紧急情况,
  • Rust使用总和类型 / panics)。
  • FP语言一般。

C ++曾经有某种形式的受检查的异常,您可能已经注意到它已被弃用并简化为基本的noexcept(<bool>):将函数声明为可能抛出,或者将其声明为永不抛出。受检查的异常有些问题,因为它们缺乏可扩展性,这可能导致笨拙的映射/嵌套。复杂的异常层次结构(虚拟继承的主要用例之一是异常...)。

相反,Go和Rust采用的方法是:

  • 错误应在频段内发出,
  • 异常应该用于真正的特殊情况。

后者相当明显,因为(1)他们将异常恐慌命名为(2)这里没有类型层次结构/复杂子句。该语言不提供检查“紧急事件”内容的功能:没有类型层次结构,没有用户定义的内容,只是“糟糕,事情太糟糕了,无法恢复”。

这有效地鼓励用户使用适当的错误处理,同时在异常情况下仍然可以轻松摆脱困境(例如:“等等,我还没有实现!”)。

当然,不幸的是,Go方法很像您的方法,因为您很容易忘记检查错误...

... Rust方法主要围绕两种类型:

  • Option,类似于std::optional
  • Result,这是两种可能的变体:Ok和Err。

这非常整洁,因为在没有检查成功的情况下就没有机会意外使用结果:如果这样做,则程序会出现紧急情况。


FP语言在结构中形成其错误处理,该结构可分为三层:-函子-应用性/替代性-Monads /替代性

让我们看一下Haskell的Functor类型类:

class Functor m where
  fmap :: (a -> b) -> m a -> m b

首先,类型类有些相似,但不等于接口。Haskell的功能签名在初看起来时有点吓人。但是,让我们破译它们。该函数fmap将一个函数作为第一个参数,该参数有点类似于std::function<a,b>。接下来的事情是m a。你可以想像m,就像这样std::vectorm a,就像这样std::vector<a>。但是区别是,这m a并不表示必须明确std:vector。因此也可能是一个std::option。通过告诉语言我们Functor对于特定类型(例如std::vector或)具有类型类的实例std::option,我们可以fmap对该类型使用函数。对typeclass必须做同样的事情ApplicativeAlternative并且Monad这使您可以进行有状态的,可能失败的计算。该Alternative类型类工具错误恢复抽象。这样一来,您可以说出类似a <|> b意思,无论是term a还是term b。如果两个计算都没有成功,那仍然是错误。

让我们看一下Haskell的Maybe类型。

data Maybe a
  = Nothing
  | Just a

这意味着,在您期望a的地方Maybe a,您将得到NothingJust afmap从上方看时,实现可能看起来像

fmap f m = case m of
  Nothing -> Nothing
  Just a -> Just (f a)

case ... of表达式称为模式匹配,与OOP世界中的相似visitor pattern。试想线case m of作为m.apply(...)与点是实现调度功能的类的实例。case ... of表达式下面的行是各自的分派函数,它们按名称将类的字段直接引入作用域。在Nothing分支中,我们创建Nothing,在Just a分支中,我们命名唯一值,aJust ...使用f应用于的转换函数创建另一个值a。读取为:new Just(f(a))

现在,它可以处理错误的计算,同时消除实际的错误检查。存在其他接口的实现,这使这种计算非常强大。实际上,这Maybe是Rust的Option-Type 的灵感。


我希望在那里鼓励您重新Success上课Result。实际上Alexandrescu的一些建议非常接近,称之为expected<T>标准提案它作了

我会坚持使用Rust命名和API仅仅是因为...它已被记录并可以正常工作。当然,Rust有一个漂亮的?后缀运算符,它将使代码更甜美。在C ++中,我们将使用TRY宏和GCC的语句表达式来模拟它。

template <typename E>
struct Error {
    Error(E e): error(std::move(e)) {}

    E error;
};

template <typename E>
Error<E> error(E e) { return Error<E>(std::move(e)); }

template <typename T, typename E>
struct [[nodiscard]] Result {
    template <typename U>
    Result(U u): ok(true), data(std::move(u)), error() {}

    template <typename F>
    Result(Error<F> f): ok(false), data(), error(std::move(f.error)) {}

    template <typename U, typename F>
    Result(Result<U, F> other):
        ok(other.ok), data(std::move(other.data)),  error(std::move(other.error)) {}

    bool ok = false;
    T data;
    E error;
};

#define TRY(Expr_) \
    ({ auto result = (Expr_); \
       if (!result.ok) { return result; } \
       std::move(result.data); })

注意:这Result是一个占位符。适当的实现将使用封装和union。但这足以说明要点。

这使我可以编写(在操作中查看):

Result<double, std::string> sqrt(double x) {
    if (x < 0) {
        return error("sqrt does not accept negative numbers");
    }
    return x;
}

Result<double, std::string> double_sqrt(double x) {
    auto y = TRY(sqrt(x));
    return sqrt(y);
}

我觉得这很整洁:

  • 与使用错误代码(或您的Success类)不同,忘记检查错误将导致运行时错误1而不是某些随机行为,
  • 与使用异常不同,在调用站点上很明显哪些功能可能会失败,因此不会感到惊讶。
  • 使用C ++-2X标准,我们可能会concepts加入该标准。这将使这种编程更加令人愉悦,因为我们可以将选择权留给错误类型。例如,通过实施std::vectoras结果,我们可以一次计算所有可能的解决方案。或者我们可以按照您的建议选择改进错误处理。

1 具有正确封装的Result实现;)


注意:与例外不同,这种轻量级Result没有回溯,这使得日志记录的效率降低。您可能会发现,至少记录生成错误消息的文件/行号,并通常编写一个丰富的错误消息很有用。这可以通过每次使用TRY宏时捕获文件/行,本质上手动创建回溯或使用特定于平台的代码和库(例如libbacktrace在调用堆栈中列出符号)来实现。


不过,有一个很大的警告:现有的C ++库,甚至std都是基于异常的。使用这种样式将是一场艰苦的战斗,因为任何第三方库的API必须都包装在适配器中...


3
该宏看起来...非常错误。我假设({...})是一些gcc扩展名,但是即使这样,不是if (!result.ok) return result;吗?您的情况向后出现,并且您不必要地复制了该错误。
Mooing Duck's

@MooingDuck答案说明({...})是gcc的语句表达式
jamesdlin


1
如果您使用的是C ++ 17,建议使用std::variant来实现Result。此外,如果忽略错误,也要获得警告,请使用[[nodiscard]]
Justin

2
@Justin:std::variant考虑到异常处理的权衡取舍,是否使用在某种程度上是个问题。[[nodiscard]]确实是一次纯粹的胜利。
Matthieu M.

46

CLAIM:异常机制是用于处理错误的语言语义

例外是控制流机制。这种控制流机制的动机是专门错误处理与非错误处理代码分开,在通常情况下,错误处理具有很高的重复性,与逻辑的主要部分关系不大。

要求:对我来说,没有“没有借口”的功能无法完成任务:要么我们错误地定义了前后条件,所以该功能无法确保结果,或者某些特殊情况对于花时间在开发上没有足够重要的意义。一个办法

考虑:我尝试创建一个文件。存储设备已满。

现在,这并不是定义我的先决条件的失败:一般来说,您不能使用“必须有足够的存储空间”作为先决条件,因为共享存储空间受制于竞争条件,因此这无法满足。

因此,我的程序是否应该以某种方式释放一些空间然后成功进行,否则我就懒得“开发解决方案”?坦率地说,这似乎是荒谬的。所述“溶液”来管理的共享存储是我的程序的范围之外,并允许一旦用户已经释放或者一些空间,或加入了一些更多的存储我的程序正常失败,并且重新运行,是


成功类所做的是非常明确地将错误处理与程序逻辑进行交织。每个功能都需要在运行之前检查是否已发生一些错误,这意味着它不应执行任何操作。每个库函数都需要包装在另一个函数中,并带有另一个参数(并希望是完美的转发),它的作用完全相同。

还要注意,即使函数失败(或先前的函数失败),也mySqrt需要返回一个值。因此,您要么返回一个神奇的值(例如),要么将不确定的值注入到程序中,并且希望没有任何用处,而无需检查您已通过线程执行的成功状态。NaN

为了确保正确性和性能,一旦无法取得任何进展,最好将控制权移回范围之外。异常和带有早期返回的 C样式显式错误检查都可以实现此目的。


为了进行比较,您的想法确实有效的一个示例是Haskell中的Error monad。与您的系统相比,它的优势在于您可以正常编写大量逻辑,然后将其包装在monad中,以确保在某一步骤失败时停止评估。这样,唯一直接接触错误处理系统的代码就是可能失败的代码(引发错误)和需要处理失败的代码(捕获异常)。

我不确定monad样式和惰性评估是否可以很好地转换为C ++。


1
多亏您的回答,它为主题增添了亮点。我猜用户and allowing my program to fail gracefully, and be re-run在失去2小时工作后会不同意:
Adrian Maire

14
您的解决方案意味着您可能在每个位置创建文件,都需要提示用户解决此问题并重试。然后,其他所有可能出错的问题,也都需要以某种方式在本地修复。除例外情况外,您只需std::exception了解逻辑操作的较高级别,告诉用户“ X由于ex.what()而失败”,并愿意在准备就绪时重试整个操作。
无用的

13
@AdrianMaire:“允许优雅失败并重新运行”也可以实现为showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try。这是对问题的很好的处理,通常无法通过检测到第一个存储位置已满的代码来完成该问题。
巴特·范·英根·谢瑙

3
@Useless Lazy评估与Error monad的使用无关,严格的评估语言(如Rust,OCaml和F#)都充分利用了它,这证明了这一点。
8bittree '17

1
@对于高质量软件而言,无用的IMO 确实有意义,“您可能在每个位置创建文件,都需要提示用户解决问题并重试”。早期的程序员经常花大量的精力进行错误恢复,至少Knuth的TeX程序充满了错误。借助他的“识字编程”框架,他找到了一种将错误处理保留在另一部分中的方法,从而使代码保持可读性,并且更仔细地编写了错误恢复(因为当您编写错误恢复部分时,这就是重点,程序员往往会做得更好。
ShreevatsaR

15

我想更好地了解在项目中使用这种范例的含义:

  • 问题的前提是否正确?还是我错过了一些相关的东西?
  • 解决方案是好的构架思想吗?还是价格太高?

您的方法在您的源代码中带来了一些大问题:

  • 它依赖于始终记住检查的值的客户端代码s。这在使用返回码进行错误处理的方法中很常见,并且是在语言中引入异常的原因之一:对于异常,如果失败,您就不会默默地失败。

  • 使用这种方法编写的代码越多,用于错误处理(您的代码将不再是简约的),您还必须添加更多的错误样板代码,并且您的维护工作也会增加。

但是,作为开发人员的几年时间使我从另一种方法来看问题:

这些问题的解决方案应在技术主管级别或团队级别寻求解决方案:

程序员倾向于通过抛出异常来简化他们的任务,而这种特殊情况似乎太少而无法仔细实现。典型的情况是:内存不足问题,磁盘已满问题,文件损坏等,这可能就足够了,但并不总是从体系结构级别决定。

如果您发现自己一直在处理各种可能引发的异常,那么设计就不好了;处理哪些错误应根据项目的规范来决定,而不是根据开发人员的实现意愿来决定。

通过设置自动化测试,分离单元测试的规范和实现来解决(必须由两个人来完成)。

程序员往往不会仔细阅读文档,甚至,即使他们知道,他们也并没有真正地管理它们。

您不会通过编写更多代码来解决此问题。我认为您最好的选择是精心应用的代码审查。

程序员往往没有足够早地捕获异常,当他们捕获异常时,主要是记录并抛出更多异常。(请参阅第一点)。

正确的错误处理很困难,但是与返回值(无论它们是实际返回还是作为I / O参数传递)相比,异常处理的乏味。

错误处理中最棘手的部分不是如何接收错误,而是如何确保应用程序在出现错误时保持一致的状态。

为了解决这个问题,需要更多地注意识别并在错误条件下运行(更多测试,更多单元/集成测试等)。


12
如果您记得每次检查一个实例作为参数时,都将跳过错误后的所有代码。这就是我的意思,“使用这种方法编写的代码越多,您必须添加的错误样板代码也就越多”。您将必须在成功实例上将代码与ifs混淆,并且每次忘记时,它都是一个错误。忘记检查导致的第二个问题:直到再次检查才执行的代码根本不应该执行(如果忘记检查则继续执行,会破坏数据)。
utnapistim

11
不,处理异常(或返回错误代码)不会导致崩溃-除非错误/异常在逻辑上是致命的,或者您选择不对其进行处理。您仍然有机会处理错误情况,不必在每个步骤中都明确检查以前是否发生过错误
无用,

11
@AdrianMaire在几乎所有我正在处理的应用程序中,我都非常希望崩溃而不是无声地继续。我从事关键业务软件的开发,如果输出不正确并继续对其进行操作,可能会导致大量金钱损失。如果正确性至关重要并且可以接受崩溃,那么异常在这里具有很大的优势。
克里斯·海斯

1
@AdrianMaire-我认为忘记处理一个忘记if语句的方法的异常要困难得多...此外-异常的主要好处是可以由哪一层处理它们。您可能希望让系统异常进一步冒泡,以显示应用程序级别的错误消息,但在较低级别上处理您所了解的情况。如果您使用的是第三方库或其他开发人员代码,那么这实际上是唯一的选择……
Milney

5
@Adrian没错,您似乎误读了我写的内容或错过了下半年的内容。我的意思并不是说在测试/开发过程中会抛出异常,而开发人员会意识到他们需要处理它们。关键是,生产中完全未处理的异常的结果比未检查的错误代码的结果更可取。如果您错过了错误代码,则会得到并继续使用错误的结果。如果你错过了异常,应用程序崩溃,并且不继续运行,你会得到任何结果没有错误的结果。(续)
Mindor先生
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.