云计算百科
云计算领域专业知识百科平台

C++类与对象完整学习路线(中):如何理解默认成员函数?

内容导航

前序       

一、构造函数

二、析构函数

三、最抽象的默认成员函数——拷贝构造函数


前序       

 大家好,我是小沐。上一篇我们弄懂了类的定义、对象实例化和this指针的底层原理。但是我们创建出来的对象如果不手动初始化,成员变量里面就会全是垃圾值。

思考几个问题:

1.能不能让对象被创建出来的瞬间自动完成初始化?

2.对象销毁的时候如果在堆上申请了申请了资源,能否让它自动释放,避免内存泄漏?

        答案是:当然!

        这就是C++6大成员函数中的构造函数和析构函数。

一、构造函数

        构造函数虽然名称叫构造,但是主要任务并不是开辟空间创建对象,而是对象实例化过程中进行初始化。

构造函数的特点:

        1.函数名与类名相同。

        2.无返回值。(C++规定:返回值不用给,显式定义时也不需要void)

        3.构造函数可以重载。

        5. 如果类中没有显式定义构造函数,C++编译器会自动默认生成一个无参的默认构造函数;如果显式定义了,C++编译器就不再自动生成默认构造函数。       

        我们来看这段代码

#include <iostream>
using namespace std;

class Date
{
public:
Date(int year = 2026 , int month = 8, int day = 4)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}

private:
int _year;
int _month;
int _day;
};

int main()
{
Date d;
d.Print();

return 0;
}

        在这里面,我们并没有手动调用Date(int year = 2026 , int month = 8, int day = 4) ,按照C语言的习惯,这段代码应该会打印出随机的垃圾值,在这里到底是什么样的?我们来看结果

        显然,过程中它已经自动帮我们调用了构造函数对变量进行默认初始化。如果我们不想要默认初始化的值想手动初始化怎么办?那么C++也给我们提供了这样的初始化方式

        这样的初始化方式显然是比C语言方便了很多。

上面特点有提到,析构函数可以重载

问:当出现默认函数重载时,编译器默认会怎么处理?

class Date
{
public:
Date(int year)
{
_year = 1;
_month = 1;
_day = 1;
}
Date()
{
_year = 2;
_month = 2;
_day = 2;
}

void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}

private:
int _year;
int _month;
int _day;
};

int main()
{
Date d;
d.Print();
return 0;
}

        答案:和正常的函数重载一样,只要访问不存在歧义,编译器就会自动根据参数调用相应的构造函数。

总结:无参构造函数、全缺省构造函数虽然构成函数重载,但在调用时会存在歧义。

再问:

        1.不是说,默认构造函数可以会自动生成吗,那是不是只要我们没有显示定义构造函数,都会自动生成呢?

        2.自动生成的默认构造函数都能满足我们的初始化要求吗?

我们来看这段代码,思考一下它的输出结果。

class Date
{
public:
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};

int main()
{
Date d;
d.Print();
return 0;
}

        根据C语言的习惯,我们并没有对它进行手动初始化,也没有手动写初始化函数,那输出结果应该是随机的垃圾值才对。

        没错,结果就是一串垃圾值,这时候应该有个疑问——不是说没有显式写会默认生成构造函数帮我们进行初始化吗?这里也没有初始化啊,它是不是没有生成构造函数呢?

        实际上编译器确实生成了默认构造函数。但是这个合成的默认构造,不会对内置类型成员做初始化,我们访问到的自然就是一个垃圾值。

        注:这里要分清一件事情。语法层面,这个默认构造确实是生成了;但它不会帮你初始化内置类型的成员。另外,因为这个啥活都不干,编译器还可以进一步优化,直接删掉它对应的机器指令,导致反汇编靠不到调用构造的call。

        既然内置类型不会帮我们进行初始化,那我们来看这一段自定义类型的,究竟有没有帮我们进行初始化。

typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(n * sizeof(STDataType));
if (_a == nullptr)
{
perror("Stack::malloc");
return;
}
_capacity = n;
_top = 0;
}
private:
STDataType* _a;
STDataType _capacity;
STDataType _top;
};

//两个栈实现队列
class MyQueue
{
public:
//
private:
Stack phusST;
Stack popST;
};

int main()
{
MyQueue mq;
return 0;
}

        我们通过调试来观察,如果成构造函数干活了,那么肯定已经自动帮我们进行初始化。

        可以看到,在我们没有在MyQuee手写构造函数的情况下,它也自动完成了初始化。

        也就是说,自动默认构造函数确实存在,但是对内置类型的行为不确定,

