《Effective C++》《构造/析构/赋值运算——11、在operator=中处理“自我赋值”》

1、Terms11:Handle assignment to self in operator=

“自我赋值”发生在对象被赋值给自己时:

class Widget{};
 
Widget w;
w = w;  //自我赋值

这看起来有点蠢,但它合法,所以不要认定客户绝不会这样做。此外赋值动作并不总是那么可被一眼辨识出来,例如:

a[i] = a[j];

如果 a 是一个数组,且索引i和j相等,那么也是一个潜在的自我赋值。

*px = *py;

如果这两个指针px和py指向于同一块内存,那么也是一个潜在的自我赋值。
这些并不明显的自我赋值,是“别名”带来的结果:所谓“别名”就是有一个以上的方法(指涉)某对象。

类中自我赋值问题及如何解决

假设你建立一个类Widget,用来保存一个指针指向一块动态分配的位图(bitmap):

class Bitmap { ... };
class Widget {
	...
private:
	Bitmap* pb; // 指针,指向一个从 heap 分配而得的对象
};

下面是operator=的实现代码,表面上看起来合理,但自我赋值出现时并不安全(也不具备异常安全性)

class Bitmap {};
class Widget {
public:
    //赋值运算符
    Widget&
    Widget::operator=(const Widget& rhs) // 一份不安全的 operator= 实现版本
    {
        delete pb;
        pb = new Bitmap(*rhs.pb); //是使用rhs's bitmap的副本
        return *this;
    }
private:
    Bitmap* pb;		       //指针,指向一个从heap分配而得的对象
};

这里自我赋值的问题是,operator=函数内的 *this (赋值的目的端)和 rhs 有可能是同一个对象,果真如此 delete 就不只是销毁当前对象的 bitmap,它也销毁 rhs 的 bitmap。在函数末尾,Wigdet——它原本不该被自我赋值动作改变的——发现自己持有一个指针指向一个已被删除的对象!
现在来分析这个运算符如果出现自我赋值而产生的错误:

如果参数rhs传入的就是自身,那么当pb被释放之后,下面再次new的时候又将参数(自己)的pb指针所指的内容传入进去,但是pb的内容已经被释放了,因此再次使用到这个对象的时候就会产生不确定的行为。

自我赋值问题解决:

想要阻止这种错误,做法是在赋值运算符最前面的一个 “认同测试” 达到“自我赋值”的检验目的:
  根据上面“自我赋值”而产生的错误,我们应该在赋值运算符函数的第一步判断传入的对象是否为自己,如果为自己的话做相应的处理

Widget& Widget::operator=(const Widget& rhs)
{
    //判断是否为“自我赋值”
    //注意,此处的&为取地址
    if (this == &rhs)//测试
        return *this;
 
    //其他的与上面介绍的一样
    delete pb;
    pb = new Bitmap(*rhs.pb); //以参数为副本调用拷贝构造函数重新创建
    return *this;
}

异常处理问题解决

上面介绍的operator=虽然解决了“自我赋值”检测,但是不是“异常安全的”。例如在new操作符执行时跑出了异常(内存不足或因为Bitmap类的拷贝构造函数抛出异常),最终Widget对象会只有一个一块已被删除的Bitmap,因此代码是不安全的。
我们对上面代码进行优化:

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap *pOrig = pb; 	    //记住原先的pb
    pb = new Bitmap(*rhs.pb);   //以参数为副本让pb重新创建一个对象
    delete pOrig;  				//删除原先的pb
    
    return *this;
}

这段代码可以来处理异常:如果new时抛出了异常,此时我们的pb对象还没有删除
  这段代码还可以来处理“自我赋值”:我们对原bitmap做了一份复制、删除原bitmap,然后将pb再指向于复制的那一份。这个虽然不是处理“自我赋值”最高效的办法,但是行得通。
  关于效率:此处为什么我们不在代码最前面进行“对象是否为自己”的检测了:此处我们的代码已经可以处理自我赋值了,如果还添加那种“自我检测”的代码,会使代码增多并多了一个语句判断,会使执行速度降低。
  
可编译的代码示例:

#include <iostream>  
  
class Bitmap {  
public:  
    // Bitmap的构造函数  
    Bitmap() {  
        std::cout << "Bitmap constructor called\n";  
        // 初始化Bitmap对象的代码(例如分配内存等)  
    }  
  
