如果构造函数中抛出异常,有什么最佳实践可以防止内存泄漏?

12

我知道如果构造函数中抛出异常,析构函数将不会被调用(简单类,没有继承)。因此,如果构造函数中抛出异常并且有可能某些堆内存没有清除。那么这里的最佳实践是什么?假设我必须在构造函数中调用某个函数,并且它可能会抛出异常。在这种情况下,我是否应该始终使用共享指针?还有什么替代方案?谢谢!


@OtávioDécio,那是胡说八道。构造函数是抛出异常的唯一地方——如果初始化参数不合理,还有哪里可以抱怨呢? - Walter
@ycshao 你是指你所分配的内存还是你调用的函数所分配并且抛出的内存? - hetepeperfan
1
@Walter 我不同意。在构造函数中不抛出异常可以获得很多好处(例如,如果T的构造函数声明为noexcept(true),则std::vector<T>将使用std::move进行push_back,否则它将进行复制)。此外,抛出异常会展开堆栈,这可能太昂贵了。一些人更喜欢使用“两阶段构造函数”,其中构造函数是noexcept(true),并且通过可能或可能不会抛出异常(或以其他方式发出失败信号)的方法获取各个资源以获得好处。 - Escualo
@Arrieta 好的,我同意复制和移动构造函数应该避免抛出异常。然而,它们是从现有对象构造的,当检查参数的合理性不是问题时。关键是那些可能具有非常规值的参数的构造函数。 - Walter
@Walter 良好的设计可以通过类型检查或类似结构来避免“无意义”的值。当然,你总是可以有一个不存在的文件名或类似类型正确但仍然无意义的构造函数参数。在这种情况下,一些人更喜欢返回错误代码或类似的“成功标志”,以避免抛出异常和解开堆栈。例如,dynamic_cast<T*>可以返回空指针,而不是抛出bad_cast。我非常支持使用异常,但我真的认为构造函数不一定是抛出异常的最佳位置(它们可能会)。 - Escualo
@hetepeperfan 在构造函数中抛出异常之前,我分配了一些内存。 - ycshao
4个回答

18

我会坚持RAII惯用法。

如果你避免使用“裸露”的资源(如operator new、裸指针、裸互斥锁等),而是将所有东西都包装在一个具有适当RAII行为的容器或类中,即使存在异常,你也不会遇到描述中的问题。

也就是说,不要在构造函数中获取裸露资源。相反,创建一个对象实例,该实例本身遵循RAII。这样,即使构造函数失败(即创建实例的构造函数),已初始化的对象的析构函数也将被调用。

因此,这是一种不好的做法:

#include<iostream>
#include<stdexcept>

struct Bad {
  Bad() {
    double *x = new double;
    throw(std::runtime_error("the exception was thrown"));
  }

  ~Bad() {
    delete x;
    std::cout<<"My destructor was called"<<std::endl;
  }

  double *x;  
};

int main() {
  try {
    Bad bad;
  } catch (const std::exception &e) {
    std::cout<<"We have a leak! Let's keep going!"<<std::endl;
  }
  std::cout<<"Here I am... with a leak..."<<std::endl;
  return 0;
}

输出:

We have a leak! Let's keep going!
Here I am... with a leak...

与这个刻意且愚蠢的优秀实现相比:

#include<iostream>
#include<stdexcept>

struct Resource {

  Resource() {
    std::cout<<"Resource acquired"<<std::endl;    
  }

  ~Resource() {
    std::cout<<"Resource cleaned up"<<std::endl;        
  }

};

struct Good {
  Good() {
    std::cout<<"Acquiring resource"<<std::endl;
    Resource r;
    throw(std::runtime_error("the exception was thrown"));
  }

  ~Good() {
    std::cout<<"My destructor was called"<<std::endl;
  }  
};


int main() {
  try {
    Good good;
  } catch (const std::exception &e) {
    std::cout<<"We DO NOT have a leak! Let's keep going!"<<std::endl;
  }
  std::cout<<"Here I am... without a leak..."<<std::endl;
  return 0;
}

输出:

Acquiring resource
Resource acquired
Resource cleaned up
We DO NOT have a leak! Let's keep going!
Here I am... without a leak...

