从 C `goto` 错误处理范式过渡到 C++ 异常处理范式

Transitioning from C `goto` error handling paradigm to C++ exception handling paradigm

我是一名学习 C++ 的 C 程序员。在C中,有一个常见的goto idiom used to handle errors and exit cleanly from a function。我读过在面向对象的程序中首选通过 try-catch 块进行异常处理,但我在 C++ 中实现这种范例时遇到了麻烦。

以使用 goto 错误处理范例的 C 中的以下函数为例:

unsigned foobar(void){
    FILE *fp = fopen("blah.txt", "r");
    if(!fp){
        goto exit_fopen;
    }

    /* the blackbox function performs various
     * operations on, and otherwise modifies,
     * the state of external data structures */
    if(blackbox()){
        goto exit_blackbox;
    }

    const size_t NUM_DATUM = 42;
    unsigned long *data = malloc(NUM_DATUM*sizeof(*data));
    if(!data){
        goto exit_data;
    }

    for(size_t i = 0; i < NUM_DATUM; i++){
        char buffer[256] = "";
        if(!fgets(buffer, sizeof(buffer), fp)){
            goto exit_read;
        }

        data[i] = strtoul(buffer, NULL, 0);
    }

    for(size_t i = 0; i < NUM_DATUM/2; i++){
        printf("%lu\n", data[i] + data[i + NUM_DATUM/2]);
    }

    free(data)
    /* the undo_blackbox function reverts the
     * changes made by the blackbox function */
    undo_blackbox();
    fclose(fp);

    return 0;

exit_read:
    free(data);
exit_data:
    undo_blackbox();
exit_blackbox:
    fclose(fp);
exit_fopen:
    return 1;
}

我尝试使用异常处理范例在 C++ 中重新创建函数:

unsigned foobar(){
    ifstream fp ("blah.txt");
    if(!fp.is_open()){
        return 1;
    }

    try{
        // the blackbox function performs various
        // operations on, and otherwise modifies,
        // the state of external data structures
        blackbox();
    }catch(...){
        fp.close();
        return 1;
    }

    const size_t NUM_DATUM = 42;
    unsigned long *data;
    try{
        data = new unsigned long [NUM_DATUM];
    }catch(...){
        // the undo_blackbox function reverts the
        // changes made by the blackbox function
        undo_blackbox();
        fp.close();
        return 1;
    }

    for(size_t i = 0; i < NUM_DATUM; i++){
        string buffer;
        if(!getline(fp, buffer)){
            delete[] data;
            undo_blackbox();
            fp.close();
            return 1;
        }

        stringstream(buffer) >> data[i];
    }

    for(size_t i = 0; i < NUM_DATUM/2; i++){
        cout << data[i] + data[i + NUM_DATUM/2] << endl;
    }

    delete[] data;
    undo_blackbox();
    fp.close();

    return 0;
}

我觉得我的 C++ 版本没有正确实现异常处理范例;事实上,C++ 版本的可读性似乎更差,而且更容易出错,因为随着函数的增长,catch 块中积累了清理代码。

我读到,由于名为 RAII 的原因,catch 块中的所有这些清理代码在 C++ 中可能是不必要的,但我不熟悉这个概念。 我的实现是否正确,或者是否有更好的方法来处理错误并干净地退出 C++ 中的函数?

RAII的原理是你使用class类型来管理任何使用后需要清理的资源;清理由析构函数完成。

这意味着您可以创建一个本地 RAII 管理器,它会在超出范围时自动清理它管理的任何内容,无论是由于正常程序流还是异常。永远不需要 catch 块来清理;仅当您需要处理或报告异常时。

在您的例子中,您拥有三种资源:

  • 文件fpifstream 已经是 RAII 类型,所以只需删除对 fp.close() 的冗余调用,一切都很好。
  • 分配的内存data。如果它是一个小的固定大小(像这样),则使用本地数组,如果需要动态分配,则使用 std::vector ;然后去掉 delete.
  • blackbox设置的状态。

您可以为 "black box" malarkey 编写自己的 RAII 包装器:

struct blackbox_guard {
    // Set up the state on construction
    blackbox_guard()  {blackbox();}

