从构造函数中抛出异常

343

我正在和一位同事就从构造函数中抛出异常进行辩论,想要听取一些反馈意见。

从设计的角度来看,从构造函数中抛出异常是否可以接受?

比如说,我正在将一个POSIX互斥锁封装在一个类中,它会像这样:

class Mutex {
public:
  Mutex() {
    if (pthread_mutex_init(&mutex_, 0) != 0) {
      throw MutexInitException();
    }
  }

  ~Mutex() {
    pthread_mutex_destroy(&mutex_);
  }

  void lock() {
    if (pthread_mutex_lock(&mutex_) != 0) {
      throw MutexLockException();
    }
  }

  void unlock() {
    if (pthread_mutex_unlock(&mutex_) != 0) {
      throw MutexUnlockException();
    }
  }

private:
  pthread_mutex_t mutex_;
};
我的问题是,这是标准的做法吗?因为如果 pthread_mutex_init 调用失败,互斥对象就无法使用,所以抛出异常确保互斥对象不会被创建。
我是否应该为 Mutex 类创建一个成员函数 init,并在其中调用 pthread_mutex_init,根据 pthread_mutex_init 的返回值返回一个布尔值?这样我就不必为这种低级对象使用异常了。

从构造函数中抛出异常和从任何其他函数中抛出一样,这是可以的。话虽如此,你应该在任何函数中小心地抛出异常。 - g24l
5
是否考虑移除你的锁定/解锁方法,直接在构造函数中锁定互斥量,在析构函数中解锁?这样,在作用域内简单地声明一个自动变量就会自动执行锁定/解锁操作,无需处理异常、返回等问题...请参见 std::lock_guard 的类似实现。 - Laurent Grégoire
除非它们非常简单且没有任何清理代码,否则你将会遭受到难以预料的打击。 - Andy Krouwel
2
@LaurentGrégoire:在构造函数中创建和锁定互斥量是没有意义的,因为没有其他人会引用该互斥量,所以它不会保护任何东西。你需要使用lockunlock,这样你的互斥类型才能与std::lock_guard一起工作;他正在重新实现std::mutex,而不是std::lock_guard,这就是C++标准库中这两个类分开的原因。 - ShadowRanger
@ShadowRanger 说得没错;但我这里假设他可以在类的构造函数中给出某种上下文来允许此重用(甚至使用全局共享互斥量)。无论如何,重新发明轮子并不是非常有用的。 - Laurent Grégoire
显示剩余2条评论
11个回答

305

是的,从失败的构造函数中抛出异常是这样做的标准方法。阅读此处理失败的构造函数的常见问题解答以了解更多信息。拥有init()方法也可以工作,但是创建互斥锁对象的每个人都必须记住调用init()。我认为这违背了RAII原则。


16
在大多数情况下,不要忘记像std::fstream这样的东西。即使失败了,它仍然会创建一个对象,但是因为我们通常测试对象的状态,所以它通常运行良好。因此,在正常使用中测试其自然状态的对象可能不需要抛出异常。 - Martin York
1
@Widor:感谢您审核我的建议编辑编号278978。我可以再问一个与编辑相关的问题吗?这个评论所附的答案有一个过时的超链接。为了修复它,需要更改URL中的一个字符,将“#faq-17.2”替换为“#faq-17.8”。然而,Stackoverflow的软件要求像我这样声望较低的用户提交的编辑至少更改六个字符。很明显,需要修复损坏的链接,但这不是一个六个字符的修复。请问您知道我该如何修复它吗? - thb
4
在这种特定情况下,实际上不会调用他的Mutex析构函数,可能会导致pthread mutex泄漏。解决方法是使用智能指针来管理pthread mutex,最好使用boost mutexes或std::mutex,没有必要继续使用旧的功能型操作系统构造,因为有更好的替代品。 - user90843
2
@Martin York:我不确定std:: fstream是一个好的例子。是的,它确实依赖于后构造错误检查。但是它应该吗?这是一种可怕的设计,它来自一个禁止构造函数抛出异常的C++版本。 - Robin Davies

122

如果你在构造函数中抛出异常,要记得使用函数try/catch语法,如果需要在构造函数初始化列表中捕获该异常。

例如:

func::func() : foo()
{
    try {...}
    catch (...) // will NOT catch exceptions thrown from foo constructor
    { ... }
}

vs.

=>

对比

func::func()
    try : foo() {...}
    catch (...) // will catch exceptions thrown from foo constructor
    { ... }

39
需要注意的是,无法抑制从子对象构造中引发的异常:http://www.gotw.ca/gotw/066.htm - Éric Malenfant

36

抛出异常是处理构造函数失败的最佳方式。您应该特别避免半构造对象,然后依赖于类的用户通过测试某种标志变量来检测构造失败。

