本篇博客是学校大一下C++期末考察的要求,包含了C++中类和对象的大部分内容
起初为了避免被同校copy,本篇博客只在我的HEXO个人博客上发布【链接】
现在期末考试已经结束,于是也发到CSDN上面
有任何问题,都可以在评论区提出!
面向对象有下面几个特征:
通过这些特征,我们可以看得到,其实面向对象的程序也是在C语言的面向过程的基础上得来的。其目的也是为了更好的服务某一个具体的问题,并通过这个问题衍生出不同的解题方法,并用代码来表示解决一个问题对象的过程。
类和对象与命名空间类似,都是存在一种封装。不同的是,类是对C语言struct结构体类型的拓。除了变量以外,类里面还可以定义成员函数,并设置它们的权限,实现了对一个接口的完整封装。
众所周周知,当我们写C语言的顺序表、链表等代码的时候,一般都会写一个Init
函数来初始化内容。
void Init()
{
a=(int*)malloc(sizeof(int)*4);
size=0;
capa=4;
}
但是这样有一个缺点,就是不够智能,需要我们自己来调用它进行初始化。
于是C++就整出来了一个构造函数来解决这个问题
特性
构造函数:名字和类名相同,创建类对象的时候编译器会自动调用,初始化类中成员变量,使其有一个合适的初始值。构造函数在对象的生命周期中只调用一次
构造函数有下面几个特性:
下面用一个队列来演示一下构造函数
class Queue{
public:
Queue()
{
cout<<"Queue Init"<<endl;//测试是否调用
_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
}
void Print()
{
cout<<this<<": ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
}
private:
int* _a;
int _size;
int _capa;
};
可以看到,在创建对象q1的时候,编译器就自动调用了类中的构造函数,帮我们初始化了这个队列
除了上面这种最基本的无参构造函数以外,一般写构造函数的时候,我们都会带一个有缺省值的参数,这样可以更好地灵活使用这个队列
Queue(int Capacity=4)
{
_a=(int*)malloc(sizeof(int)*Capacity);
_size=0;
_capa=Capacity;
}
调用这种构造函数也更加灵活,我们可以根据数据类型的长度,来创建不同容量的队列,避免多次realloc
造成的内存碎片
Queue q1;//调用无参的构造函数
Queue q2(100);//调用带参的构造函数
多种构造函数是可以同时存在的,不过!它们需要满足函数重载的基本要求
当你调用一个无参的函数,和一个全缺省的函数的时候,编译器会懵逼!
Queue();
Queue(int Capacity=4);
//这两个函数不构成重载,会报错
正确的重载应该是下面的情况
Queue();
Queue(int Capacity);
编译器在创建对象的时候,就会智能选择这两个构造函数其中之一进行调用。但是同一个对象只会调用一个构造函数。
除了在构造函数内部初始化参数,我们还可以在初始化列表处进行操作
Queue(int Capacity=4)
:_a(new int[Capacity]),
_size(0),
_capa(Capacity)
{ }
上面提到过,如果我们不写构造函数,编译器会自己生成一个。
但测试过以后,你会发现,这个默认生成的构造函数,好像啥事都没有干——或者说,它把_a _b _c
都初始化成了随机值!
实际上,编译器默认生成的构造函数是不会处理内置类型的
在处理的时候,编译器忽略内置类型;外置类型会调用它的构造函数
class Date{
public:
//默认构造函数:不传参就能调用的
//1.全缺省 2.无参 3.编译器自动生成
//可以是半缺省的,但是不实用
Date(int year=2022,int month=2,int day=30)
{
_year=year;
_month=month;
_day=day;
}
void Print()
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
_A.Print();
}
private:
//编译器会自动生成构造函数(如果你没有自己写的话)
//自动生成的构造函数是不会初始化内置类型的
//内置类型:int,char,double等等
int _year;
int _month;
int _day;
//外置类型:自定义类型
//外置类型会调用它自己的默认构造函数
Queue _A;
};
可以看到,编译器调用了自己的构造函数的同时,还调用了外置类型Queue
的构造函数,搞定了它的初始化
如果我们去掉Date的构造函数,就能看到下面的情况。Queue
成功初始化,但是内置类型的年月日都是随机值
一般情况下一个C++类都需要自己写构造函数,下面这两个情况除外
注:只有类在声明变量的时候才可以给缺省值
//下面的情况就不需要写
class MyStack{
private:
Queue q1;//自定义类型
Queue q2;
int a=1;//内置类型声明的时候给了缺省值
};
拷贝构造是一个特殊的构造函数,它的参数是另外一个Date类型。在用已有的类类型对象来创建新对象的时候,由编译器自动调用
因为拷贝的时候我们不会修改d的内容,所以传的是const
。另外,我们必须进行传引用调用!
如下面的这个函数,在传参的时候,编译器会去调用Date的拷贝构造
void func(Date d);
如果你没有写拷贝构造,或者拷贝构造里面不是传引用,编译器会就递归不断创建新的对象进行值拷贝构造,程序就死循环辣
//拷贝构造,如果不写的时候,编译器会默认生成一个
//对内置类型进行值拷贝(浅拷贝)
Date(const Date& d)
{
_year=d._year;
_month=d._month;
_day=d._day;
//外置类型会调用外置类型的拷贝构造
Queue b(_A);
}
和构造、析构不同的是,编译器自己生成的拷贝构造终于有点用了
外置类型拷贝问题
但是!如果你使用了外置类型,该类型中包含malloc的时候,编译器默认生成的构造函数就不能用辣!
因为这时候,编译器默认生成的拷贝构造会进行值拷贝,拷贝完了之后,就会出现q1和q2指向同一个空间的情况。修改q2会影响q1,free的时候多次释放同一个空间会报错,不符合我们的拷贝构造的要求
注意注意,malloc不行的原因是,数据是存在堆区里面,拷贝的时候,q2的_a
得到的是一个地址,而不是拷贝了新的数据内容。
int arr[10]
数组,这时候拷贝构造就相当于memcpy
,是可以完成拷贝的工作的如何解决这个问题呢?我们需要使用深拷贝
了解new和delete
从C语言转到C++,多了new和delete关键字,它们分别对应malloc和free
int main()
{
int*p1=new int;//开辟一个int类型的空间
int*p2=new int(10);//开辟一个int类型的空间,并初始化为10
int*p3=new int[10];//开辟10个int类型的空间
//注意后两个的括号区别!
delete p1;//销毁p1指向的单个空间
delete p2;//同上
//delete p3;//销毁p3指向的第一个空间,不能用于数组
delete[] p3;//销毁p3指向的数组
return 0;
}
深拷贝实现
在上面写道过,编译器会自动生成拷贝构造函数,完成值拷贝工作。但是队列的代码里面包含堆区的空间,需要我们正确释放。这时候就需要自己写一个拷贝构造完成深拷贝
//拷贝构造
Queue(const Queue& q)
{
_a=new int[q._capa];//注意解引用
memcpy(_a, q._a, q._capa*sizeof(int));//拷贝内容
_size=q._size;
_capa=q._capa;
}
用下面这个队列的代码来测试深拷贝
#include
#include
#include
using namespace std;
class Queue{
public:
Queue()
{
cout<<"Queue Init"<<endl;//测试是否调用
//_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
_a=new int[_capa];
for(int i=0;i < _capa;i++)
{
_a[i]=i+1;
}
}
//拷贝构造
Queue(const Queue& q)
{
cout<<"Queue Copy"<<endl;
_a=new int[q._capa];
memcpy(_a, q._a, q._capa*sizeof(int));
_size=q._size;
_capa=q._capa;
}
void Print()
{
cout<<"this:"<<this<<" ";
cout<<"_a:"<<_a<<" ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
for(int i=0;i < _capa;i++)
{
cout<<_a[i]<<" ";
}
cout<<endl;
}
~Queue()
{
//析构函数
//free(_a);
delete[] _a;
_a=nullptr;
_size=_capa=0;
cout<<"distory:"<<this<<endl;
}
private:
int* _a;
int _size;
int _capa;
};
int main()
{
Queue q1;
q1.Print();
cout<<endl;
Queue q2=q1;
q2.Print();
cout<<endl;
return 0;
}
深拷贝效果
先注释掉Queue
的拷贝构造函数析构函数(不然会报错)
看一看,发现在不写拷贝构造函数的时候,q2和q1的_a
指向了同一个地址
取消析构函数的注释,可以看到两次释放同一片空间,发生了报错
如果我们把写好的深拷贝构造加上,就不会出现这个问题
当你加上给_a
里面初始化一些数据,以及打印_a
数据的函数后,就可以看到,不仅q2的_a
有了自己全新的地址,其内部的值也和q1一样了
这样写出来的拷贝构造,即便把队列中的int* _a
修改为char*
或者其他类型,都能正确完成拷贝工作
和构造函数相对应,析构函数是对象在出了生命周期后自动调用的函数,用来爆破对象里的成员(如进行free操作)
生命周期是离这个对象最近的{ }
括号
特性
~
和构造函数一样,编译器自己生成的析构函数不会处理内置类型;会调用外置类型的析构函数
基本使用
析构函数的定义和我们在外部写的Destroy
函数一样,主要执行free(delete)操作
#include
#include
using namespace std;
class Queue{
public:
Queue()
{
cout<<"Queue"<<endl;//测试是否调用
_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
}
void Print()
{
cout<<this<<": ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
}
~Queue()
{
//析构函数
free(_a);
_a=nullptr;
_size=_capa=0;
cout<<"distory:"<<this<<endl;//测试调用
}
private:
int* _a;
int _size;
int _capa;
};
假设我们在main函数里面定义了两个对象,你能说出q1和q2谁先进行析构函数的调用吗?
可以看到,先调用的是q2的析构函数
因为在底层操作中,编译器会给main函数开辟栈帧
栈遵从后进先出的原则,q2是后创建的,所以在析构的时候会先析构
和普通的成员变量不同,静态成员变量不属于某一个对象,而是属于整一个类
class A{
//定义一个静态成员变量
static int _a
};
也因为这个特性,静态成员变量是不会被sizeof计入的
什么时候会用到静态成员变量?比如当我们需要计算一个类究竟开辟了多少个对象的时候。如果使用普通成员变量,它的值是属于某一个对象的,无法完成正确的count计数。使用静态成员变量后,该变量的值不会因为定义多个对象而被重置。这时候,我们就可以在构造函数和拷贝构造函数里面,使用count++
,来实现对类开辟对象个数的统计。
class STU{
public:
STU()
{
count++;
//...
}
STU(const STU& d)
{
count++;
//...
}
//声明一个静态成员变量
static int count;
};
int STU::count=0;//必须要在类外定义
这时候,每次对象创建都会让conunt+1
,我们可以通过下面两种方式访问来得到count
的值
STU::count
STU s1; s1.count;
如果想在类外直接访问静态成员变量,就不能用private
,必须是公有权限
如果把静态成员变量定义为公有,那么外部的所有函数都可以通过类域或者对象来访问这个静态成员变量,这时候就不利于我们程序的封装。所以我们可以借助静态成员函数来访问私有的静态成员变量
#include
using namespace std;
class STU{
public:
STU() {
count++;
}
STU(const STU& d){
count++;
}
static int GetCount() {
return count;
}
private:
static int count;
};
int STU::count=0;
int main()
{
STU s1;
cout<<"通过对象s1访问 "<<s1.GetCount()<<endl;
cout<<"通过类域STU::访问 "<<STU::GetCount()<<endl;
return 0;
}
静态成员函数有下面几个特点
::
静态成员或者对象.静态成员来访问有一部分数据成员,是一个定值。比如我们定义了某一个学科的类class MATH
class MATH{
int piont=6.0;//数学的学分
};
可以看到,作为一个学科,它的考试学分/绩点是固定的。这时候我们不需要在后续修改这个学分的定义,就可以将它设置为const属性,避免被其他成员误修改
class MATH{
const int piont=6.0;//数学的学分
};
和普通成员和静态成员变量不同的是
class MATH{
public:
MATH()
:score(6.0)//初始化列表处定义
{
cout<<score<<endl;//打印测试
}
private:
const double score=0;//必须在声明的时候定义初值
};
const修饰的类成员函数称之为const成员函数
,即常成员函数。const修饰类成员函数,实际修饰的是该成员函数隐含的this指针
,表明在该成员函数中不能对类的任何成员进行修改。
基本的修饰方法如下,在函数的括号后加const即可
void Print()const
{
cout<<_year<<endl;
}
实际修饰的是该函数隐含的this指针
this指针本身是Date*const
类型的,修饰后变为const Date* const
类型
void Print(const Date* const this)
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
}
这么说好像有点迷糊,我们用实例来演示一下为什么需要const修饰成员函数
class Date{
public:
Date(int year=2022,int month=2,int day=30)
{
_year=year;
_month=month;
_day=day;
}
void Print()
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
}
private:
int _year;
int _month;
int _day;
};
假设我们需要在函数中调用Print
函数,在main中是可以正常调用的
int main()
{
Date d1(2022,5,10);
d1.Print();
return 0;
}
但当你用一个函数来进行这个操作的时候,事情就不一样了
void TEST(const Date& d)
{
d.Print();//d.Print(&d) -->const Date*
}
int main()
{
Date d1(2022,5,10);
d1.Print();//d1.Print(&d1) -->Date*
TEST(d1);
return 0;
}
这时候我们进行了引用调用,因为在TEST中我们不会修改d1的内容,所以用const
进行了修饰
d.Print()
函数调用,传入的是const Date*
指针,指针指向的内容不能被修改d1.Print();
函数调用,传入的是Date*
指针于是就会发生权限冲突问题:
这时候如果我们在函数后面加了const,就可以避免此种权限放大问题。这样不管是main函数还是TEST函数中对Print()函数
的调用,就都可以正常打印了!
总结一下:
众所周周知,const修饰指针有下面两种形式
*
之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改)*
之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)this指针本身就是类型名* const
类型的,它本身不能被修改。加上const之后,this指向的内容,既类里面的成员变量也不能被修改了。
知道了这一点后,我们可以合理的判断出:只要是需要修改类中成员变量的函数,就不需要在()
后面加const修饰
如果一个函数中不需要修改成员变量,就可以加const进行修饰
注意:如果你用了声明和定义分离的写法,那么声明和定义的函数都需要加上const修饰
这里有一点需要提醒的是,如果你对某一个函数进行了const修饰,那么这个函数里面包含的其他类里面的函数,都需要进行const修饰。不然就会报错
出现该报错的情况如下
这个情况也提醒我们,不能在const修饰的函数中,调用非const修饰的成员函数
可以用const来修饰一个对象,称为常对象
const <classname> s1;
<classname> const s1;
在初始化设置完常对象后,该对象的内容就不能进行修改。我们可以通过这个对象来访问内部被const修饰的函数,且只能调用类的 const 成员(包括 const 成员变量和 const 成员函数)
const STU s1("小明", 15, 90.6);
如果你想在定义const对象后依旧可以修改某一个成员变量的值,可以用mutable来修饰该成员变量,这样依旧可以修改这个值
class STU{
mutable char *m_name;//mutable修饰的成员变量,在const对象中仍然可以被修改
int m_age;
float m_score;
};
如上面的name变量,即便定义了const对象,也可以对它进行修改
引用的基本方式如下
int a=10;
int& b=a;
int& c=a;//同一个变量可以有多个别名
//可以用两个不同的变量名引用同一个
//但是引用了之后不可以更改对象
此时的b和c都是a的别名,注意是别名!
可以用两个不同的变量名引用同一个变量,而且引用了之后不可以更改对象
int& b;
比如你叫李华,有人叫你“小李”,还有人叫你“英语作文人”,这两个外号都是你的别名。指针并不是别名,指针是通过地址访问某个变量。而引用是给a变量起另外的两个名字,实际上b和c都可以当作a来使用
编译运行代码,让编译器打印出这三者的地址,可以看到它们的地址是一样的,因为它们本来就是同一个变量的不同名字。
指针变量的地址和指针变量所指向对象的地址是不同的,引用的类型必须和引用实体的类型相同,不能用int&
引用double类型
const常量
引用可以引用常量,但是必须加const
修饰
基本的思路就是“权限可以缩小,但不可以放大”。
const int&d=a;
中的d是不能修改,只可读取a的内容。int&
来放大权限int和double相互引用
在1.1
中有提到,我们不能用int&
来引用double
类型的变量,编译器会报错
不过我们可以用const int&
类型来引用double,此时引用就不是简单的一个别名了。
先来了解一下把double复制给int类型,这时候会产生“隐式类型转换”,h保存的是z的整数部分
在这个过程中,编译器会产生一个临时变量存放z的整数部分,然后赋值给h
而当我们用const int&
类型来引用double时,实际上引用的是编译器产生的临时变量,它是一个常量,所以我们需要用const int&
来引用
const int& i=z;//这里的i是临时变量的别名
//在引用的时候,创建了一个临时变量存放d的整数部分
//i的地址和z不相同,且临时变量不会销毁,生命周期和i同步
//生成的这个临时变量是常量,所以i的本质是引用了一个int类型
cout <<"i= "<<i<<endl;
cout <<"&i= "<< &i <<endl;
cout <<"&z= "<< &z <<endl;
//在c++中函数主要使用引用传参,后面会进一步学习
一个非常直观的验证方法,就是打印一下,瞅瞅它们的地址是否相同。可以看到,i的值和h是相同的,因为它引用的就是那个存放了整数部分的临时变量,这个临时变量的地址和z不同
当你用同样的图纸建了很多个屋子后,有没有想过应该如何区分它们呢?
C++在设计这部分的时候,添加了一个this指针来解决这个问题:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参 数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
类名* const
就用下面这个函数举例
void Print()
{
cout<<_name<<endl;
cout<<_sex <<endl;
}
实际上,在调用它的时候,编译器会做如下处理。因为只有这样,才能完整的区分两个不同的类。
void Print(Student*const this)
{
cout<<this->_name<<endl;
cout<<this->_sex <<endl;
}
进一步看看下面这个代码,可以帮助你理解this指针
bool operator==(const Date& d){
return _year == d._year
&& _month== d._month
&& _day == d._day;
}
这是一个日期的比较函数,是操作符重载(后面会讲到)。你可以看到,这个函数我们传入了一个Date类型的引用,这是区别于this的另外一个类的对象。
如果没有this,那就很难区分两个变量的_year
,于是编译器会把它优化成下面这样,就不会存在无法区分的问题了
bool operator==(Date*const this,const Date& d){
return this->_year == d._year
&& this->_month== d._month
&& this->_day == d._day;
}
在程序中,访问NULL不会报错,但是解引用Null会报错
#include
using namespace std;
class ta{
public:
void Print()
{
cout<<"print ta"<<endl;
//cout<<_a<
}
private:
int _a;
};
int main()
{
ta* p=nullptr;
p->Print();
//可以去访问空指针的函数
//因为函数只是去调用了类里面的Print函数
//同时传了一个p的this指针(空指针传参是不会报错的)
//但是如果你去访问p里面_a变量,就会报运行错误
return 0;
}
友元分为友元函数和友元类。友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元函数相当于这个类的好朋友,它并不是类的成员函数,但是可以访问这个类的私有成员。友元函数没有this指针,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部明,声明时需要加friend关键字。
class DATE{
// 友元函数
friend std::ostream& operator<<(std::ostream& out, const Date& d);
friend std::istream& operator>>(std::istream& in, Date& d);
};
以<<和>>操作符重载为例,如果我们直接在类里面定义的话,使用这个重载的方式就会变成下面这样
DATE d1;
d1<<cout;
d1>>cin;
因为对于双目操作符,重载的时候,编译器是将第一个参数作为操作符的左值的。在类里面定义时,第一个操作数是隐含的this指针。即必须用对象名作为左操作数来进行使用。这样虽然也能完成既定任务,但这个使用方式未免太过奇葩了。
定义为友元后,没有隐含的this指针,就可以使用cout<
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
class B;//前置声明
class A{
//声明友元类
friend class B;
private:
int _n;
};
class B{
public:
//可以在类里面通过对象访问A的非公有成员
void Print()
{
cout<<_a1._n<<endl;
}
private:
A _a1;
};
内嵌对象:在一个类中定义另外一个类的对象
在一些应用场景中,我们会需要在一个类里面定义另外一个类的对象。如下面这道OJ题中,我们需要用两个队列的类来实现这里的MyStack
。
class Queue{
//队列的实现
};
class MyStack {
public:
MyStack() {
}
//.....
private:
Queue q1;
Queue q2;
};
对于内部对象Queue,需要注意的有以下几点:
如果在Queue中定义了有参的构造函数,需要在MyStack构造函数中的初始化列表处进行初始化操作
当一个类的对象拥有另一个类的对象时,就会发生类聚合:
依旧以上面的MyStack代码为例,当我们在里面定义了Queue对象q1和q2后,它们的生命周期和MyStack类对象的生命周期同步。MyStack类会先构造,然后构造q1和q2;q1和q2分别析构后,才会析构MyStack的类对象。
当定义内部类时,内部类默认为外部类的友元,可以直接访问外部类的非公有成员。但是内部类是一个独立的类,外部类不能访问内部类的非私有成员,也不能通过外部类的对象来访问内部类的成员。
class A{
public:
class B{
void Prin(const A& a1)
{
cout<< a1._a <<endl;//可以直接通过对象来访问私有成员
cout<< count<<endl;//外部的静态成员可以直接访问
cout<< _b <<endl;
}
int _b;
};
private:
int _a;
static int count;
};
int A::count=0;
继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员
//举例:网站的公共部分
class ART {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}
void footer()//所有网站页面都有这个
{
cout << "关于我们" << endl;
cout << "网站访问量" << endl;
}
void func()//文章页面
{
cout << "文章" << endl;
}
};
class LINK {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}
void footer()//所有网站页面都有这个
{
cout << "关于我们 " << " 网站访问量" <<endl;
}
void func()//友链页面
{
cout << "友链" << endl;
}
};
在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的
//下面使用继承的方式来写,WEB类是网站的公共部分
class WEB {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}
void footer()//所有网站页面都有这个
{
cout << "关于我们" << endl;
cout << "网站访问量" << endl;
}
};
//ART、LINK是两个子类,继承了WEB的公共部分
//这样就减少了代码量
class ART : public WEB{
public:
void func()//文章页面
{
cout << "文章" << endl;
}
};
class LINK : public WEB {
public:
void func()//友链页面
{
cout << "友链" << endl;
}
};
测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员
同一个类可以同时继承多个基类
class C : public A,public B{
//.....
};
继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别
class A{
public:
int a;
protected:
int b;
private:
int c;
};
当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的
当基类和派生类中出现同名成员函数或者同名成员变量时,会出现冲突。这时候编译器会做一定的处理:直接访问变量名和函数名的时候,优先访问派生类自己的成员
//继承同名成员的处理
// 普通的同名成员
class DAD1 {
public:
DAD1()
{
_a = 100;
}
void func()//同名函数
{
cout << "DAD func" << endl;
}
void func(int i)
{
cout << "DAD func int: " << i << endl;
}
int _a;//基类中的该变量
};
class SON1 : public DAD1{
public:
SON1()
{
_a = 20;
}
void Print()
{
cout <<"SON: " << _a << endl;//优先访问派生类的_a
cout <<"DAD: " << DAD1::_a << endl;//访问基类的_a
}
void func()//同名函数
{
cout << "SON func" << endl;
}
int _a;//派生类的同名变量
};
如果是静态成员,访问方法就有所变化
//访问同名的静态成员
class DAD2{
public:
static int D_a;
static void Test1()
{
cout << "DAD2 Test1 " << endl;
}
static void Test1(int n)
{
cout << "DAD2 Test1(int) " << n << endl;
}
};
int DAD2::D_a = 100;
class SON2 : public DAD2 {
public:
static int D_a;
static void Test1()
{
cout << "SON2 Test1 " << endl;
}
};
int SON2::D_a = 200;
有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类
这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”
和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。
//解决方法1(治表不治本)
//用类域来修改和访问
cout << "intel: " << n1.INTEL::_Structure << endl;
cout << "amd: " << n1.AMD::_Structure << endl;
这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural
关键字
class CPU {
public:
CPU()
:_Structure("x86")
{ }
char _Structure[100];
};
class INTEL : virtual public CPU {
public:
INTEL()
:i_Brand("intel")
{}
char i_Brand[10];
};
class AMD : virtual public CPU {
public:
AMD()
:a_Brand("amd")
{}
char a_Brand[10];
};
这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。
在讲解赋值运算符重载之前,我们可以来认识一下完整的运算符重载:C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名为:关键字 operator
运算符,如operator=
函数原型:返回值类型 operator操作符(参数列表),如Date operator=();
下面有几点注意:
.*
、::
、sizeof
、? :
、.
以下是在全局定义的操作符重载,用于判断日期是否相等
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year;
&& d1._month == d2._month
&& d1._day == d2._day;
}
当我们在main函数中使用d1==d2
的时候,编译器就会自动调用该操作符重载
当然,你也可以自己来传参使用,如if(operator==(d1,d2))
但是这样非常不方便,和调用一个而普通函数没啥区别,压根算不上操作符重载。所以我们一般是在类里面定义操作符重载的
当我们把它放入类Date
中间,就需要修改成下面这样
bool operator==(const Date& d2)
{
return _year == d2._year;
&& _month == d2._month
&& _day == d2._day;
}
编译器在调用的时候,会优化成下面这样
bool operator==(Date* this, const Date& d2)
//显示调用为 d1.operator==(d2);
而在main里面使用的时候,这个重载后的操作符和原本的使用方法完全相同
Date d1(2022,6,1)
Date d2(2022,5,1)
d1==d2;//自动调用操作符重载d1.operator==(d2);
因为每一个类都有不同的成员,编译器不可能智能的进行赋值操作。这时候就需要我们自己写一个赋值运算符重载来进行赋值操作了
以日期类为例,赋值操作其实就是把内置类型成员一一赋值即可
Date& operator=(const Date& d){
if(this != &d)//避免自己给自己赋值
{
_year=d._year;
_month=d._month;
_day=d._day;
}
return *this;
}
编写赋值重载代码的时候,需要注意下面几点:
*this
,出了函数后没有销毁,所以可以用传引用返回如果类中有自定义类型,编译器会默认调用它的赋值运算符重载
当赋值操作符和拷贝构造同时存在的时候,什么时候会调用赋值,什么时候会调用拷贝构造呢?
在这两个函数中添加cout
进行打印提示,可以看到:
虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。以下面这个动物说话的代码为例
#include
using namespace std;
class Animal {
public:
//void Talk()
virtual void Talk()//虚函数
{
cout << "Animal is talking" << endl;
}
};
class CAT : public Animal{
public:
void Talk()//同名函数
{
cout << "CAT is talking" << endl;
}
};
class DOG : public Animal {
public:
void Talk()//同名函数
{
cout << "DOG is talking" << endl;
}
};
//基类中不使用虚函数时,该函数的内容已确定
//不管传参什么类,都会调用Animal自己的Talk函数
//加上虚函数virtual后,会调用CAT和DOG的Talk函数
void MakeTalk(Animal& it) {
it.Talk();//调用对应的Talk函数
}
当基类Animal中的Talk函数没有用virtual修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数
当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。
对于虚函数,有几点需要注意:
有的时候,我们需要析构一个对象时,往往会给基类的析构函数加上virtual修饰,这样只要传派生类的对象给基类的指针/引用,就可以直接调用派生类对应的析构函数,完成不同的析构操作。而不是都呆呆的调用基类的析构函数——那样就会产生内存泄漏
class Queue {
public:
Queue()
:_a(new int[10])
{ }
virtual ~Queue() {
cout << "~Queue" << endl;
delete[] _a;
}
private:
int* _a;
};
class MyStack :public Queue {
public:
MyStack(int capa)
:_a1(new int[capa])
{}
~MyStack() {
cout << "~MyStack" << endl;
delete[] _a1;
}
private:
int* _a1;
};
int main()
{
Queue* q1=new MyStack(4);//父类指针指向子类
delete q1;//调用子类的析构函数
return 0;
}
在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。
纯虚函数的基本形式如下
//virtual 函数返回类型 函数名()=0;
virtual void Print()=0;
派生类中必须重写基类的纯虚函数,否则该类也是抽象类
class A {
public:
//virtual void Print();//虚函数
virtual void Print() = 0;//纯虚函数
};
class B :public A {
public:
void Print() {
cout << "B print " << endl;
}
};
class C :public A {
public:
void Print() {
cout << "C print " << endl;
}
};
当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数
和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数
包含纯虚函数的类就是抽象类,抽象类有下面几个特点:
矩阵类要求:设计一个矩阵类,要求能够根据用户需求构建row行、column列的矩阵,并灵活接受反馈矩阵元素信息(如:某行、某列、某行某列元素)。实现矩阵的相关运算,包括矩阵加(+)、矩阵乘(*)、矩阵输出(<<)、矩阵赋值(=)、获取矩阵指定位置元素值([])
设计该程序的时候,需要注意下面几点:
int a1=1,a2=2;
a1+a2;//这样相加并不会影响a1和a2的值
如果需要修改原本的矩阵,应该重载的操作符是+=和-=
//重载[]操作符
int* operator[](int row)
{
return arr[row];
}
//使用该操作符
a1[1][3];
//第一个[]是操作符重载
a1[1];//返回了第二行的数组名
//第二个[]是普通的操作符本身,访问返回的数组名找到对应列的值
矩阵类完整代码实现见附录。
银行账号管理系统要求:管理不同用户在银行的金融资产,每个用户可以拥有多种银行账户(如:定期储蓄、活期储蓄、信息卡、电子账户、贷款账户等)。账户包括账号、余额、利率等基本信息,用户可以进行账户信息查询、存款、取款、结算利息等操作。银行需统计所有账户的总金额、验证银行系统收支平衡,并能够及时预警反馈。设计Account抽象类作为所有银行账户顶层祖先,根据实际应用需求合理设置派生层次及相应子类。结合银行利息结算、用户贷款申请等实际应用需求,适当添加辅助类协同操作。合理定义虚基类、虚函数、纯虚函数、抽象类完成银行账号管理系统的稳定可靠运行。
以下是我设计该管理系统的思路:
Account
类的主框架,作为后续类的基类switch case
语句和do while
语句实现多次调用不同函数接口的操作在设计该菜单的过程中,我发现了很多问题需要注意,这些问题也加深了我对编程知识点的理解。
完整代码实现见附录。
在设计贷款账户的时候,容易出现double浮点数存放精度的问题。用户贷款额度和余额直接若直接判断相等,很难得到正确结果(因为浮点数后面会跟着很多没有打印出来的小数)
这就会出现,即便你根据程序接口中“查看待还金额”来得到自己的待还金额,并执行还款操作后,还是会有一部分小数位的数据并没有完整还完,这一部分的处理是非常困难的。为了避免用户永远都还不完自己的贷款,我设置了double的修约规则,即当用户余额和贷款/透支额度的差值小于0.2时,不再处理后续的小数位
if (_Max - _money < 0.2)//浮点数精度问题
{
cout << "无需还款" << endl;
return -1;
}
同时在余额计算中进行取零修约,保证“剩余待还”打印值和“查看待还金额”函数的打印值同步。用这种方式间接解决了精度问题。
//待还金额为透支额度+利息
double money = (_Max - _money) + Income(tmp);
if (money < 0.02) {
money = 0;//修约浮点数,解决精度问题
}
在设计销户接口时,为了避免用户在销户后仍然能操作该账户,不能只是简单的使用break跳出单层循环,而是需要用return直接终止该程序。
case 8:
{
cout << "销户后,账户内的一切信息都会被清空" << endl;
cout << "请问您确认销户吗?Y/N" << endl;
char a;
cin >> a;
if (a == 'Y') {
delete A;
A = nullptr;
cout << "销户完成" << endl;
return;//终止程序
}
break;
}
1.文件操作
该银行管理类还可以添加文件操作,来保存用户的信息。原先想法是在Account类中定义static全局变量进行count++
,以此得出所有派生类构造对象的总和。再利用for循环进行读取文件操作,这样就能在下一次打开程序的时候,通过用户的账户来定位用户的某一个特定账户对象,进行后续的操作。
但是Account类作为抽象类是无法实例化对象的,如果用各个派生类来进行文件管理操作,该程序就会变得很臃肿。且由于本人能力问题,没能设计出循环读取文件内容,并进行定位下一个对象位置的操作,故在最终的设计中没有实现文件操作。
2.时间问题
为了代码测试需要,每一次操作都需要用户手动输入日期。在实际应用中,这项工作应该由银行用户终端自动化承担。可以设计读取预定义宏__TIME__
来获取每一次操作的时间,从而实现和现实中的时间对照,去除每一次都需要手动输入代码的繁琐
学习代码需要有一个持之以恒的心,再学习知识点的基础上要同时坚持写代码的练习。我在Gitee码云上创建了自己的学习仓库,坚持每天托管代码,作为自己学习编程打卡的一个记录
和其他科目不同,CPP的学习是不能只停留在书籍和纸笔上的,只有你上手自己敲代码了,才能认识到一些光是听讲和看书学习不到的知识。比如一些程序出现bug之后的VS调试技巧,都是我在练习中学会的。
同时还需要学会利用工具,如在cplusplus网站上查找函数的定义,根据给出的代码示例尝试自己使用这个函数,并做到能在后面的程序设计中活学活用。
个人认为,不管学习的编程语言是什么,只有综合以上几点,才能真正学好编程。