编译器默认生成的函数行为不确定,大部分情况下不满足我们的要求,所以我们对构造函数的行为应当是:应写尽写

二、析构函数

        析构函数和构造函数恰好相反,析构函数的在对象被销毁时会被自动调用。

分清两个东西:

        1.析构函数并不是对对象本身进行销毁,因为对象是建立在栈里面的,程序结束时会自动回收栈帧。

        2.析构函数的主要作用是在于对象被销毁时对开辟的堆区资源进行清理释放。

强调:析构函数是对在堆区开辟的堆资源进行清理释放,不是销毁对象,对象的空间在栈区

        那么我们就清楚了,析构函数就像我们栈和队列里面的Destroy函数一样。

析构函数的特点:

        1.析构函数名是在类名的前面加上字符 ~

        2.无参数返回值(在定义时也不需要加void)

        3.一个类只能有一个析构函数。若未显示定义,系统会自动生成默认的析构函数。

        4.对象声明周期结束时会自动调用析构函数。(注意不是程序结束时才会调用)

        5.跟构造函数一样,编译器自动生成的析构函数不对内置类型成员做处理,自定义类型会自动调用他们的析构函数。

        6.如果类中没有申请资源,析构函数可以不写;如果在堆上申请了资源时,需要我们手写析构函数,否则会造成内存泄漏。

        我们依然用栈的例子来理解这几个特点。

class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(n * sizeof(STDataType));
if (_a == nullptr)
{
perror("Stack::malloc");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack" << endl;
free(_a);
_top = 0;
_capacity = 0;
}
private:
STDataType* _a;
STDataType _capacity;
STDataType _top;
};

        由于我们自定义类型申请了堆资源,就要有手动写的析构函数

~Stack()     {         cout << "~Stack" << endl;         free(_a);         _top = 0;         _capacity = 0;     }

        其实_top和_capacity是否置0都没有影响,因为这两个是位于栈区,程序结束时都会自动回收,但为了养成良好习惯,我们都会手动置0。

在上面的代码基础上补充下列代码

class MyQueue
{
public:
//
private:
Stack phusST;
Stack popST;
};

int main()
{
MyQueue mq;
return 0;
}

问:程序运行到哪时会调用析构函数?

        让我们通过反汇编来观察

        注意看我画圈的地方,我们看到在执行到retrun 0 时执行的指令中有一条是

call MyQueue::~MyQueue (09A1532h)

        这时候有的对前面的特点模模糊糊的同学可能懵了,我们没有写这个函数啊,这从哪里的,我们写的是

class MyQueue
{
public:
//

private:
Stack phusST;
Stack popST;
};

而析构函数我们是手动写在了Stack啊

public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(n * sizeof(STDataType));
if (_a == nullptr)
{
perror("Stack::malloc");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack" << endl;
free(_a);
_top = 0;
_capacity = 0;
}

private:
STDataType* _a;
STDataType _capacity;
STDataType _top;
};

        怎么会调用了这么个玩意?

        OK,前面的特点有一点说:对象声明周期结束时会自动调用析构函数。

请问:这里面的mq是不是一个对象?mq队列里面的Stack phusST和 Stack popST是不是一个对象?

        对吧,既然是,那么在mq声明周期结束时,就会自动调用它对应的析构函数,而这个析构函数我们没有显式写,系统就会自动生成mq的析构函数调用。我们继续往下调试

        由于我们已经从重新开始调试,导致~MyQueue后面的地址发生变化,但是不影响本质逻辑。当执行到call MyQueue::~MyQueue 就会跳转到它后面显示的地址,到这里你就会惊奇的发现我们手写的析构函数式是在这里,然后

        分别入栈了两次(调用了两次),刚好对应两次调用,析构函数,堆两个栈进行释放清理,但是这里连续出现的

Stack::~Stack

后面跟的地址有两个,很多小白可能就有点懵;确实能看出它调用了两次Stack::~Stack ,但是并不明白为什么会连着出现两个不同的地址。

09A19A0h是Stack::~Stack析构函数本身的地址,而09A1541是链接MyQueue::~MyQueue和Stack::~Stack的中转地址,关系如图

        有了析构函数之后,不会再忘记调用Destroy函数了,更大程度上规避了内存泄漏问题。

三、最抽象的默认成员函数——拷贝构造函数

        在理解这个函数之前,请你把这条规则多念几遍,一定要放在心上,否则将难于理解拷贝构造函数。

C++规定:所有函数在传值传参时都会触发拷贝构造函数的调用;拷贝函数的调用又等于一次传值传参。

