书接上回说 类与对象(上)
目录
有这样一道面试题: 给出下面这一段代码请问这段代码的运行结果是什么?
A 编译错误 B运行崩溃 C 正常运行
class A
{
public:
void Print()
{
cout << _a << endl;
cout << "Print()" << endl;
}
// private:
int _a;
};
int main()
{
A* p = nullptr;
// 成员函数的地址不在对象中
// 成员变量是存在对象中
p->Print();
return 0;
}
许多同学乍一看p是nullptr,然后p->Print()对p这个空指针进行了解引用,所以这个程序的运行结果是运行崩溃。这种想法其实是错误的。那么究竟为什么呢?
我们来转到反汇编看一看
p->Print call了一下A::Print这个成员函数,但是我们知道的是成员函数的地址并不存放在对象中,而是存放在了公共代码段,相当于没有对p进行解引用,直接在公共代码段找到了Print函数,所以这段代码会正常运行。
这里还有一道类似的题目:那么下面这个程序的运行结果又是什么呢?
class A
{
public:
void Print()
{
cout << _a << endl;
}
// private:
int _a;
};
int main()
{
A* p = nullptr;
// 成员函数的地址不在对象中
// 成员变量是存在对象中
p->_a=1;
return 0;
}
同上题,我们先转到反汇编看一看
与成员函数不同的是,成员变量是存储在对象中的。
在上面cout时相当于cout<<this->_a<<<endl; 这里的this是一个空指针,那么此时就会运行崩溃
1.类的六个默认成员函数
2.构造函数
在之前数据结构的学习栈的过程中,我们极有可能未对栈进行初始化就对栈进行 删除插入等操作,此时编译器会崩溃,我们也十分的崩溃,那么有没有什么好的方法,来避免这种情况,使得我们不需要初始化,在调用某个函数时自动进行初始化呢?C++中的构造函数很好的解决了这个问题
构造函数概念与特性
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
下面是构造函数的特征:
1. 函数名与类名相同。
2. 无返回值。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。5.如果类中没有显示定义构造函数,则c++编译器会自动生成一个无参的默认构造函数,一旦用户显示定义后编译器将不再自动生成。
下面讲讲几个在使用构造函数时容易出错的情况
1.不传参的情况
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
d1直接不传参数直接全缺省调用了构造函数,那为什么这里Date d1后面不加括号呢?
原因是Date d1(); 与函数声明冲突了,混淆了,编译器不知道这是构造函数的调用还是某个函数的调用
构造函数重载
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
这个时候两个构造函数 构成了函数重载,但是不能够同时存在,因为无参调用的时候会发生歧义,编译器此时也会报错
默认构造函数
#include<iostream>
using namespace std;
class Date
{
public:
//Date()
//{
// _year = 1;
// _month = 1;
// _day = 1;
//}
//Date(int year = 1, int month = 1, int day = 1)
//{
//_year = year;
//_month = month;
//_day = day;
//}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
class Stack
{
public:
/*Stack(size_t capacity = 3)
{
cout << "Stack(size_t capacity = 3)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
}
_capacity = capacity;
_top = 0;
}*/
private:
int* _a;
int _capacity;
int _top;
};
// 两个栈实现一个队列
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
int _size = 1;
};
int main()
{
Date d1;
d1.Print();
Stack st1;
MyQueue mq;
return 0;
}
首先我们来观察一下Date,Stack和MyQueue的成员的类型。
Date的三个私有成员的类型都是int类型的,Stack的三个私有成员有两个是int类型的,一个是int*类型的,而MyQueue的三个私有成员函数一个是int类型的另外两个是Stack类型的。
上面这三个类,只有MyQueue的成员有自定义类型,其余都是内置类型的。
此时我们将构造函数注释掉,观察一下默认构造函数对哪些类型的成员起作用。
我们可以观察到下图,在这里d1和st1的成员都没有进行初始化,全部都是随机值,而mq中的两个自定义类型的成员进行了初始化,需要注意的是这里的mq中的_size的值被编译器编译成了1,但是默认构造函数是没有对这里的_size起初始化作用的,这里显示他为1也只是编译器的一个不太合理的优化罢了
所以我们得到了默认构造函数的一个特点:内置成员不做处理(其实是不同编译器的处理方式不同,我们将他当作不处理),是自定义类型的话会去调用它的默认构造函数
what‘s more,Date *也是一个内置类型,这是一个指针类型
关于构造函数,我们一般都需要自己去写,成员全都是自定义类型或者声明时给了缺省值的,可以考虑让编译器自己调用它的构造函数。
3.析构函数
就像上面构造函数中说的,我们经常会忘记对类进行初始化,同样的,我们也有可能忘记释放类的成员所开辟的空间,C++的编译器会帮助我们处理很多东西,其中也包括释放类的成员开辟的空间,而实现这个功能的函数叫做析构函数
析构函数的特性:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
// Date严格来说,不需要写析构函数
cout << "~Date()" << endl;
}
private:
// C++11支持,声明时给缺省值
int _year = 1;
int _month = 1;
int _day = 1;
};
class Stack
{
public:
Stack(size_t capacity = 3)
{
cout << "Stack(size_t capacity = 3)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
}
_capacity = capacity;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_capacity = _top = 0;
_a = nullptr;
}
private:
int* _a;
int _capacity;
int _top;
};
class MyQueue
{
// 默认生成析构函数,行为跟构造类似
// 内置类型成员不做处理
// 自定义类型成员会去调用他的析构
private:
Stack _pushst;
Stack _popst;
int _size = 1;
};
int main()
{
//Date d1;
//Stack st1;
MyQueue mq;
return 0;
}
与构造函数类似,析构函数也有以下的特点:
默认生成的析构函数,行为跟构造类似
内置类型成员不做处理
自定义类型成员会去调用它的默认析构函数
那关于默认生成的构造函数会去进行一些什么操作呢?
class Time
{
public:
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
程序运行结束后输出:~Time()
在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month,
_day三个是
内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以在
d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数
中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函
数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time
类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁
main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数
注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
4.拷贝构造函数
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错
因为会引发无穷递归调用。
C++规定,自定义类型对象传参拷贝,必须调用拷贝构造
void func(Date d1)
{
d1.printf;
}
上面这个方式叫做浅拷贝也叫做值拷贝。这种方法对于成员函数都是内置类型的对象的影响不是很大,但是如果一个对象的成员中有自定义类型的话那么就会报错,那么这是什么原因呢?
void func2(Stack st)
{
//...
}
int main()
{
Stack st1;
func2(st1);
return 0;
}
我们用st1拷贝了st的内容,其中st的_a和st1的_a都指向同一片空间在出了作用域之后st会去调用析构函数,会把_a这片空间释放掉,当st1出作用域之后,st1也会调用析构函数,_a这片空间会再一次被释放,_a指向的空间被释放了两次,第一次释放之后_a就变成了野指针,再对他进行释放操作,编译器就会报错。那我们如何解决问题:
自定义类型对象拷贝的时候,调用一个函数,这个函数就叫拷贝构造。
那么我们就开始写Stack类的拷贝构造函数了:
Date d2(d1);
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
但是,编译器这个时候又开始发力了,没错,编译器又开始报错了,那这究竟是为什么呢?
原因是这样的写法引发了无限递归!!!
我们陷入了这样的一个循环:
调用拷贝构造->传参->传值传参->在形成新的拷贝构造->调用拷贝构造......
下面是传值时,编译器发生无限递归报错的图示:
承接拷贝构造函数的特性中的第二条, 拷贝构造函数的参数只有一个且必须是类类型对象的引用,否则就会引发无限递归。最后拷贝构造函数的写法是这样的
Date d2(const &d1);
Date (Date& dd)
{
_year =dd.year;
_month=dd.month;
_day=dd.day;
}
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& dd)
{
_year = dd._year;
_month = dd._month;
_day = dd._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 10, 22);
func1(d1);
Date d2(d1);
Stack st1;
func2(st1);
Stack st2(st1);
return 0;
}
Date的成员调用拷贝构造函数的过程如下图:
dd是d1的别名 this指针指向了d。但是这种调用方式有一些麻烦,我们可以这样写
这里dd是d1的别名,this指针指向d2。
Stack类的拷贝构造函数这样写:
Stack(const Stack& stt)
{
cout << " Stack(Stack& stt)" << endl;
// 深拷贝
_a = (int*)malloc(sizeof(int) * stt._capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, stt._a, sizeof(int) * stt._top);
_top = stt._top;
_capacity = stt._capacity;
}
默认拷贝构造函数
编译器自动生成的默认拷贝构造函数会自动对内置类型进行值拷贝,对自定义类型会去调用它的成员的默认拷贝构造函数
我们将主函数改成如下,对应的Stack的拷贝构造函数不变
class MyQueue
{
Stack _pushst;
Stack _popst;
int _size = 0;
};//使用两个栈实现一个队列
int main()
{
MyQueue q1;
MyQueue q2(q1);
return 0;
}
我们没有写自定义类型MyQueue的拷贝构造函数但是我们发现还是可以拷贝构造成功,原因是编译器帮我们调用了MyQueue的成员的拷贝构造函数。
所以我们得到了一下的一些结论:
1、内置类型成员编译器会完成值拷贝
2、自定义类型成员调用这个成员的拷贝构造
stack需要自己写拷贝构造,完成深拷贝。
顺序表、链表、二叉树等等的类,都需要深拷贝3.需要直接开空间的对象一般都需要深拷贝,但是MyQueue类是通过Stack类开辟的空间,所以MyQueue类不需要自己去写拷贝构造函数。
至于传参数时,前面为什么要用const修饰,我们来观察下面这段代码对比
Date(Date& dd)
{
_year=dd.year;
_month=dd.month;
_day=dd.day;
}
Date(Date& dd)
{
dd.year=_year;
dd.month=_month;
dd.day=_day;
}//错误的
我们发现如果是拷贝的时候,我们把赋值的顺序搞反了,这个时候编译器不会报错,但是拷贝的结果会都是随机数,拷贝出错了,所以我们在传的参数前加上const修饰,防止这样的情况发生。
5.运算符重载operator
对于一个自定义类型,我们如果想比较他们的大小该怎么比较呢?编译器中的库函数是不具有这样的能力的,但是我们可以使用opereator运算符重载,需要注意的是:
不能通过连接其他符号来创建新的操作符,比如:operator@
重载操作符必须有一个类型参数
用于内置类型的运算符,其含义不能改变,例如operator+的含义应该与+的含义相同
作为类成员函数时,其形参看起来比操作数目少1,因为成员函数的第一个参数为隐藏的this
.* :: sizeof ?:(三目运算符) . 这五个运算符不能重载
预备知识:
我们将运算符重载写在了类外面,但是写在了类外面又要面临一个问题:在类外我们无法访问到类中的私有成员,友元函数也许是一个解决方法,但是c++中一般会选择将运算符重载写在类中,放在类中的时候我们发现operator会报错:
原因是,类中的成员都会有一个隐藏的this指针,实际的参数会比传的参数多一个。
那么我们可以这样写:
某些重载符传参数时 传的是const修饰的 引用,原因是:Date x会去调用拷贝构造,如果数据比较多的话空间的开销还是比较大的,所以我们在这里选择传引用,而且不对传过来的日期修改,所以传const
为了方便这里日期类的各种重载符的实现我们在这里写了一个GetMonthDay()函数,用于获取某一年中,某一个月份的天数
GetMonthDay()
int Date::GetMonthDay(int year, int month)
{
assert(year >= 1 && month >= 1 && month <= 12);
int monthArray[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
/*monthArray[2] = 29;*/
return 29;
}
return monthArray[month];
}
1.operator==
bool operator==(const Date& x, const Date& y)
{
return x._year == y._year
&& x._month == y._month
&& x._day == y._day;
}
2.operator>
bool operator>(const Date& y)
{
if (_year > y._year)
{
return true;
}
else if (_year == y._year && _month > y._month)
{
return true;
}
else if (_year == y._year &&_month == y._month && _day > y._day)
{
return true;
}
return false;
}
这个时候我们的调用d1>d2就应该是d1.operator(d2);
3.operator+=
在这里传引用的开销比较小,注意的是这里对d1做了修改,返回的结果是加了day之后的,所以此操作为+=而不是+
Date& operator+=(int day)
{
if (day < 0)
{
return *this -= (-day);
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
4.operator+
+和+=有什么区别呢? +=操作符会对自身进行修改,和+操作符不会对自身进行修改。所以我们可以在+操作符重载中 先将d1拷贝一份,然后对tmp进行修改,最后返回tmp的值,但是对d1本身没有做任何修改,而且这里没有用传值返回,原因是返回值为tmp,是一个局部变量,出了作用域之后就被销毁了。
Date operator+(int day)
{
Date tmp(*this);;
tmp._day += day;
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
tmp._day -= GetMonthDay(tmp._year, tmp._month);
tmp._month++;
if (tmp._month == 13)
{
tmp._year++;
tmp._month = 1;
}
}
return tmp;
}
同时,我们也可以使用上面的+=来对这里的+操作符进行复用
Date operator+(int day)
{
Date tmp(*this);
tmp += day;
return tmp;
}
5.+=和+的另一种写法
上面的+和+= 是+操作符对复用了+=操作符。下面这种写法是+=复用+操作符。
但是这种方法不是很推荐,相比于上面一种方法的+和+=对象拷贝次数一共只有2次,而下面这种方法的对象拷贝次数多达5次
operator+
Date Date::operator+(int day)
{
Date tmp(*this);
tmp._day += day;
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
tmp._day -= GetMonthDay(tmp._year, tmp._month);
++tmp._month;
if (tmp._month == 13)
{
tmp._year++;
tmp._month = 1;
}
}
return tmp;
}
operator+=
Date& Date::operator+=(int day)
{
*this = *this + day;
return *this;
}
6.operator=
operator=我们不写的话,编译器会生成默认的operator=
跟拷贝构造的行为类型,内置类型值拷贝,自定义类型调用它的赋值
同样的Date类MyQueue类可以不写,但是Stack类必须自己实现,实现深拷贝
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
赋值运算符写一个返回值,方便多组连续赋值。 加一个if判断,如果自己给自己赋值那么就直接返回自己
缺省参数不能在声明和定义时同时给缺省值,规定在声明的时候给。
7.operator!=
bool Date::operator!=(const Date& y)
{
return !(*this == y);
}
8.operator>=
bool Date::operator>=(const Date& y)
{
return (*this > y) || (*this == y);
}
9.operator<
bool Date::operator<(const Date& y)
{
return !(*this >= y);
}
10.operator<=
bool Date::operator<=(const Date& y)
{
return !(*this > y);
}
11.operator-=
Date& Date::operator-=(int day)
{
if (day < 0)
{
return *this += (-day);
}
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
12.operator-(日期减去天数)
Date Date::operator-(int day)
{
Date tmp(*this);
tmp -= day;
return tmp;
}
13.前置++ 与 后置++
运算符重载基本上都是opereator运算符,但是我们这里的++有些许的问题,operator++究竟是前置++还是后置++呢?我们是无法分清楚的,所以c++语法规定,在使用时传int整型的operator++为后置++,不传参的operator为前置++
Date& operator++();//前置++不需要传参数
//这里传不传int用于区分前置++和后置++
Date operator++(int);//后置++传一个int参数
//前置++
Date& Date::operator++()//前置++ 不传参数
{
*this += 1;
return *this;
}
//后置++
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
14.operator-(日期减日期)
将较小的日期一直++,并定义一个计数器count,小的日期每次++,count也跟着++,直到min=max的时候,count就是相差的日期。
int Date::operator-(const Date& d)
{
int flag = 1;
Date max = *this;
Date min = d;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int count = 0;
while(min!=max)
{
++min;
++count;
}
return count * flag;
}
15.opeartor<<
在此之前我们需要知道双操作数的运算符,第一个参数是左操作数,第二个参数是右操作数。
void Date::operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
}
但是我们又发现写好之后,不能用正常使用,原因是,operator在类中,类中有一个隐藏的参数this,为该函数的左操作数,所以我们需要把operator<<操作符写在类外面,以保证左操作数能为cout。
ostream& operator<<(ostream& out,const Date& d)
{
out<<d._year<<"年"<<d.month<<"月"<<d._day<<"日"<<endl;
return out;
}
16.operator>>
istream& operator>>(istream& in,Date& d)
{
in>>d._year>>d._month>>d._day;
return in;
}
17.前置operator --
Date& Date::operator--()
{
*this-=-1;
return *this;
}
18.后置operator--(int)
Date Date::opeartor--(int)
{
Date tmp(*this);
*this-=1;
return tmp;
}
6.const成员函数
我们可以用下面这段代码来验证一下;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
void Print() const
{
cout << "Print()const" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
void Test()
{
Date d1(2022,1,13);
d1.Print();
const Date d2(2022,1,13);
d2.Print();
}
const调用的结果是Print()const。
成员函数定义的规则:
1.能定义成const的成员函数都应该定义成const,这样const对象和非const对象都可以调用,非const对象调用const成员函数是权限的缩小。权限可以缩小但是不可以放大,const对象不能调用非const对象
2.要修改成员变量的成员函数,不能定义成const