C++类和对象(中)
文章目录
- 1 类的6个默认成员函数
- 2 构造函数
- 2.1 概念
- 2.2 特性
- 3 析构函数
- 3.1 概念
- 3.2 特性
- 4 拷贝构造函数
- 4.1 概念
- 5 赋值运算符重载
- 5.1 运算符重载
- 5.2 赋值运算符重载
- 5.3 前置++和后置++重载
- 6 const成员
- 6.1 [ ]运算符重载及const修饰的成员函数特性
- 7 取地址及const取地址操作符重载
1 类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数: 用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。
2 构造函数
2.1 概念
对于以下Date类:
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(2025, 2, 16);
d1.Print();
Date d2;
d2.Init(2025, 2, 17);
d2.Print();
return 0;
}
对于Date类,可以通过 Init 公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?
🍊构造函数是一个特殊的成员函数,名字与类名相同, 创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
🥥其特征如下:
1.函数名与类名相同。
2.无返回值(不需要写return, 也不需要写void)。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载(本质就是可以写多个构造函数,提供多种初始化方式)。
class Date
{
public:
// 1.无参构造函数
Date()
{
cout << "Date()" << endl;
_year = 1;
_month = 1;
_day = 1;
}
// 2.带参构造函数
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; //调用无参构造函数
Date d2(2025, 2, 16); //调用带参的构造函数
d1.Print();
d2.Print();
//注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
//以下代码的函数:声明了d3是一个函数,该函数无参,返回一个日期类型的对象
Date d3();
return 0;
}
程序运行结果:
注意:当创建有无参的构造函数时,若想要创建对象后自动调用该无参的构造函数初始化对象,不能像上面的d3一样加括号,这样编译器会认为你是在声明一个函数,一个无参且返回类型为Date类类型的函数。
上面Date类中的两个构造函数还可以合并为一个函数:
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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(2025, 2, 16);
d1.Print();
d2.Print();
return 0;
}
其实就是前面学过的全缺省参数,如果在创建对象时没有给初始值,那就会自动调用该构造函数,使用默认的缺省值。
5.如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
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类中的构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
//将Date类中的构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成默认构造函数
//无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用
Date d1;
return 0;
}
上面虽然定义有构造函数,但是却不是无参的或者全缺省的,所以不是默认构造函数。当定义有了构造函数以后,编译器便不会再生成默认构造函数了。当显式的定义了一个默认构造函数后,运行时,编译器才会自动调用自己写的默认构造函数。
6.关于编译器生成的默认成员函数,很多童鞋会有疑惑: 不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生成的默认构造函数,但是d对象的_year/_month/_day依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用吗???
解答: C++把类型分成内置类型(基本类型) 和 自定义类型。内置类型就是语言提供的数据类型,如: int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型(注意:指针都是内置类型,包括类/结构体指针)。看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员 _t 调用的它的默认构造函数,而对内置类型的成员是不做处理的。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//基本类型(内置类型)
int _year;
int _month;
int _day;
//自定义类型
Time _t;
};
int main()
{
Date d;
d.Print();
return 0;
}
程序运行结果:
注意: C++11中针对内置类型成员不初始化的缺陷,又打了补丁,即: 内置类型成员变量在类中声明时可以给默认值。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//基本类型(内置类型)
int _year = 1;
int _month = 1;
int _day = 1;
//自定义类型
Time _t;
};
int main()
{
Date d;
d.Print();
return 0;
}
程序运行结果:
构造函数,也是默认成员函数。我们不写,编译器会自动生成。
🍃编译器生成的默认构造函数特点:
1.我们不写才会生成;我们写了任意一个构造函数以后,编译器就不会再生成默认构造函数了
2.内置类型的成员不会处理(有些编译器会处理, 在C++11中, 声明支持给缺省值)
3.自定义类型的成员才会处理,会去调用这个成员的默认构造函数
总结: 一般情况下都需要我们自己写构造函数,决定初始化方式。成员变量全是自定义类型时,可以考虑不写构造函数。
7.无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意: 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是"默认构造函数"(不传参就可以调用的就叫默认构造函数)。
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;
};
int main()
{
//测试能通过编译吗?
Date d;
return 0;
}
上面这段程序运行后会报错!因为Date类中的两个构造函数虽然构成了函数重载,但是调用的时候存在歧义,编译器不知道要调用哪个!
构造函数的好处是什么呢?
比如以前我们学过的栈,当我们定义了栈结构以后,要对其进行初始化后,才能直接进行插入删除操作,但是有时候我们可能会忘记对栈进行初始化,就直接进行插入删除操作了。而且最初可能不确定需要多大的空间;这样在插入数据时,可能会造成频繁的扩容(原地扩容或异地扩容),这样效率就会很差。那此时默认构造函数就会起很大的作用:
class Stack
{
public:
Stack(size_t n = 4)
{
if (n == 0)
{
a = nullptr;
top = capacity = 0;
}
else
{
a = (int*)realloc(a,sizeof(int) * n);
if (a == nullptr)
{
perror("realloc fail");
exit(-1);
}
top = 0;
capacity = n;
}
}
private:
int* a;
int top;
int capacity;
};
如果我们提前定义一个默认构造函数,那在创建对象时,如果我们忘记初始化栈,它会自动调用初始化开一点空间。如果我们确定需要多大的空间,那就可以调用该默认构造函数传参给它。这样就很大程度上减少了扩容的次数。
3 析构函数
3.1 概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数: 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象的销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。(注意: 析构函数不能重载)
我们没写, 编译器默认生成的析构函数有如下特点:
○ 对内置类型的成员不做处理
○ 对于自定义类型的成员,会去调用这个成员的默认析构函数
4. 对象生命周期结束时,C++编译系统会自动调用析构函数。
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 3)
{
_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()
{
if (_array)
{
free(_array);
_array = NULL;
_size = _capacity = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
5. 关于编译器自动生成的析构函数,是否会完成一些事情呢??下面的程序我们会看到,编译器生成的默认析构函数,对自定义类型成员会调用它的析构函数。
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;
}
程序运行结果:
在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类生成的默认析构函数。
注意: 创建哪个类的对象则调用该类的构造函数,销毁那个类的对象则调用该类的析构函数。
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类; 有资源申请时(如动态申请的空间),一定要写,否则会造成资源泄漏,比如Stack类。
注意:如果在一个程序中定义多个类对象时,在程序结束时,自动调用的析构函数会先清理后创建的对象,其次才销毁先创建的对象。因为是在栈帧上面创建的对象,所以有后进先出的性质。
析构函数在对象的生命周期结束后就会自动调用,完成资源的清理工作。这样的好处就是:如果以后我们忘记释放那些需要归还操作系统的空间,那通过析构函数的自动调用就能完美解决。(析构函数的作用类比与栈中的销毁Destroy工作)
4 拷贝构造函数
4.1 概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
那在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?
🧣拷贝构造函数: 只有单个形参,该形参是对同类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数也是特殊的成员函数,其特征如下:
1.拷贝构造函数是构造函数的一个重载形式。(特殊的构造函数)
2.拷贝构造函数的参数只有一个且必须是同类型对象的引用,使用传值方式编译器会直接报错因为会引发无穷递归调用。(下面这种算是强制检查)
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// Date(const Date& d) //正确写法
Date(Date d) // 错误写法: 类 "Date" 的复制构造函数不能带有 "Date" 类型的参数
{
_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;
}
3.若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。(拷贝构造函数也是一个默认成员函数)
浅拷贝:浅拷贝创建一个新对象,该对象与原对象具有相同的值,但对引用类型字段只会复制引用地址,而不复制实际的对象。这意味着原对象和新对象的引用类型属性指向同一块内存。
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 = 1970;
int _month = 1;
int _day = 1;
//自定义类型
Time _t;
};
int main()
{
Date d1;
//用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
//但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
程序运行结果:
🔥🔥🔥所以拷贝构造函数正确的写法是参数要传引用,因为引用的底层是指针,引用就是取别名。所以采用引用传参来实现拷贝构造,那么对于函数传引用传参,就不会触发无穷无尽的递归。而在C++中对于自定义类型传值传参,只要是值传递,就会先去调用拷贝构造函数拷贝实参(深拷贝),完事后才进行函数的调用。而传引用传参,实参传给了形参以后,形参就是实参的别名,那就不会触发拷贝构造。
🍂注意: 在编译器默认生成的拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
我们不写,编译器默认生成的拷贝构造函数有以下特点:
1.对于内置类型,会进行值拷贝
2.对于自定义类型,会去调用它的拷贝构造函数
4.编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?
我们先看下面一个例子:
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;
_size = _capacity = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
void func(Stack s)
{
}
int main()
{
Stack s1;
func(s1);
return 0;
}
上面将s1作为参数传给了func函数,那这里是值传递,会将s1的_array、_size、_capacity的值拷贝给形参s。可以在监视窗口里看下:
可以发现传参给func后,将s1里的成员变量值都拷贝给了s,即s1的_array指针的值也拷贝给了s(浅拷贝)。那当调用完func函数后,会销毁func函数的栈帧,会去调用析构函数释放s,这样就会将_array指向的空间释放掉;等到func销毁了以后,又要去销毁main函数栈帧里的s1,而s1里的_array所指向的空间已经被释放掉了,现在s1的_array就是一个野指针,所以当调用析构函数清理s1时,就会报错;因为对一块空间进行了多次释放。再看下面:
当然像日期类这样的类是没必要写拷贝构造的。那么下面的Stack类呢?验证一下试试?
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;
_size = _capacity = 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;
}
上面这段程序运行后会崩掉,原因如下:
原因就是将s1拷贝给s2时,是值拷贝。所以将s1的_array指针的值拷贝给了s2,这就导致s2里的_array和s1里的_array都是指向同一块空间的,所以在最后调用析构函数销毁时,会对_array指向的空间释放两次,那一块空间多次释放必然导致程序崩溃。
所以Stack类需要我们自己实现一个深拷贝的拷贝构造函数,否则默认生成的拷贝构造函数会出问题。
深拷贝:深拷贝创建一个新对象,并且复制原对象中的所有字段,包括引用类型的字段。对于每个引用类型的字段,它都会创建一个新的实例(空间),确保源对象和目标对象之间没有任何共享的引用。
//Stack类的拷贝构造函数
Stack(const Stack& s)
{
//深拷贝
_array = (DataType*)malloc(sizeof(DataType) * s._capacity);
if (_array == nullptr)
{
perror("malloc fail!");
return;
}
memcpy(_array, s._array, sizeof(DataType) * s._size);
_size = s._size;
_capacity = s._capacity;
}
定义了拷贝构造函数以后,我们再来看上面的func函数调用:
一运行调用func函数就会先传参,一传参就会先去调用拷贝构造函数,将实参s1深拷贝给形参s,然后再去正式调用func函数(执行函数体内的操作):
通过拷贝构造s1后,在调用完func函数以后,析构s变量中的_array就不会影响到s1中的_array,因为它们各自是独立的空间。
注意: 类中如果没有涉及资源申请时,拷贝构造函数写不写都可以; 一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
5.拷贝构造函数典型调用场景:
○ 使用已存在的对象去初始化创建一个新对象★★★
○ 函数参数类型为类类型对象(传值传参)
○ 函数返回值类型为类类型对象
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;
}
补充:在拷贝构造函数中为什么形参还要加const修饰呢?原因是防止对传过来的实参进行误改:
本来应该是将d的年月日赋值给当前Date类对象,但是上面就写反了。所以加上const修饰,就是为了防止误改传过来的实参。
当然拷贝构造除了上面那种写法外,还可以如下写:
int main()
{
Date d1(2022, 1, 13);
Date d2(d1);
Date d3 = d1;//和上面一行的意思一样,都是进行拷贝构造
return 0;
}
5 赋值运算符重载
5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型、函数名字以及参数列表,其返回值类型和参数列表 与 普通的函数类似。
函数名字为: 关键字operator后面接需要重载的运算符符号
函数原型: 返回值类型 operator操作符(参数列表)
注意:
● 不能通过连接其他符号来创建新的操作符: 比如operator@
● 重载操作符必须有一个类(自定义)类型参数
● 用于内置类型的运算符, 其含义不能改变, 例如: 内置的整型+, 不能改变其含义
● 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
(.*)、(::)、(sizeof)、(?:)、(.) 注意括号中的这5个运算符不能重载。这个经常在笔试选择题中出现。
注意:重载操作符,不能改变操作符的操作数个数(即一个操作符有几个操作数,那么重载的时候,就有几个参数)。
比如我们想实现两个日期类对象的比较,怎么办呢?
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
public:
int _year;
int _month;
int _day;
};
//这里会发现运算符重载成全局的就需要成员变量是公有的,那么问题来了,封装性如何保证?
//这里其实可以用我们后面学习的友元解决,或者干脆重载成成员函数。
bool operator<(const Date& d1,const Date& d2)
{
if (d1._year < d2._year)
{
return true;
}
else if (d1._year == d2._year && d1._month < d2._month)
{
return true;
}
else if (d1._year == d2._year && d1._month == d2._month && d1._day < d2._day)
{
return true;
}
else
{
return false;
}
}
int main()
{
Date d1(2025, 2, 11);
Date d2(2024,8,14);
cout << (d1 < d2) << endl;
//d1 < d2等价于operator<(d1,d2)
cout << operator<(d1, d2) << endl;
return 0;
}
程序运行结果:
由于两个日期类不能直接通过运算比较大小,我们通常是定义一个函数来进行年月日的比较。所以上面通过运算符重载,定义了一个比较日期类的函数operator<。上面的40和42行意思是一样的。40行(d1<d2)加括号的原因是<<的优先级比<要高,所以加了()。
上面定义的是一个全局的函数operator<,但是这要求类的成员变量是公有的才能实现外部访问。其实我们也可以将运算符重载定义成类的成员函数,这样就解决了类的成员变量是私有的也能够访问的问题:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator<(const Date& d)
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year && _month < d._month)
{
return true;
}
else if (_year == d._year && _month == d._month && _day < d._day)
{
return true;
}
else
{
return false;
}
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2025, 2, 11);
Date d2(2024,8,14);
cout << (d1 < d2) << endl;
//d1 < d2等价于d1.operator<(d2)
cout << d1.operator<(d2) << endl;
return 0;
}
程序运行结果:
只是在类中定义运算符重载时,要少传一个参数。因为有隐含的this指针。注意:在上面的类中定义运算符重载时,不能交换d1和d2的位置,因为明确了this指针是这里的左操作数,传的参数是右操作数。如果交换了d1和d2的位置,那就变成了d2<d1的比较了!
运算符重载:本质就是对运算符运算方式的重新定义,因为对于内置类型通过运算符运算机器是知道的,因为这是C/C++语言原生定义的运算方式!而自定义类型就不能简单的通过运算符来进行运算了!所以才引入了运算符重载,你自己重新定义运算符的运算规则。当你重新定义了运算符的规则以后,再通过运算符进行类类型之间的运算,编译器就会自动转换成你定义的运算符重载函数进行运算,这样程序的可读性就会增强。当然编译器会自动分辨你写的是内置类型的运算还是自定义类型的运算!
当然如果我们在类中定义了运算符重载,那在类中再定义其他的运算符重载的话,我们也是可以复用的。比如我们要计算一个Date类变量加上一个天数后的日期,可以如下定义运算符重载:
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;
}
bool operator<(const Date& d)
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year && _month < d._month)
{
return true;
}
else if (_year == d._year && _month == d._month && _day < d._day)
{
return true;
}
else
{
return false;
}
}
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool operator<=(const Date& d)
{
return *this < d || *this == d;
}
bool operator>(const Date& d)
{
return !(*this <= d);
}
bool operator>=(const Date& d)
{
return !(*this < d);
}
bool operator!=(const Date& d)
{
return !(*this == d);
}
int GetMonthDay(int year,int month)
{
int Day[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)))
{
return 29;
}
return Day[month];
}
Date& operator+=(int day)
{
_day += day;
while(_day > GetMonthDay(_year, _month))
{
//天满了,月进位
_day -= GetMonthDay(_year, _month);
_month++;
//月满了,年进位
if (_month > 13)
{
_year++;
_month = 1;
}
}
return *this;
}
Date operator+(int day)
{
Date tmp(*this);
tmp += day;
return tmp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 23);
Date ret = d1 + 50;
ret.Print();
return 0;
}
程序运行结果:
所以如果在类中定义了运算符重载的话,那就很方便了!在定义其他的运算符重载时,就可以直接复用,大大减少了程序的工程量。
5.2 赋值运算符重载
1.赋值运算符重载的格式
○ 参数类型: const T&, 传递引用可以提高传参效率
○ 返回值类型: T&, 返回引用可以提高返回的效率; 有返回值的目的是为了支持连续赋值
○ 需要检测是否自己给自己赋值
○ 返回*this: 要符合连续赋值的含义
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._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(2025, 2, 18);
Date d2(2024, 6, 19);
d1.Print();
d2.Print();
d1 = d2;
d1.Print();
}
程序运行结果:
在赋值运算符重载中,参数用引用并且用const修饰,是因为在比较两个日期类是否相等,所以直接传引用就可以了,不需要传值,不然会调用拷贝构造会有消耗,其次加const是为了防止误改d。由于出了函数operator=作用域以后,this指针指向的对象还在,所以用引用作返回值,就不会去调用拷贝构造,提高了程序的效率。
由于怕自己给自己赋值,所以上面加了if语句,判断传过来的形参d的地址是否和this指针相等。
我们再来看一个表达式的返回结果:
int i = 0;
cout << (i = 10) << endl;
程序运行结果:
所以一个赋值以后的表达式是有返回值的,返回值就是赋值后左操作数的结果。所以我们构造的赋值运算符重载要带有返回值,这样赋值运算符重载就能支持连续赋值。
🍓🍓注意:赋值运算符重载和拷贝构造是有区别的!拷贝构造是一个已经存在的对象去初始化另一个要创建对象;而赋值是两个已经存在的对象进行拷贝。
2.赋值运算符只能重载成类的成员函数不能重载成全局函数
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
//赋值运算符重载成全局函数,注意:重载成全局函数时就没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
原因: 赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
3.用户没有显式定义赋值运算符重载时,编译器会生成一个默认的赋值运算符重载,以值的方式逐字节拷贝。注意: 内置类型成员变量是直接赋值的,而自定义类型的成员变量是去调用其对应类的赋值运算符重载完成赋值。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time& operator=(const Time& t)
{
cout << "Time& operator=(const Time& t)" << endl;
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
//基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
//自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2;
d1 = d2;
return 0;
}
程序运行结果:
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了, 还需要自己实现吗?
当然像日期类这样的类是没必要的。那么下面的Stack类呢?验证一下试试?
//这里会发现下面的程序会崩溃掉?这里就需要我们以后讲的深拷贝去解决。
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;
s2 = s1;
return 0;
}
当我们没有显式的实现赋值运算符重载函数时,则默认生成的赋值运算符重载会造成浅拷贝的问题,也就是指针_array的值会拷贝给另一个对象,造成了s2的_array和s1的_array都指向了同一块空间。这样最后析构的时候就会造成同一块空间释放两次,程序会崩溃!而且赋值以后还会造成内存泄漏,因为原来s2申请的空间由于赋值了其他Stack类变量的值,导致s2原来指向的空间找不到!
注意: 如果类中未涉及到资源管理,赋值运算符是否实现都可以; 一旦涉及到资源管理则必须要(显式)实现。
5.3 前置++和后置++重载
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
//前置++:返回+1之后的结果
Date& operator++()
{
_day += 1;
return *this;
}
//后置++:返回+1之前的结果
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
Date d1(2022, 1, 13);
d = d1++; // d: 2022,1,13 d1:2022,1,14
d.Print();
d = ++d1; // d: 2022,1,15 d1:2022,1,15
d.Print();
return 0;
}
程序运行结果:
(1) 前置++:返回+1之后的结果
注意:this指向的对象函数调用结束后不会销毁,故以引用方式返回提高效率。
(2) 后置++:返回+1之前的结果
前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确的函数重载,两个函数做了区分:
🔥C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递(或者显式调用的时候随便给一个整型值即可,调用的时候会自动匹配)。
注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需要在实现时先将this保存一份,然后给this+1。而temp是临时对象,因此只能以值的方式返回,不能返回引用!
6 const成员
将const修饰的"成员函数"称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
我们来看看下面的代码:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_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();
}
int main()
{
Test();
return 0;
}
程序运行结果:
通过上面的结果可以看到:当日期类变量d2加了const修饰以后就具有了常性,即不能修改d2的值。所以匹配调用的是Print() const函数来进行打印(权限的平移)。C++语法规定:由于在成员函数的形参位置不能显式的用this指针,那就没办法在( )里对this指针加const修饰;所以C++规定将const写在括号外面的右边,就表示const Date* this !
我们说过权限可以平移、缩小,但是不能放大!所以上面的d1变量可以调用Print( ) const(权限的缩小)。
那如果在返回值类型前面加上const会怎么样呢?看下面:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
const int func()
{
int ret;
return ret;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d;
d.func() = 1;
return 0;
}
程序运行后报错:
因为func函数的返回值是调用拷贝构造生成的临时对象,而临时对象具有常性,即不能修改临时对象的值,所以上面的d.func( )是不能被修改的!(所以上面在返回值类型前面加上const和没加都是一样的)
6.1 [ ]运算符重载及const修饰的成员函数特性
由于有运算符重载,我们就可以将[ ]运算符重载;当我们定义了顺序表以后,结构体变量就可以直接使用[ ]来访问数组中的元素了:
struct SeqList
{
void PushBack(int x)
{
//容量检查…
_a[_size++] = x;
}
//只能读不能写
size_t Size() const
{
return _size;
}
//只读不能写
const int& operator[](size_t i) const
{
assert(i < _size);
return _a[i];
}
//能读能写
int& operator[](size_t i)
{
assert(i < _size);
return _a[i];
}
private:
//声明可以给_a指针一个malloc的缺省值(C++11支持)
int* _a = (int*)malloc(sizeof(int) * 10);
size_t _size = 0;
size_t _capacity = 10;
};
//const修饰形参s,则s[i]匹配的是operator[](size_t i) const
void Print(const SeqList& s)
{
for (size_t i = 0; i < s.Size(); i++)
{
cout << s[i] << " ";
}
cout << endl;
}
int main()
{
SeqList sl;
sl.PushBack(1);
sl.PushBack(2);
sl.PushBack(3);
sl.PushBack(4);
Print(sl);
for (size_t i = 0; i < sl.Size(); i++)
{
//sl[i]等价于sl.operator[](i)
cout << sl[i] << " ";
}
cout << endl;
for (size_t i = 0; i < sl.Size(); i++)
{
sl[i]++; //调用int& operator[](size_t i)
}
cout << endl;
Print(sl);
return 0;
}
程序运行的结果:
🔥由于[ ]运算符被重载了,所以上面的sl变量可以直接使用[ ]来访问顺序表中的数据。但是有时候我们可能并不想改掉顺序表中的内容。那在运算符重载中,const修饰就显得至关重要。如果我们只想通过运算符重载去读一个变量中的数据,那就可以让const修饰引用的返回值类型:
🔥上面const修饰的返回值类型为int&,即返回 _a[i]的别名,并且不能修改_a[i]的值!最右边的const修饰的是this指针(const struct* this), 表示传过来的实参不能被修改。当我们想能通过运算符重载去修改结构sl中的_a的数据时,就可以用不加const修饰的引用作返回值:
上面的两个运算符重载成员函数构成了函数重载!这样我们在修改结构体变量中的_a的值时,就会自动匹配对应的运算符重载函数。💡注意: 上面顺序表中const修饰了this指针,只是限制了sl的成员变量_a、_size、_capacity不能被修改,但 _a所指向的堆上申请的空间里的内容是没有被限制修改的!限制只是_a这个指针的值不能被修改!
总结:
🍂1.如果我只想使用(读)自定义类型对象中的内容时,我们就可以加const修饰;这样就只能读数据,不能写数据(const修饰this及引用作返回值)。
🍂1.如果我们不仅想读自定义类型对象中的内容,还想改变对象中的内容时,就可以定义函数重载(不加const修饰, 但是引用作返回值)。在使用时编译器会自动匹配最适合的那个运算符重载函数!
答:1.const修饰的对象不可以调用非const修饰的成员函数,因为权限会放大。
2.非const修饰的对象可以调用const修饰的成员函数,因为这是权限的缩小,是可以的!
3.const修饰的成员函数内也不可以调用非const修饰的成员函数,还是因为权限会放大!
4.非const成员函数内可以调用其他的非const修饰的成员函数,因为权限缩小是可以的!
🍃所以总结一下就是:const修饰的和非const修饰的对象或成员函数都可以调用const修饰的成员函数!所以我们应该在成员函数处考虑是否要加上const修饰。如果是只读函数(即内部不涉及修改成员的), 就可以加上const修饰!
🧣🧣注意:在类中如果将成员函数的声明和定义分开写了,那如果要加上const修饰成员函数的话,就必须在声明和定义处都要加上const。而且也要注意在类中如果涉及成员函数之间的相互调用时,有成员函数加上了const修饰,那在相互调用时就要注意传参时不能出现权限的放大。
7 取地址及const取地址操作符重载
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print() const
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
public:
Date* operator&()
{
return this;
}
const Date* operator&() const
{
//cout << "Date* operator&() const" << endl;
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1(2025, 2, 20);
d1.Print();
cout << &d1 << endl;
const Date d2;
d2.Print();
cout << &d2 << endl;
return 0;
}
程序运行结果:
这两个运算符一般不需要重载(即不需要我们自己实现),使用编译器生成的默认取地址重载即可,只有特殊情况才需要重载,比如: 不想让别人获取到指定的内容!
🍓🍓取地址及const取地址运算符重载也是两个默认成员函数。即我们不写, 编译器也会自动生成(如果我们显式的写了,编译就不会再生成了)。由于自定义类型的对象是不可以直接对其使用运算符的,所以我们平时对自定义类型的对象用&运算符,能实现的原因就是编译认生成的默认取地址运算重载(库里面写了)。这两个运算符重载不怎么重要,了解知道就行。