在相关点上,您有几种不同的异常类型来处理互斥错误的事实让我有些担忧。继承是一个很好的工具,但它可能会被过度使用。在这种情况下,我可能更喜欢一个单一的MutexError异常,可能包含一个信息丰富的错误消息。


我赞同Neil关于异常层次结构的观点 - 除非你特别想以不同方式处理锁错误,否则单个MutexError可能是更好的选择。如果你有太多的异常类型,捕获它们所有可能会变得繁琐且容易出错。 - markh44
我认同只需要一种互斥锁异常。这样还能使得错误处理更加直观。 - lkristjansen

21
#include <iostream>

class bar
{
public:
  bar()
  {
    std::cout << "bar() called" << std::endl;
  }

  ~bar()
  {
    std::cout << "~bar() called" << std::endl;

  }
};
class foo
{
public:
  foo()
    : b(new bar())
  {
    std::cout << "foo() called" << std::endl;
    throw "throw something";
  }

  ~foo()
  {
    delete b;
    std::cout << "~foo() called" << std::endl;
  }

private:
  bar *b;
};


int main(void)
{
  try {
    std::cout << "heap: new foo" << std::endl;
    foo *f = new foo();
  } catch (const char *e) {
    std::cout << "heap exception: " << e << std::endl;
  }

  try {
    std::cout << "stack: foo" << std::endl;
    foo f;
  } catch (const char *e) {
    std::cout << "stack exception: " << e << std::endl;
  }

  return 0;
}

输出结果:

heap: new foo
bar() called
foo() called
heap exception: throw something
stack: foo
bar() called
foo() called
stack exception: throw something

析构函数不会被调用,所以如果构造函数需要抛出异常,就需要进行大量的处理(例如清理操作?)。


2
非常好的观点。我很惊讶没有其他答案涉及这种类型的泄漏。 - Carlton
16
你应该使用 std::unique_ptr 或类似的智能指针。如果在构造过程中抛出异常,成员的析构函数会被调用,但是普通指针却没有这个特性。你需要将 bar* b 替换为 std::unique_ptr<bar> b(同时删除 delete b; 并添加 <memory> 头文件),然后重新运行程序。 - cbuchart
4
这种行为相当合理。如果构造函数失败(未成功完成),为什么要调用析构函数?它没有什么需要清理的内容。如果试图清理那些甚至没有正确实例化的对象(比如一些指针),会导致更多不必要的问题。 - zar
1
@zar 是的,问题不在于是否应该调用析构函数。在这个例子中,在抛出异常之前应该先清理一下。我并不是说我们不能在构造函数中抛出异常,我只是意味着开发人员应该知道他在做什么。没有好坏之分,但在做之前要考虑清楚。 - Xiaofeng
1
根据@Naveen的回答,似乎内存已经被释放了。但是valgrind --leak-check=full ./a.out报告块丢失:_ERROR SUMMARY: 2 errors from 2 contexts_。 - Xiaofeng

17

从构造函数中抛出异常是可以的,但你必须确保对象在 main 函数开始之后构建,且在其结束之前完成构建:

class A
{
public:
  A () {
    throw int ();
  }
};

A a;     // Implementation defined behaviour if exception is thrown (15.3/13)

int main ()
{
  try
  {
    // Exception for 'a' not caught here.
  }
  catch (int)
  {
  }
}

6

唯一不应该在构造函数中抛出异常的情况是,如果您的项目规定禁止使用异常(例如Google不喜欢使用异常)。在这种情况下,您不会想在构造函数中使用异常,与在其他任何地方一样,您必须使用某种init方法。


6
有趣的讨论。我个人认为,只有在实际设计程序的错误处理结构以利用它们时,你才应该使用异常。如果你在编写代码后尝试进行错误处理,或者试图将异常强行放入不适用于它们的程序中,这只会导致到处都是try/catch(消除了异常的优势)或者程序在最小错误时崩溃。我每天都要处理这两种情况,我不喜欢这样。 - Michael Kohne

5
除了所有的回答之外,我想提到一个非常具体的理由/情景,在这种情况下,您可能希望优先从类的Init方法抛出异常,而不是从构造函数(当然这是首选和更常见的方法)。
我提前说明,这个例子(情况)假设您不使用“智能指针”(即std::unique_ptr)作为类的指针数据成员。
所以,重点来了:如果您希望在捕获Init()方法抛出的异常后调用它之后,您的类的Dtor将“采取行动”,那么您必须不要从Ctor中抛出异常,因为对于“半成品”对象,不会调用Ctor的Dtor。
请看下面的示例来演示我的观点:
#include <iostream>

using namespace std;

