构造与析构:C++对象背后的生死较量

开发
C++编译器就像是一位贴心的管家,当你只写了一个析构函数时,它会默默地为你准备好所有需要的"礼物" !

在C++的奇妙世界里,构造函数和析构函数就像是一对可爱的舞台搭档 - 构造函数负责热情地喊出"欢迎光临!",而析构函数则优雅地说着"后会有期~"。它们就像是照看对象的尽职保姆 ,从出生到离别的每一刻都不离不弃,默默守护着对象的整个生命周期。这对搭档虽然经常"斗嘴" ,但却配合得天衣无缝,为我们的程序演绎着最动人的代码故事。

默认构造函数的神奇魔法

你知道吗?C++编译器就像是一位贴心的管家 🫅,当你只写了一个析构函数时,它会默默地为你准备好所有需要的"礼物" !这些礼物包括默认构造函数、拷贝构造函数、移动构造函数(C++11的新玩具 ),以及它们的赋值运算符小伙伴们。

来看看这个有趣的派对场景:

class Party {
public:
    ~Party() { /* 收拾派对现场 */ }  // 你只负责打扫就好

    // 以下函数由编译器自动生成
    Party();                          // 默认构造函数
    Party(const Party&);              // 拷贝构造函数
    Party(Party&&);                   // 移动构造函数
    Party& operator=(const Party&);   // 拷贝赋值运算符
    Party& operator=(Party&&);        // 移动赋值运算符
};

// 瞧瞧管家为我们准备的这些精彩玩法 🎪
Party p1;                    // 开启新派对!🎈
Party p2(p1);               // 复制一个一模一样的派对 👯
Party p3 = std::move(p1);   // 把派对搬到新地方 🚚
p2 = p3;                    // 把派对方案复制一份 📝
p2 = std::move(p3);         // 派对场地大转移 🏃

有趣的是,我们的管家还很节俭呢!如果你没用到某个功能,比如从没搬过派对场地,管家就不会为移动构造函数操心。这就是所谓的"按需服务",多贴心啊!

默认构造函数的神奇魔法

你一定会好奇,为什么C++要这么贴心地帮我们准备这些默认函数呢?这就像是准备一场完美派对 - 当你说"我要收拾派对现场"(定义析构函数)的时候,C++就会想:"哎呀,既然要收拾,那一定是开过派对的吧!" 

所以它会自动帮你准备好开派对的所有必需品(默认构造函数),复制派对方案的工具(拷贝构造函数),甚至还有搬家用的箱子(移动构造函数)。这些都是为了确保我们的对象能够快乐地诞生 、成长、搬家,最后优雅地说再见 。

这就像是一个全套的生命服务,缺一不可 。因为在C++的世界里,有始就要有终,有终就必须有始,这是一个完整的生命周期呀!

所以,尽管你只定义了析构函数,C++依然会为你生成一个默认构造函数,确保你的Party对象能够顺利地被创建。就像一个无声的英雄,默默地为你的代码保驾护航。

总之,C++的构造函数和析构函数就像是派对的开场和谢幕,虽然你可能只关注了谢幕,但开场的精彩同样不容错过!

虚析构函数 - 继承体系中的安全卫士

在继承关系中,析构函数是否声明为虚函数变得尤为重要。让我们通过一个小例子来看看为什么需要虚析构函数:

class Animal {
public:
    ~Animal() { 
        std::cout << "再见,动物!" << std::endl; 
    }
};

class Dog : public Animal {
public:
    ~Dog() { 
        std::cout << "再见,小狗!" << std::endl; 
    }
};

int main() {
    Animal* pet = new Dog();  // 通过基类指针指向派生类对象 🐾
    delete pet;               // 糟糕!只会调用 Animal 的析构函数 😱
}

在上面的例子中,delete pet 只会调用Animal 的析构函数,而不会调用Dog 的析构函数。这会导致Dog 类中可能存在的资源没有被释放,从而引发内存泄漏。

让我们来修复这个问题:

class Animal {
public:
    virtual ~Animal() {  // 添加 virtual 关键字 ✨
        std::cout << "再见,动物!" << std::endl; 
    }
};

class Dog : public Animal {
public:
    ~Dog() override {    // 使用 override 更清晰 🐶
        std::cout << "再见,小狗!" << std::endl; 
    }
};

int main() {
    Animal* pet = new Dog();
    delete pet;  // 现在会正确调用 Dog 的析构函数,然后是 Animal 的析构函数 🎉
}

通过将Animal 的析构函数声明为虚函数,delete pet 会首先调用Dog 的析构函数,然后调用Animal 的析构函数,确保所有资源都被正确释放。这样就不会有内存泄漏的问题啦!

为什么需要虚析构函数? 

在继承关系中,使用基类指针指向派生类对象时,如果基类的析构函数不是虚函数,删除该指针时只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中分配的资源没有被正确释放,从而引发内存泄漏。💥