    // Restore the state on destruction
    ~blackbox_guard() {undo_blackbox();}

    // Prevent copying per the Rule of Three
    blackbox_guard(blackbox_guard const &) = delete;
    void operator=(blackbox_guard) = delete;
};

现在您可以删除所有错误处理代码;我会通过异常(抛出或允许传播)而不是神奇的 return 值来指示失败,给出:

void foobar(){
    ifstream fp ("blah.txt"); // No need to check now, the first read will fail if not open
    blackbox_guard bb;

    const size_t NUM_DATUM = 42;
    unsigned long data[NUM_DATUM];   // or vector<unsigned long> data(NUM_DATUM);

    for(size_t i = 0; i < NUM_DATUM; i++){
        string buffer;

        // You could avoid this check by setting the file to throw on error
        // fp.exceptions(ios::badbit); or something like that before the loop
        if(!getline(fp, buffer)){
             throw std::runtime_error("Failed to read"); // or whatever
        }

        stringstream(buffer) >> data[i]; // or data[i] = stoul(buffer);
    }

    for(size_t i = 0; i < NUM_DATUM/2; i++){
        cout << data[i] + data[i + NUM_DATUM/2] << endl;
    }
}

是的,您应该尽可能使用 RAII(资源获取即初始化)。它导致代码既易于阅读安全。

核心思想是在对象初始化期间获取资源,并设置对象,以便它在销毁时正确释放资源。之所以有效,关键在于 析构函数 运行 通常在通过异常退出作用域时。

在您的情况下,RAII 已经可用,您只是没有使用它。 std::ifstream(我假设这就是你的 ifstream 所指的)确实在销毁时关闭。因此,catch 中的所有 close() 调用都可以安全地省略,并且会自动发生——这正是 RAII 的用途。

对于 data,您也应该使用 RAII 包装器。有两个可用:std::unique_ptr<unsigned long[]>std::vector<unsigned long>。两者都在各自的析构函数中处理内存释放。

最后,对于 blackbox(),您可以自己创建一个简单的 RAII 包装器:

struct BlackBoxer
{
  BlackBoxer()
  {
    blackbox();
  }

  ~BlackBoxer()
  {
    undo_blackbox();
  }
};

用这些重写后,您的代码会变得更简单:

unsigned foobar() {
  ifstream fp ("blah.txt");
  if(!fp.is_open()){
    return 1;
  }

  try {
    BlackBoxer b;

    const size_t NUM_DATUM = 42;
    std::vector<unsigned long> data(NUM_DATUM);
    for(size_t i = 0; i < NUM_DATUM; i++){
      string buffer;
      if(!getline(fp, buffer)){
        return 1;
      }

      stringstream(buffer) >> data[i];
    }

    for(size_t i = 0; i < NUM_DATUM/2; i++){
      cout << data[i] + data[i + NUM_DATUM/2] << endl;
    }

    return 0;
  } catch (...) {
    return 1;
  }
}

此外,请注意您的函数使用 return 值来指示成功或失败。这可能是您想要的(如果此函数的失败是 "normal"),或者可能只是表示只进行了一半(如果失败也应该是例外)。

如果是后者,只需将函数更改为void,摆脱trycatch构造,并抛出合适的异常而不是return 1; .

最后,即使您决定保留 return 价值方法(这是完全有效的),请考虑将函数更改为 return bool,并使用 true意味着成功。它更地道。

让我使用 C++ 惯用法为您重写,并在代码中内联解释