class A
{
    public:
    A(int a)
        : m_a(a)
    {
        cout << "A::A - setting m_a to:" << m_a << endl;
    }

    ~A()
    {
        cout << "A::~A" << endl;
    }

    int m_a;
};

class B
{
public:
    B(int b)
        : m_b(b)
    {
        cout << "B::B - setting m_b to:" << m_b << endl;
    }

    ~B()
    {
        cout << "B::~B" << endl;
    }

    int m_b;
};

class C
{
public:
    C(int a, int b, const string& str)
        : m_a(nullptr)
        , m_b(nullptr)
        , m_str(str)
    {
        m_a = new A(a);
        cout << "C::C - setting m_a to a newly A object created on the heap (address):" << m_a << endl;
        if (b == 0)
        {
            throw exception("sample exception to simulate situation where m_b was not fully initialized in class C ctor");
        }

        m_b = new B(b);
        cout << "C::C - setting m_b to a newly B object created on the heap (address):" << m_b << endl;
    }

    ~C()
    {
        delete m_a;
        delete m_b;
        cout << "C::~C" << endl;
    }

    A* m_a;
    B* m_b;
    string m_str;
};

class D
{
public:
    D()
        : m_a(nullptr)
        , m_b(nullptr)
    {
        cout << "D::D" << endl;
    }

    void InitD(int a, int b)
    {
        cout << "D::InitD" << endl;
        m_a = new A(a);
        throw exception("sample exception to simulate situation where m_b was not fully initialized in class D Init() method");
        m_b = new B(b);
    }

    ~D()
    {
        delete m_a;
        delete m_b;
        cout << "D::~D" << endl;
    }

    A* m_a;
    B* m_b;
};

void item10Usage()
{
    cout << "item10Usage - start" << endl;

    // 1) invoke a normal creation of a C object - on the stack
    // Due to the fact that C's ctor throws an exception - its dtor
    // won't be invoked when we leave this scope
    {
        try
        {
            C c(1, 0, "str1");
        }
        catch (const exception& e)
        {
            cout << "item10Usage - caught an exception when trying to create a C object on the stack:" << e.what() << endl;
        }
    }

    // 2) same as in 1) for a heap based C object - the explicit call to 
    //    C's dtor (delete pc) won't have any effect
    C* pc = 0;
    try
    {
        pc = new C(1, 0, "str2");
    }
    catch (const exception& e)
    {
        cout << "item10Usage - caught an exception while trying to create a new C object on the heap:" << e.what() << endl;
        delete pc; // 2a)
    }

    // 3) Here, on the other hand, the call to delete pd will indeed 
    //    invoke D's dtor
    D* pd = new D();
    try
    {
        pd->InitD(1,0);
    }
    catch (const exception& e)
    {
        cout << "item10Usage - caught an exception while trying to init a D object:" << e.what() << endl;
        delete pd; 
    }

    cout << "\n \n item10Usage - end" << endl;
}

int main(int argc, char** argv)
{
    cout << "main - start" << endl;
    item10Usage();
    cout << "\n \n main - end" << endl;
    return 0;
}

我想再次提醒,这并不是推荐的方法,只是想分享另一种观点。
此外,正如您可能已经从代码中看到的一些打印内容所示,它基于 Scott Meyers(第一版)的杰出著作《More effective C++》中的第10项。

4
除了你的特定情况下不需要在构造函数中抛出之外,事实上pthread_mutex_lock如果你的互斥锁没有被初始化,实际上会返回一个EINVAL,并且你可以在调用lock后抛出异常,就像在std::mutex中所做的那样。
void
lock()
{
  int __e = __gthread_mutex_lock(&_M_mutex);

  // EINVAL, EAGAIN, EBUSY, EINVAL, EDEADLK(may)
  if (__e)
__throw_system_error(__e);
}

一般来说,构造函数中的抛出操作对于在构造过程中发生的获取错误是可以接受的,并且符合 RAII(资源获取即初始化)编程范式。请参考 RAII示例
void write_to_file (const std::string & message) {
    // mutex to protect file access (shared across threads)
    static std::mutex mutex;

    // lock mutex before accessing file
    std::lock_guard<std::mutex> lock(mutex);

    // try to open file
    std::ofstream file("example.txt");
    if (!file.is_open())
        throw std::runtime_error("unable to open file");

    // write message to file
    file << message << std::endl;

    // file will be closed 1st when leaving scope (regardless of exception)
    // mutex will be unlocked 2nd (from lock destructor) when leaving
    // scope (regardless of exception)
}

关注以下语句:

  1. static std::mutex mutex
  2. std::lock_guard<std::mutex> lock(mutex);
  3. std::ofstream file("example.txt");

