您现在的位置是:首页 >技术杂谈 >【C++初阶】类和对象(三)网站首页技术杂谈
【C++初阶】类和对象(三)
📝个人主页:@Sherry的成长之路
🏠学习社区:Sherry的成长之路(个人社区)
📖专栏链接:C++初阶
🎯长路漫漫浩浩,万事皆有期待
上一篇博客:【C++初阶】类和对象(二)
1.const 成员
若定义了一个 const 的对象,然后访问其成员函数,会报错,这是为什么?
因为在传参时,d2 的地址 &d2 会被传递给 Print() ,作为隐藏的参数 this 指针:
void Print(Date* const this) // this 指针隐藏
{
cout << _year << '-' << _month << '-' << _day << endl;
}
对于 Date d1 ,传递过去的 &d 是 Date* ;而 const Date d2 ,传递过去的 &d2 是 const Date* .
对于 this 指针本身是 Date* const this ,此刻 const 修饰的是 this ,this 不可改,但是 * this 是可改的 。
而传参时传过来的 &d2 为 const Date* ,这时 const 修饰指针指向的内容,即对象本身不可改了。
但对于Print()函数的 this 来说,*this,也就是指向的内容,即对象本身是可改的,但是现在由于 const 使得指向内容不可改,对于权限来说,只能对等和缩小,但是const Date d2在传递时权限放大了,所以报错 。
为了解决这一问题,C++ 引入了 const 成员 ,在该成员后加上 const :
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
void Print() const
{
cout << _year << '-' << _month << '-' << _day << endl;
}
这时 this 指针的类型变为 const Date* const this ,权限对等了,这时 this 指针不能改,且 *this ,即 this 指向的对象也不能改,和 const Date d2 的目的相同:不可改 d2 。 这时,就不会报错了。
而对于 d1 对象,它虽然没有 const ,但是也只是 权限缩小,使得 d1 在 Print() 成员函数中不可修改而已,也是没问题的。
总结
:成员函数加上 const 是好的,建议能加上 const 都加上。这样普通对象和 const 对象,都可以调用。但是如果对于要对 对象 进行修改的成员函数不要加上,不然就完成不了目的了。
注
:对于构造和析构不能加上const修饰。
2.取地址与const取地址操作符重载
我们知道,对于自定义类型成员来说,平常的操作符需要重载后才能对对象进行操作。但是对于自定义类型的对象来说,如果不写这两个成员函数,使用默认的成员函数照样也可以完成目的:
打印结果:
所以一般不写,但是写的话也可以:
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
};
就只要返回 this 就可以;对于 const 取地址操作符,则要加上 const 成员,并且返回的指针也要加上 const 修饰。
取地址与const取地址操作符重载:
可以直接取出成员的地址,一般不自己写
运用场景:使其取不到地址
总结
: 对于六个默认成员函数,前四个最重要:构造、析构、拷贝构造、运算符重载。后两个有一定作用,但是作用不大。
3. 再谈构造函数
3.1 构造函数体赋值
在创建对象时,编译器会通过调用构造函数,给对象中的各个成员变量一个合适的初始值:
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
注意
:虽然通过调用上述的构造函数后,对象中的每个成员变量都有了一个初始值,但是构造函数中的语句只能将其称作为赋初值,而不能称作为初始化。因为初始化只能初始化一次,而构造函数体内可以进行多次赋值。
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;// 第一次赋值
_year = 2022;// 第二次赋值
//...
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
3.2 初始化列表
3.2.1 定义
以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式。初始化列表:对象的成员定义的位置
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
3.2.2 注意事项
:
1.每个成员变量在初始化列表中最多只能出现一次
因为初始化只能进行一次,所以同一个成员变量在初始化列表中不能多次出现。
2.类中包含以下成员,必须放在初始化列表进行初始化
2.1 引用成员变量
引用类型的变量在定义时就必须给其一个初始值,所以引用成员变量必须使用初始化列表对其进行初始化。
int a = 10;
int& b = a;// 创建时就初始化
2.2 const成员变量
被const修饰的变量也必须在定义时就给其一个初始值,也必须使用初始化列表进行初始化。
const int a = 10;//correct 创建时就初始化
const int b;//error 创建时未初始化
2.3 自定义类型成员(该类没有默认构造函数)
若一个类没有默认构造函数,那么我们在实例化该类对象时就需要传参对其进行初始化,所以实例化没有默认构造函数的类对象时必须使用初始化列表对其进行初始化。
默认构造函数是指不用传参就可以调用的构造函数:
1.编译器自动生成的构造函数。
2.无参的构造函数。
3.全缺省的构造函数。
class A //该类没有默认构造函数
{
public:
A(int val) //注:这个不叫默认构造函数(需要传参调用)
{
_val = val;
}
private:
int _val;
};
class B
{
public:
B()
:_a(2021) //必须使用初始化列表对其进行初始化
{}
private:
A _a; //自定义类型成员(该类没有默认构造函数)
};
总结
:在定义时必须进行初始化的变量类型,就必须放在初始化列表进行初始化。
三、尽量使用初始化列表初始化
因为初始化列表实际上就是当实例化一个对象时,该对象的成员变量定义的地方,所以无论是否使用初始化列表,都会走这么一个过程(成员变量需要定义出来)
严格来说:
1.对于内置类型,使用初始化列表和在构造函数体内进行初始化实际上是没有差别的,其差别就类似于如下代码:
// 使用初始化列表
int a = 10
// 在构造函数体内初始化(不使用初始化列表)
int a;
a = 10;
2.对于自定义类型,使用初始化列表可以提高代码的效率
class Time
{
public:
Time(int hour = 0)
{
_hour = hour;
}
private:
int _hour;
};
class Test
{
public:
// 使用初始化列表
Test(int hour)
:_t(12)// 调用一次Time类的构造函数
{}
private:
Time _t;
};
当我们要实例化一个Test类的对象时,我们使用了初始化列表,在实例化过程中只调用了一次Time类的构造函数。
我们若是想在不使用初始化列表的情况下,达到我们想要的效果,就不得不这样写了:
class Time
{
public:
Time(int hour = 0)
{
_hour = hour;
}
private:
int _hour;
};
class Test
{
public:
// 在构造函数体内初始化(不使用初始化列表)
Test(int hour)
{ //初始化列表调用一次Time类的构造函数(不使用初始化列表但也会走这个过程)
Time t(hour);// 调用一次Time类的构造函数
_t = t;// 调用一次Time类的赋值运算符重载函数
}
private:
Time _t;
};
这时,当我们要实例化一个Test类的对象时,在实例化过程中会先在初始化列表时调用一次Time类的构造函数,然后在实例化t对象时调用一次Time类的构造函数,最后还需要调用了一次Time类的赋值运算符重载函数,效率就降下来了。
3.初始化列表虽好,但有些地方还是需要函数体赋值,比如判断开辟空间是否成功·
还要一些工作是初始化列表做不完的,比如动态开辟二维数组
四、成员变量在类中声明的次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后顺序无关:
#include <iostream>
using namespace std;
int i = 0;
class Test
{
public:
Test()
:_b(i++)
,_a(i++)
{}
void Print()
{
cout << "_a:" << _a << endl;
cout << "_b:" << _b << endl;
}
private:
int _a;
int _b;
};
int main()
{
Test test;
test.Print(); //打印结果test._a为0,test._b为1
return 0;
}
代码中,Test类构造函数的初始化列表中成员变量_b先初始化,成员变量_a后初始化,按道理打印结果test._a为1,test._b为0,但是初始化列表的初始化顺序是成员变量在类中声明次序,所以最终test._a为0,test._b为1。
例题:
答案:D,按申明的顺序,先初始化_a2,为随机值,后初始化_a1,为1
所以在写程序时要尽量按照申明的顺序初始化,否则容易入坑:
运行以上程序,会崩溃,因为按申明的顺序,先初始化_a,但此时capacity还未初始化,为随机值,开辟的空间太大,程序崩溃。
五、到底是否使用初始化列表,具体问题具体分析
3.3 explicit关键字
构造函数不仅可以构造和初始化对象,对于单个参数的构造函数,还支持隐式类型转换。
#include <iostream>
using namespace std;
class A
{
public:
A(int a) //单个参数的构造函数
:_a(a)
{
cout << "A(int a)" << endl;
}
private:
int _a;
};
int main()
{
A aa1 (1);
A aa2 = 2;//隐式类型转换
return 0;
}
在语法上,代码中A aa2 = 2等价于以下两句代码:
Date tmp(2); //先构造
Date aa2(tmp); //再拷贝构造
在早期的编译器中,当编译器遇到 A aa2 = 2 这句代码时,会先构造一个临时对象(临时对象具有常性),再用临时对象拷贝构造 aa2;但是现在的编译器已经做了优化,当遇到 A aa2 = 2这句代码时,会按照 A aa2 (2)这句代码处理,这就是隐式类型转换。
实际上,我们早就接触了隐式类型转换:
int i = 10;
double d = i; //隐式类型转换
在这个过程中,编译器会先构建一个double类型的临时变量接收i的值,然后再将该临时变量的值赋值给d。这就是为什么函数可以返回局部变量的值,因为当函数被销毁后,虽然作为返回值的变量也被销毁了,但是隐式类型转换过程中所产生的临时变量并没有被销毁,所以该值仍然存在。
但是,对于单参数的自定义类型来说,A aa2 = 2 这种代码的可读性不是很好,我们若是想禁止单参数构造函数的隐式转换,可以用关键字explicit来修饰构造函数。
4. static成员
4.1 概念
声明为static的类成员称为类的静态成员。用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化。
4.2 特性
一、静态成员为类对象所共享,不属于某个具体的对象
#include <iostream>
using namespace std;
class Test
{
private:
static int _n;
};
int main()
{
cout << sizeof(Test) << endl;
return 0;
}
结果计算Test类的大小为1,因为静态成员_n是存储在静态区的,属于整个类,也属于类的所有对象。所以计算类的大小或是类对象的大小时,静态成员并不计入其总大小之和。
二、静态成员变量必须在类外定义,定义时不添加static关键字
class Test
{
private:
static int _n;
};
// 静态成员变量的定义初始化
int Test::_n = 0;
注意
:这里静态成员变量_n虽然是私有,但是我们在类外突破类域直接对其进行了访问。这是一个特例,不受访问限定符的限制,否则就没办法对静态成员变量进行定义和初始化了。
三、静态成员函数没有隐藏的this指针,不能访问任何非静态成员
class Test
{
public:
static void Fun()
{
cout << _a << endl; //error不能访问非静态成员
cout << _n << endl; //correct
}
private:
int _a; //非静态成员
static int _n; //静态成员
};
注意
:含有静态成员变量的类,一般含有一个静态成员函数,用于访问静态成员变量。
四、访问静态成员变量的方法
1.当静态成员变量为公有时,有以下几种访问方式:
#include <iostream>
using namespace std;
class Test
{
public:
static int _n; //公有
};
// 静态成员变量的定义初始化
int Test::_n = 0;
int main()
{
Test test;
cout << test._n << endl; //1.通过类对象突破类域进行访问
cout << Test()._n << endl; //3.通过匿名对象突破类域进行访问
cout << Test::_n << endl; //2.通过类名突破类域进行访问
return 0;
}
2.当静态成员变量为私有时,有以下几种访问方式:
#include <iostream>
using namespace std;
class Test
{
public:
static int GetN()
{
return _n;
}
private:
static int _n;
};
// 静态成员变量的定义初始化
int Test::_n = 0;
int main()
{
Test test;
cout << test.GetN() << endl; //1.通过对象调用成员函数进行访问
cout << Test().GetN() << endl; //2.通过匿名对象调用成员函数进行访问
cout << Test::GetN() << endl; //3.通过类名调用静态成员函数进行访问
return 0;
}
五、静态成员和类的普通成员一样,也有public、private和protected这三种访问级别
所以当静态成员变量设置为private时,尽管我们突破了类域,也不能对其进行访问。
提两个问题:
1、静态成员函数可以调用非静态成员函数吗?
答:不可以。因为非静态成员函数的第一个形参默认为this指针,而静态成员函数中没有this指针,故静态成员函数不可调用非静态成员函数。
2、非静态成员函数可以调用静态成员函数吗?
答:可以。因为静态成员函数和非静态成员函数都在类中,在类中不受访问限定符的限制。
六、拓展
:设计一个类,只能在栈/堆上创建对象
思路1:将构造函数私有化,但都不用创建对象了
思路2:利用静态成员函数,通过类名调用静态成员函数进行对象的创建
同时,单例模式也是类似的思想
5.日期类的实现
在学习了C++的6个默认成员函数后,我们现在动手实现一个完整的日期类,来加强对这6个默认成员函数的认识。
这是日期类中所包含的成员函数和成员变量:
class Date
{
public:
// 获取某年某月的天数
int GetMonthDay(int year, int month);
// 构造函数
Date(int year = 0, int month = 1, int day = 1);
// 拷贝构造函数
Date(const Date& d);
// 赋值运算符重载
Date& operator=(const Date& d);
// 析构函数
~Date();
// 打印函数
void Print() const;
// 日期+=天数
Date& operator+=(int day);
// 日期+天数
Date operator+(int day) const;
// 日期-=天数
Date& operator-=(int day);
// 日期-天数
Date operator-(int day) const;
// 前置++
Date& operator++();
// 后置++
Date operator++(int);
// 前置--
Date& operator--();
// 后置--
Date operator--(int);
// 日期的大小关系比较
bool operator>(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator<(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
// 日期-日期
int operator-(const Date& d) const;
private:
int _year;
int _month;
int _day;
};
5.1 构造函数、拷贝构造函数、赋值运算符重载、 析构函数
进入构造函数体,首先需要检查日期的合法性,只有当日期合法时,才能进行后续的构造操作。
// 获取某年某月的天数
static int GetMonthDay(int year, int month)//inline
{
// 数组存储平年每个月的天数
static int dayArray[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)))
{
//闰年2月的天数
return 29;
}
return dayArray[month];
}
// 构造函数
Date::Date(int year, int month, int day)
{
// 检查日期的合法性
if (year >= 0
&& month >= 1 && month <= 12
&& day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
// 严格来说抛异常更好
cout << "非法日期" << endl;
cout << year << "年" << month << "月" << day << "日" << endl;
}
}
GetMonthDay函数中的细节:
1.该函数可能被多次调用,但指令较多,所以我们没有将其设置为内联函数。
2.函数中存储每月天数的数组最好是用static修饰,存储在静态区,避免每次调用该函数都需要重新开辟数组。
3.逻辑与应该先判断month == 2是否为真,因为当不是2月的时候我们不必判断是不是闰年。
4.用static修饰就可以在类外面调用该函数了
注意
:当函数声明和定义分开时,在声明时注明缺省参数,定义时不标出缺省参数。
拷贝构造函数:
// 拷贝构造函数
// d2(d1)
Date(const Date& d)
{
this->_year = d._year;
_month = d._month;
_day = d._day;
}
赋值运算符重载:
// 赋值运算符重载
// d2 = d3 -> d2.operator=(&d2, d3)
Date& operator=(const Date& d)
{
if (this != &d)
{
this->_year = d._year;
this->_month = d._month;
this->_day = d._day;
}
return *this;
}
析构函数:
// 析构函数
~Date()
{
// 清理工作
}
5.2 打印函数
// 打印函数
void Date::Print() const
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
5.3 日期 += 天数
对于+=运算符,我们先将需要加的天数加到日上面,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
1.若日已满,则日减去当前月的天数,月加一。
2.若月已满,则将年加一,月置为1。
反复执行1和2,直到日期合法为止。
// 日期+=天数
Date& Date::operator+=(int day)
{
if (day<0)
{
// 复用operator-=
*this -= -day;
}
else
{
_day += day;
// 日期不合法,通过不断调整,直到最后日期合法为止
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month > 12)
{
_year++;
_month = 1;
}
}
}
return *this;
}
注
:当需要加的天数为负数时,转而调用-=运算符重载函数。
5.4 日期 + 天数
+运算符的重载,我们可以复用上面已经实现的+=运算符的重载函数。但是要注意:虽然我们返回的是加了之后的值,但是对象本身的值并没有改变。就像a = b + 1,b + 1的返回值是b + 1,但是b的值并没有改变。所以我们还可以用const对该函数进行修饰,防止函数内部改变了this指针指向的对象。
// 日期+天数
Date Date::operator+(int day) const
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator+=
tmp += day;
return tmp;
}
注意
:+=运算符的重载函数采用的是引用返回,因为出了函数作用域,this指针指向的对象没有被销毁。但+运算符的重载函数的返回值只能是传值返回,因为出了函数作用域,对象tmp就被销毁了,不能使用引用返回。
5.5 日期 -= 天数
对于-=运算符,我们先用日减去需要减的天数,然后判断日期是否合法,若不合法,则通过不断调整,直到日期合法为止。
调整日期的思路:
1.若日为负数,则月减一。
2.若月为0,则年减一,月置为12。
3.日加上当前月的天数。
反复执行1、2和3,直到日期合法为止。
// 日期-=天数
Date& Date::operator-=(int day)
{
if (day < 0)
{
// 复用operator+=
*this += -day;
}
else
{
_day -= day;
// 日期不合法,通过不断调整,直到最后日期合法为止
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
}
return *this;
}
注
:当需要减的天数为负数时,转而调用+=运算符重载函数。
5.6 日期 - 天数
和+运算符的重载类似,我们可以复用上面已经实现的-=运算符的重载函数,而且最好用const对该函数进行修饰,防止函数内部改变了this指针指向的对象。
// 日期-天数
Date Date::operator-(int day) const
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator-=
tmp -= day;
return tmp;
}
注意
:-=运算符的重载函数采用的是引用返回,但-运算符的重载函数的返回值只能是传值返回,也是由于-运算符重载函数中的tmp对象出了函数作用域被销毁了,所以不能使用引用返回。
5.7 前置 ++
前置++,我们可以复用+=运算符的重载函数。
// 前置++
Date& Date::operator++()
{
// 复用operator+=
*this += 1;
return *this;
}
5.8 后置 ++
由于前置++和后置++的运算符均为++,为了区分它们的运算符重载,我们给后置++的运算符重载的参数加上一个int型参数,使用后置++时不需要给这个int参数传入实参,因为这里int参数的作用只是为了跟前置++构成重载,仅仅是占位,与前置区分。
// 后置++
Date Date::operator++(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator+=
*this += 1;
return tmp;
}
注意
:后置++也是需要返回加了之前的值,只能先用对象tmp保存之前的值,然后再然对象加一,最后返回tmp对象。由于tmp对象出了该函数作用域就被销毁了,所以后置++只能使用传值返回,而前置++可以使用引用返回。
由于前置效率高,且不需要进行拷贝,所以我们一般用前置++
5.9 前置 – –
前置–,我们也是可以复用前面的-=运算符的重载函数。
// 前置--
Date& Date::operator--()
{
// 复用operator-=
*this -= 1;
return *this;
}
5.10 后置– –
后置–需要注意的事项和后置++是一样的,我这里就不过多阐述了。
// 后置--
Date Date::operator--(int)
{
Date tmp(*this);// 拷贝构造tmp,用于返回
// 复用operator-=
*this -= 1;
return tmp;
}
5.11 日期类的大小关系比较
日期类的大小关系比较需要重载的运算符看起来有6个,实际上我们只用实现两个就可以了,然后其他的通过复用这两个就可以实现。
注意
:进行日期的大小比较,我们并不会改变传入对象的值,所以这6个运算符重载函数都应该被const所修饰。
5.11.1 >运算符的重载
先判断年是否大于,再判断月是否大于,最后判断日是否大于,这其中有一者为真则函数返回true,否则返回false。
bool Date::operator>(const Date& d) const
{
if (_year > d._year)
{
return true;
}
else if (_year == d._year)
{
if (_month > d._month)
{
return true;
}
else if (_month == d._month)
{
if (_day > d._day)
{
return true;
}
}
}
return false;
}
5.11.2 ==运算符的重载
==,年月日均相等,则为真。
bool Date::operator==(const Date& d) const
{
return _year == d._year
&&_month == d._month
&&_day == d._day;
}
5.11.3 >=运算符的重载
>=,即大于或者等于,满足其中之一即可。
bool Date::operator>=(const Date& d) const
{
return *this > d || *this == d;
}
5.11.4 <运算符的重载
<,大于等于的反面即是小于。
bool Date::operator<(const Date& d) const
{
return !(*this >= d);
}
5.11.5 <=运算符的重载
<=,大于的返回即是小于等于。
bool Date::operator<=(const Date& d) const
{
return !(*this > d);
}
5.11.6 !=运算符的重载
!=,等于的反面即是不等于。
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
5.17 日期 - 日期
日期 - 日期,即计算传入的两个日期相差的天数。我们只需要让较小的日期的天数一直加一,直到最后和较大的日期相等即可,这个过程中较小日期所加的总天数便是这两个日期之间差值的绝对值。若是第一个日期大于第二个日期,则返回这个差值的正值,若第一个日期小于第二个日期,则返回这个差值的负值。
// 日期-日期
int Date::operator-(const Date& d) const
{
Date max = *this;// 假设第一个日期较大
Date min = d;// 假设第二个日期较小
int flag = 1;// 此时结果应该为正值
if (*this < d)
{
// 假设错误,更正
max = d;
min = *this;
flag = -1;// 此时结果应该为负值
}
int n = 0;// 记录所加的总天数
while (min != max)
{
min++;// 较小的日期++
n++;// 总天数++
}
return n*flag;
}
代码中使用flag变量标记返回值的正负,flag为1代表返回的是正值,flag为-1代表返回的是负值,最后返回总天数与flag相乘之后的值即可。
6.总结:
今天我们认识并具体学习了类和对象的 const成员、取地址与const取地址操作符重载、初始化列表、explicit关键字、隐式类型转换、static成员的知识,并通过实现一个完整的日期类加强了默认成员函数的认识和近期所学知识的理解。接下来,我们将继续学习类和对象的相关知识。希望我的文章和讲解能对大家的学习提供一些帮助。
当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~