    // Bitmap的拷贝构造函数  
    Bitmap(const Bitmap& other) {  
        std::cout << "Bitmap copy constructor called\n";  
        // 拷贝初始化Bitmap对象的代码  
    }  
  
    // Bitmap的析构函数  
    ~Bitmap() {  
        std::cout << "Bitmap destructor called\n";  
        // 清理Bitmap对象的代码(例如释放内存等)  
    }  
  
    // 可能还需要其他的成员函数和数据成员  

};  
  
class Widget {  
public:  
    // 构造函数  
    Widget() : pb(new Bitmap()) {  
        std::cout << "Widget constructor called\n";  
    }  
  
    // 析构函数  
    ~Widget() {  
        std::cout << "Widget destructor called\n";  
        delete pb; // 释放pb指向的内存  
    }  
  
    // 拷贝构造函数  
    Widget(const Widget& other) : pb(new Bitmap(*other.pb)) {  
        std::cout << "Widget copy constructor called\n";  
    }  
  
    // 赋值运算符  
    Widget& operator=(const Widget& rhs){
        //检查自我赋值
        if(this == &rhs)    return *this;
        
        Bitmap *pOrig = pb; 	    //记住原先的pb
        pb = new Bitmap(*rhs.pb);   //以参数为副本让pb重新创建一个对象
        delete pOrig;               //删除原先的pb
        
        return *this;
    }
    
    // 可能还需要其他的成员函数和数据成员  
    const Bitmap* getPb() const {  
        return pb;  
    }  
  
private:  
    Bitmap* pb; // 指向动态分配的Bitmap对象的指针  
};  
  
int main() {  
    // 创建两个Widget对象  
    Widget widget1;  
    Widget widget2;  
  
    // 输出当前widget1和widget2的pb指针指向的Bitmap对象的地址  
    std::cout << "widget1 pb address: " << widget1.getPb() << std::endl;  
    std::cout << "widget2 pb address: " << widget2.getPb() << std::endl;  
  
    // 使用赋值运算符将widget2赋值给widget1  
    widget1 = widget2;  
  
    // 输出赋值后widget1的pb指针指向的Bitmap对象的地址  
    // 它应该与widget2的pb指针指向的地址不同(因为创建了新的Bitmap对象)  
    std::cout << "widget1 pb address after assignment: " << widget1.getPb() << std::endl;  
  
    // 释放Widget对象时,它们的析构函数会自动释放Bitmap对象  
    // 因此,我们不需要在main函数的最后手动删除pb  
  
    return 0;  
}

输出结果:

Bitmap constructor called
Widget constructor called
Bitmap constructor called
Widget constructor called
widget1 pb address: 0x55f6914baeb0
widget2 pb address: 0x55f6914bb2e0
Bitmap copy constructor called
Bitmap destructor called
widget1 pb address after assignment: 0x55f6914bb300
Widget destructor called
Bitmap destructor called
Widget destructor called
Bitmap destructor called

使用“copy and swap”技术来处理自我赋值

替换上面的所有办法,我们可以使用“copy and swap”技术来解决“自我赋值”以及“异常处理”
  copy and swap技术和“异常安全性”有密切关系,会在条款29详细讲述
手法1:

class Bitmap {};
class Widget {
public:
    void swap(Widget& rhs); //将参数rhs与*this进行数据交换,详情见条款29
    Widget& Widget::operator=(const Widget& rhs)
    {
        Widget temp(rhs);//以函数参数为参数调用Wiget的拷贝构造函数创建一个对象
        //不能将rhs直接传入swap,因为这样的话会改变=号后面对象的内容,因此上面需要创建一个临时对象temp
        swap(temp);      //交换参数所指的对象与*this
        return *this;
    }
private:
    Bitmap* pb;
};

手法2:
  实现这种技术的手法还有一种是以“传值调用”operator=,实现如下:
  这种技术手法实现的功能与上面的一样,但是代码没有那么清晰
  但是这种手法将“拷贝”动作从函数体内移动到了“函数参数构造阶段”,因此效率提高了。

class Bitmap {};
class Widget {
public:
    void swap(Widget& rhs); //将参数rhs与*this进行数据交换
    //rhs是被传对象的一份副本,这样的话我们就不用在operator=为参数创建一个临时对象了
    Widget& Widget::operator=(Widget rhs)
    {
        swap(rhs); //将传入的副本与*this进行数据替换
        return *this;
    }
private:
    Bitmap* pb;
};

补充说明(copy and swap技术):

