一文看懂三五法则

1.前言

你是否想过这样一个问题,当我们写一个类Class的时候,一般都不怎么关注构造函数,但为什么在代码使用过程中,可以直接构造对应的类呢?这难道是传说中的黑魔法吗?其实不然,编译器在这背后做了很多不为人知的事情,简单来说就是,当你在创建一个类的时候,且代码中用到的时候,编译器会帮你自动生成对应的构造函数。当然,这个自动生成构造函数是有前提的,让我们一步一步揭开这编译器背后神秘的面纱

1.1 构造函数

我们先来看一段代码:


#include <iostream>

class Nova {

public:

    int _n;

    int _o;

};

int main()

{

    Nova nova;

    std::cout << nova._n << std::endl;

}

请问上面的代码为什么可以编译通过?我们不是没有写构造函数吗?

请问以上代码有什么问题?或者说潜在的风险?

上述代码中的类成员变量未进行初始化,这个是极其危险的行为,局部变量未初始化,直接使用,这是未定义的行为。简单来说就是你无法预知程序运行的时候会发生什么,可能直接crash,也可能是一个非法值。那么全局变量未初始化,会不会有什么问题呢,这个留给大家去思考和测试一下

接下来,我们来看第二段代码:


#include <iostream>

class Nova {

public:

    Nova(const std::string &name) :_name(name) {}

    std::string _name{ "" };

};

int main()

{

    Nova nova;

    std::cout << nova._name << "\n";

}

1、请问以上的代码有什么问题?

通过以上代码测试可以得知:仅当一个类没有声明任何构造函数的时候,编译器才会生成默认的构造函数


#include <iostream>

class Nova {

public:

    Nova() :_p(new char[4]) {}

    int _n{ 0 };

    int _o{ 0 };

    char* _p{ nullptr };

    ~Nova() { delete _p; }

};

int main()

{

    Nova nova;

    std::cout << std::hex << (void*)nova._p << "\n";

    Nova nova2 = nova;

    std::cout << nova._n << "\n";

    std::cout << std::hex << (void*)nova2._p << "\n";

}

1、请问以上代码有什么问题?或者说潜在的风险

通过以上代码分析,我们得出以下结论:

1、当我们类中没有写任何构造函数且当我们代码中需要用到的时候,编译器会隐式地帮我们定义构造函数、拷贝赋值运算符以及拷贝构造函数。

2、编译器隐式定义的拷贝赋值运算符以及拷贝构造函数都是浅拷贝(值拷贝),因此,当我们的类中出现了指针、文件句柄等成员的时候,我们需要自己定义相应的拷贝赋值运算符、拷贝构造函数和析构函数。浅拷贝,最终导致内存释放的时候double free,导致程序崩溃或者其他未定义行为。

3、当申请内存是一段连续的内存的时候,释放内存需要使用delete[]来释放,否则会造成内存泄漏.

1.2 析构函数

接下来,我们探讨一下前面所说的析构函数,当我们定义了析构函数又会出现什么情况,且在继承关系中的析构函数又应该注意些什么?我们先来看一段代码:


#include <iostream>

class Base {

public:

    Base() {

        std::cout << "Base()" << "\n";

    }

    virtual void say() = 0;

    ~Base() {

        std::cout << "~Base()" << "\n";

    }

};

class Sub :public Base {

public:

    Sub() {

        std::cout << "Sub()" << "\n";

    }

    ~Sub() {

        std::cout << "~Sub()" << "\n";

    }

    void say()override {

        std::cout << "i am Sub" << "\n";

    }

};



int main() {

    Sub *s = new Sub();

    s->say();

    delete s;

    std::cout << "--------------------基类指针指向子类对象---------------" << "\n";

    Base* s1 = new Sub();

    s1->say();

    delete s1;

    return 0;

}

请问以上的代码存在什么样的问题?应该如何修改?

结论:C++中基类采用virtual虚析构函数是为了防止内存泄漏。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。

所以为了防止内存泄漏这种情况的发生,C++中基类的析构函数应该是public且virtual的

接下来,我们看看析构函数对其他构造函数的影响,先看一段代码:


#include <iostream>

class Nova {

public:

    Nova(const std::string& name) :_name(name) {}

    Nova() = default;

    ~Nova() = default;

    std::string _name{ "" };

};

int main()

{

    Nova nova;

    Nova nova2 = std::move(nova);

    std::cout << nova._name << "\n";

}

请问,上面代码中是调用了拷贝赋值运算符还是移动赋值运算符?

答案是,调用了拷贝赋值运算符,当显示定义了析构函数之后,编译器便不会为我们生成移动相关构造函数了。我们可以看看cppinsight里的代码,进一步验证我们的结论:


#include <iostream>

class Nova

{



  public:

  inline Nova(const std::basic_string<char> & name)

  : _name{std::basic_string<char>(name)}

  {

  }



  inline constexpr Nova() noexcept(false) = default;

  inline ~Nova() noexcept = default;

  std::basic_string<char> _name;

  // inline Nova(const Nova &) noexcept(false) = default;

};




int main()

{

  Nova nova = Nova();

  Nova nova2 = Nova(static_cast<const Nova &&>(std::move(nova)));

  std::operator<<(std::operator<<(std::cout, nova._name), "\n");

  return 0;

}



