65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 异常:优点和缺点

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (100投票s)

2009年7月25日

CPOL

25分钟阅读

viewsIcon

296028

对使用异常与错误代码的优劣案例进行公正分析。

  1. 引言
  2. 使用异常的理由
    1. 异常将错误处理代码与正常程序流程分离,从而使代码更具可读性、鲁棒性和可扩展性。
    2. 抛出异常是从构造函数报告错误的唯一干净方式。
    3. 异常很难被忽略,不像错误代码。
    4. 异常可以很容易地从深度嵌套的函数中传播。
    5. 异常可以是(并且通常是)用户定义的类型,它比错误代码承载更多的信息。
    6. 异常对象通过类型系统与处理程序匹配。
  3. 不使用异常的理由
    1. 异常通过创建多个不可见的退出点来破坏代码结构,使代码难以阅读和检查。
    2. 异常容易导致资源泄露,尤其是在没有内置垃圾回收器和 finally 块的语言中。
    3. 学习编写异常安全代码很难。
    4. 异常开销大,违背了“只为所用付费”的承诺。
    5. 将异常引入遗留代码很难。
    6. 异常很容易被滥用于执行属于正常程序流程的任务。
  4. 结论

1. 引言

异常自20世纪90年代初以来一直是 C++ 的一部分,并被标准批准为该语言中编写容错代码的机制。然而,许多开发人员出于各种原因选择不使用异常,并且对这种语言特性持怀疑态度的声音仍然很多且响亮:Raymond Chen 的文章更清晰、更优雅,但错误,Joel Spolsky 的博客异常,以及Google C++ 风格指南是一些经常被引用的建议反对使用异常的文本。

我不想在这场争论中站队,而是试图对使用异常的优缺点提出一个平衡的看法。本文的目的不是说服读者使用异常或错误代码,而是帮助他们为自己的特定项目做出明智的决定。我将文章结构为在 C++ 社区中经常听到的六个支持使用异常的论点和六个反对使用异常的论点。

2. 使用异常的理由

2.1 异常将错误处理代码与正常程序流程分离,从而使代码更具可读性、鲁棒性和可扩展性。

为了说明这一点,我们将比较两个简单套接字库的使用,它们仅在错误处理机制上有所不同。以下是我们如何使用它们从网站获取 HTML

// sample 1: A function that uses exceptions

