目录
一. 前言
二. 默认成员函数
三. 构造函数
3.1 概念
3.2 特性
四. 析构函数
4.1 概念
4.2 特性
五. 拷贝构造函数
5.1 概念
5.2 特性
六. 运算符重载
6.1 引入
6.2 概念
6.3 注意事项
6.4 重载示例
6.5 赋值运算符重载
6.6 前置++和后置++运算符重载
七. const成员函数
7.1 问题引入
7.2 定义方式
7.3 使用细则
八. 取地址运算符重载
上期我们介绍了一些关于类的基础知识,学会了如何定义一个类,体会到了面向对象中封装的特征。本期我们将继续类和对象的学习,重点讨论C++类中的成员函数,并在下期我们将自己动手实现一个类----日期类。
话不多说,上菜咯!!!
//空类
class Date
{
};
但是空类中真的什么都没有吗?实则不然。任何类在什么都不写时,编译器会自动生成6个默认成员函数。默认成员函数:用户没有显式实现,编译器会自动生成的成员函数。如下所示:
我们来看看下面的日期类:
class Date
{
public:
void Init(int year, int month, int day)
{
_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.Init(2022, 7, 5);
d1.Print();
Date d2;
d2.Init(2022, 7, 6);
d2.Print();
return 0;
}
对于上面Date类,我们发现我们每次创建一个对象后,都要通过Init 方法给对象设置日期,这未免显得过于麻烦,那能否在对象创建时,就同步将信息设置进去呢?
使用构造函数就能很好的进行解决。构造函数是一个特殊的成员函数,函数名与类名相同,创建类对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。其形式如下:
class Date
{
public:
//Date的构造函数
Date()
{
//进行初始化
//...
}
};
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名为构造,但是其主要任务并不是创建对象开辟空间,而是初始化对象。
构造函数有如下特征:
class Date
{
public:
//Date的构造函数
Date()
{
}
void Date(){} //错误写法,没有返回值
};
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//1.创建对象
//2.调用相应的构造函数
Date d1; //调用无参构造函数
Date d2(2023, 8, 22); //调用带参构造函数
}
需要注意的是,调用无参的构造函数时,对象后面无需带(),否则会变成函数声明:
Date d1; //调用无参构造函数
Date d3(); //声明一个没有形参的函数d3,它的返回值类型为Date
构造函数是默认成员函数。如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
class Date
{
public:
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; //调用编译器自动生成的默认构造函数,默认构造是无参的,相匹配
Date d2(2023, 8, 22); //该行代码会报错,没有匹配的带参构造函数
}
而如果我们显式地定义了构造函数,编译器就不会自动生成无参的默认构造函数,如下:
class Date
{
public:
//显式定义带参的构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; //该行代码会报错,没有匹配的默认构造函数
Date d2(2023, 8, 22); //调用带参的构造函数
}
编译器自动生成的默认构造函数对内置类型不会进行初始化,如:int,char,double等等;而对于自定义类型,会去调用该自定义类型的默认构造函数。
class Time
{
public:
Time() //Time类的默认构造函数
{
_hours = 0;
_minute = 0;
_second = 0;
}
private:
int _hours;
int _minute;
int _second;
};
class Date
{
public:
private:
//内置类型
int _year;
int _month;
//自定义类型
Time _day;
};
int main()
{
Date d; //调用编译器自动生成的默认构造函数
return 0;
}
我们发现Date的默认构造函数对_year和_month没有进行初始化,依然是随机值,而对_day则去调用了Time类的默认构造函数,将其成员变量初始化为0。我们可以通过调试进一步进行验证:
默认构造函数调试
值得一提的是:在C++11中,针对默认构造函数对内置类型不进行初始化的缺陷进行了改进,支持内置类型的成员变量在类中声明时给默认值。如下:
class Date
{
public:
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year = 0; //声明时给默认值
int _month = 0;
int _day = 0;
};
int main()
{
Date d;
d.Print();
return 0;
}
构造函数也支持给缺省值。无参的构造函数和全缺省的构造函数都称作默认构造函数。而默认构造函数只能有一个,故二者不能同时存在。举例如下
class Date
{
public:
Date() //无参的构造函数
{
_year = 2023;
_month = 8;
_day = 22;
}
Date(int year = 2023, int month = 8, int day = 22) //全缺省的构造函数
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024); //编译通过,调用全缺省的构造函数
Date d2; //这里编译会报错,d2调用默认构造函数,但存在两个默认构造函数,编译器不知道调用哪个
return 0;
}
小贴士:一般我们显式定义构造函数时,习惯将构造函数写成全缺省的,以提高代码的健壮性。
构造函数是在对象创建时对其进行初始化,有初始化便有销毁,析构函数的作用就是在对象生命周期结束时,完成对象中资源的清理和释放。和构造函数一样,析构函数由编译器自动调用。下面是Stack类的构造函数和析构函数的实现
class Stack
{
public:
Stack(size_t capacity = 4) //构造函数,初始化一个栈,写成全缺省的形式
{
_array = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}
~Stack() //析构函数,在类名前加~号
{
free(_array); //堆上动态申请的空间需要由用户自行释放
//下面的代码也可以不写,栈上的空间操作系统会自动释放
_array = nullptr;
_capacity = _top = 0;
}
private:
int* _array;
int _capacity;
int _top;
};
析构函数也是特殊的成员函数,其特征如下:
class Stack
{
public:
Stack(){
cout << "Stack()" << endl;
}
~Stack(){
cout << "~Stack()" << endl;
}
private:
int* _array;
int _capacity;
int _top;
};
int main()
{
Stack s;
return 0;
}
和构造函数类似,编译器默认生成的析构函数不会对内置类型成员进行清理,最终由操作系统自动进行回收即可;而对于自定义类型成员,默认析构函数会去调用它的析构函数,保证其内部每个自定义类型成员都可以正确销毁。
回到我们之前的日期类:class Time
{
public:
Time() //Time类的默认构造函数
{
cout << "Time()" << endl;
}
~Time() //Time类的析构函数
{
cout << "~Time()" << endl;
}
private:
int _hours;
int _minute;
int _second;
};
class Date
{
public:
//没有显式写出构造函数和析构函数,使用编译器自动生成的
private:
int _year;
int _month;
Time _day;
};
int main()
{
Date d; //调用编译器自动生成的默认构造函数
return 0;
}
尽管我们没有直接创建Time类的对象,但依然调用了Time类的构造函数和析构函数。这是因为Date类中的_day成员是Time类的对象,在Date类的默认构造函数和默认析构函数中,会去调用Time类这个自定义类型的构造函数和析构函数,对_day成员进行初始化和清理工作。
如果类中没有动态申请内存时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成内存泄漏,比如Stack类
类的析构函数一般按照构造函数调用的相反顺序进行调用,但是要注意static对象的存在, 因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放static对象。
Q:假设已经有A,B,C,D 4个类的定义,则程序中A,B,C,D析构函数调用顺序为?
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
答案是BADC。解析如下:
1、全局变量优先于局部变量进行构造,因此构造的顺序为cabd
2、析构的顺序和构造的顺序相反
3、static和全局对象需在程序结束才进行析构,故会放在局部对象之后进行析构
综上:析构的顺序即为BADC。
在现实生活中,可能存在一个与你长相,我们称其为双胞胎
那我们在创建类对象时,能不能创建一个和已有对象一模一样的对象呢?Ctrl+C和Ctrl+V想必没有人不喜欢吧嘿嘿这就要谈到我们的拷贝构造函数惹。
拷贝构造函数:只有单个形参,该形参是对本类型对象的引用(一般常用const修饰),在用已存在的类对象创建新对象时编译器会自动调用拷贝构造函数。
class Date
{
public:
Date() {};
Date(const Date& d) //Date的拷贝构造函数
{
_day = d._day;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1); //用d1拷贝构造d2
return 0;
}
拷贝构造函数也是属于特殊的成员函数,其特征如下:
//拷贝构造函数的写法
Date(const Date d) // 错误写法:编译报错,会引发无穷递归
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Date(const Date& d) // 正确写法
{
_year = d._year;
_month = d._month;
_day = d._day;
}
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 内置类型
int _year = 2023;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1; //d1调用默认的构造函数进行初始化
// 用已经存在的d1拷贝构造d2,此时会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,因此编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
我们可以通过监视窗口来查看d2对象的拷贝情况可以看出,编译器默认生成的拷贝构造函数不仅会对自定义类型成员进行拷贝(通过调用相应的拷贝构造函数),也会对内置类型成员进行拷贝(按字节序的浅拷贝)。
默认拷贝构造函数调试
class Stack
{
public:
Stack(size_t capacity = 4)
{
_array = (int*)malloc(capacity * sizeof(int));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
int* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
那这种浅拷贝的问题要如何解决呢?
一般有两种解决方案:深拷贝或者引用计数。
所谓深拷贝,就是手动再申请一段空间,然后将原空间的内容依次拷贝到新空间中,最后让s2的_array指针指向这个新空间。这种方法避免了一块空间被多个对象指向的问题。
而引用计数,就是在类中额外增加一个变量count记录堆空间被引用的次数,只有当引用次数变为1时,我们才对这段空间进行释放。这种方法避免了一块空间被多次释放的问题。
现在我们对这两种方式有个初步的印象即可,后续我们会详细讲解。不过无论是深拷贝还是引用计数,都是编译器默认生成的拷贝构造函数无法做到的,需要我们显式地实现拷贝构造函数。
拷贝构造函数有三个典型的调用场景:使用已存在的对象创建新对象、函数形参为类对象、函数返回值为类对象。
class Date
{
public:
Date(int year, int minute, int day)
{
cout << "Date(int,int,int):" << this << endl;
}
Date(const Date& d)
{
cout << "Date(const Date& d):" << this << endl;
}
~Date()
{
cout << "~Date():" << this << endl;
}
private:
int _year;
int _month;
int _day;
};
Date Test(Date d)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2022, 1, 13);
Test(d1);
return 0;
}
结论:为了提高效率,减少拷贝构造的次数,一般对象传参时,我们尽量使用引用传参,函数返回时也是根据实际场景,能用引用返回尽量使用引用返回。
对于内置类型,我们可以使用==、>号运算符判断它们的大小关系,可以使用+,-号运算符对其进行加减......如下所示
int main()
{
int a = 10;
int b = 20;
a = a + 10;
b = b - 10;
cout << (a == b);
cout << (a > b);
//还可以使用许许多多的运算符进行操作,这里就不一一挪列了
//...
return 0;
}
但对于自定义类型来说,也就是我们的类,这些运算符仿佛都失效了
class Date
{
public:
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 8, 24);
Date d2(2023, 8, 25);
d1 = d1 + 10; //d1类对象使用+号运算符
d1 == d2; //d2类对象使用==号运算符
}
很明显编译器报错了,这是因为对于几个固定的内置类型,编译器知道它们的运算规则,而对于我们自定义的类型,编译器并不知道它的运算规则,例如d1+10究竟是年份+10还是月份+10呢?编译器无法进行确定,故报错。
有一种很简单的解决方法就是给类定义成员函数,通过调用成员函数来实现我们想要的运算,如下所示:
class Date
{
public:
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void AddYear(int val)
{
_year += val;
}
bool isSame(const Date& d)
{
return _year == d._year && _month == d._month && _day == d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 8, 24);
Date d2(2023, 8, 24);
d1.AddYear(1); //年份+1
cout << d1.isSame(d2); //比较d1和d2是否相等
}
上面的方式的确可以解决问题,但还是不够直观,每次进行运算都需要调用函数,代码未免有点挫有没有什么方法可以让类使用运算符进行运算吗?
这就不得不谈到我们的主角----运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型和参数列表与普通的函数类似。
运算符重载的函数名为:关键字operator后面+需要重载的运算符符号
其函数原型为:返回值类型 operator操作符(参数列表)
//==号运算符重载
bool operator==(const Date& d)
{
//函数内容
return _year == d._year && _month == d._month && _day == d._day;
}
通过运算符重载,我们可以对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型,且不改变原有的功能。
进行运算符重载需要注意以下几点:
1、作为全局函数重载
class Date
{
public:
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//private: //为了让==运算符重载函数能够访问,将成员变量设置为共有的
int _year;
int _month;
int _day;
};
//作为全局函数重载
bool operator==(const Date& d1,const Date& d2)
{
//这里需要类外访问成员变量
return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day;
}
int main()
{
Date d1(2022, 8, 24);
Date d2(2023, 8, 24);
cout << (d1 == d2);
return 0;
}
当运算符重载作为全局函数时,由于我们难免需要对成员变量进行访问,我们需要类的成员函数是共有的,可这难免会破坏了类的封装性。
当然,我们还可以使用友元函数来解决,关于友元在下篇会介绍到。不过最推荐的还是将其重载为成员函数。
2、作为成员函数重载
class Date
{
public:
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// bool operator==(Date* const this, const Date& d2)
// this指向调用的对象
bool operator==(const Date& d) //重载为成员函数
{
//类内访问成员变量不受访问限定符限制
return _year == d._year && _month == d._month && _day == d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 8, 24);
Date d2(2023, 8, 24);
//下面两种写法是等价的
cout << (d1 == d2);
cout << d1.operator==(d2);
return 0;
}
在使用类的过程中,当我们想将一个类赋值给另一个类时,我们便可以对=赋值运算符进行重载。其重载格式如下:
class Date
{
public:
Date(int year = 2023, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d) //赋值运算符重载
{
if (this != &d) //避免自己给自己赋值
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this; //返回自身的引用,连续赋值
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2, d3;
d3 = d2 = d1; //调用赋值运算符重载
}
// 赋值运算符重载成全局函数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
为什么呢?实际上,在我们前面介绍的默认成员函数中,赋值运算符重载也在其中。因此,当我们在类中没有显式地实现,编译器会自动生成默认的重载函数。而如果用户又自行在类外写一个赋值运算符重载函数,就会和编译器默认生成的默认赋值运算符重载冲突,故赋值运算符只能重载为成员函数。下面来个小问题试试对拷贝构造的理解:
int main()
{
Date d1, d2;
Date d3 = d1; //这里调用的是拷贝构造还是赋值重载呢?
d2 = d1; //这里呢?
return 0;
}
答:第一问调用的是拷贝构造函数,第二问调用的是赋值重载。
解析:拷贝构造函数是用已存在的对象去构造新对象,而d3就是我们需要构造的新对象,第一问就是用d1对象去构造d3对象,故调用拷贝构造,这种写法与Date d3(d1)等价。而赋值运算符载函数是两个已存在对象之间进行赋值,d1和d2都是已经存在的对象,故d2=d1调用的是赋值重载。
前置++
下面我们来尝试对Date类实现前置++运算符的重载,用于对天数进行自增。所谓前置++,就是先自增1再返回结果,如下:
class Date
{
public:
Date(int year = 2023, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator++() //前置++运算符重载
{
_day += 1; //先自增 (注意:这里为了演示先忽略日期进位,进位处理请看Date类的模拟实现)
return *this; //返回自身,为了提高效率,用引用返回
}
private:
int _year;
int _month;
int _day;
};
后置++
而后置++,就是先返回结果再进行自增。但有个问题:前置++和后置++都是一元运算符,即只有一个操作数:对象本身。在作为成员函数重载时,重载函数就是无参的,那编译器要如何区分是前置++还是后置++呢?
int main()
{
Date d;
//编译器要如何区分哪个operator++()函数是前置++,哪个又是后置++ ???
++d; //相当于d.operator++()
d++; //也相当于d.operator++()
return 0;
}
为此,C++做了特殊规定:后置++重载时多增加一个int类型的参数用于占位,但调用函数时该参数不用传递,编译器会自动传递。
class Date
{
public:
Date(int year = 2023, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date operator++(int) //后置++运算符重载,int用于占位区分
{
Date temp(*this); //由于要返回+1前的结果,所以先对对象进行拷贝
_day += 1; //然后天数+1
return temp; //然后将+1前的对象返回。由于temp出了函数就销毁了,故不能用引用返回
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
++d; //相当于d.operator++()
d++; //相当于d.operator++(int),int类型参数由编译器自动传递
return 0;
}
我们来看下面的代码:
class Date
{
public:
void Print()
{
cout << "void Print()" << endl;
}
private:
int _year = 2023;
int _month = 1;
int _day = 1;
};
int main()
{
const Date d;
d.Print();
return 0;
}
由于对象d被const所修饰,故其类型为const Date,表示不能对类的任何成员进行修改。当d调用Print函数时,传入的实参就是d的地址,即const Date*类型的指针,而在Print函数中,用于接收的this指针却是Date*类型的,这无疑是一种权限的放大,故编译器会进行报错。
那要怎么解决这个问题呢?很简单,给this指针加上const进行修饰即可。
由于this指针是 "非静态成员函数" 的隐藏形参,我们无法显式地去定义this指针,因此C++规定,在成员函数后面加上const代表它为const成员函数,其this指针的类型为const A* this,编译器会自动进行识别处理
回到上面的代码,当我们在Print函数后加上const后,程序就正常运行啦
class Date
{
public:
void Print() const //this指针的类型是const Date*
{
cout << "void Print() const" << endl;
}
private:
int _year = 2023;
int _month = 1;
int _day = 1;
};
int main()
{
const Date d;
d.Print();
return 0;
}
class Date
{
public:
void Print() //非const成员函数
{
cout << "void Print()" << endl;
}
void Print() const //const成员函数
{
cout << "void Print() const" << endl;
}
private:
int _year = 2023;
int _month = 1;
int _day = 1;
};
int main()
{
const Date d1;
Date d2;
d1.Print(); //const类型的对象调用cosnt成员函数
d2.Print(); //非const类型的对象调用非const成员函数
return 0;
}
建议给只读成员函数加上const修饰,即内部不涉及修改成员变量的函数
构造函数不能加const修饰。构造函数是对成员变量进行初始化的,显然会涉及到成员变量的修改。
取地址运算符重载有两个版本,一个是const的,一个是非const的。这两个成员函数也是我们一开始讲的默认成员函数,当用户没有显式定义时,编译器会自动生成。
class Date
{
public:
Date* operator&() //非const版本,this指针类型为Date*
{
return this;
}
const Date* operator&()const //const版本,this指针类型为const Date*
{
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
这两个版本的成员函数和上面的不同,一般使用编译器默认生成的取地址重载即可。只有特殊情况下,才需要显式定义,比如想让别人获取到指定的内容!