我的观点是:尝试将所有需要释放的资源封装到它们自己的类中,其中构造函数不会抛出异常,析构函数正确释放资源。然后,在可能抛出异常的其他类中,只需创建包装资源的实例,获取的资源包装器的析构函数将保证进行清理。
以下可能是一个更好的示例:
#include<mutex>
#include<iostream>
#include<stdexcept>

// a program-wide mutex
std::mutex TheMutex;

struct Bad {
  Bad() {
    std::cout<<"Attempting to get the mutex"<<std::endl;
    TheMutex.lock();
    std::cout<<"Got it! I'll give it to you in a second..."<<std::endl;
    throw(std::runtime_error("Ooops, I threw!"));
    // will never get here...
    TheMutex.unlock();
    std::cout<<"There you go! I released the mutex!"<<std::endl;    
  }  
};

struct ScopedLock {
  ScopedLock(std::mutex& mutex)
      :m_mutex(&mutex) {
    std::cout<<"Attempting to get the mutex"<<std::endl;
    m_mutex->lock();
    std::cout<<"Got it! I'll give it to you in a second..."<<std::endl;    
  }

  ~ScopedLock() {
    m_mutex->unlock();
    std::cout<<"There you go! I released the mutex!"<<std::endl;        
  }
  std::mutex* m_mutex;      
};

struct Good {
  Good() {
    ScopedLock autorelease(TheMutex);
    throw(std::runtime_error("Ooops, I threw!"));
    // will never get here
  }  
};


int main() {
  std::cout<<"Create a Good instance"<<std::endl;
  try {
    Good g;
  } catch (const std::exception& e) {
    std::cout<<e.what()<<std::endl;
  }

  std::cout<<"Now, let's create a Bad instance"<<std::endl;
  try {
    Bad b;
  } catch (const std::exception& e) {
    std::cout<<e.what()<<std::endl;
  }

  std::cout<<"Now, let's create a whatever instance"<<std::endl;
  try {
    Good g;
  } catch (const std::exception& e) {
    std::cout<<e.what()<<std::endl;
  }

  std::cout<<"I am here despite the deadlock..."<<std::endl;  
  return 0;
}

输出结果(使用gcc 4.8.1编译,使用-std=c++11选项):

Create a Good instance
Attempting to get the mutex
Got it! I'll give it to you in a second...
There you go! I released the mutex!
Ooops, I threw!
Now, let's create a Bad instance
Attempting to get the mutex
Got it! I'll give it to you in a second...
Ooops, I threw!
Now, let's create a whatever instance
Attempting to get the mutex

现在,请不要仿照我的例子创建自己的作用域守卫。C++(特别是C++11)是以RAII为设计思想并提供了丰富的生命周期管理器。例如,std::fstream会自动关闭,[std::lock_guard][2]将执行我在示例中尝试执行的操作,而std::unique_ptrstd::shared_ptr将负责销毁。

最好的建议是:阅读关于RAII的文章(并根据其进行设计),使用标准库,不要创建裸资源,并熟悉Herb Sutter关于“异常安全性”的观点(可以访问他的website,或者搜索“Herb Sutter Exception Safety”)。


4
RAII 是正确的方法,但把资源作为局部变量的情况很少见。通常你会担心那些最终将由正在构建的对象拥有的资源。 - Ben Voigt
@BenVoigt 同意。那你会建议什么呢?双阶段构造? - Escualo
1
您可以在初始化列表中初始化智能指针,使用执行分配并返回智能指针的辅助函数。或者您可以拥有一个本地智能指针,并将其交换/移动到成员变量中。但是,您提出了一个很好的资源作为本地变量的例子,其中包括互斥锁所有权范围。 - Ben Voigt
关于RAII的注意事项:该技术考虑到在C++中,可以保证在异常抛出后执行的唯一代码是驻留在堆栈上的对象的析构函数。通过包装代码并避免使用“裸露”的资源,一切都基于堆栈和这些资源在发生异常时肯定会被释放的事实。 - Nick Louloudakis
@Ben Voigt:你说得对。我猜维基上的观点是,手动分配的对象并不保证完全释放 - 实际上,存在很大的内存泄漏潜在风险,而这正是RAII试图防止的。 我们应该删除我们的评论吗? - Nick Louloudakis
显示剩余5条评论

