返回

解决C++隐式operator=导致的崩溃: COM对象管理实战

windows

解决隐式声明的operator=导致的App崩溃

程序崩溃,并指向隐式声明的 operator= 运算符,这是一个让人困惑的问题。尽管代码中并没有直接调用赋值运算符,但崩溃仍然发生在 TestImageEncoder::operator= 中,这通常意味着某个地方发生了隐式复制,理解这一点至关重要。 这种隐式调用源于C++中的对象拷贝机制,即当你需要对对象进行复制或者以传值方式传递时,默认会触发编译器生成的隐式复制行为。 这会导致我们没有显式声明或者实现这个operator,但系统又会帮我们自动合成一个。 当隐式生成的赋值运算符尝试复制未妥善管理的资源(如动态分配的内存或 COM 对象)时,往往就会导致程序崩溃。

原因分析

问题关键在于TestImageEncoder类中管理着 IWICImagingFactory 对象,这个 COM 对象是通过 CoCreateInstance 创建,并通过 Release 来释放资源。类中使用了默认的拷贝构造函数和赋值运算符,这些隐式生成的函数只是简单地复制指针 mpFactory。如果在不同 TestImageEncoder 对象中同时释放同一资源,就会导致 double-free 或是内存访问错误。
以下情况可能会触发隐式operator= 调用:

  • 按值传递 : 当 TestImageEncoder 实例以值方式传递给函数时。
  • 复制构造 : 使用一个TestImageEncoder对象来初始化另一个对象时。
  • 临时对象赋值 : 返回的临时对象赋值给新的对象时,或隐式的构造新对象然后复制给目标对象。

由于TestProcess 中定义局部对象,且是在循环里不停实例化, 理论上在每个for循环时都会新创建一个实例,然后生命周期结束后释放, 但不排除在某些异常情况下会有上述三种隐式operator=的情况。

解决方案

解决此问题的关键在于控制 TestImageEncoder 对象的复制行为。可以采用以下几种策略:

禁用复制和赋值

最简单的策略就是禁用复制构造函数和赋值运算符。这种做法明确避免了任何隐式拷贝操作,强制使用者使用引用或者指针的方式传递对象。这在某些情况下能够有效的阻止由于复制操作导致的潜在崩溃风险。

代码示例:

TestImageEncoder.h中声明禁用:

class TestImageEncoder
{
private:
    IWICImagingFactory* mpFactory;
    
    // 禁用拷贝构造函数和赋值运算符
    TestImageEncoder(const TestImageEncoder& other) = delete;
    TestImageEncoder& operator=(const TestImageEncoder& other) = delete;

public:
    TestImageEncoder();
    virtual ~TestImageEncoder();
     //...
};

操作步骤:

  1. TestImageEncoder类中,声明并使用 = delete 禁用拷贝构造函数和赋值运算符。
  2. 重新编译应用程序,确保错误消除。
  3. 观察运行情况。这种做法在一定程度上会避免程序由于对象拷贝而崩溃的情况,但是也要修改调用方的代码。

自定义复制和赋值操作

如果需要在 TestImageEncoder 类之间进行复制,则必须提供自定义的拷贝构造函数和赋值运算符。 这种方案虽然复杂一些,但是提供对对象拷贝过程的精细控制,需要手动处理COM对象的引用计数和资源复制。 这里,主要的核心在于需要手动去管理引用的生命周期,保证每一个对象都能够正确释放各自的资源,而不会重复释放。

代码示例:
TestImageEncoder.h 中添加:

 class TestImageEncoder
{
private:
    IWICImagingFactory* mpFactory;

public:
     TestImageEncoder();
    TestImageEncoder(const TestImageEncoder& other);   // 自定义拷贝构造函数
    TestImageEncoder& operator=(const TestImageEncoder& other);  // 自定义赋值运算符
    virtual ~TestImageEncoder();
  // ...
};

TestImageEncoder.cpp 中实现自定义拷贝和赋值:

TestImageEncoder::TestImageEncoder(const TestImageEncoder& other) : mpFactory(nullptr) {
    Initialize();  
    if(other.mpFactory){
         other.mpFactory->AddRef();
        mpFactory= other.mpFactory;  //增加引用
    }

}

TestImageEncoder& TestImageEncoder::operator=(const TestImageEncoder& other) {
        if (this != &other) {
         if(mpFactory){
            mpFactory->Release();   //先释放自身
            mpFactory=nullptr;
         }
       
         if(other.mpFactory)
         {
             other.mpFactory->AddRef();   //复制的同时要加引用,因为这个资源又被另外一个对象持有了
              mpFactory= other.mpFactory;
         }
    }
     return *this;
}

操作步骤:

  1. TestImageEncoder类中声明拷贝构造函数和赋值运算符。
  2. 实现这两个方法,确保对 mpFactory 对象进行正确的引用计数管理(AddRefRelease)。
  3. 编译并运行程序,验证解决方案。
    • 增加AddRef,保持引用计数正确,当析构时释放引用。
    • 赋值前,先释放原有资源,然后新引用传入资源的引用。
    • 进行自我检查,防止 a = a 时出现问题

使用智能指针管理COM资源

更好的方式是使用智能指针来管理COM对象的生命周期。 使用 Microsoft::WRL::ComPtr 是一种推荐方法,它能够自动管理COM对象的引用计数,极大的简化了资源管理过程,并且更安全高效。 这种做法可以减少代码量和错误,建议多采用。

代码示例:

TestImageEncoder.h 中修改:

#include <wrl/client.h>  // 添加头文件
 class TestImageEncoder
{
private:
    Microsoft::WRL::ComPtr<IWICImagingFactory> mpFactory;  // 使用智能指针

public:
    TestImageEncoder();
    virtual ~TestImageEncoder();
   //...
};

TestImageEncoder.cpp 中修改:

TestImageEncoder::TestImageEncoder()
{
     // Initialize COM
    HRESULT hr;
    hr = CoInitialize( NULL );
    if ( FAILED( hr ) )
    {
        _RPTN( _CRT_WARN, "Failed CoInitialize(): %d\n", hr );
       return;
    }
   
    hr = CoCreateInstance( CLSID_WICImagingFactory, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS( &mpFactory ) );
    if ( FAILED( hr ) )
    {
       _RPTN( _CRT_WARN, "Failed to create a WIC imaging factory: %d\n", hr );
    }
}

TestImageEncoder::~TestImageEncoder()
{
     CoUninitialize();
}

操作步骤:

  1. 引入 wrl/client.h 头文件, 并将 IWICImagingFactory* 成员替换为 Microsoft::WRL::ComPtr<IWICImagingFactory>
  2. 构造函数和析构函数中的资源管理相关代码都可以移除,交给ComPtr 自动管理
  3. 编译并运行程序。
    • 这种方法简化了COM资源的生命周期管理, 资源管理更加安全。
      

代码审查和额外安全建议

除了上述的直接修复方法, 还有必要检查 TestProcess 的代码是否存在其它潜在的线程安全和内存安全问题。同时确保每个地方都进行了恰当的初始化、释放,特别是关于资源的使用,例如CoInitialize, CoUninitialize, 以及其他 COM 对象的引用。 如果是在多线程环境下, 还需要使用正确的线程同步机制。

此外, 建议开启编译器的代码静态分析功能和代码审查流程, 尽可能在编码早期就发现和纠正错误,这将能够更好的提高软件的稳定性和质量。 养成良好的编码习惯至关重要, 可以通过检查资源分配,复制构造,对象生命周期等,进一步降低系统崩溃的风险。