learn C++ NO.4 ——类和对象(2)
1.类的6个默认成员函数
1.1.默认成员函数的概念
在 C++ 中,如果没有显式定义类的构造函数、析构函数、拷贝构造函数和赋值运算符重载函数,编译器会自动生成这些函数,这些函数被称为默认成员函数。
class Date
{
};
初步了解了默认成员函数,上面的空类Date,其实在程序运行时,编译器会默认生成它的默认成员函数。
小结
当没有显示实现默认成员函数时,编译器会自动生成默认成员函数。
2.构造函数
2.1.构造函数的概念
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成 员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2.为什么类需要构造函数呢?
我们以日期类举例,如果不用构造函数来初始化一个对象会是怎么样的呢?
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(2023, 5, 11);
d1.Print();
Date d2;
d2.Init(2023, 5, 12);
d2.Print();
return 0;
}
每次初始化对象都要调用这个Init函数是不是太麻烦了,这也许就是祖师爷们的年轻时想法。于是便有了构造函数。构造函数就是帮助我们初始化对象用的。下面正式介绍构造函数。
2.3.构造函数的语法及特点
首先,需要注意的是构造函数中的构造二字并不是意味着开辟空间创建对象,而是初始化对象。
语法格式
1、函数名与类名相同。
2、无返回值
3、构造函数可以重栽。
构造函数特点一
对象实例化时,编译器会自动调用对应的构造函数
class Date
{
public:
Date()//无参构造
{
}
Date(int year = 2023,int month = 5,int day = 12)//带参构造
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2002,11,24);//OK?
Date d2;//OK?
Date d3();//OK?
return 0;
}
可以看到上面的Date类定义两个构造函数,在语法的层面上,两个构造函数构成函数的重载。但是,在初始化调用无参数构造函数时,会产生歧义。编译器不知道调用哪一个函数。所以,d1这样初始化是OK的,d2这样是不行的。而d3也是错误的,因为d3这样调用构造函数会和函数的声明语法上有所冲突。
构造函数特点二
如果类中没有显式定义构造函数,则c++编译器会自动生成一个无参的默认构造函数,一旦用户显示定义编译器便不再生成。
class Date
{
public:
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;//OK?
return 0;
}
class Date
{
public:
Date(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;//OK?
return 0;
}
通过上面的事例可以验证出,编译器在我们不定义构造函数时,会自动生成一个无参数的默认构造函数。而我们显式定义构造函数后,编译器就不会再生成默认构造函数。
构造函数特点三
这里主要介绍默认构造函数对于类型的处理。首先,先介绍类型的概念。c/c++程序中对于数据类型本质分为两个种。一种是内置类型,即语言自带的数据类型,如int/char/指针等。另一种就是自定义类型,比如struct/class等,我们上面的Date类就是一种自定义类型。
class Date
{
public:
Date(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.Print();//会做处理吗?
return 0;
}
从上图可以看到,xcode编译器对于内置类型是不做处理的。当然在有些编译器下对于内置类型会做特殊处理。补充一点,在c++11标准中,对于这种情况打了补丁。即类的成员变量在声明时,可以给缺省值。这样给编译器生成的默认构造函数提供了参数。
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << " " << _month << " " << _day << endl;
}
private:
//在声明时给上缺省值
int _year = 2023;
int _month = 5;
int _day = 12;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
下面请看自定义类型的处理。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型) int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
return 0;
}
编译器自动生成的默认构造函数对于自定义类型的处理方式为:编译器回去调用自定义类型它本身的默认构造函数。
构造函数特点四
无参的构造函数、全缺省的构造函数以及我们没写编译器默认生成的构造函数都被称为默认构造函数。而默认构造函数有且只能有一个。
class Date
{
public:
Date()
{
_year = 1900;
_month = 1;
_day = 1;
}
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
// 以下测试函数能通过编译吗?
void Test()
{
Date d1;
}
上面的代码肯定是编译不通过的。因为这里定义了两个默认构造函数,产生了歧义。编译器也不知道该调用哪一个构造函数。
3.析构函数
3.1.析构函数的概念
析构函数顾名思义,与构造函数的功能相反,析构函数不是完成对对象本身的销毁,局部对象的销毁工作是由编译器完成的。对象在销毁时,会自动调用析构函数,以完成对于对象中的资源进行清理工作。如动态申请的内存的释放就可以由析构函数来处理。
3.2.语法以及特点
语法
1、析构函数的名称就是在类名前面加上~符号。
2、无参数和无返回值。(析构函数不能重栽)
3、一个类只能有一个析构函数。若未显式定义,系统会自动生成默认析构函数。
特点一
析构函数的工作原理:对象生命周期结束时,c++编译系统自动调用析构函数
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 3)
{
cout<< "Stacl()"<<endl;
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
~Stack()
{
cout<<"~Stack()"<<endl;
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s;
s.Push(1);
s.Push(2);
}
int main()
{
TestStack();
Stack s1;
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() 。所以Date类型生成的默认析构函数调用了Time类型的默认析构函数。这也说明了对于内置类型,编译器会调用它的析构函数。这里main函数直接调用了Time函数的析构函数吗?答案是并不是。这里main函数生命周期结束后,局部变量d销毁。因为d的类型是自定义类型,所以编译器调用Date类的析构函数。而Date类型中还有一个自定义类型Time类。这里Date的析构函数会去调用Time的析构函数。以确保类的对象都正确的销毁。
对于都是内置类型的类中,析构函数可以考虑使用编译器默认生成的。如Date类。但是如果这个类中有需要手动释放的资源(例如动态分配的内存、打开的文件等),那么我们就需要自己实现析构函数来确保这些资源能够被正确释放。如Stack类。如果需要释放资源的成员变量都是自定义类型,那么这些成员变量会去调用它们对应类型的析构函数。这样也可以不用自己写析构函数。
特点三
由于函数内部的值通常存放在栈区空间中,而栈的性质决定了后面定义的成员变量先被析构,先定义的成员变量后被析构。
4.拷贝构造函数
4.1.拷贝构造函数的概念
拷贝构造函数:拷贝构造函数是构造函数的一种重载形式,本质是一种特殊的构造函数。拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
4.2.拷贝构造函数语法及特点
//以日期类举例
class Date
{
public:
Date(int year = 2023,int month = 5,int day = 12)//构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)//拷贝构造函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
我们就以这个例子展开来讲。首先,拷贝构造函数的形参的部分一定得是该类的同类型引用。否则将会产生无穷递归。这是因为c++规定函数传参,内置类型直接拷贝,而自定义类型需要调用它的拷贝构造函数。
那么又有一个问题,为什么需要const修饰引用呢?且看下面的案例分析。
//错误事例
class Date
{
public:
Date(int year = 2023, int month = 5, int day=12)//构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(Date& d)//拷贝构造函数
{
d._year = _year;
d._month = _month;
d._day = _day;
}
void Print()
{
cout << _year << '-' << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
Date d2(d1);
d2.Print();
d1.Print();
return 0;
}
如果加了const修饰之后,上面的拷贝构造函数的问题就一目了然。
这是因为const缩小了权限,这样就保护了this指针指向的内容不被修改。我将上面的代码再写的更加详细一些,以便理解
//错误事例
Date(const Date& d)//拷贝构造函数
{
d._year = this->_year;
d._month = this->_month;
d._day = this->_day;
}
可以看到我们的函数两个具体的参数,一个是this指针,一个是实参的引用d。我们的本意是希望将d的值拷贝给this指针指向的成员变量。如果没有const修饰缩小权限,那岂不是赔了夫人又折兵。
4.3.系统自动生成的拷贝构造函数
如果我们不自己写拷贝构造函数,编译器也会自动生成一个拷贝构造函数。那么对于内置类型,编译器生成的默认拷贝构造函数会按照对象在内存中的数据按照字节序拷贝,这也就是通常说的浅拷贝。所以对于Date类这种成员变量都是内置类型的对象来说,编译器默认生成的拷贝构造函数可以完成对对象的拷贝。
class Date
{
public:
Date(int year = 2023, int month = 5, int day=12)//构造函数
{
_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();
Date d2(d1);
d2.Print();
d1.Print();
return 0;
}
如果是想栈这样的类对象,编译器生成的拷贝构造函数还能否解决拷贝问题呢?
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType *_array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
可以看到程序直接崩溃了,这是因为同一块内存空间被释放了两次。
而析构函数又对这一块动态申请的内存释放了两次,导致了程序的崩溃。对于这种指向有动态申请内存的类对象时,就需要使用深拷贝。由于编译器默认生成的默认拷贝构造只能实现浅拷贝,所以需要我们自己写拷贝构造函数。
//深拷贝的拷贝构造函数
Stack(const Stack& s)
{
_array = (int*)malloc(sizeof(int) *_capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = s._size;
_capacity = s._capacity;
memcpy(_array, s._array, sizeof(int) * _capacity);
}
4.4 拷贝构造函数的经典调用场景
1、使用已存在的对象拷贝创建新对象
2、函数参数可为自定义类型的类对象
3、函数返回值为自定义类型的类对象
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;
}
5.运算符重载
5.1.运算符重载的概念
C++运算符重载是指在类中重载运算符,使得这些运算符能够适用于类的对象。C++支持重载的运算符包括算术运算符、比较运算符、逻辑运算符、位运算符、赋值运算符等等。运算符重载可以提升代码的可读性。
5.2.运算符重载的语法
返回值类型 + operator关键字 + 运算符(参数列表)
注意:
1、不能通过链接其他的符号来创建新的操作符,比如operator @
2、重载的操作符必须有一个类的类型
3、用于内置的运算符,其含义不能改变,例如:加法操作符+,不能改变其原有的含义。
4、作为类成员函数重载是,主要注意的是成员函数的第一个参数为隐藏的this指针。
5、[.*],[::],[sizeof],[?:],[.] 注意以上5个运算符是不能重载的
5.3.赋值符号的运算符重载举例
同过赋值符重载完成上面介绍的拷贝构造函数的功能。首先,我们需要了解赋值符的特性,如赋值符可以支持连续的赋值操作。注意的是,赋值符重载只能在类域内部声明定义,重载到全局域中会和编译器默认生成的赋值符重载造成冲突。
class Date
{
public:
Date(int year = 2023, int month = 5, int day = 12)//构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)//拷贝构造函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//运算符重载
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
void Print()
{
cout << _year << '-' << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 1, 4);
Date d2 = d1;
Date d3(2002, 11, 24);
d1.Print();
d2.Print();
d1 = d2 = d3;
d1.Print();
d2.Print();
d3.Print();
d1 = d1;
d1.Print();
return 0;
}
运算符重载的参数和原来操作符的参数是必须一样多的。不过this指针是作为隐含的参数传递的。
对于这个赋值符重载还有没有可以优化的地方呢?答案是有的。不难发现其实当两个类对象的成员变量值完全一样时,这里还有进行赋值拷贝。所以在一开始就判断一下两个类的成员变量值是否完全相等是比较合适的。所以下面介绍的就是逻辑等运算符的重载。
//运算符重载
Date& operator=(const Date& d)
{
//if(*this != d)//两个类对象比较效率差
if(this != &d)//判断地址效率更高
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
5.4.逻辑等运算符的重载举例
bool operator==(const Date& d)const
{
if (_year != d._year)
return false;
else if (_month != d._month)
return false;
else if (_day != d._day)
return false;
else
return true;
}
也许你会好奇这个()后面跟的const是什么意思,我将下面告诉你。这里一个逻辑等运算符的重载就写好了,但是我们需要的是一个逻辑不等运算符。其实这里只要对这个逻辑等于进行一下复用即可
bool operator!=(const Date& d)const
{
if (*this == d)
{
return false;
}
else
{
return true;
}
}
下面我们就可以通过上面重载的逻辑不等运算符来完善一下我们的赋值操作符了。
Date& operator=(const Date& d)
{
if (*this != d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
6.const修饰成员函数
将const属性修饰成员函数,则会赋予该成员函数的this指针const属性。使该成员函数不能对任何类的成员进行修改。
6.1.语法形式
返回类型 成员函数名()const
6.2.const对象传参调用非const成员函数
class Date
{
public:
Date(int year = 2023, int month = 5, int day = 12)//构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '-' << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 14);
const Date d2(2023, 5, 13);
d1.Print();
d2.Print();//error
return 0;
}
这里d2无法调用Print函数,因为const修饰的成员对象调用非const修饰的成员函数,会造成权限的放大。d1是非const修饰的成员对象,调用非const修饰的成员函数,权限是平移的。所以可以调用。
若加上const修饰成员函数,d1还可以调用吗?答案是可以的。因为d1虽然没有被const修饰,但是并不妨碍它被成员函数中的const影响。权限是可以缩小的,但是权限不能被放大。
7.取地址及const取地址操作符重载
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比
如想让别人获取到指定的内容!
// 设计一个类,在类外面只能在栈上创建对象
// 设计一个类,在类外面只能在堆上创建对象
class A
{
public:
static A GetStackObj()
{
A aa;
return aa;
}
static A* GetHeapObj()
{
return new A;
}
private:
A()
{}
private:
int _a1 = 1;
int _a2 = 2;
};
int main()
{
//static A aa1; // 静态区
//A aa2; // 栈
//A* ptr = new A; // 堆
A::GetStackObj();
A::GetHeapObj();
return 0;
}