3
通过使用标准库容器,避免在堆上分配内存(通过newnew[])。如果这不可能, 始终使用智能指针,例如std::unique_ptr<>来管理在堆上分配的内存。然后你就不需要编写释放内存的代码了,即使在构造函数中抛出异常也会自动清理内存(实际上,构造函数通常是发生异常的地方,但析构函数真的不应该抛出异常)。

3
另一种说法是要以这样的方式实现你的类,使得默认析构函数就足够了。 - jxh

1
如果你必须处理一个资源,而且你的使用情况没有被标准库中的任何实用程序处理,那么规则很简单。只处理一个资源。需要处理两个资源的任何类都应该存储两个能够处理自己的对象(即遵循RAII的对象)。作为一个简单的例子,假设你想编写一个需要动态数组和双精度浮点数的类(暂时忘记标准库),你不会做这样的事情:
class Dingbat
{
public:
    Dingbat(int s1, int s2)
    {
        size1 = s1;
        size2 = s2;
        a1 = new int[s1];
        a2 = new int[s2];
    }
    ...
private:
    int * a1;
    double * a2;
    int size1, size2;
};

上述构造函数的问题在于,如果为 a2 分配内存失败,则会抛出异常,并且 a1 的内存不会被释放。当有多个资源时,您可以使用 try catch 块来处理它,但这变得更加复杂(没有必要)。
相反,您应该编写类(或在本例中单个类模板),以正确处理单个动态数组,负责初始化自身、复制自身和处理自身的销毁。如果只调用了一次 new,则无需担心分配是否失败。将抛出异常并且无需释放任何内存。(您可能仍然希望处理它并抛出自己的自定义异常,以便更具说明性)
一旦您完成了那些类,那么您的 Dingbat 类将包括每个对象。然后,Dingbat 类就会简单得多,可能不需要任何特殊的程序来处理初始化、复制或销毁。
当然,此示例是假设的,因为上述情况已经由 std::vector 处理。但正如我所说,这是针对您可能遇到的标准库未覆盖的情况。

1
你经常可以在构造函数之前调用可能失败的函数,然后使用该函数返回的值调用构造函数。
#include <string>
#include <iostream>
#include <memory>

class Object {};

这只是我们类所需要的一些Object。它可以是一个已连接的套接字,也可以是一个绑定的套接字。当它在构造函数中尝试连接或绑定时,可能会失败。

Object only_odd( int value ) {
    if ( value % 2 == 0 )
        throw "Please use a std::exception derived exception here";
    else
        return Object();
}

这个函数返回一个对象,并在失败时抛出异常(对于每个偶数)。所以这可能是我们最初希望在析构函数中完成的内容。
class ugly {
    public:
        ugly ( int i ) {
            obj = new Object;
            try{
                *obj = only_odd( i );
            }
            catch ( ...) {
                delete obj;
                throw ( "this is why this is ugly" );
            }
        }

        ~ugly(){ delete obj; }

    private:

        Object* obj;
};

better接受可能失败并因此抛出的预构造值。因此,我们也可以从已初始化的对象构造better类。然后,我们可以在类被构造之前进行错误处理,这样我们就不必从构造函数中抛出异常。而且,它使用智能指针来处理内存,这样我们可以非常确信内存被删除了。

class better {

    public:

        better ( const Object& org ) : obj { std::make_shared<Object>(org) }
        {
        }

    private:
        /*Shared pointer will take care of destruction.*/
        std::shared_ptr<Object>  obj;
};

这可能是我们使用它的方式。
int main ( ) {
    ugly (1);

    /*if only odd where to fail it would fail allready here*/
    Object obj = only_odd(3); 
    better b(obj);

    try { /*will fail since 4 is even.*/
        ugly ( 4  );
    }
    catch ( const char* error ) {
        std::cout << error << std::endl;
    }
}

使用初始化而不是赋值。better(const Object& org) : obj{std::make_shared<Object>(org)} {} - Ben Voigt
@BenVoigt 感谢您的评论,我同意并更新了答案。 - hetepeperfan

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