析构顺序的秘密

你可能会问:"为什么声明为虚函数后,会依次调用 Dog 和 Animal 的析构函数呢?不是已经重写了吗?" 让我们来揭开这个秘密:

class Animal {
protected:
    int* animalResource;  // 基类的资源 🏷️
public:
    Animal() { animalResource = new int(1); }
    virtual ~Animal() { 
        delete animalResource;
        std::cout << "再见,动物!" << std::endl; 
    }
};

class Dog : public Animal {
private:
    int* dogResource;    // 派生类的资源 🎈
public:
    Dog() { dogResource = new int(2); }
    ~Dog() override { 
        delete dogResource;
        std::cout << "再见,小狗!" << std::endl; 
    }
};

这是因为在 C++ 中,派生类对象的析构过程遵循特定的顺序:

  • 首先调用派生类(Dog)的析构函数
  • 然后自动调用基类(Animal)的析构函数

这个过程是自动且必然的,原因如下:

(1) 内存布局:Dog 对象不仅包含自己的成员(dogResource),还包含从 Animal 继承来的所有成员(animalResource)

(2) 资源清理:

  • Dog 的析构函数负责清理 Dog 特有的资源
  • Animal 的析构函数负责清理继承来的资源
  • 如果不调用基类的析构函数,基类的资源就会泄露

(3) 执行顺序:就像盖房子和拆房子

  • 盖房子时是从下往上(先构造基类,再构造派生类)
  • 拆房子时是从上往下(先析构派生类,再析构基类)

所以当我们执行:

Animal* pet = new Dog();
delete pet;

输出会是:

再见,小狗!    // 先清理 Dog 的资源
再见,动物!    // 再清理 Animal 的资源

这不是普通的函数重写,而是 C++ 特有的析构机制,确保对象的完整清理。就像拆房子必须从顶层开始拆一样,析构也必须从派生类开始,层层向下进行!

普通函数重写 vs 析构函数

让我们来对比一下普通虚函数的重写和析构函数的区别:

class Animal {
public:
    // 普通虚函数
    virtual void speak() {
        std::cout << "动物在说话" << std::endl;
    }
    
    // 析构函数
    virtual ~Animal() {
        std::cout << "再见,动物!" << std::endl;
    }
};

class Dog : public Animal {
public:
    // 普通函数重写 - 只会调用这个版本
    void speak() override {
        std::cout << "汪汪汪!" << std::endl;
    }
    
    // 析构函数 - 会调用这个,然后自动调用基类版本
    ~Dog() override {
        std::cout << "再见,小狗!" << std::endl;
    }
};

int main() {
    Animal* pet = new Dog();
    
    pet->speak();    // 输出:汪汪汪!
    delete pet;      // 输出:再见,小狗! 再见,动物!
}
  • 普通函数重写:完全替换基类的版本,只会执行派生类的实现
  • 析构函数:是一个特殊的过程,会依次执行派生类和基类的析构函数

这种区别的设计是有意义的:

  • 普通函数重写:我们希望完全替换掉基类的行为
  • 析构函数:我们需要清理整个继承链上的所有资源,不能遗漏

性能考虑

添加虚析构函数会带来一些开销:

  • 每个对象都会多一个虚函数表指针(vptr) 
  • 类的大小会增加(通常是一个指针的大小) 
  • 虚函数调用比普通函数调用稍慢 

但是相比于内存泄漏的风险,这点开销是值得的!

最佳实践 

  • 如果你的类将被继承,请将析构函数声明为虚函数 
  • 如果你的类不会被继承,则不需要虚析构函数
  • 在声明虚析构函数时,建议使用override 关键字(C++11及以后) 

通过遵循这些最佳实践,你的代码将更加健壮,避免不必要的内存泄漏问题。

责任编辑:赵宁宁 来源: everystep
相关推荐

2011-06-15 09:47:14

C++

2009-08-14 17:24:28

C#构造函数和析构函数

2009-09-03 13:14:55

C#构造函数C#析构函数

2011-07-15 01:29:39

C++析构函数

2010-02-04 16:39:26

C++析构函数

2010-01-18 15:53:27

C++析构函数

2009-07-30 15:24:13

C#析构函数C#构造函数

2010-02-05 13:35:19

C++虚析构函数

2010-07-20 09:52:27

Perl构造函数

2021-01-17 16:29:51

C++Python语言

2010-01-27 10:13:22

C++类对象

2024-04-28 11:01:27

C++编程语言函数

2009-09-02 10:49:46

C#调用析构方法

2011-07-20 13:40:09

拷贝构造函数

2009-08-13 17:30:30

C#构造函数

2012-08-15 13:31:02

笔试题

2010-01-27 17:16:52

C++构造函数

2015-05-25 10:52:49

2010-01-14 09:27:44

C++语言

2023-12-04 10:57:52

函数C++
点赞
收藏

51CTO技术栈公众号