可以看到,编译器默认生成的是拷贝构造函数。你也可以定义一下拷贝构造函数来再进一步的验证这个结论。

除此之外,我们可以看到,当我们使用std::move将类型转换成右值的时候,尝试调用的移动构造函数,实际上调用的是拷贝构造函数。这里有个小tips:std::move 实际上是一个static_cast(&&)类型强转,编译器在进行类型匹配的时候,如果无法进行移动相关操作,会自动匹配拷贝相关操作。

1.3 三法则

至此,我们引出三法则:如果某个类需要用户定义的析构函数、用户定义的复制构造函数或用户定义的复制赋值运算符,那么它几乎肯定需要全部三者。(摘自cppreference)

这里还有一个历史遗留问题,理论上,当你需要自定义析构函数的时候,那么拷贝相关的函数编译器就不应该自动生成的,如果生成了那必然是错误的,但是由于在C++11之前并没有这样的认识和规定,所以如果编译器加上了这一限制会导致很多历史代码无法通过编译(摘自Effective Modern C++ 条款17 P108)

1.4 移动构造函数

我们先来看看移动构造函数和移动赋值操作符的声明:


#include <iostream>

class Nova {

public:

    Nova() = default;

    Nova(const Nova&) = default;

    Nova& operator=(const Nova&) = default;

    Nova(Nova&&)noexcept = default;

    Nova& operator=(Nova&&)noexcept = default;

    ~Nova()noexcept =default;

private:

    int _n{ 0 };

    int _o{ 0 };

};

可以看到移动相关函数多了一个关键字noexcept,这个关键字声明了之后,表明在该函数体内不会抛出异常,如果抛出了异常,则会直接调用std::teminiate来结束程序的运行

通过上面的测试,我们已经知道了自定义的析构函数会让编译器不会自动生成默认的移动相关函数,拷贝构造函数、拷贝赋值运算符也有同样的效果。

除此之外,我们还需要知道一个就是 拷贝赋值运算符和拷贝构造函数是相互独立的,一个的生成不会影响另外一个编译器生成隐式定义;而移动构造函数和移动赋值运算符不是相互独立的,一个声明了,编译器就不会生成另外一个的隐式定义。

由以上可以得出

1.5 五法则

因为用户定义(或被声明为 = default 或 = delete)的析构函数、复制构造函数或复制赋值运算符的存在,会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数。

番外

接下来,我们来探讨多态类的拷贝切片问题。多态类是定义或继承了至少一个虚函数的类见 https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#c67-a-polymorphic-class-should-suppress-public-copymove

请看代码:


#include <iostream>

#include <string>



using namespace std;



// 基类

class Animal {

protected:

    string name{"none"};

    int age{0};

public:

    // 构造函数

    Animal() = default;

    Animal(string _name, int _age) : name(_name), age(_age) {}



    // 虚析构函数

    virtual ~Animal() =default;



    // 成员函数

    virtual void speak() const {

        cout << "I am an animal." << "\n";

    }

};

// 子类

class Dog : public Animal {

private:

    string breed{"taidi"};

public:

    // 构造函数

    Dog() = default;

    Dog(string _name, int _age, string _breed) : Animal(_name, _age), breed(_breed) {}



    // 重写基类的成员函数

    void speak() const override {

        cout << "Woof! I am a dog named " << name << " of breed " << breed << "." << "\n";

    }

};



int main() {

    // 创建基类对象

    Animal *animal = new Dog("Buddy", 3, "Golden Retriever");

    // 释放内存

    delete animal;

    Dog d;

    Animal b = d;

    b.speak();

    return 0;

}

那么如果我真的需要对多态类进行深拷贝,那应该怎么办呢?在《c++ core guidelines》中的C.130明确指出 若要对多态类进行深拷贝,应使用虚函数clone,而不是公开的拷贝构造/赋值

请看代码:


#include <iostream>

#include <string>



using namespace std;



// 基类

class Animal {

protected:

    Animal(const Animal&) = default;

    Animal& operator=(const Animal&) = default;

    string name{ "none" };

    int age{ 0 };

public:

    // 构造函数

    Animal() = default;

    Animal(string _name, int _age) : name(_name), age(_age) {}

    virtual Animal* clone() {

        return new Animal(*this);

    }

    // 虚析构函数

    virtual ~Animal() = default;



    // 成员函数

    virtual void speak() const {

        cout << "I am an animal." << "\n";

    }

};

// 子类

class Dog : public Animal {

private:

    Dog(const Dog&) = default;

    Dog& operator=(const Dog&) = default;

    string breed{ "taidi" };

public:

    // 构造函数

    Dog() = default;

    Dog(string _name, int _age, string _breed) : Animal(_name, _age), breed(_breed) {}

    Animal* clone()override {

        return new Dog(*this);

    }

    // 重写基类的成员函数

    void speak() const override {

        cout << "Woof! I am a dog named " << name << " of breed " << breed << "." << "\n";

    }

};



int main() {

    // 创建基类对象

    Animal* animal = new Dog("Buddy", 3, "Golden Retriever");

    // 释放内存

    delete animal;

    Dog d("hhh",100,"sss");

    auto b = d.clone();

    Animal *b1 = d.clone();

    b->speak();

    b1->speak();

    return 0;

}

  • 25
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值