第一条语句是 RAII 并且 noexcept。在(2)中,明显地,RAII 应用于 lock_guard 上,它实际上可以 throw,而在(3)中,ofstream 似乎不是 RAII,因为必须通过调用 is_open() 来检查对象的状态,该函数检查了 failbit 标志。

乍一看,似乎还没有确定什么是 标准方式,并且在第一种情况下,与 OP 实现相比,std::mutex 在初始化时不会抛出异常。在第二种情况下,无论从 std::mutex::lock 中抛出什么异常,它都会抛出异常,而在第三种情况下,则完全没有抛出异常。

请注意这些差异:

(1) 可以声明为静态变量,实际上将被声明为成员变量 (2) 实际上不需要声明为成员变量 (3) 应该声明为成员变量,底层资源可能并不总是可用。

所有这些形式都是RAII的;要解决这个问题,必须分析RAII

  • 资源:您的对象
  • 获取(分配):创建您的对象
  • 初始化:您的对象处于其不变状态

这并不要求您在构造时初始化和连接所有内容。例如,当您创建网络客户端对象时,实际上不会在创建时将其连接到服务器,因为这是一个速度缓慢且容易出错的操作。相反,您应该编写一个connect函数来完成这个任务。另一方面,您可以创建缓冲区或仅设置其状态。

因此,您的问题归结为定义初始状态。如果在您的情况下,您的初始状态是 必须初始化互斥锁,那么您应该从构造函数中抛出异常。相反,不初始化也可以(就像在std::mutex中所做的那样),并将不变状态定义为已创建互斥锁。无论如何,其成员对象的状态并不一定会影响其不变式,因为mutex_对象通过Mutex公共方法Mutex::lock()Mutex::unlock()lockedunlocked之间变化。
class Mutex {
private:
  int e;
  pthread_mutex_t mutex_;

public:
  Mutex(): e(0) {
  e = pthread_mutex_init(&mutex_);
  }

  void lock() {

    e = pthread_mutex_lock(&mutex_);
    if( e == EINVAL ) 
    { 
      throw MutexInitException();
    }
    else (e ) {
      throw MutexLockException();
    }
  }

  // ... the rest of your class
};

4

如果您的项目通常依赖于异常来区分好数据和坏数据,那么从构造函数中抛出异常比不抛出更好。如果不抛出异常,则对象将以僵尸状态初始化。这样的对象需要公开一个标志,指示对象是否正确。可以像这样:

class Scaler
{
    public:
        Scaler(double factor)
        {
            if (factor == 0)
            {
                _state = 0;
            }
            else
            {
                _state = 1;
                _factor = factor;
            }
        }

        double ScaleMe(double value)
        {
            if (!_state)
                throw "Invalid object state.";
            return value / _factor;
        }

        int IsValid()
        {
            return _status;
        }

    private:
        double _factor;
        int _state;

}

这种方法的问题在于调用方需要进行if判断,才能正式使用对象。这会导致出现错误 - 忘记在继续之前测试条件是再简单不过的事情了。
如果从构造函数中抛出异常,构造对象的实体应立即解决问题。下游的对象消费者可以假设他们获得的对象是100%可操作的。
这个讨论可以延伸很多方向。
例如,将异常用作验证的方式是一种不好的做法。一种方法是使用Try模式与工厂类结合使用。如果您已经在使用工厂,则编写两种方法:
class ScalerFactory
{
    public:
        Scaler CreateScaler(double factor) { ... }
        int TryCreateScaler(double factor, Scaler **scaler) { ... };
}

使用此解决方案,您可以在工厂方法的返回值中直接获取状态标志,而无需使用错误数据进入构造函数。
另一件事是,如果您正在使用自动化测试覆盖代码。在这种情况下,每个使用不会抛出异常的对象的代码片段都必须用一个额外的测试来覆盖 - 当IsValid()方法返回false时,它是否能正确地执行。这很好地解释了将对象初始化为僵尸状态是个坏主意。

1
这个问题中,将 CreateScaler 和 TryCreateScaler 设为静态方法是可行的吗? - ATL_DEV

0
请注意:在从构造函数抛出异常后,析构函数永远不会被调用
struct B
{
    char* p;
    B() { 
        cout << "Constructor - B" << endl; 
        p = new char[1024];
        throw std::exception("some exception");
    }
    ~B() { // NEVER GETS CALLED AFTER EXCEPTION !!!! - memory leak 
        cout << "Destructor - B" << endl; 
        delete[] p;
    } 
};

int main()
{
    try {
        B b;
    }
    catch (...) {
        cout << "Catch called " << endl;
    }
}

输出:

Constructor - B
Catch called       (Note: B's Destructor is NEVER called)

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