string get_html(const char* url, int port)
{
    Socket client(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    client.connect(url, port);
    
    stringstream request_stream;
    request_stream << "GET / HTTP/1.1\r\nHost: " 
       << url << "\r\nConnection: Close\r\n\r\n";

    client.send(request_stream.str());

    return client.receive();
}

现在,考虑一个使用错误代码的版本

// sample 2: A function that uses error codes

Socket::Err_code get_html(const char* url, int port, string* result)
{
    Socket client;
    Socket::Err_code err = client.init(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (err) return err;
        
    err = client.connect(url, port);
    if (err) return err;
    
    stringstream request_stream;
    request_stream << "GET / HTTP/1.1\r\nHost: " << url 
       << "\r\nConnection: Close\r\n\r\n";

    err = client.send(request_stream.str());
    if (err) return err;

    return client.receive(result);
}

在这两种情况下,代码执行相同的操作,错误处理委托给调用者,资源清理由析构函数执行。唯一的区别是,在前一种情况下,套接字库在失败时抛出异常,而在后一种情况下,使用错误代码。

很容易注意到,带有异常的示例具有更清晰、更简单的流程,没有一个 `if` 分支来中断它。错误处理完全隐藏,只有“正常”代码流可见。传播异常的基础设施由编译器生成:如果发生异常,堆栈将正确“展开”,这意味着所有堆栈帧中的局部变量都将正确销毁——包括运行析构函数。

事实上,这里显示的示例2可能比大多数情况更简单、更清晰:我们在函数中只使用一个库,并返回该库的错误代码。在实践中,我们可能不得不考虑在函数中遇到的不同错误代码类型,然后我们需要将所有这些错误代码映射到我们从函数返回的类型。

健壮性如何?当使用异常时,是编译器生成“错误路径”的代码,而不是程序员手动完成,这意味着引入 bug 的机会更少。当代码发生更改时,这一点尤其重要——当对正常代码路径引入更改时,很容易忘记更新错误处理部分,或者在更新时引入 bug。

2.2 抛出异常是从构造函数报告错误的唯一干净方式。

构造函数的目的是建立类不变式。为此,它通常需要获取系统资源或通常执行可能失败的操作。如果构造函数未能建立不变式,则对象处于无效状态,必须通知调用者。显然,错误代码不能用于此目的,因为构造函数不允许返回值。但是,在失败的情况下可以从构造函数中抛出异常,通过这样做,我们可以防止创建无效对象。

从失败的构造函数抛出异常有哪些替代方案?最流行的一种是“两阶段构造”习语,它在我们的示例2中使用。构造对象的过程分为两个步骤:构造函数只执行不会失败的初始化部分(即设置基本类型成员),而可能失败的部分则进入一个单独的函数,该函数通常名为 `init()` 或 `create()` 并返回错误代码。在这种情况下,建立类不变式不仅涉及构造对象,还涉及调用此其他函数并检查返回的错误代码。这种方法的缺点非常明显:正确初始化对象需要更多的工作,并且很容易最终得到处于无效状态的对象而不自知。此外,无法以这种方式实现复制构造——无法告诉编译器插入第二步并检查错误代码。话虽如此,这种习语在许多库中都非常有效地使用,并且通过一些纪律,它可能会成功。

从构造函数抛出异常的另一种替代方法是维护一个“坏状态标志”作为成员变量,在构造函数中设置该标志,并暴露一个函数来检查该标志。标准 IO 流使用这种方法

// sample 3: Use of the bad state flag

ifstream fs("somefile.txt");
if (fs.bad())
    return err_could_not_open;

这种技术类似于两阶段构造习语。它需要一个额外的数据成员——状态标志,这在某些情况下可能是 prohibitive 的开销。另一方面,复制构造可以通过这种方式实现,尽管它远非安全——例如,标准容器在内部进行了大量复制,并且无法让它们检查状态标志。然而,就像两阶段构造一样,只要我们知道自己在做什么,这种方法就可以奏效。

其他替代方案包括在构造函数中设置一些全局值,例如 `errno`,并希望调用者会记住检查它。这种方法显然不如以前的方法,我们不会在这里进一步讨论它。

2.3 异常很难被忽略,不像错误代码。

为了说明这个论点,我们所要做的就是从示例2中删除错误检查——它会编译得很好,只要没有运行时错误,它也能正常工作。然而,想象一下在调用 `init()` 时出现了错误:对象 `client` 将处于无效状态,当其成员函数被调用时,可能会发生任何事情,这取决于 `Socket` 类的内部实现、操作系统等;程序可能会立即崩溃,甚至可能执行所有函数但什么也不做就返回,没有任何迹象表明出了问题——除了结果。另一方面,如果从构造函数中抛出了异常,则无效对象将永远不会被构造,并且执行将在异常处理程序处继续。通常的说法是:“我们会直接面对一个异常”。

但忽略异常真的那么难吗?让我们回到堆栈顶部看看 `get_html()` 的调用者

// sample 4: "Swallowing exceptions"

try {
    string html = get_html(url);
}
catch (...)
{}

这段糟糕的代码被称为“吞噬异常”,其效果与忽略错误代码没有太大区别。吞噬异常比忽略错误代码需要更多的工作,并且这些构造在代码审查期间更容易检测到,但事实是异常仍然很容易被忽略,并且人们确实会这样做。

然而,即使它们很容易被忽略,异常也比错误代码更容易检测。在许多平台上,如果进程从调试器运行,可以在抛出异常时中断。例如,GNU gdb 支持“catchpoints”用于此目的,Windows 调试器支持“如果抛出异常则中断”选项。对于错误代码,获得类似功能要困难得多,甚至不可能。

2.4 异常可以很容易地从深度嵌套的函数中传播。

我们通常无法在错误最初检测到的地方处理它。我们需要将错误信息传播到可以处理它的级别,异常允许直接跳转到处理程序,而无需手动编写所有管道。

回到我们带有 Socket 类的示例1。假设 `get_html()` 由一个名为 `get_title()` 的函数调用,该函数由 UI 事件处理程序调用。类似这样

// sample 5: A function that calls sample 1 get_html()

string get_title(const string& url)
{
    string markup = get_html(url);

    HtmlParser parser(markup);
    
    return parser.get_title();
}

异常可能在 UI 事件处理程序级别处理

// sample 6: Exception  handler

void AppDialog::on_button()
{
    try {
        string url = url_text_control.get_text();
        result_pane.set_text(
            get_title(url);
    }
    catch(Socket::Exception& sock_exc) {
        display_network_error_message();
    }
    catch(Parser::Exception& pars_exc) {
        display_parser_errorMessage();
    }
    catch(...) {
        display_unknown_error_message();
    }
}

在上面的示例中,`get_title()` 不包含任何代码用于将错误信息从 `get_html()` 传播到 `on_button()`。如果使用错误代码而不是异常,`get_title()` 需要在调用 `get_html()` 后检查返回值,将该值映射到自己的错误代码,并将新的错误代码传回给 `on_button()`。换句话说,使用异常将使 `get_title()` 看起来像我们的示例1,而错误代码将使其类似于示例2。类似这样

// sample 7: get_title with error codes

enum Get_Title_Err {Get_Title_Err_OK, 
                    Get_Title_Err_NetworkError, Get_Title_Err_ParserError};
Get_Title_Err get_title(const string& url, string* title)
{
    string markup;
    Socket::Err_code sock_err = get_html(url.c_str(), &markup);
    if (sock_err) return Get_Title_Err_NetworkError;

    HtmlParser parser;
    HtmlParser::Err_code parser_err = parser.init(markup);
    if (parser_err) return Get_Title_Err_ParserError;
    
    return parser.get_title();
}

与示例2一样,如果尝试传播更具体的错误代码,示例7很容易变得更复杂。在这种情况下,我们的 `if` 分支需要将库中的错误代码映射到适当的 `Get_Title_Err` 值。此外,请注意,在此示例中我们只显示了函数嵌套的一部分——不难想象将错误代码从解析器代码深处传播到 `get_title` 函数所需的工作量。

2.5 异常可以是(并且通常是)用户定义的类型,它比错误代码承载更多的信息。

错误代码通常是整数类型,只能携带有关错误的这么多信息。微软的 `HRESULT` 类型实际上是一个令人印象深刻的尝试,它试图在一个32位整数中打包尽可能多的信息,但这清楚地显示了这种方法的局限性。当然,错误代码可以是一个功能齐全的对象,但是将这样的对象多次复制直到它到达错误处理程序的成本使得这种技术不值得。

另一方面,异常通常是对象,并且可以携带大量有关错误的有用信息。异常携带有关抛出它的源文件和行(使用 `_FILE_` 和 `_LINE_` 等宏)的信息是一种非常常见的做法,它们甚至可以自动向日志系统发送消息。

为什么使用对象作为返回错误代码很昂贵,而对异常却不呢?有两个原因:首先,异常对象仅在实际发生错误时才创建,这应该是一个异常事件——双关语。即使操作成功,也需要创建错误代码。另一个原因是异常通常通过引用传播到处理程序,并且不需要复制异常对象。

2.6 异常对象通过类型系统与处理程序匹配。

让我们稍微扩展一下示例6,并在建立套接字连接时发生错误的情况下显示特定的错误消息

// sample 8: Exception  handler

void AppDialog::on_button()
{
    try {
        string url = url_text_control.get_text();
        result_pane.set_text(
            get_title(url));
    }
    catch(Socket::SocketConnectionException& sock_conn_exc) {
        display_network_connection_error_message();
    }
    catch(Socket::Exception& sock_exc) {
        display_general_network_error_message();
    }
    catch(Parser::Exception& pars_exc) {
        display_parser_errorMessage();
    }
    catch(...) {
        display_unknown_error_message();
    }
}

示例8演示了使用类型系统对异常进行分类。处理程序从最具体的到最一般的,这是通过服务于此目的的语言机制:继承来表达的。在我们的示例中,`Socket::SocketConnectionException` 派生自 `Socket::Exception`。

如果使用错误代码而不是异常,错误处理程序可能是一个 `switch` 语句,每个单独的 `case` 将处理一个错误代码值。`default` 最可能对应于 `catch(...)`。这是可以做到的,但不太一样。在示例8中,我们使用 `Socket::Exception` 的处理程序来处理套接字库中的所有异常,除了我们上面处理的 `SocketConnectionException`;对于错误代码,我们需要明确列出所有这些其他错误代码。如果忘记了一个,或者以后添加了一个新的错误代码但忘记更新它……您就明白了。

3. 不使用异常的理由

3.1 异常通过创建多个不可见的退出点来破坏代码结构,使代码难以阅读和检查。

这听起来与我们2.1中谈论的完全相反。异常怎么可能同时使代码难以阅读和易于阅读呢?要理解这个观点,请记住,通过使用异常,我们设法只显示了“正常流程”。错误处理代码仍然由编译器为我们生成,它有自己的流程,与我们编写的代码流程正交。实际上,使用异常时,正常代码流程更清晰、更易于阅读和理解,但这只是故事的一部分。现在我们需要意识到,在任何函数调用中,我们都会引入一个不可见的分支,在发生异常时,该分支会立即退出(如果处理程序在同一个函数中,则跳转到处理程序)。这听起来比 `goto` 更糟糕,与 `longjmp` 类似,但公平地说,C++ 异常比 `longjmp` 更安全:正如我们在2.1节中看到的,编译器生成了展开堆栈的代码。

尽管如此,可读性问题不一定是假的:如果我们快速浏览一段代码以了解它做了什么,正常流程确实是我们需要的一切,但如果我们需要真正了解它呢?例如,假设您需要进行代码审查或扩展一个函数——您是希望所有可能的代码路径都摆在您面前,还是必须猜测哪个函数可能抛出异常以及在什么情况下?当然,如果您确保异常仅用于错误处理,并且代码最初是在考虑异常安全的情况下编写的,那么这就不那么令人担忧了。

3.2 异常容易导致资源泄露,尤其是在没有内置垃圾回收器和 finally 块的语言中。

为了理解我们在这里谈论的是什么,让我们暂时忘记面向对象编程及其类、析构函数和异常,回到古老的 C 语言。许多函数采用以下形式

// sample 9: acquiring and releasing resources

void func()
{
    acquire_resource_1();
    acquire_resource_2();

    use_resources_1();
    use_resources_2();
    use_resources_3();

    release_resource_2();
    release_resource_1();
}

基本上,我们在函数开头获取一些资源,使用它们进行一些处理,然后释放它们。所涉及的资源可以来自系统(内存、文件句柄、套接字……)或外部库。无论如何,它们都是有限的,需要释放,否则可能会在某个时候耗尽,这就是我们所说的“泄露”。

那么问题出在哪里呢?我们示例9中的函数是一个结构良好、单入口单出口代码的例子,我们总会在函数结束时清理资源。现在想象一下 `use_resources_1()` 中可能会出错。在这种情况下,我们不想执行其余的 `use_resources_...` 函数,而是报告错误并立即退出。嗯,不是立即——我们首先需要清理资源。如何用 C 这样的语言最好地做到这一点?我们只能说关于这个话题的讨论甚至比我们的小“异常与错误代码”困境更有激情:有些人复制粘贴清理代码并在每次退出时调用它;另一些人尽可能为此目的制作宏;有些开发人员保留函数的“SESE”结构,并为每个可能失败的函数引入 `if-else` 分支。这样的函数看起来像一个右尖括号“>”,有些人称之为“箭头反模式”。许多 C 开发人员使用 `goto` 跳转到函数的清理部分。

无论如何,即使没有异常,这也很混乱。如果我们切换到 C++,并且 `use_resources_1()` 在发生错误时抛出异常怎么办?按照函数的写法,清理部分将不会执行,我们将出现内存泄漏。垃圾回收器和 `finally` 块在这里会有什么帮助呢?假设 Java 标准库中没有网络支持,我们使用类似于示例1但来自 Java 的东西

// sample 10: A Java version of sample 1

public String getHtml(String url, int port) throws (Socket.Exception)
{
    Socket client = null;
    try {
        client = new Socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
        client.connect(url, port);

        StringBuffer requestStream = new StringBuffer("GET / HTTP/1.1\r\nHost: ");
        requestStream.append(url).append("\r\nConnection: Close\r\n\r\n");

        client.send(requestStream.toString());

        return client.receive();
    }
    finally {
        if (client != null)
            client.close();
    }
}

在上面的 `getHtml` 方法中,我们获取了一些资源:一个套接字,一个字符串缓冲区,以及几个临时字符串对象。垃圾回收器负责最终只需向系统释放内存的对象,而套接字在 `finally` 块中关闭,该块在我们离开 `try` 块时执行——无论是“正常”离开还是通过异常离开。

C++ 没有内置的垃圾回收器,也不支持 `finally` 块;为什么我们的示例1(或示例2)中的函数没有泄漏呢?答案通常被称为 RAII(资源获取即初始化),它真正的意思是,声明为局部变量的对象在超出作用域后会被销毁,无论它们以何种方式退出该作用域。当它们被销毁时,析构函数首先执行,然后内存返回给系统。析构函数的任务是释放对象获取的所有资源——事实上,示例1中使用的 `Socket` 类的析构函数很可能与示例10中 `finally` 块的主体非常相似。最棒的是,在 C++ 中,我们只需要编写一次这个清理代码,它就会在对象超出作用域时自动执行。RAII 的另一个优点是它以统一的方式处理所有资源——无需区分“GC 可回收”资源和“非托管”资源,并将前者留给 GC,后者在 `finally` 块中清理:清理是不可见的,但它可靠地发生,就像使用异常报告错误一样。

我是不是说缺少垃圾回收器和 `finally` 块并不是 C++ 中不使用异常的真正原因,因为 RAII 是一种更优越的资源管理方法?是也不是。RAII 确实优于垃圾回收器和 `finally` 块的组合,它是一种非常直接的习语,易于使用。然而,为了获得 RAII 的好处,它需要始终如一地使用。有很多 C++ 代码看起来像这样

// sample 11: An example of non exception safe code

string get_html(const char* url, int port)
{
    Socket client = new Socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    client->connect(url, port);
    
    stringstream request_stream;
    request_stream << "GET / HTTP/1.1\r\nHost: " << url 
      << "\r\nConnection: Close\r\n\r\n";

    client->send(request_stream.str());

    string html = client->receive();

    delete client;
    return html;
}

在示例11中,除了套接字,所有资源都使用了 RAII——然而,这是一场灾难即将发生:如果函数中的任何地方抛出异常,我们就会泄露一个套接字。如果有人拔掉网线,我们的程序可能会很快崩溃。当然,可以将函数包装在 `try-catch` 块中,然后在 `catch` 块和之后都关闭套接字——从而模拟不存在的 `finally` 结构,但这正是那种重复而繁琐的任务,迟早会被遗忘,然后我们就会遇到一个严重的 bug。

但是,这种代码到底有多常见呢?在可能的情况下,在栈上创建对象不仅更安全,也比配对 `new` 和 `delete` 更容易——人们会期望大多数程序员一直使用 RAII。不幸的是,情况并非如此。有很多代码看起来像示例11。例如,找到流行的 Xerces-C 库的 SAX2 解析器的官方示例代码——它在堆上创建对象,并在使用后删除它们——如果发生异常,就会出现内存泄漏。在20世纪90年代早期,即使没有理由,在堆上创建对象也被认为是“更面向对象”的。现在,许多年轻的 C++ 程序员在大学里首先学习 Java,并以 Java 式的方式编写 C++ 代码,而不是地道的 C++。无论如何,现有的大量代码不依赖确定性析构函数来自动释放资源。如果您正在处理这样的代码库,唯一明智的做法是使用错误代码并关闭异常。

请注意,防止资源泄漏只是异常安全难题的一部分。有时一个函数需要是事务性的:它需要要么成功,要么保持状态不变。在这种情况下,如果失败,我们需要在离开函数之前回滚操作。毫不奇怪,如果抛出异常,我们再次求助于析构函数来触发此类操作。这种习语被称为“Scope Guard”,我建议阅读Andrei Alexandrescu 和 Petru Marginean 的原始文章,而不是在这里讨论它。

3.3 学习编写异常安全代码很难。

对这个说法常见的反驳是,错误处理通常很难,而异常使它变得更容易,而不是更难。虽然这有很多道理,但学习曲线问题仍然存在:复杂的语言特性通常确实有助于有经验的程序员简化他们的代码,但初学者大多看到学习的额外痛苦。要在生产代码中使用异常,C++ 程序员不仅要学习语言机制,还要学习使用异常的最佳实践。初学者多久能学会从析构函数中从不抛出异常?或者只在异常情况下使用异常,而不是在正常代码流中?或者通过引用捕获,而不是通过值或指针?异常规范的有用性(或缺乏)又如何呢?异常安全保证?晦涩的角落,例如多态抛出?

从“半满杯”的角度来看,无论有没有异常,C++ 始终是一种对专家友好的语言,对初学者提供的帮助很少。一个优秀的 C++ 程序员不会依赖语言的限制,而是求助于社区来教导他使用语言的最佳实践。社区的指导方针比语言本身变化更快:一个在1997年从 C++ 转向 Java 的人,在接触现代 C++ 代码时常常会感到非常困惑——它看起来不同,但语言本身并没有改变多少。回到异常,C++ 社区自异常首次引入语言以来,学到了很多如何有效使用它们的方法;一个关注新出版物和新闻组的人应该努力学习最佳实践。一个遵循社区最佳实践的良好编码标准对学习曲线有很大帮助。

3.4 异常开销大,违背了“只为所用付费”的承诺。

当谈论语言特性或库的性能影响时,通常给开发人员的最佳建议是自行测量并决定是否存在任何性能影响以及在其场景中是否可接受。不幸的是,有时在异常方面很难遵循此建议,主要是因为异常处理机制的性能与实现高度相关;因此,基于一个编译器测量的结论在代码移植到另一个编译器,甚至是同一编译器的另一个版本时可能无效。至少,好消息是编译器在存在异常的情况下生成高效代码方面越来越好,如果异常现在不影响您的代码性能,那么将来它们的影响会更小。

要理解异常处理的总体性能影响,我强烈建议阅读C++ 性能技术报告(草案)的第5.4章。它详细讨论了性能开销的来源:编译器添加到 `try-catch` 块的代码、对常规函数的影响以及实际抛出异常的成本。它还比较了两种最常见的实现方法:“代码”方法,其中代码添加到每个 `try-catch` 块中;以及“表格”方法,其中编译器生成静态表。

在了解了异常的一般性能影响场景之后,最好的做法是针对您特定的平台和编译器研究该主题。微软的 Kevin Frei 在西北 C++ 用户组进行了一次非常棒且相对较新的演示,涵盖了针对32位和64位 Windows 平台的 Visual C++ 编译器。另一篇与 Itanium 上的 GNU C++ 编译器相关的非常有趣的文本是Itanium C++ ABI:异常处理

在结束关于性能和异常的讨论之前,值得补充的是,当前的 C++ 实现不提供异常的可预测性保证,这使得它们不适用于硬实时系统。

3.5 将异常引入遗留代码很难。

换句话说,异常安全绝不能是事后诸葛亮。如果代码在编写时没有考虑到异常,那么最好不要引入异常,或者完全重写——出错的方式太多了。在这方面,它让我想起了在没有考虑到线程安全的情况下使代码线程安全——千万不要这样做!不仅很难审查代码以发现潜在问题,更重要的是,很难测试。您将如何触发所有可能导致抛出异常的场景?

另一方面,如果代码库是模块化的,那么编写带有异常的新代码并与旧代码和平共处是完全可能的。重要的是划定界限,不要让异常跨越它们。

3.6 异常很容易被滥用于执行属于正常程序流程的任务。

使用异常的优点,特别是它们可以很容易地从深度嵌套的代码结构中传播,使得它们很容易被用于错误处理以外的任务——最有可能用于某种穷人的延续。

几乎总是,使用异常来影响“正常”流程是一个坏主意。正如我们已经在3.1节中讨论过的,异常会生成不可见的代码路径。如果这些代码路径仅在错误处理场景中执行,那么它们可以说是可接受的。然而,如果我们出于任何其他目的使用异常,我们的“正常”代码执行就会被分成可见和不可见的部分,这使得代码非常难以阅读、理解和扩展。

即使我们接受异常仅用于错误处理,有时也很难判断代码分支是代表错误处理场景还是正常流程。例如,如果 `std::map::find()` 导致未找到值,这是错误还是正常流程?有时,如果我们从“异常”而不是“错误”状态的角度思考,答案会更容易找到。在我们的示例中,在 map 中未找到值是否是异常情况?一般来说,答案可能是否定的——有很多“正常”场景中我们会检查 map 中是否存储了值并根据结果进行分支——这些分支都不是错误状态。这就是为什么 `map::find` 在 map 中没有请求的键时不会抛出异常。

依赖用户输入的功能如何?如果用户输入了无效数据,是抛出异常好还是不抛出好?用户的输入最好被认为是无效的,除非另有证明,如果可能的话,应该有一些函数在处理之前验证输入。对于这些验证函数,无效数据是预期结果,没有理由触发异常。问题是,通常不可能在实际处理之前验证输入(例如解析器);在这种情况下,使用异常来中断处理并报告错误通常是一个好方法。我们示例5中的 `HtmlParser` 如果无法解析损坏的 HTML 就会抛出异常。

我个人认为,如果程序在正常条件下并使用有效数据工作,则不应抛出异常。换句话说,当您从调试器运行程序进行测试时,请务必设置一个“catchpoint”,只要抛出异常就会触发。如果程序执行因为抛出异常而中断,那表示要么是一个应该处理的错误,要么是异常处理机制的滥用,也应该修复。当然,异常可能是由于环境的异常条件(例如,网络连接突然丢失,或字体句柄分配失败)或无效输入数据而触发的,在这种情况下,抛出异常是有充分理由的。

4. 结论

对于“异常还是错误代码”的问题,没有简单的答案。决策需要根据开发团队面临的具体情况来做出。一些粗略的指导方针可能是

  • 如果你有一个良好的开发流程和实际遵循的代码标准,如果你正在编写依赖 RAII 为你清理资源的现代风格 C++ 代码,如果你的代码库是模块化的,那么使用异常可能是一个好主意。
  • 如果你正在处理没有考虑异常安全而编写的代码,如果你觉得你的开发团队缺乏纪律,或者你正在开发硬实时系统,那么你可能不应该使用异常。
© . All rights reserved.