触发时机:调用函数之后,跳进被调用函数之前。在函数栈上,构造形参,用obj做拷贝构造,把副本造出来。

        我们先来细品,拷贝函数的定义

定义:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,此构造函数就叫做拷贝构造函数。

        “此构造函数就叫做拷贝构造函数”,也就是说拷贝构造函数是一种特殊的构造函数啊,又因为它们两个同时存在,那么拷贝构造函数就是一个构造函数的一个重载。

拷贝函数的特点:

1.拷贝构造函数是构造函数的一个重载。

2.拷贝构造函数的第一个参数必须是类类型对象的引用。

3.C++规定自定义类型对象进行拷贝行为调用拷贝构造。

4.若未显示定义构造拷贝,编译器会自动生成拷贝构造对内置类型变量完成值拷贝。对内置类型会进行浅拷贝,对自定义类型成员变量调用其它的拷贝构造。

5.传值返回会产生一个临时对象调用拷贝构造;如果是传值引用返回,返回的是引用对象的别名,没有产生拷贝。(有坑)

        好,现在我们来进行灵魂拷问 

——问1:下面这段代码手有拷贝构造函数吗?    

class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << endl;
cout << _month << endl;
cout << _day << endl;
}
private:
int _year;
int _month;
int _day;

};

问2:下面这段代码的编译结果是什么?

class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day
}
void Print()
{
cout << _year << endl;
cout << _month << endl;
cout << _day << endl;
}
private:
int _year;
int _month;
int _day;

};

int main()
{
Date d;
Date d2(d);
return 0;
}

答案:

        1.问题1中的代码没有手写拷贝构造,那个是普通的构造函数。

        2.问题2中的代码编译报错。

        对于问题1:

上面我们一再抢强调拷贝构造函数的第一个参数必须是类类型对象的引用。我们再看问题1的第一个参数是什么? 如果你的答案是int year = 1 ,那么恭喜你,你的this指针部分得回炉重造了。我们都知道成员函数里面都有一个隐含的参数——this指针,默认它作为成员函数的第一个参数。它在形参里面是这样定义的

Date(Date* const this, int year = 1, int month = 1, int day = 1)

        虽然它不能在形参和实参部分显式写出来,但它式真的存在的。既然第一个参数式 Date* const this ,那么它就不是拷贝构造函数啊。

        对于问题2:

我们一点一点来拆解代码,首先当代码运行到这一行时

d2(d);

因为前面d在定义式会自动调用构造函数完成初始化,那到了这一行,就是把d作为实参传到拷贝构造函数以完成d2的初始化,那么这里就出问题了;我们再回顾刚刚让你记住的规则

C++规定:所有函数在传值传参时都会触发拷贝构造函数的调用;拷贝函数的调用又等于一次传值传参。

触发时机:调用函数之后,跳进被调用函数之前。在函数栈上,构造形参,用obj做拷贝构造,把副本造出来。

        发现没有,这里刚好就是传值传参操作啊。那么过程中就要为d拷贝构造一个临时变量吧,为d拷贝构造一个临时变量就需要调用拷贝构造,调用拷贝构造就需要进行传值传参…..

停!!!

        发现问题没有,好像开始死循环了呀,你至始至终都在拷贝构造一个临时变量和传值传参中循环,无限递归了呀!!!

        就像这样,卡在调用Date(Date d)和进入Date(Date d)之间,无限递归。

我总结为:想做,但又临时起义去做另一件事,就这样无限切换。好比左右脑互搏一样。

因此,祖师爷本贾尼也想到了这一点,就规定——拷贝构造函数的第一个参数必须是类类型对象的引用。像上面例子中的写法就是非法的,正确写法如下

class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d) //必须加上引用,const建议加上避免写错带来的麻烦
{
_year = d._year;
_month = d._month;
_day = d._day
}
void Print()
{
cout << _year << endl;
cout << _month << endl;
cout << _day << endl;
}
private:
int _year;
int _month;
int _day;

};

int main()
{
Date d;
Date d2(d);
return 0;
}

        解了这里,拷贝构造基本也就掌握70%了。我们继续来看一个比较偏但又容易犯的错误。

        temp是一个局部的变量,当函数调用return时,Date&就相当于是对temp取别名,可是当函数结束后,栈帧被系统收回,就出现了相当于野指针一样的也引用了。


本篇结束!!你在学习这部分的时候有没有踩过什么坑?评论区聊聊,下一篇我们将从赋值运算符重载继续分享~  


赞(0)
未经允许不得转载:网硕互联帮助中心 » C++类与对象完整学习路线(中):如何理解默认成员函数?
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!