// void return type, we may no guarantees about exceptions
// this function may throw
void foobar(){
   // the blackbox function performs various
   // operations on, and otherwise modifies,
   // the state of external data structures
   blackbox();

   // scope exit will cleanup blackbox no matter what happens
   // a scope exit like this one should always be used
   // immediately after the resource that it is guarding is
   // taken.
   // but if you find yourself using this in multiple places
   // wrapping blackbox in a dedicated wrapper is a good idea
   BOOST_SCOPE_EXIT[]{
       undo_blackbox();
   }BOOST_SCOPE_EXIT_END


   const size_t NUM_DATUM = 42;
   // using a vector the data will always be freed
   std::vector<unsigned long> data;
   // prevent multiple allocations by reserving what we expect to use
   data.reserve(NUM_DATUM);
   unsigned long d;
   size_t count = 0;
   // never declare things before you're just about to use them
   // doing so means paying no cost for construction and
   // destruction if something above fails
   ifstream fp ("blah.txt");
   // no need for a stringstream we can check to see if the
   // file open succeeded and if the operation succeeded
   // by just getting the truthy answer from the input operation
   while(fp >> d && count < NUM_DATUM)
   {
       // places the item at the back of the vector directly
       // this may also expand the vector but we have already
       // reserved the space so that shouldn't happen
       data.emplace_back(d);
       ++count;
   }

   for(size_t i = 0; i < NUM_DATUM/2; i++){
       cout << data[i] + data[i + NUM_DATUM/2] << endl;
   }
}

c++最强大的特性不是类,而是析构函数。析构函数允许在退出作用域时解除或释放资源或职责。这意味着您不必多次重写清理代码。此外,因为只有构造的对象才能被破坏;如果你从来没有得到一个项目,因此永远不会建造它,那么如果发生什么事情,你不会因为破坏而受到任何惩罚。

如果您发现自己重复清理代码,那应该是一个标志,表明有问题的代码没有利用析构函数和 RAII 的强大功能。

In C, there is a common goto idiom used to handle errors and exit cleaning from a function. I've read that exception handling via try-catch blocks is preferred in object-oriented programs,

对于 C++ 来说完全不是这样。

但 C++ 具有确定性析构函数而不是 finally 块(例如,在 Java 中使用),这是错误处理代码的游戏规则改变者。

I've read that all this cleanup code in the catch blocks may be unnecessary in C++ due to something called RAII,

是的,在 C++ 中您使用 "RAII"。对于一个伟大的概念来说,这是一个糟糕的名字。名字不好听,是因为强调了initialisation(资源获取即初始化)。相比之下,RAII的重要之处在于破坏。由于本地对象的析构函数将在块的末尾执行,因此无论发生什么情况,无论是早期returns还是异常,它都是释放资源的代码的最佳位置。

but I'm unfamiliar with the concept.

嗯,一开始,你可以从维基百科的定义开始:

http://en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization

或者您直接访问 Bjarne Stroustrup 的网站:

http://www.stroustrup.com/bs_faq2.html#finally

我相信我们非常乐意回答有关成语特定方面的问题或您在使用它时遇到的问题:)

Is my implementation proper, or is there a better way to handle errors and cleanly exit a function in C++?

您的实现不是人们对优秀 C++ 代码的期望。

这是一个使用 RAII 的例子。它使用异常来报告错误,并使用析构函数来执行清理操作。

#include <fstream>
#include <stdexcept>
#include <vector>

// C or low-level functions to be wrapped:
int blackbox();
void undo_blackbox();

// just to be able to compile this example:
FILE *fp;

// The only self-made RAII class we need for this example
struct Blackbox {
    Blackbox() {
        if (!blackbox()) {
            throw std::runtime_error("blackbox failed");
        }
    }

    // Destructor performs cleanup:
    ~Blackbox() {
        undo_blackbox();
    }   
};

void foobar(void){
    // std::ifstream is an implementation of the RAII idiom,
    // because its destructor closes the file:
    std::ifstream is("blah.txt");
    if (!is) {
        throw std::runtime_error("could not open blah.txt");
    }

    Blackbox local_blackbox;

    // std::vector itself is an implementation of the RAII idiom,
    // because its destructor frees any allocated data:
    std::vector<unsigned long> data(42);

    for(size_t i = 0; i < data.size(); i++){
        char buffer[256] = "";
        if(!fgets(buffer, sizeof(buffer), fp)){
            throw std::runtime_error("fgets error");
        }

        data[i] = strtoul(buffer, NULL, 0);
    }

    for(size_t i = 0; i < (data.size()/2); i++){
        printf("%lu\n", data[i] + data[i + (data.size()/2)]);
    }

    // nothing to do here - the destructors do all the work!
}

顺便说一下,+1 表示尝试用新语言学习新概念。用不同的语言改变你的思维方式并不容易! :)