C++知识整理day3类与对象(下)——赋值运算符重载、取地址重载、列表初始化、友元、匿名对象、static
文章目录
- 1.赋值运算符重载
- 1.1 运算符重载
- 1.2 赋值运算符重载
- 2.取地址重载
- 2.1 const成员函数
- 2.2 取地址运算符重载
- 3.类与对象的补充
- 3.1 再探构造函数---初始化列表
- 3.2 类型转换
- 3.3 static成员
- 3.4 友元
- 3.5 内部类
- 3.6 匿名对象
- 3.7 对象拷贝时的编译器优化
1.赋值运算符重载
赋值运算符重载是六个默认成员函数之一,在讲解这个我们要先了解一下运算符重载。
1.1 运算符重载
当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符的时候,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会报编译错误。
注意:对于下面的例子中,都是使用的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;
};
- 运算符重载是具有特别名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其他函数一样,他也具有其返回类型和参数列表以及函数体。
- 重载运算符函数的参数个数和该运算符作⽤的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第⼆个参数。(注意:成员函数的第一个参数默认是this指针。)
示例:
如果我们写了这行代码,那么毫无疑问编译器会报错,因为对于类类型判断相等的符号编译器是无法识别的(只有内置类型才可以使用):
那么我们就需要写一个赋值运算符重载了,如下:
Q1:上面的代码为什么会报错?
A1:我们是定义了一个全局函数,而类内的成员变量是私有的,即类外是无法访问的。
Q2:我们如何解决上面错误呢?
A2:我们有四个方法:
- 成员变为公有(不建议,不安全)
- Date提供GetYear之类的函数
- 使用友元函数(之后会介绍)
- 重载为成员函数(推荐,下面第3点就会讲到)
- 如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数⽐运算对象少⼀个。
示例:
- 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。
- 不能通过连接语法中没有的符号来创建新的操作符:⽐如operator@。
- ⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,⽐如Date类重载operator-就有意义(两个日期相减得到天数),但是重载operator+就没有意义(两个日期相加没有意义)。
- 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,⽆法很好的区分。C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,⽅便区分。
- 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调⽤时就变成了 对象<<cout,不符合使⽤习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。
示例:
//流插入
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
//流提取
istream& operator>>(istream& in, Date& d)
{
cout << "请依次输入年月日:>";
in >> d._year >> d._month >> d._day;
return in;
}
Q:为什么返回值要是ostream&和istream&?
A:因为我们他的对象是为了让他支持连续输入输出 的特性,对于&是因为流插入和流提取是不允许被修改的。
对于上面的讲解,下面我会依次给出例子:
示例①:重载运算符+和+=
// d1 += 50
Date& 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;
}
// d1 + 50
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;
}
由于每月的天数不一样,并且闰年的二月会少一天,所以定义GetMonthDay是获取某年某月的天数,如下:
int GetMonthDay(int year, int month)
{
static int day[13] = { -1,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];
}
但是对于上面的重载运算符+和+=,其实我么你只需要实现一个就可以了,另外一个只需要通过已经重载的那个运算符来使用它即可,如下是两种不同重载对比:
我们可以发现,我们先重载运算符+=,在用+=就可以间接使用运算符重载+,这样的效率更高,只需要调用两次拷贝构造。
示例②:同上,重载运算符-和-=
// d1 -= 50
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;
}
// d1 - 50
Date Date::operator-(int day)
{
Date tmp = *this;
tmp -= day;
return tmp;
}
示例③:重载运算符>(大于)
// >
bool Date::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)
return _day > d._day;
return false;
}
示例④:运算符重载==(等于)
// ==
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
有了>和==运算符重载,可以衍生如下运算符重载:
- 大于等于
// >=
bool Date::operator>=(const Date& d)
{
return *this > d || *this == d;
}
- 小于
// <
bool Date::operator<(const Date& d)
{
return !(*this >= d);
}
- 小于等于
// <=
bool Date::operator<=(const Date& d)
{
return !(*this >= d);
}
- !=
// !=
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
示例⑤:前置++和后置++
// 前置++
Date& Date::operator++()
{
*this += 1;
return *this;
}
// 后置++
Date Date::operator++(int)
{
Date tmp = *this;
*this += 1;
return tmp;
}
注意:对于前置++,我们返回的是Date&,因为前置++,自增之后,我们返回的是+1的值。而后置++,我们返回的是没有+1之前的值,但是我们本身this指向的对象时自增了的。
1.2 赋值运算符重载
赋值运算符重载是⼀个默认成员函数,⽤于完成两个已经存在的对象直接的拷⻉赋值,这⾥要注意跟拷⻉构造区分,拷⻉构造⽤于⼀个对象拷⻉初始化给另⼀个要创建的对象。
赋值运算符的特点:
- 赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const 当前类类型引⽤,否则会传值传参会有拷⻉。
- 有返回值,且建议写成当前类类型引⽤,引⽤返回可以提⾼效率,有返回值⽬的是为了⽀持连续赋值场景。
- 没有显式实现时,编译器会⾃动⽣成⼀个默认赋值运算符重载,默认赋值运算符重载⾏为跟默认构造函数类似,对内置类型成员变量会完成值拷⻉/浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的赋值运算符重载。
- 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器⾃动⽣成的赋值运算符重载就可以完成需要的拷⻉,所以不需要我们显⽰实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器⾃动⽣成的赋值运算符重载完成的值拷⻉/浅拷⻉不符合我们的需求,所以需要我们⾃⼰实现深拷⻉(对指向的资源也进⾏拷⻉)。像MyQueue这样的类型内部主要是⾃定义类型Stack成员,编译器⾃动⽣成的赋值运算符重载会调⽤Stack的赋值运算符重载,也不需要我们显⽰实现MyQueue的赋值运算符重载。这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就需要显⽰写赋值运算符重载,否则就不需要。(与拷贝构造函数类似)
示例:
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << " Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
// 传引⽤返回减少拷⻉
// d1 = d2;
Date& operator=(const Date& d)
{
// 不要忘记检查⾃⼰给⾃⼰赋值的情况
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// d1 = d2表达式的返回对象应该为d1,也就是*this
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 11, 28);
Date d2(d1);
Date d3(2024, 11, 11);
d1 = d3;
// 需要注意这⾥是拷⻉构造,不是赋值重载
// 请牢牢记住赋值重载完成两个已经存在的对象直接的拷⻉赋值
// ⽽拷⻉构造⽤于⼀个对象拷⻉初始化给另⼀个要创建的对象
Date d4 = d1;
return 0;
}
2.取地址重载
2.1 const成员函数
- 将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后面
- const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进⾏修改。const 修饰Date类的Print成员函数,Print隐含的this指针由 Date* const this 变为 const Date* const this
意思就是我们在成员函数的后面加上了const,就意味着不可以修改this指向的对象了。
注意:
- const对象必须调用const成员函数,这是权限的平移
- const对象若调用非const成员函数,会报错误,这是权限的放大
- 非const对象调用const成员函数,是允许的,这是权限的缩小
2.2 取地址运算符重载
取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,⼀般这两个函数编译器⾃动⽣成的就可以够我们⽤了,不需要去显⽰实现。除⾮⼀些很特殊的场景,⽐如我们不想让别⼈取到当前类对象的地址,就可以⾃⼰实现⼀份,胡乱返回⼀个地址。
示例:
class Date
{
public :
Date* operator&()
{
return this;
// return nullptr;
}
const Date* operator&()const
{
return this;
// return nullptr;
}
private :
int _year ; // 年
int _month ; // ⽉
int _day ; // ⽇
};
3.类与对象的补充
3.1 再探构造函数—初始化列表
- 之前我们实现构造函数时,初始化成员变量主要使⽤函数体内赋值,构造函数初始化还有⼀种⽅式,就是初始化列表,初始化列表的使⽤⽅式是以⼀个冒号开始,接着是⼀个以逗号分隔的数据成员列表,每个"成员变量"后⾯跟⼀个放在括号中的初始值或表达式。
示例:
class Time
{
public:
Time(int hour)
:_hour(hour) //初始化列表
{
cout << "Time()" << endl;
}
private:
int _hour;
};
- 每个成员变量在初始化列表中只能出现⼀次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地⽅。
- 引⽤成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进⾏初始化,否则会编译报错。
Q:为什么这三种情况必须在初始化列表进行初始化呢?
A:我们知道,①引用是不可以修改的,他不像指针可以修改,也不像指针初始化成空。在之前我们就提到过:引用必须初始化和不可以修改。②const修饰的成员变量是不可以进行修改的,他是一个常变量,存储到内存中的代码段(只读变量)。③对于自定义的类类型当中还存在自定义的类类型,我们必须调用它的默认构造函数,不然就会报错。
示例:
class Time
{
public:
Time(int hour)
:_hour(hour) //初始化列表
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date(int& x, int year = 1, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
, _t(12)
, _ref(x)
, _n(1)
{
// error C2512: “Time”: 没有合适的默认构造函数可⽤
// error C2530 : “Date::_ref” : 必须初始化引⽤
// error C2789 : “Date::_n” : 必须初始化常量限定类型的对象
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t; // 没有默认构造
int& _ref; // 引⽤
const int _n; // const
};
int main()
{
int i = 0;
Date d1(i);
d1.Print();
return 0;
}
- C++11⽀持在成员变量声明的位置给缺省值,注意:这个缺省值只是是给没有显⽰在初始化列表初始化的成员使⽤的。
- 尽量使⽤初始化列表初始化,因为那些你不在初始化列表初始化的成员也会⾛初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会⽤这个缺省值初始化。如果你没有给缺省值,对于没有显⽰在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显⽰在初始化列表初始化的⾃定义类型成员会调⽤这个成员类型的默认构造函数,如果没有默认构造会编译错误。
示例:
class Time
{
public:
Time(int hour)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date()
:_month(2)
{
cout << "Date()" << endl;
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// 注意这⾥不是初始化,这⾥给的是缺省值,这个缺省值是给初始化列表的
// 如果初始化列表没有显⽰初始化,默认就会⽤这个缺省值初始化
int _year = 1;
int _month = 1;
int _day;
Time _t = 1;
const int _n = 1;
int* _ptr = (int*)malloc(12);
};
int main()
{
Date d1;
d1.Print();
return 0;
}
- 初始化列表中按照成员变量在类中声明顺序进⾏初始化,跟成员在初始化列表出现的的先后顺序⽆关。建议声明顺序和初始化列表顺序保持⼀致。
示例:下⾯程序的运⾏结果是什么()
A. 输出 1 1
B. 输出 2 2
C. 编译报错
D. 输出 1 随机值
E. 输出 1 2
F. 输出 2 1
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2 = 2;
int _a1 = 2;
};
int main()
{
A aa(1);
aa.Print();
}
所以这道题应该选D,这道题还是很重要的。
3.2 类型转换
- C++⽀持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数
- 构造函数前⾯加explicit就不再⽀持隐式类型转换
示例:
class A
{
public:
// 构造函数explicit就不再⽀持隐式类型转换
// explicit A(int a1)
A(int a1)
:_a1(a1)
{}
//explicit A(int a1, int a2)
A(int a1, int a2)
:_a1(a1)
,_a2(a2)
{}
void Print()
{
cout << _a1 << " " << _a2 << endl;
}
private:
int _a1 = 1;
int _a2 = 2;
};
int main()
{
// 用1来构造一个A的临时对象,再用这个临时对象拷贝构造aa1
// 编译器优化:遇到连续构造+拷贝构造 -> 优化为直接构造
A aa1 = 1;
aa1.Print();
const A& aa2 = 1;
// C++11之后开始支持多参数转化
A aa3 = { 2, 2 };
return 0;
}
3.3 static成员
- ⽤static修饰的成员变量,称之为静态成员变量,静态成员变量⼀定要在类外进⾏初始化。
示例:
class A
{
private:
// 类内声明
static int _scount;
};
// 类外初始化
int A::_scount = 0;
- 静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。
示例:对于上面的示例中,我们计算类A的大小:cout << sizeof(A) << endl;
输出的结果是1,之前我们讲过1是为了占位使用的。
- ⽤static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针。
- 静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,因为没有this指针
示例:
- 非静态的成员函数,可以访问任意的静态成员变量和静态成员函数。
- 突破类域就可以访问静态成员,可以通过类名::静态成员 或者 对象.静态成员 来访问静态成员变量和静态成员函数。
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
- 注意:静态成员变量不能在声明位置给缺省值初始化,因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。
示例:实现一个类,计算程序共创建了多少个类对象
class A
{
public:
A()
{
++_scount;
}
A(const A& a)
{
++_scount;
}
~A()
{
--_scount;
}
static int GetACount()
{
return _scount;
}
private:
// 类内声明
static int _scount;
};
//类外初始化
int A::_scount = 0;
int main()
{
//静态成员不属于某个对象,可以直接通过类域访问到
cout << A::GetACount() << endl;
A a1, a2;
A a3(a1);
cout << A::GetACount() << endl;
cout << a1.GetACount() << endl;
// 编译报错:error C2248: “A::_scount”: ⽆法访问 private 成员(在“A”类中声明)
//cout << A::_scount << endl;
return 0;
}
牛客网的一个例题,不允许使用各种循环和递归,实现 1 + 2 + 3 + … + n ?
我们可以通过创建类来实现,创建第几个类就让他加上几。示例:
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
static int GetRet()
{
return _ret;
}
private:
static int _i;
static int _ret;
};
int Sum::_i = 1;
int Sum::_ret = 0;
class Solution
{
public:
int Sum_Solution(int n) {
//VS不支持边长数组,会报错
Sum arr[n];
return Sum::GetRet();
}
};
注意:VS不支持边长数组。
例题:有A、B、C、D四个类
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
A:D B A C
B:B A D C
C:C D B A
D:A B D C
E:C A B D
F:C D A B
Q1:程序中A,B,C,D构造函数调用顺序为?(E)
A1:这个很简单
Q2程序中A,B,C,D析构函数调⽤顺序为?(B)
A2:这里要注意一点,static修饰的变量是存放在静态区的,static修饰的变量生命周期会变长,即整个main函数栈帧销毁才会销毁D,所以辉县析构B和A,在析构D,由于C是在全局变量中,所以最后析构他(它是最先创建的,先创建的后析构)
3.4 友元
- 友元提供了⼀种突破类访问限定符封装的方式,友元分为:友元函数和友元类,在函数声明或者类声明的前⾯加friend(没有要求,可放在public中也可以放在private中),并且把友元声明放到⼀个类的里面。
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制。
- 外部友元函数可访问类的私有和保护成员,友元函数仅仅是⼀种声明,他不是类的成员函数。
- ⼀个函数可以是多个类的友元函数。
- 友元类中的成员函数都可以是另⼀个类的友元函数,都可以访问另⼀个类中的私有和保护成员。
- 友元类的关系是单向的,不具有交换性,⽐如A类是B类的友元,但是B类不是A类的友元。
- 友元类关系不能传递,如果A是B的友元, B是C的友元,但是A不是B的友元。
- 有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元函数示例:
// 前置声明,否则A的友元函数声明编译器不认识B
class B;
class A
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _b1 = 3;
int _b2 = 4;
};
void func(const A& aa, const B& bb)
{
cout << aa._a1 << endl;
cout << bb._b1 << endl;
}
int main()
{
A aa;
B bb;
func(aa, bb);
return 0;
}
友元类示例:
class A
{
// 友元声明
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
void func1(const A& aa)
{
cout << aa._a1 << endl;
cout << _b1 << endl;
}
void func2(const A& aa)
{
cout << aa._a2 << endl;
cout << _b2 << endl;
}
private:
int _b1 = 3;
int _b2 = 4;
};
int main()
{
A aa;
B bb;
bb.func1(aa);
bb.func2(aa);
return 0;
}
3.5 内部类
- 如果⼀个类定义在另⼀个类的内部,这个内部类就叫做内部类。内部类是⼀个独⽴的类,跟定义在全局相⽐,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类。
- 内部类默认是外部类的友元类
- 内部类本质也是⼀种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使⽤,那么可以考虑把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其他地方都用不了。
示例:
class A
{
public:
class B //B默认就是A的友元
{
public:
void func(const A& a)
{
cout << _k << ' ' << a.h << endl;
}
};
private:
static int _k;
int h = 1;
};
int A::_k = 1;
int main()
{
//输出结果是4,所以内部类是不占空间的
cout << sizeof(A) << endl;
A::B b; //实例化B
A aa;
b.func(aa);
return 0;
}
我们把上面那个牛客网的题目优化一下:
class Solution
{
public:
class Sum
{
Sum()
{
_ret += _i;
_i++;
}
};
public:
int Sum_Solution(int n)
{
// 变长数组
Sum arr[n];
return _ret;
}
private:
static int _i;
static int _ret;
};
int Solution::_i = 1;
int Solution::_ret = 0;
这样写是不是更简单、优美了。
3.6 匿名对象
- 用类型(实参) 定义出来的对象叫做匿名对象,相比之前我们定义的 类型 对象名(实参) 定义出来的叫有名对象。
- 匿名对象⽣命周期只在当前⼀行,⼀般临时定义⼀个对象当前用⼀下即可,就可以定义匿名对象。
示例:
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a = 0)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
class Solution
{
public:
int Sum_Solution(int n) {
return n;
}
};
int main()
{
A aa1;
//不可以这样定义对象,因为编译器⽆法识别下⾯是⼀个函数声明,还是对象定义
//这里VS没有报错,也没有调用它的构造和析构函数,说明编译器把它当做是函数声明
//A aa2();
//但是我们可以这么定义匿名对象,匿名对象的特点不⽤取名字
//但是他的⽣命周期只有这⼀⾏,我们可以看到下⼀⾏他就会⾃动调⽤析构函数
A();
A(1);
// 匿名对象在这样场景下就很好⽤,当然还有⼀些其他使⽤场景
Solution().Sum_Solution(10);
return 0;
}
3.7 对象拷贝时的编译器优化
- 现代编译器会为了尽可能提⾼程序的效率,在不影响正确性的情况下会尽可能减少⼀些传参和传参过程中可以省略的拷⻉。
- 如何优化C++标准并没有严格规定,各个编译器会根据情况⾃⾏处理。当前主流的相对新⼀点的编译器对于连续⼀个表达式步骤中的连续拷⻉会进⾏合并优化,有些更新更"激进"的编译还会进⾏跨⾏跨表达式的合并优化。
这一点未完待续!!!