探究C++构造函数及其优化

一、 类的六个默认成员函数

1.1 框架图

在这里插入图片描述

1.2 具体介绍

重点关注前四个默认成员函数,对于最后两个即对普通对象取地址和对const普通对象取地址用编译器默认生成的即可。

(1)构造函数

作用: 构造函数可以帮助我们完成类的初始化的工作,如对成员变量进行赋值,开辟空间等。

特性: 当我们没有自己写构造函数时,编译器将默认生成一个构造函数,该构造函数无需参数。

该构造函数将具有以下功能:

  1. 对于类内的自定义类型,会调用他们的默认构造函数;对于内置类型不会进行初始化。
  • 自定义类型:如class, struct
  • 内置类型:语言原生定义的类型,如int, char, double,指针…,

补充:对默认构造函数的全面认识
默认构造函数并不仅仅指代我们不写,编译器自动生成的构造函数。事实上有三个默认构造函数,对默认构造函数更准确的理解为:不用参数就可以调用的构造函数。

三个默认构造函数分别为:

  1. 自己写的无参构造函数
  2. 自己写的全缺省构造函数
  3. 我们没写编译器默认生成的构造函数

以上三个都可以认为是默认构造函数。除了我们没写编译器默认生成的构造函数之外,无参的构造函数和全缺省的构造函数也是默认构造函数。注意:以上三个默认构造函数同时只能存在一个。
综上:写构造函数最好写一个全缺省的,这样既是默认构造函数,又可以传递参数进行构造。

(2)析构函数

作用: 完成资源清理的工作。备注:析构函数不是完成对象的销毁,对象的销毁是编译器完成的,当对象超出生命周期将由编译器进行销毁对象,而对象在销毁时会自动调用析构函数。

特性: 当我们没有自己写析构函数时,编译器将默认生成一个析构函数。编译器自动生成的析构函数,会调用自定义类型成员的析构函数,而对内置类型成员不进行操作。

补充:构造和析构顺序
因为对象是定义在函数中,函数调用会建立栈帧,栈帧中的构造和析构函数符合先进后出。即析构顺序和构造顺序是反着的。

(3)拷贝构造函数

作用: 用于将一个已存在的对象拷贝创建一个新对象。如果没有定义任何拷贝构造函数,编译器将生成一个默认的拷贝构造函数。默认的拷贝构造函数执行浅拷贝,即复制对象的所有成员变量。

特性: 当我们自己没有写拷贝构造函数时,编译器将默认生成一个拷贝构造函数。对于对象的自定义类型,会去调它的拷贝构造函数,对于对象的内置类型成员变量,则进行浅拷贝。

注意:

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。

(4)赋值运算符重载函数

作用: 用于将一个已存在的对象的内容赋值给另一个已经存在的对象。

特性: 当我们没有自己写赋值运算符时,编译器将生成一个默认的赋值运算符。它对内置类型进行浅拷贝,对于自定义类型会调用它们赋值运算符重载函数。

注意:

  1. 赋值运算符重载的返回值是该类对象的引用,目的是支持连续赋值。
  2. 赋值运算符重载的参数只有一个,推荐使用引用传参,使用传值方式可以避免调用拷贝构造。
  3. 若自己实现该函数,需要确保赋值操作不会出现对象的自我复制(即对象不能等于本身)。

初始化和赋值的区分
赋值运算符重载只能用于已经存在的对象,而初始化则是在创建对象时进行的。示例代码如下:

class MyClass {
public:
    MyClass(int n = 0) : num(n) {}
private:
    int num;
};

int main() {
    MyClass obj1(1);    // 构造函数
    MyClass obj2(2);    // 构造函数
    MyClass obj3(obj1); // 拷贝构造函数  
    obj2 = obj1;        // 赋值运算符重载
    return 0;
}

归纳我们不写,编译器默认生成了什么:

  1. 无参构造函数
  2. 拷贝构造函数
  3. 赋值运算符函数
  4. 默认析构函数

二、深入学习初始化列表

要先要明确一个结论:

初始化列表是每一个成员变量定义的地方

这个结论将贯穿这一章节的内容。

2.1 作用

构造函数初始化有两种方式

  1. 函数体内初始化(有些情况下发生的是赋值)
  2. 初始化列表初始化

为什么要有初始化列表的方式,因为当类中包含以下成员,它们必须在定义的时候初始化。

  1. 引用成员变量
  2. const成员变量
  3. 没有默认构造函数的自定义类型成员

代码如下:

class MyClass2 {
public:
    MyClass2(const int i) : m_i(i) {}
private:
    int m_i;
};

class MyClass {
public:
    MyClass(int n, int& ref, const int c) : m_ref(ref), m_c(c), m_myClass2(10)//correct
    {
        m_num = n;//correct
        // 以下初始化方式都是错误的,不能在函数体内初始化
        /*m_ref = ref;
        m_c = c;
        m_myClass2(10);*/
    }
private:
    int m_num;
    int& m_ref;
    const int m_c;
    MyClass2 m_myClass2;
};