在C++中,copy and swap技术是一种用于实现赋值操作符(operator=)的通用且安全的方法。这种技术的主要目的是简化赋值操作的实现,同时避免潜在的错误和异常安全问题,特别是在涉及动态内存分配和复杂资源管理的类中。
copy and swap技术的基本思路如下:
(1)拷贝源对象:首先,在目标对象内部创建一个源对象的副本。这通常通过调用拷贝构造函数或使用其他复制机制来完成。
(2)交换资源:然后,使用std::swap或其他交换机制来交换目标对象与刚刚创建的副本之间的资源。这包括所有动态分配的内存、句柄、指针等。
(3)销毁副本:最后,当副本对象离开作用域时,其析构函数会自动清理原本属于目标对象的资源。
通过这种方法,赋值操作被分解为两个相对简单的步骤:拷贝和交换。这使得代码更加清晰和易于理解,同时也减少了出错的可能性。此外,由于交换操作通常是异常安全的(即不会抛出异常),因此整个赋值过程也变得更加健壮。

总的来说:

  • 类的拷贝赋值操作符可能被声明“以 by value 方式接受实参”;
  • 以传值方式传递东西会生成一份副本;

2、面试相关

在C++中,处理operator=中的“自我赋值”是一个重要的问题,因为它涉及到资源管理和避免不必要的操作。以下是几个与operator=中的“自我赋值”相关的高频面试题目:

2.1 什么是自我赋值?为什么它是个问题?

  • 自我赋值指的是对象尝试将自己赋值给自己,即obj = obj。这通常不是一个预期的操作,而且在没有正确处理的情况下,可能会导致资源泄漏或程序崩溃。

2.2 在重载赋值操作符时,如何避免自我赋值问题?

  • 一个常见的策略是在赋值前检查源对象(即赋值操作符右侧的对象)和目标对象(即赋值操作符左侧的对象)是否相同。这通常通过比较它们的地址来实现。

2.3 如果不处理自我赋值,可能会发生什么?

  • 如果不处理自我赋值,那么在尝试释放和重新分配对象的资源时(如动态分配的内存),可能会遇到问题。例如,释放同一块内存两次会导致未定义行为,或者尝试删除空指针也可能导致问题。

2.4 在赋值操作符中,如何处理深拷贝和浅拷贝?

  • 深拷贝会创建源对象内容的新副本,而浅拷贝则只复制指针或引用。在处理动态分配资源的对象时,通常需要实现深拷贝来避免多个对象共享同一份资源的问题。

2.5请提供一个示例,展示如何在赋值操作符中正确处理自我赋值和资源管理。

  • 示例可能包括检查源对象和目标对象是否相同,只有在它们不同时才执行资源释放和重新分配的操作。同时,可能需要考虑异常安全性,确保在发生异常时资源仍然得到正确管理。

示例代码:

#include <iostream>  
#include <algorithm> // 用于 std::swap  
  
class MyResource {  
public:  
    MyResource() {  
        std::cout << "Allocating resource\n";  
        // 假设这里进行了某些资源的动态分配  
    }  
      
    ~MyResource() {  
        std::cout << "Deleting resource\n";  
        // 假设这里释放了之前分配的资源  
    }  
      
    // 假设这里还有其他管理资源的成员函数...  
};  
  
class MyClass {  
private:  
    MyResource* resource;  
  
public:  
    MyClass() : resource(new MyResource()) {}  
    ~MyClass() { delete resource; }  
  
    // 拷贝构造函数和拷贝赋值操作符(为了完整性)  
    MyClass(const MyClass& other) : resource(new MyResource(*other.resource)) {}  
  
    // 重载赋值操作符,正确处理自我赋值  
    MyClass& operator=(const MyClass& other) {  
        if (this != &other) { // 检查是否为自我赋值  
            // 释放当前对象的资源  
            delete resource;  
  
            // 分配新的资源  
            resource = new MyResource(*other.resource);  
        }  
          
        return *this;  
    }  
  
    // ... 其他成员函数 ...  
};  
  
int main() {  
    MyClass obj1;  
    MyClass obj2;  
      
    // 正常的赋值操作  
    obj1 = obj2;  
      
    // 自我赋值测试  
    obj1 = obj1; // 应该安全执行,不导致任何问题  
      
    return 0;  
}

3、总结

天堂有路你不走,地狱无门你自来。

4、参考

4.1 《Effective C++》

  • 21
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值