理解为什么有些变量的初始化必须在初始化列表中完成
首先要再次强调一个结论:初始化列表是每一个成员变量定义的地方

  • 对于const变量,引用都必须在定义的时候初始化,而初始化列表就是定义的地方,因此要在初始化列表那初始化
    • 初始化列表对成员变量做的事情叫做定义,而构造函数体内对成员变量做的事情就叫做赋值
  • 对于自定义类型
    • 假设它无需参数可完成初始化,那么初始化列表处自动调用默认构造函数即可完成对象的定义,无需在函数体内初始化
    • 假设它需要参数初始化且没写在初始化列表中,那么若该类没有默认构造函数(即不需要参数的构造函数),在初始化列表处自动调用默认构造函数时就会报错。即是提供了默认构造函数使得不报错,则此时调用了一次默认构造函数,在函数体内还需先定义一个新对象,再赋值给目标对象,使得函数体内初始化比初始化列表多调用一次构造函数和赋值函数。
class MyClass2 {
public:
    MyClass2(const int i) : m_i(i) {}
private:
    int m_i;
};

class MyClass {
public:
    MyClass(int n) : m_myClass2(10) // 使用初始化列表,调用一次构造函数
    {
        // 不使用初始化列表,等于 调用一次构造函数 + operator=
        /*MyClass2 tmp(10);
        m_myClass2 = tmp;*/
    }
private:
    MyClass2 m_myClass2;
};

2.2 关于初始化顺序

成员变量在在初始化列表中的初始化顺序就是在类中的声明次序,与其在初始化列表中的先后次序无关
建议:类中成员的声明次序应该与初始化列表中的初始先后顺序写的一致

三、单参数的构造函数支持隐式类型转换

单参数的构造函数可以隐式地将参数转换为对象;如果单参数的构造函数声明为 explicit(显式),则必须显式地调用该构造函数才能将参数转换为对象。
假设我们有以下类定义:

class Person {
public:
    Person(int age) : m_age(age) {}
private:
    int m_age;
};

在没有explict关键字修饰下,支持隐式类型转换

int main() {
    Person p = 18; // 单参数的构造函数支持隐式类型转换
    return 0;
}

实际编译器背后会用18构造一个无名对象Person(18),最后用无名对象给p对象进行赋值。
当上述代码中的构造函数被声明为 explicit,则无法进行隐式类型转换,即无法通过(对象=参数)来赋值。这意味着,以下代码将无法通过编译:

class Person {
public:
    explicit Person(int age) : m_age(age) {}
private:
    int m_age;
};

int main() {
    Person p = 18; // 隐式类型转换,编译错误
}

单参数的构造函数的赋值模式的应用,价值

  1. string类生成对象可直接等号
  2. vector类无需先创建对象,再push对象,可直接push参数进行单参数隐式类型转换构造对象,写起来方便。

代码示例:

int main() {
    vector<string> vec;
    string s1 = "Jack"; // string类生成对象可直接等号
    vec.push_back(s1); // 先创建对象再push麻烦
    vec.push_back("Mike"); // 直接push方便,本质是因为支持单参数的隐式类型转换
}

a. 对于“Jack”,先调用string的有参构造函数,再被引用
b. 对于“Mike”,参数被直接创建临时对象,该临时对象被const引用

四、C++11 的成员初始化新特性

C++11支持非静态成员变量在声明时进行初始化赋值。注意这里不是初始化,这里是给声明的成员变量缺省值

class MyClass2 {
private:
    int m_i;
};

class MyClass {
private:
    int num = 10; // 给缺省值
    MyClass2 m_myclass2 = 20;
};

五、编译器的三个优化场景

前提:
设已经有一个Date类,代码如下
Date类

  1. 第一个优化场景传参场景:当在函数调用传一个匿名对象(临时对象)过去,再用这个对象拷贝给形参对象,编译器可能会优化,将这两个对象合二为一,构造出一个对象。说明如下:
    传参优化
    可以看到,main函数调用func时传递匿名对象,理论上应该此处调用构造函数,然后该匿名对象拷贝给func函数形参d,触发一次拷贝构造函数。但实际上这个过程只有一次构造函数,所以实参和形参的对象用的是同一个,编译器优化了。即出现了传参场景下的优化。

  2. 第二个优化场景单参数的构造函数支持隐式类型转换

    先看现象:

第二个优化场景
在上面的代码中,Date d=3理论上应该先用3定义个临时对象Date tmp(3),再用tmp拷贝构造dDate d(tmp),但是运行结果并没有调用拷贝构造,原因是现在的的编译器会优化,直接用3调用构造函数,相当于Date d(3)。更准确地说,Date d=3先用3定义个匿名对象 Date(3),再用该匿名对象拷贝构造d。此时优化变为第一个场景,故没有出现拷贝构造函数被调用。

  1. 第三个优化场景返回值场景

以下是优化场景演示:

第三个优化场景
构造后马上去拷贝构造或者拷贝构造再马上拷贝构造,编译器可能会优化。
在一个表达式中,连续多个构造函数(包括无参,有参,拷贝构造),可能会被编译器优化为一次构造。如上图自己的代码,当func函数返回时,理论上是先用d拷贝一个临时对象,再把临时对象拷贝给dd,共两次拷贝构造函数,即蓝色箭头逻辑。但实际上只发生了一次拷贝,即红色箭头逻辑,dd直接用d进行拷贝构造。

补充:拷贝构造出的对象马上赋值给已存在的对象不能被优化,如下图,d还是先拷贝构造生成tmptmp赋值给dd

分析
总结:如果编译器要优化,只有构造和拷贝构造才会被优化合并,且需在表达式中(函数参数传递和返回值返回也视作连续的表达式),优化掉的是临时对象或匿名对象。更多细节见《深度探索C++对象模型》

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值