类和对象超详细版本


面向对象的特点:封装、继承、多态
面向对象编程的特点:

  1. 易维护:可读性高,由于是面向对象而不是过程,即便改变了一些需求由于继承的特性,也只需要对局部模块进行修改,维护成本低且十分方便
  2. 效率高:利用面向对象的特性对现实世界的事物进行抽象从而产生了类
  3. 复用性高:由于多态和继承,可以使用现有的类从而提高了代码的复用率

一、封装

1.封装的意义

封装是面向对象的三大特性之一,以下是封装的一些意义
1.1. 数据隐藏
封装的一个关键方面是数据隐藏,也称为信息隐藏,这意味着类的内部实现细节(特别是其数据成员)对类的使用者是隐藏的,类的用户只能通过类提供的公共成员函数(也称为接口)来访问或修改数据,这种设计有助于减少类之间的耦合度,提高系统的可维护性和可扩展性

1.2. 访问控制
C++通过主要通过以下几个访问控制符进行访问权限的控制:

public:成员在类的外部和内部都可以访问
protected:成员在类的内部、派生类(子类)中可以访问,但在类的外部不可访问
private:成员仅在类的内部可以访问,类的外部和派生类都不能直接访问
通过合理设置成员的访问级别,可以实现数据的封装和隐藏

1.3. 抽象
封装还涉及到抽象(Abstraction)的概念,抽象是指将复杂的现实世界对象简化为仅包含其基本属性和行为的模型,在C++中,这通常通过定义类来实现,类封装了对象的属性和方法,提供了对复杂对象的简单抽象

1.4. 类的接口
类的接口是封装的一个重要组成部分,接口定义了类的使用者可以如何与类的实例交互,这包括类提供的公共成员函数(包括构造函数和析构函数)、操作符重载函数等。良好的接口设计应该隐藏类的内部实现细节,同时提供足够的功能让类的使用者能够完成任务

1.5. 封装的好处
提高代码的安全性:通过隐藏数据细节,防止外部代码直接访问或修改数据,从而减少错误和意外的发生
提高代码的复用性:通过封装,可以将一个类的实现细节封装起来,只提供接口供其他类使用,从而实现代码的复用
便于维护和扩展:由于封装隐藏了类的内部实现细节,当需要修改类的内部实现时,只需要修改类的内部代码,而不需要修改使用该类的代码,从而降低了维护成本,同时,由于类的接口是稳定的,因此也便于对类进行扩展

2.struct和class的区别

在C++中,structclass关键字都用于定义用户自定义的数据类型,因此它们的形式十分类似,它们之间的主要区别在于默认的访问权限和继承方式

默认的访问权限

  • struct:在C++中,struct的默认成员访问权限是public,这意味着,如果你没有明确指定成员的访问权限(public、protected或private),那么它们默认就是public的,这使得struct更适合用作一个简单的数据结构,其中的数据成员和成员函数都可以被外部直接访问

  • class:与struct相反,class的默认成员访问权限是private,这意味着,如果你没有明确指定成员的访问权限,那么它们默认就是private的,这种设计使得class更适合封装和隐藏实现细节,这是面向对象编程中的一个核心概念

继承方式

虽然这个区别不是struct和class之间直接的差异,但值得一提的是,在C++中,使用class关键字时,默认的继承方式是private继承,而使用struct时,默认的继承方式是public继承,这意味着,如果你没有明确指定继承方式(public、protected和private),那么从class派生的类默认会private继承基类,而从struct派生的类默认会public继承基类,然而,在实际使用中,明确指定继承方式是一个好习惯,可以避免混淆

3.成员属性设置为私有

好处:

  1. 可以自己控制读写权限
  2. 对于写可以控制数据有效性
    下面进行一个举例:
    //设计一个学生类并且隐藏其成员属性
    //姓名 可读可写
    //性别 可读不写
    //年龄 可写不可读
    思路:通过公有的成员函数对成员属性进行读写控制,也就是设置对外的接口
class student
{
private:
	string name;
	string sex="男";
	int age;
public:
	void setname()
	{
		cout << "请输入姓名:" ;
		cin >> name ;
	}
	void setage()
	{
		cout << "请输入年龄:";
		//进行判断,也就是控制数据有效性
		while(true){
			cin >> age;
			if (age < 0 || age>150)
				cout << "输入有误,请重新输入年龄:";
			else break;
		}
	}
	void getname()
	{
		cout << "姓名:" << name << endl;
	}
	void getsex()
	{
		cout << "性别:" << sex << endl;
	}
};
int main()
{
	student s1;
	s1.setname();
	s1.setage();
	s1.getname();
	s1.getsex();
}

4.练习案例

案例一、设计一个立方体类

设计一个立方体类,求出立方体的体积和面积并分别用全局函数和成员函数判断两个立方体是否相等
头文件:cbue.h

#include<iostream>
#include<string>
using namespace std;
//设计一个立方体类
class cube
{
public:
//用成员函数判断两个立方体是否相等
	bool judge(const cube& c1,const cube& c2);
	void set();//设置立方体数据
	double getV();//计算立方体体积
	double getS();//计算立方体表面积
private:
	double c_h;
	double c_w;
	double c_l;
};

方法体:cube.cpp

#include"cube.h"
void cube::set()
{
	cout << "请输入长方体的长宽高:";
	cin >> c_l >> c_w >> c_h;
}
double cube::getV()
{
	return c_h * c_l * c_w;
}
double cube::getS()
{
	return 2*(c_h * c_l + c_h * c_w + c_w * c_l);
}
bool cube::judge(const cube& c1, const cube& c2)
{
	if (c1.c_h == c2.c_h && c1.c_l == c2.c_l && c1.c_w == c2.c_w)
		return true;
	else return false;
}

主函数:main.cpp

#include"cube.h"
int main()
{
	cube c1,c2;
	c1.set();
	c2.set();
	cout<<"c1的体积为:"<<c1.getV()<<endl;
	cout << "c1的面积为:" << c1.getS() << endl;
	cout << "c2的体积为:" << c2.getV()<<endl;
	cout << "c2的体积为:" << c2.getS() << endl;
	if (c1.judge(c1, c2))
		cout << "c1和c2两个长方体相等";
	else cout<< "c1和c2两个长方体不相等";
}

案例二、设计一个圆形类和点类,判断点和圆的关系

最初,我的思路就是按部就班,创建一个圆类和一个点类,不过因为要计算圆心和点的距离,所以避免麻烦这里没有将点类的数据成员设为私有,方便在圆类的判断函数里读取点的数据,代码如下:

#include<iostream>
using namespace std;
//设计一个圆形类和一个点类并计算它们的关系
class point
{
public:
	double p_x;
	double p_y;
	void set()
	{
		cout << "请设置点的坐标:";
		cin >> p_x >> p_y;
	}
};
class circle
{
private:
	double r;
	double c_x;
	double c_y;
public:
	void judge(point& p)
	{
		if ((c_x - p.p_x) * (c_x - p.p_x) + (c_y - p.p_y) * (c_y - p.p_y) == r * r)
			cout << "点在圆上" << endl;
		else if ((c_x - p.p_x) * (c_x - p.p_x) + (c_y - p.p_y) * (c_y - p.p_y) > r * r)
			cout << "点在圆外" << endl;
		else cout << "点在圆内" << endl;
	}
	void set()
	{
		cout << "请设置圆心的坐标:";
		cin >> c_x >> c_y;
		cout << "请设置圆的半径:";
		cin >> r;
	}
};

int main()
{
	point p;
	circle c;
	p.set();
	c.set();
	c.judge(p);
}

优化代码分为三个文件,头文件、方法体和主函数,思路如下:

  1. 首先我们要设置圆和点的数据,所以要有设置函数
  2. 经过学习不难发现其实圆类的圆心也是也是一个点类,所以我们其实可以将圆心坐标改成点类
  3. 判断它们的位置关系要用到直角坐标系的距离公式,也就是需要圆心坐标和点坐标,如果要对点坐标进行隐藏那么就不能在两个不同的类中只使用一个接口函数,否则就无法获取这两个坐标,因此可以设置一个判断函数和一个距离函数分别封装在这两个类中,这样做的好处是保证了坐标不会暴露出来,也使接口更少了(如果要获取坐标每个函数至少要有两个接口也就是xy)而判断函数的参数就是距离函数的返回值,距离参数已经在点类里得到了返回值,这样就解决了接口过多和坐标暴露的问题
    那么代码如下:

头文件:circle.h

#include<iostream>
using namespace std;
class point//定义点类
{
private:
	double x;
	double y;
public:
	void set();
	double distance(point p);//获得点坐标计算距离
};
class circle//定义圆类
{
private:
	double r;
	point center;
public:
	void set();
	void judge(point p);//获得半径进行判断
};

方法体:circle.cpp

#include"circle.h"
void point::set()
{
	cin >> x >> y;
}
void circle::set()
{
	cout << "请输入圆心坐标:";
	center.set();
	cout << "请输入圆的半径:";
	cin >> r;
}
double point::distance(point p)
{
	return (x - p.x) * (x - p.x) + (y - p.y) * (y - p.y);
}
void circle::judge(point p)
{
	if (p.distance(center) > r * r)
		cout << "点在圆外" << endl;
	else if (p.distance(center) < r * r)
		cout << "点在圆内" << endl;
	else
		cout << "点在圆上" << endl;
}

主函数:main.cpp

int main()
{
	circle c;
	point p;
	c.set();
	cout << "请输入点坐标:";
	p.set();
	c.judge(p);
}

二、对象的初始化和清理

  • 生活中我们购买的电子产品在使用之前都会有一个初始化的过程,而某一天我们不再使用时就会将设备进行恢复出厂设置的操作来保证我们的隐私安全
  • 在编程中面向对象的思想是为了更好地描述现实生活而产生的,而c++就是一门很好的面向对象的编程语言,每个对象也会有初始设置以及对象销毁时的清理设置

1.构造函数和析构函数

在编写代码时创建的对象其初始化和清理工作也是两个非常重要的安全问题

  • 一个对象或者变量没有初始化那么它的使用后果也就是未知的
  • 同样在使用完一个对象或者变量时如果不及时进行清理工作也会造成一定的安全问题

而c++中就利用了构造函数和析构函数来解决这两个问题,如果我们不对这两个函数进行提供那么编译器就会在创建对象时自动强制进行调用默认构造函数和析构函数,并且自动调用时提供的是空实现
构造函数语法:
定义:构造函数是一种特殊的成员函数,主要用于在创建对象时初始化对象
作用:初始化对象并分配资源(如动态内存分配)
语法:类名(){}

  1. 没有返回值也不写void
  2. 函数名称和类名相同
  3. 构造函数可以有参数,因此可以发生重载
  4. 如果有多个构造函数,编译器会根据提供的参数类型选择最合适的构造函数进行调用并且只能调用一次,如果创建对象时想调用默认构造函数时,不能添加(),否则编译器会认为其是一个函数声明
  5. 如果不提供构造函数,编译器会自动提供一个默认的无参构造函数

析构函数语法
定义:析构函数也是一种特殊的成员函数,用于在对象生命周期结束时执行清理工作
作用:进行清理工作并释放资源(如动态分配的内存)
语法:~类名(){}
6. 没有返回值也不写void
7. 函数名和类名相同,类名前面要加~
8. 析构函数没有参数,因此析构函数不能被重载,也不能被声明为const
9. 如果不提供析构函数,编译器会提供一个默认的析构函数,且只自动调用一次

注意

  1. 构造函数和析构函数自动被调用,无需(也不能)手动调用它们
  2. 当使用new操作符动态创建对象时,构造函数被自动调用;当使用delete操作符删除对象时,析构函数被自动调用
  3. 当对象作为局部变量离开作用域时,或全局/静态对象在程序结束时,析构函数也会被自动调用
    构造函数可以调用其他成员函数,但通常不推荐在构造函数中调用虚函数,因为此时对象的类型尚未完全确定,可能会导致错误的行为(尤其是当涉及到继承时)

下面进行举例验证:

#include<iostream>
#include<string>
using namespace std;
class p
{
public://访问权限设置为共有的类外方便调用
	p ()
	{
		cout << "构造函数的调用"<<endl;
	}
	p(int)
	{
		cout<<"有参构造函数的调用"<<endl;
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
void test1()
{
	p p1;//在栈区创建一个对象p1
	//下面两行注释代码会报错,因为不能手动调用构造和析构函数
	//p1.~p;
	//p1.~p;
	cout<<"p1创建结束"<<endl;
}
void test2()
{
	cout << "其他函数的执行" << endl;
}
int main()
{
	test1();//函数调用
	p p2;//在主函数进行创建对象p2
	cout<<"p2创建结束"<<endl;
	p p3(10);//主函数创建对象p3
	cout<<"p3创建结束"<<endl;
	test2();
	return 0;
}
结果:
构造函数的调用
p1创建结束
析构函数的调用
构造函数的调用
p2创建结束
有参构造函数的调用
p3创建结束
其他函数的执行
析构函数的调用
析构函数的调用

由上方的代码看出在对象创建时就会自动调用构造和析构函数,并且不能通过手动去调用否则会报错,如同test1里面的两行注释代码一样,并且创建一个对象就强制自动调用一次且仅调用有一次;此外在test1这个函数中创建了一个局部对象,执行完这个函数后对象就会销毁,所以结果显示在构造函数调用之后紧接着就是析构函数;而在主函数中创建的对象p2和p3的生命周期就是整个主函数,所以在程序执行完之前都不会销毁,那么在这之前就不会调用析构函数;为了证明,我们用test2函数模拟了实际编程时的其他行为,结果显示主函数对象p2和p3的析构函数在其他函数执行完之后才进行调用;最后,p3的创建证明了如果有多个构造函数,编译器会根据提供的参数类型选择最合适的构造函数进行调用

2.构造函数的分类及调用

构造函数的分类:

  • 按参数分类:有参构造和无参构造
  • 按类型分类:普通构造和拷贝构造(将传入的对象的属性拷贝到新创建的对象身上)

下面是代码举例:

#include<iostream>
#include<string>
using namespace std;
class p
{
private:
	int num;
public:
	p()
	{
		cout << "无参构造函数的调用" << endl;
	}
	p(int)
	{
		cout << "有参构造函数的调用" << endl;
		num = 10;
		cout << "普通构造函数的num=" << num << endl;
	}
	p(const p& a)
	{
		cout << "拷贝构造函数的调用" << endl;
		num = a.num;
		cout << "拷贝构造函数的num=" << num << endl;
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
int main()
{
	p p1;//无参构造
	p p2(10);//有参构造
	p p3(p2);//拷贝构造
}
结果:
无参构造函数的调用
有参构造函数的调用
普通构造函数的num=10
拷贝构造函数的调用
拷贝构造函数的num=10
析构函数的调用
析构函数的调用
析构函数的调用

通过以上结果可以看出拷贝构造将p2的属性num拷贝到了p3身上

构造函数的三种调用方法:

  1. 括号法
  2. 显示法
  3. 隐式转换法

下面是代码演示:

#include<iostream>
#include<string>
using namespace std;
class p
{
public:
	int num;
	p()
	{
		cout << "无参构造函数的调用" << endl;
	}
	p(int)
	{
		cout << "有参构造函数的调用" << endl;
		num = 10;
		
	}
	p(const p& a)
	{
		cout << "拷贝构造函数的调用" << endl;
		num = a.num;
		
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
void test1()
{
	p p1;//默认构造函数调用,注意不能加括号,否则就是声明,不会调用默认函数
	//下方两行都是括号法
	p p2(10);
	cout << "p2中num = " << p2.num << endl;
	p p3(p2);
	cout << "p3中num = "<< p3.num << endl;
}
void test2()
{
	p p1;
	//下方两行都是显示法
	p p2=p(10);//有参构造
	p p3 = p(p2);//拷贝构造	
}
void test3()
{
	p p1;
	p p2=10;
	p p3 = p2;
}
void test4()
{
	p(10);//匿名对象  特点:当前行执行结束后,系统会立即回收掉匿名对象
	cout << "验证函数" << endl;
}
int main()
{
	test1();//括号法函数
	cout << "------------------" << endl;//结果分割线
	test2();
	cout << "------------------" << endl;//结果分割线
	test3();
	cout << "------------------" << endl;//结果分割线
	test4();
}
结果:
无参构造函数的调用
有参构造函数的调用
p2中num = 10
拷贝构造函数的调用
p3中num = 10
析构函数的调用
析构函数的调用
析构函数的调用
------------------
无参构造函数的调用
有参构造函数的调用
拷贝构造函数的调用
析构函数的调用
析构函数的调用
析构函数的调用
------------------
无参构造函数的调用
有参构造函数的调用
拷贝构造函数的调用
析构函数的调用
析构函数的调用
析构函数的调用
------------------
有参构造函数的调用
析构函数的调用
验证函数

上方代码中test1、test2、test3、test4分别包括了括号法,显示法,隐式转换法以及匿名对象的创建,创建格式如代码所示,且格式可以相互转换;需要注意的是test4中的匿名对象就是临时创建一个对象,因为没有名字所以也无法后续进行调用,更值得注意的是不能用拷贝构造函数初始化匿名对象,因为编译器会认为是对象声明,报错对象重定义;另外test1中p2和p3中的num值相同可以证明拷贝构造确实可以拷贝已经创建好的对象的属性

3.拷贝构造函数的调用时机

在c++中触发拷贝构造函数的调用通常有三种情况,其本质都是通过复制创建数据副本以保证函数参数或返回值的独立性,因此就会触发拷贝构造函数,而以下就是三种常见情况:
在代码举例之前先定义一个p类的构造和析构函数:

class p
{
public:
	int num;
	p()
	{
		cout << "默认构造函数的调用" << endl;
	}
	p(int)
	{
		cout << "有参构造函数的调用" << endl;
		num = 10;
		
	}
	p(const p& a)
	{
		cout << "拷贝构造函数的调用" << endl;
		num = a.num;
		
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
  1. 使用一个已经创建完毕的对象来初始化一个新对象
void test1()
{
	p p1(10);
	p p2(p1);
}
调用test1函数的结果:
有参构造函数的调用
拷贝构造函数的调用
析构函数的调用
析构函数的调用

容易发现创建对象p2时调用的是拷贝构造函数,这里就是括号法对拷贝构造函数进行调用的

  1. 以值传递的方式给函数参数传值
void test2(p)
{
//空实现
}
int main()
{
	p p1;
	test2(p1);
}
结果:
默认构造函数的调用
拷贝构造函数的调用
析构函数的调用
析构函数的调用

由结果看出创建p1时调用的是默认构造函数,值传递时调用的是拷贝构造函数,这里其实是发生了隐式转换,test2(p1)中的p1等价于p p2=p1,也就是创建了一个临时对象p2,拷贝了p1的所有属性再进行传参

  1. 以返回值的方式返回局部对象
p test3()
{
	p p1;
	return p1;
}
int main()
{
	p p1=test3();
}

这里原本会调用一次默认构造和一次拷贝构造,但是由于从C++11开始,返回值会进行应用优化来避免不必要的拷贝,所以此处就不会调用拷贝构造了

4.构造函数调用规则

关于构造函数,在默认情况下c++编译器至少给一个类添加三个函数

  1. 默认构造函数(无参,函数体为空)
  2. 默认析构函数(无参,函数体为空)
  3. 默认拷贝构造函数,对属性进行值拷贝

调用规则如下:

  • 如果用户定义有参构造函数,c++就不再提供默认构造函数,但是会提供默认拷贝构造
  • 如果用户定义拷贝构造函数,c++不再提供其他构造函数

可以理解为拷贝构造>有参构造>默认构造

首先验证编译器会默认添加三个函数,前两个在前文已经解释过了,所以这里就只验证默认拷贝构造函数,下面是代码验证:

#include<iostream>
#include<string>
using namespace std;
class p
{
public:
	int num;
	p()
	{
		cout << "默认构造函数的调用" << endl;
	}
	p(int a)
	{
		cout << "有参构造函数的调用" << endl;
		num = a;
		
	}
	//p(const p& a)
	//{
	//	cout << "拷贝构造函数的调用" << endl;
	//	num = a.num;	
	//}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
int main()
{
	p p1(10);
	cout << "p1 num=" << p1.num << endl;
	p1.num = 2;
	p p2(p1);
	cout << "p2 num=" << p2.num<<endl;
}
结果1(注释前):
有参构造函数的调用
p1 num=10
拷贝构造函数的调用
p2 num=2
析构函数的调用
析构函数的调用
结果2(注释后):
有参构造函数的调用
p1 num=10
p2 num=2
析构函数的调用
析构函数的调用

在主函数中,将10传给对象p1中的num并且输出,再将p1的num改成2,再创建对象p2并拷贝p1的num,然后输出,就可以得到结果1,那么为了验证默认拷贝构造函数,就需要将我们定义的函数注释掉,就会得到结果2,我们会发现p2的num值仍然是由p1拷贝得到,这就是值拷贝

下面验证调用规则:

class p
{
public:
	p(int)
	{
		cout << "有参构造函数的调用" << endl;
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
	}
};
int main()
{
	p p1;
}
结果:
报错:类‘p’不存在默认构造函数

很明显,如果我们不提供默认构造函数,并且提供了有参构造,那么编译器也不会提供默认构造,就会报错;值得注意的是这时候编译器仍然提供默认拷贝构造;同理,如果我们只提供了拷贝构造,那么编译器将不再提供有参和默认构造,因为这没有意义

5.深拷贝和浅拷贝

  • 浅拷贝:简单的赋值拷贝操作,就是仅仅复制对象本身及其所有成员变量的值,而不复制指向成员的指针所指向的内容,简单来说浅拷贝就是位拷贝,它会复制对象中的所有字段,包括指针字段,但是不会复制指针所指向的数据;在默认情况下c++编译器为我们提供的拷贝构造函数和赋值运算符都是执行浅拷贝操作;浅拷贝的缺点是如果对象中有指向堆区的指针成员,那么浅拷贝就会导致两个对象最终指向同一块内存,这样当一个对象被销毁时就会释放掉这块内存,这就会导致另一个指针悬空,进而引发程序错误解决的办法就是进行深拷贝
  • 深拷贝:深拷贝是指拷贝对象时除了复制对象本身及其所有成员变量的值,还复制指向成员的指针所指向的内容;深拷贝还会为成员指针分配新的内存并复制指针所指向的数据;实现深拷贝通常需要自定义拷贝构造函数和赋值运算符,以确保每个对象都有自己独立的内存拷贝

由于深拷贝和浅拷贝的区别就在于是否拷贝指针指向对象的值,那么举例时我们就只列举指针的相关内容,下面是代码举例:

class p
{
public:
	int *age;//年龄
	p(int a)
	{
		cout << "有参构造函数的调用" << endl;
		age = new int (a);
	}
	p(const p& a)//这里浅拷贝由我们手动实现
	{
		cout << "拷贝构造函数的调用" << endl;
		age = a.age;
	}
	~p()
	{
		cout << "析构函数的调用" << endl;
		if(age!=NULL)
		{
			delete age;//清除age指向的内存
			age = NULL;//将age置空避免野指针的问题
		}
	}
};
int main()
{
	p p1(10);
	cout << *p1.age << endl;
	p p2(p1);
	cout << *p2.age<<endl;
}

这段代码就会导致程序崩溃,原因就是我们模仿编译器写了一个位拷贝的拷贝构造函数,相当于默认拷贝构造函数,位拷贝就是全字段拷贝,所以这里我们的age指针也接收了拷贝对象的age,相当于两个对象指向了同一块空间,当我们在析构函数中释放堆区开辟的空间时,第一次调用有参构造时就已经释放掉了,调用拷贝构造时就找不到这块空间了,所以就会导致程序崩溃;解决办法其实也很简单,就是自己提供一个拷贝构造函数,在这个函数中我们需要重新开辟一块空间用来接收拷贝对象中指针指向的数据,如果将上面第13行代码改成age=new int(*a.age)那么就是我们说的深拷贝了,开辟了一块新的空间来接受拷贝对象中指针指向的内容,这样问题就解决了

6.初始化列表

  • c++中除了可以利用构造函数中的=进行赋值,还提供了初始化列表语法用来初始化属性
  • 初始化列表的优点:可以更高效地初始化成员变量,特别是对于不能在构造函数体内赋值地常量成员或引用成员,因为它避免了先默认初始化成员然后再赋值的过程

语法:构造函数():属性1(值1),属性2(值2)...{},这里举两个特殊例子常量成员和引用成员:
初始化常量成员 由于常量成员必须在构造函数之外初始化,所以初始化列表是唯一的方法

class p
{
public:
	const int age;
	p(int a):age(a)
	{
		cout << "有参构造函数的调用" << endl;
	}
};

初始化引用成员 引用成员也必须在构造函数之外初始化,因此也需要使用初始化列表

class p
{
public:
	int &age;
	p(int &x):age(x)
	{
		cout << "有参构造函数的调用" << endl;
	}
};

注意事项:

  • 如果成员变量在类定义时就已经有初始值了那么就可以不使用初始化列表,除非想将它的值覆盖掉
  • 如果不为所有成员都提供初始化列表,那么为在初始化列表中的成员将使用默认构造函数进行初始化
  • 初始化列表中的成员初始化顺序与它们在类定义中的声明顺序相同,而不是与它们在初始化列表中的顺序相同

7.类对象作为类成员

和结构体一样,类对象也可以作为类成员,也就是说c++类中的成员可以是另一个类的对象,我们称该成员为对象成员,下面是代码举例:(定义一个人类和一个手机类,其中人的属性就包含了手机)

class phone
{
public:
	string pname;
	int num;
	phone(string a,int b) :pname(a),num(b) {
		cout << "phone构造函数的调用"<<endl;
	}
	~phone()
	{
		cout << "phone的析构函数调用"<<endl;
	}
};
class person
{
public:
	string name;
	phone p1;
	person(string a,string b,int c) :name(a),p1(b,c)
	{
		cout << "person构造函数的调用"<<endl;
	}
	~person()
	{
		cout << "person析构函数的调用" << endl;
	}
};
int main()
{ 
	person p("张三","华为",110);
	cout << p.name << "拿着" << p.p1.pname<<endl<<"电话号码是:"<<p.p1.num<<endl;
}
结果:
phone构造函数的调用
person构造函数的调用
张三拿着华为
电话号码是:110
person析构函数的调用
phone的析构函数调用

由结果可以看出我们先调用了phone构造,先析构了person,那么得出结论就是先构造内部对象,再构造外部对象,先析构外部再析构内部,因为如果不先把person对象构造出来,那么构造phone的时候就无法确定phone对象的内存大小,所以这里要从分配内存的角度进行理解,就和C语言中的结构体是一样的

8.静态成员

静态成员就是再成员变量和成员函数前加上static关键字,就称为静态成员

8.1静态成员变量

  • 所有对象共享同一份数据
  • 在编译阶段分配内存:程序还没运行之前就已经分配内存了
  • 类内声明,类外初始化:因为初始化需要内存空间,但是类内声明并不分配空间,所以初始化需要在类外先定义再初始化或者在定义的同时初始化值得注意的是进行初始化的时候要限定命名空间,这样才知道我们访问的是哪个类中的静态成员

由于静态成员变量是开辟在全局区的内存,所以它拥有两种访问方式:

class p
{
	static int a;
}
int p::a=10;
int main()
{
	p p1;//创建对象
	cout<<p1.a<<endl;
	p1.a=1;//改变p1的a值
	p p2;
	cout<<p2.a<<endl;//打印p2的a值
}
结果:
10
1

由结果可以得出结论:

  1. 静态成员变量共享同一份数据,并不独属于某个对象
  2. 访问静态成员变量有两种方式:
  • 通过类名进行访问
  • 通过对象进行访问

8.2静态成员函数

  • 所有对象共享同一个函数
  • 静态成员函数只能访问静态成员变量:因为都是建立在全局区,类定义并没有开辟空间,所以不能访问非静态成员变量

静态成员函数同样存储在全局区,所以也有两种访问方式:

class p
{
public:
	static int a;
	static void func(int x)
	{
		a = x;
	}
};
int p::a=10;
int main()
{
	p p1;
	cout << p1.a << endl;
	p1.func(100);//通过对象进行访问静态成员函数
	cout << p1.a << endl;
	p::func(200);
	cout << p1.a << endl;//通过类名进行访问
}

三、c++对象模型和this指针

1.成员变量和成员函数分开存储

在c++中类内的成员变量和成员函数分开存储,只有非静态成员变量才属于类的对象上,其余的都不占用对象空间,下方举例验证:

class p
{
	//空对象
};
int main()
{
	p p;
	cout << sizeof(p) << endl;//输出结果为1
}

可以知道空对象也是占用了一个字节的空间,因为c++编译器为了区分空对象占内存的位置也会给每个对象分配一个字节的空间,这样每个空对象也就会有一个独一无二的内存地址,而非空对象则按照非静态成员变量大小来分配内存,下方是代码验证:

class p
{
	int a;
	void b()
	{
		int a;
	}
};
int main()
{
	p p;
	cout << sizeof(p) << endl;//输出结果为4
}

可以发现非空对象中非静态成员函数是不占用内存的,而对象大小就是静态成员变量的大小,所以我们得出最终结论:只有非静态成员变量属于类的对象上,值得注意的是非静态成员函数的行为(函数代码)是共享的,但是它们操作的数据(非静态成员变量)是每个对象实例独有的,因此非静态成员函数也不属于类上

2.this指针

由于每一个非静态成员函数都共用一块代码,这就会产生一个问题,这块代码是如何区分那个对象是调用自己的呢?答案:我们可以通过this指针解决上述问题,因为this指针指向被调用的成员函数所属的对象,即哪个对象调用了这块函数代码,this指针就指向哪个对象

注意:

this指针是隐含每一个非静态成员函数内的一种指针
this指针是默认存在的,不需要定义,直接使用即可

用途:

  1. 解决名称冲突:当类的成员函数参数名与类的成员变量名相同时,可以使用 this 指针来区分成员变量和参数,因为此时的this指针是指向被调用成员函数所属的对象,例如:
class p
{
public:
	int a;
	p(int a)
	{
		this->a = a;
	}
};
int main()
{
	p p(10);
	cout <<p.a << endl;
}
结果:10

通过上方代码不难发现我们的构造函数形参名和成员变量名相同,那么在函数中的赋值操作就会出现无法区分的情况,这里如果使用this指针指向一个变量,那么这个a就不是形参了而是对象的成员变量,在改变一下

class p
{
public:
	int a;
	p(int a)
	{
		this->a = a;
	}
	void add(const p& p)
	{
		this->a += p.a;
	}
};
int main()
{
	p p1(10);
	p p2(20);
	p2.add(p1);
	cout <<p2.a << endl;
}

这里做了一个吸收操作,创建了两个对象并且分别赋值,再调用吸收函数,利用引用使两者相加,其中this指向的就是p2(this->a==p2.a)

  1. 用*this指针返回对象本身:在某些情况下,成员函数需要返回调用该函数的对象的引用,这时可以使用 this 指针

这里继续借用上方的例子,不过在进行一次吸收操作之后还想进行第二次吸收函数的调用,但是上方例子的吸收函数明显返回的空类型,所以这里要进行修改,或许有人会想到将返回类型改成int然后使用嵌套,不过这样就达不到函数参数是对象的目的了,所以这里可以使用*this指针返回其本身的操作, 那么返回类型就要改成类型p的引用了

class p
{
public:
	int a;
	p(int a)
	{
		this->a = a;
	}
	p& add(const p& p)
	{
		this->a += p.a;
		return *this;
	}
};
int main()
{
	p p1(10);
	p p2(20);
	p2.add(p1).add(p1);
	cout <<p2.a << endl;
}

需要注意的是上方吸收函数的返回类型必须加上一个引用符号,因为这里的引用是给返回的p2起别名,继续调用依然是调用的p2,如果不加&那么就是拷贝了,这样就不是调用的p2了,这就会导致p2.a始终都是30,因为后面进行的链式访问都不是对p2进行的了

3.const修饰成员函数

常函数:

  • 成员函数后加const我们称函数为常函数
  • 常函数内的成员属性不能修改,除非使用mutable关键字

常对象:

  • 声明对象时前加const称为常对象
  • 常对象只能调用常函数,因为普通函数可以修改成员属性,而这与常对象概念相悖

由于this指针的本质是一个指针常量,所以其指向无法更改(p * const this),而在函数前加const显然不合适,所以将其加在函数后面修饰this指针(const p* const this ),表示指向常量的常量指针,这样this指针所指向的值也就不能更改了,这就是const修饰成员函数的本质

class p
{
public:
	int a;
	mutable int b = 20;
	p(int a)
	{
		this->a=a;
	}
	void func()const
	{
		b=20;
	}
};
int main()
{                             
	p p1(10);                  
	p1.func();
	cout <<p1.b << endl;
}
结果:20

根据结果得出b的值已经被改变成20了,那么mutable的作用就凸显出来了,即可以使成员属性在常函数中也能被改变

class p
{
public:
	int a;
	mutable int b = 20;
	p(int a)
	{
		this->a=a;
	}
	void func()const
	{
		b=20;
	}
};
int main()
{                             
	const p p1(10);                  
	//p1.a=10;报错:左值无法修改
	p1.b = 10;
	cout <<p1.b << endl;
}

在对象前加上const可以使其变成常对象,那么其成员属性也就无法修改了,但是使用mutable关键字依然可以改变这个成员属性

四、友元

1.全局函数做友元

class building
{//声明友元
	friend void func(building& building);
public:
	building()//构造函数赋初值
	{
		sittingroom = "客厅";
		bedroom = "卧室";
	}
public:
	string sittingroom;
private:
	string bedroom;
};
void func(building& building)
{
	cout << "好基友正在访问:" << building.sittingroom << endl;
	cout << "好基友正在访问:" << building.sittingroom << endl;
}
int main()
{
	building a;
	func(a);
}

原本非成员函数是不能访问私有类成员的,但是只要在类内声明一下全局函数,那么就能访问了

2.友元类

class building//建筑类
{
	friend class goodgay;//友元声明
public:
	building()//初始化building类成员属性
	{
		sittingroom = "客厅";
		bedroom = "卧室";
	}
public:
	string sittingroom;
private:
	string bedroom;
};
class goodgay
{
public:
	void visit();
	goodgay()//初始化成员属性:在goodgay类中new一个building对象
	{
		b=new building;
	}
	~goodgay()//析构函数释放空间
	{
		if (b != NULL)
		{
			b = NULL;
			delete b;
		}
	}
private:
	building* b;
};
void goodgay::visit()
{
	cout << "好基友正在访问:" << b->sittingroom << endl;
	cout << "好基友正在访问:" << b->bedroom << endl;
}
int main()
{
	goodgay gg;
	gg.visit();
}

需要注意的是:

  • 友元关系不能被继承:如果一个类B是类A的友元,类C从B派生而来,那么C不会自动成为A的友元
  • 友元关系不是相互的:如果B是A的友元,A不一定是B的友元
  • 友元关系破坏了封装性,应该谨慎使用。在决定使用友元之前,请考虑是否有其他更好的设计方式(如使用公共接口或内部接口)

友元关系只能建立在非成员函数、整个类或模板与类之间,而不能建立在类的成员函数之间,如果可以就要加上作用域限定

五、运算符重载

1.加号运算符重载

正常来说我们的加减乘除运算符是被大众所熟知的,就是对简单的数据类型进行操作,但是当我们处理内容较多的类和对象时,运算符就无法处理,比如两个类相加,使它们里面的整型数据进行简单相加,这时候就需要用到运算符重载了
成员函数重载+运算符:

class person
{
public:
	person(){}
	person(int a, int b) :a(a), b(b){}
	int a;
	int b;
	person operator+(person& p)
	{
		person temp;
		temp.a = p.a +this-> a;
		temp.b = p.b + this->b;
		return temp;
	}
};
int main()
{
	person p1(10,20);
	person p2(30,40);
	//person p3 = p1.operator+(p2);
	person p3 = p1 + p2;
	cout <<"p3.a=   "<< p3.a << endl <<"p3.b=   " << p3.b << endl;
}
结果:
p3.a=   40
p3.b=   60

上方代码中的operator关键字是编译器提供的一个通用函数名称,方便后面直接使用两个自定义类型相加,例如代码中的person p3=p1.operator+(p2) 可以直接写成person p3 = p1 + p2,如果不用operator这个通用函数名而是其他的函数名例如add那么就不能使用直接相加的简便写法
全局函数重载+运算符:

class person
{
public:
	person(){}
	person(int a, int b) :a(a), b(b){}
	int a;
	int b;
	
};
person operator+(person& p1,person& p2)
	{
		person temp;
		temp.a = p1.a +p2.a ;
		temp.b = p1.b + p2.b;
		return temp;
	}
person operator+(person& p, int a)
{
	person temp;
	temp.a = p.a + a;
	return temp;
}
int main()
{
	person p1(10,20);
	person p2(30,40);
	//person p3 = p1.operator+(p2);
	person p3 = p1 + p2;
	person p4 = p1 + 10;
	cout <<"p1.a+p2.a =   "<< p3.a << endl <<"p1.b+p2.b =   " << p3.b << endl;
	cout << "p1.a+10=   " << p4.a << endl;
}

需要注意的是运算符重载也可以发生函数重载,正如上方代码中的p1.a+10和p1+p2发生的重载是一样的,另外编译器内置的运算符是不能重载的,比如加减乘除

2.左移运算符重载

和加号运算符类似,都是为了解决面对自定义类型时如何将复杂的内容进行输出,那么就要用到左移运算符重载,但是只能使用全局函数进行左移运算符重载,因为利用成员重载会导致在调用时cout<<右边,因为这个函数的本质就是p.operator<<(cout)

class person
{
public:
	int a;
	int b;
	ostream& operator<<(ostream &cout)
	{
		cout << a << endl << b << endl;
		return cout;
	}
	person(int a, int b) :a(a), b(b){}
};
int main()
{
	person p(1,2);
	p << cout;
}

所以一般都是进行全局函数进行左移运算符重载,因为其本质调用就是operator<<(cout,p)

class person
{
friend ostream& operator<<(ostream& cout, person& p);
private:
	int a;
	int b;
public:
	person(){}
	person(int a, int b) :a(a), b(b){}
};
ostream& operator<<(ostream& cout, person& p)
{
	cout << p.a << endl << p.b << endl;
	return cout;
}
int main()
{

	person p(1,2);
	cout << p<<endl;
}

值得注意的是这里的全局函数返回的是一个标准输出流ostream,且由于输出对象cout只有一个,所以函数参数只能使用引用,为了满足链式编程,返回对象也要是一个输出流,所以返回了一个输出流对象引用;另外左移运算符重载配合友元可以输出自定义类型

3.递增运算符重载

这里自增又分为前置自增和后置自增,两者实现重载是有区别的,并且由于参数相同所以后置自增需要用到int占位符,前置自增如下:

class person
{
	friend ostream& operator<<(ostream& cout, person& p);
	friend person& operator++(person& p);
public:
	person(int a):a(a){}
private:
	int a;
};
ostream& operator<<(ostream& cout,person&p)
{
	cout << p.a;
	return cout;
}
person& operator++(person&p)
{
	++p.a;
	return p;
}
int main()
{
	person p(10);
	cout << ++(++p) << endl;
}

在对++运算符进行重载的时候返回要求使用引用,就是为了可以实现一直对一个数进行自增操作即 ++(++p):这里前置自增也可以使用成员函数进行重载,那就不需要参数了,并且还可以在返回的时候使用this指针,因为只有在成员函数中才有this指针

接下来介绍后置自增,和前置主要的区别在于返回类型需要是一个值,而不是引用,因为后置自增需要先返回当前值在进行自增,代码如下:

class person
{
	friend ostream& operator<<(ostream& cout,const person& p);
public:
	person(){}
	person(int a):a(a){}
	person& operator++()
{
	++a;
	return *this;
}
	person operator++(int)
{
	person  temp=*this;
	a++;
	return temp;
}
	
private:
	int a;
};
ostream& operator<<(ostream& cout, const person&p)
{
	cout << p.a;
	return cout;
}
int main()
{
	person p(10);
	cout <<p++ ;
}

在写这个代码的时候遇到了一个问题就是在实现输出符重载的时候参数列表里的p没加const,导致在main中输出时报错,原因就是不加const那么传进去的就是一个临时对象,因为在实现后置自增的时候返回的是一个值,也就是一个临时对象,和前置自增的返回引用不同,而这里:

c++认为,使用普通引用绑定一个对象,就是为了能通过引用对这个对象做改变;如果普通引用绑定的是一个临时量而不是对象本身,那么改变的是临时量而不是希望改变的那个对象,这种改变是无意义的;所以规定普通引用不能绑定到临时量上; 那么为什么常量引用就可以呢,因为常量是不能改变的。也就是说,不能通过常量引用去改变对象,那么绑定的是临时量还是对象都无所谓了,反正都不能做改变也就不存在改变无意义的情况


4.赋值运算符重载

之前的几个操作符重载都是为了解决自定义的数据类型的问题,而这里或许会有人有疑问为社么要进行赋值运算符的重载,(之前说过一个类编译器至少会为其提供3个函数:默认构造,默认析构,默认拷贝构造),但其实编译器会提供4个函数,还有一个赋值运算符重载的函数operator=,既然编译器已经提供了=重载为什么我们还要进行重载,就是因为编译器提供的是浅拷贝,利用不当会造成报错

class person 
{
	friend ostream& operator<<(ostream& cout, person& p);
private:
	int* a;
public:
	person(int a)
	{
		this->a = new int(a);
	}
	~person()
	{
		if (a != NULL)
		{
			delete a;
			a = NULL;
		}
	}
	person& operator=(person& p)
	{
		a = new int(*p.a);//也可以*a = *p.a;这样也实现了直接将地址里的值进行赋值
		return *this;
	}
};
ostream& operator<<(ostream& cout, person& p)
{
	cout << *p.a;
	return cout;
}
int main()
{
	person p1(10);
	person p2(20);
	person p3(30);
	p1 = p2=p3;
	cout << p1;
}

解决浅拷贝的办法就是利用深拷贝,重新new一块空间出来存放赋值的数据;另外需要注意的是=重载的时候返回值需要是一个person对象,这样才能实现链式调用,也就是main里的连等操作

5.关系运算符重载

关系运算符重载可以实现两个自定义类型进行对比,比如大于小于或者双等号,这里就对双等和不等进行重载举例:

class person
{
public:
	person(string a, int b) :name(a), age(b) {}
	bool operator==(person& p)
	{
		if (name == p.name && age == p.age)return true;
		else return false;
	}
	bool operator!=(person& p)
	{
		if (name == p.name && age == p.age)return false;
		else return true;
	}
private:
	string name;
	int age;
};
int main()
{
	person p1("jero",20);
	person p2("jero", 20);
	if (p1 == p2)cout << "p1和p2相等" << endl;
	else cout << "p1和p2不相等"<<endl;
	if (p1 != p2)cout << "p1和p2不相等" << endl;
	else cout << "p1和p2相等" << endl;
}

6.函数调用运算符重载

函数调用运算符其实就是一对小括号,而这种重载又被称为仿函数,且仿函数没有固定写法,非常灵活,这里就举一个相加类和一个打印类的例子:

class print
{
public:
	void operator()(string a)
	{
		cout << a;
	}
};
class add
{
public:
	int operator ()(int a, int b)
	{
		return a + b;
	}
};
int main()
{
	print p;
	p("abc");//结果打印出:abc
	add a;
	cout<<a(10, 20);//结果打印出30
}

六、继承

有时候我们需要用到很多重复的代码,比如一个网页的头部,目录,底部等等,只有中心内容不同,如果每写一个内容就复制粘贴一遍那么会显得代码冗余,为了提高代码复用性就提出了继承的概念,我们可以通过公有继承调用父类的公有部分代码

1.继承的基本语法

语法:class 子类名:继承方式 父类名{}

#include <iostream>  
using namespace std;  
  
// 基类 Animal  
class Animal {  
public:  
    // 一个简单的方法,表示动物可以发出声音  
    void makeSound() {  
        cout << "Some sound" << endl;  
    }  
};  
  
// 派生类 Dog,继承自 Animal  
class Dog : public Animal {  
public:  
    // Dog 类有一个新的方法 bark(),但它同时也继承了 Animal 类的 makeSound() 方法  
    void bark() {  
        cout << "Woof!" << endl;  
    }  
  
    // 我们可以选择重写(Override)makeSound 方法,使其更适合 Dog 的行为  
    void makeSound() override { // 注意:override 是 C++11 引入的,用于明确表示重写基类中的虚函数  
        cout << "Bark!" << endl;  
    }  
};  
  
int main() {  
    Dog myDog;  
  
    // 调用继承自 Animal 的方法  
    myDog.makeSound(); // 输出: Bark! (因为我们在 Dog 类中重写了这个方法)  
  
    // 调用 Dog 类自己的方法  
    myDog.bark(); // 输出: Woof!  
  
    return 0;  
}

从代码中不难看出子类既有从父类继承过来的特性,也有属于自己的特性

2.三种继承方式

在介绍继承方式之前先重温三种访问权限:

  • 公有访问权限(Public Access):
    公有成员在类的内部和外部都可以被访问
    公有成员是类对外提供的接口,用于类的使用者与类进行交互
    公有成员函数通常用于执行类的操作,而公有成员变量则较少使用,因为直接暴露成员变量可能会破坏封装性
  • 保护访问权限(Protected Access):
    保护成员在类的内部和派生类(子类)中可以被访问,但在类的外部不可访问
    保护成员主要用于基类,以便在派生类中能够访问和修改这些成员,同时保持它们在类外部的不可见性
    保护成员是实现多态和继承时常用的技术,因为它们允许派生类访问和修改基类的实现细节,同时保持这些细节的封装性
  • 私有访问权限(Private Access):
    私有成员只能在类的内部被访问,在类的外部和派生类中都是不可访问的
    私有成员是类的内部实现细节,它们对类的使用者是隐藏的,这有助于实现封装和隐藏类的实现细节
    私有成员变量通常用于存储类的状态,而私有成员函数(也称为方法)则用于操作这些状态
    三种继承方式: 不同的继承方式会改变子类中成员的访问权限
  • 公有继承(public):子类中除了父类的私有秘密(private)其他的访问方式都不改变
  • 保护继承(protected):除了父类的私有秘密,其余的访问权限都变成保护继承
  • 私有继承(private):将父类中的成员访问权限全部变成私有的

上述三种继承方式可以由下方的这张图来描述:
在这里插入图片描述
下面是代码验证:

class father
{
public:
	int a;
protected:
	int b;
private:
	int c;
};
class son1 :public father//公有继承
{
	void func()
	{
		a = 1;//父类中的公有权限成员  公有继承子类中依然是公有权限
		b = 1;//父类中的保护权限成员  公有继承子类中依然是保护权限
		//c = 1;父类中的私有权限成员  公有继承子类中访问不到
	}
};
class son2 :protected father//保护继承
{
	void func()
	{
		a = 1;//父类中的公有权限成员  保护继承子类中变为保护权限
		b = 1;//父类中的保护权限成员  保护继承子类中依然是保护权限
		//c = 1;父类中的私有权限成员  保护继承子类中访问不到
	}
};
class son3 :private father//私有继承
{
	void func()
	{
		a = 1;//父类中的公有权限成员  私有继承子类中变为私有权限
		b = 1;//父类中的保护权限成员  私有继承子类中变为私有权限
		//c = 1;父类中的私有权限成员  私有继承子类中访问不到
	}
 };

3.继承中的对象模型

注意父类中的私有成员虽然在子类中无法访问,但依然会继承下来,所以父类和子类中的非静态成员都属于子类中

class father
{
public:
	int a;
protected:
	int b;
private:
	int c;
};
class son :public father
{
	int d;
};
int main()
{
	cout << sizeof(son) << endl;//输出16
}

4.继承中的构造和析构的顺序

当子类继承父类的时候父类也会先创建一个对象,所以也一定会调用父类的构造函数,这就会产生一个问题:子类和父类的构造和析构函数的调用顺序是怎样的

class father
{
public:
	father()
	{
		cout << "父类构造函数调用" << endl;
	}
	~father()
	{
		cout << "父类析构函数调用" << endl;
	}
};
class son :public father
{
public:
	son()
	{
		cout << "子类构造函数调用" << endl;
	}
	~son()
	{
		cout << "子类析构函数调用" << endl;
	}
};
int main()
{
	son s;
}
结果:
父类构造函数调用
子类构造函数调用
子类析构函数调用
父类析构函数调用

这类似于堆金字塔,修的时候先修底部,拆的时候先拆塔尖

5.继承同名成员处理方式

有了继承的概念后,如果在子类中调用父类中的函数,直接用.进行调用就可以了,但是如果子类和父类中有的成员函数名相同,那么在调用的时候应该怎么办呢?答案:调用父类函数时在函数名前加上作用域进行限定,调用子类成员时直接进行调用就可以了;需要注意的是即使父类和子类中同名函数参数不同也无法直接调用父类的函数,因为子类和父类有同名成员函数时子类会隐藏父类的函数,下方是代码举例:

class father
{
public:
	void func()
	{
		cout << "父类函数调用" << endl;
	}
	void func(int)
	{
		cout << "父类有参函数的调用" << endl;
	}
};
class son :public father
{
public:
	void func()
	{
		cout << "子类函数调用" << endl;
	}
};
int main()
{
	son s;
	s.func();//子类函数调用
	s.father::func();//父类函数调用
	s.father::func(10);//父类有参函数调用
}
虽然代码只写了函数的访问,但是访问成员属性也是一样的方法

6.继承同名静态成员处理方式

上面讲了非静态同名继承的问题,那么如果是静态成员呢?其实基本都是一样的,不过因为静态成员有两种访问方式(通过对象访问,通过类名访问),所以在访问时也有两种访问方式

class father
{
public:
	static void func()
	{
		cout << "父类函数调用" << endl;
	}
	static void func(int)
	{
		cout << "父类有参函数的调用" << endl;
	}
};
class son :public father
{
public:
	static void func()
	{
		cout << "子类函数调用" << endl;
	}
};
int main()
{
	son s;
	s.func();//子类函数调用
	s.father::func();//通过对象父类函数
	son::father::func();//通过类名访问父类函数
	s.father::func(10);//父类有参函数调用
}

值得注意的是即便是静态成员,如果同名子类也会隐藏父类函数

7.多继承语法

多继承的意思是C++允许一个类继承多个类,就是一个子类可以有多个父类,多继承可能导致父类中有同名成员出现,解决办法就是加个作用域区分
语法: class 子类:继承方式 父类1,继承方式 父类 2{}

class father1
{
public:
	string a = "father1";
};
class father2
{
public:
	string a = "father2";
};
class son :public father1, public father2
{
public:
	string a = "son";
};
int main()
{
	son s;
	cout << s.a << endl;//直接访问son的a
	cout << s.father1::a << endl;//访问father1的a
	cout<< s.father2::a << endl;//访问father2的a
}

8.菱形继承

在C++中,菱形继承(也称为钻石形继承)是一种多重继承的情况,其中某个类被两个或多个派生类继承,而这些派生类又被另一个类继承。这种结构形状像钻石或菱形,因此得名。菱形继承可能导致一些问题,尤其是当基类中有数据时,因为派生自同一基类的多个路径可能导致基类数据在派生类中存在多份副本,从而引发数据不一致的问题。

菱形继承的问题

考虑以下例子:

class Base {
public:
    int value;
    Base() : value(0) {}
};

class Left : public Base {};
class Right : public Base {};

class Bottom : public Left, public Right {
public:
    void setValue(int v) {
        // 这里存在歧义,因为Base有两个实例
        // Left::value = v; // 编译错误,因为value不是Left的直接成员
        // Right::value = v; // 同上
    }
};

在这个例子中,Bottom 类从 LeftRight 继承,而 LeftRight 又从 Base 继承。因此,Bottom 中有两个 Base 的实例,每个实例都有自己的 value 成员。如果我们尝试在 Bottom 类中设置 value,就会遇到歧义,因为不清楚是指哪一个 Basevalue

解决方案:

1. 虚拟继承

为了解决这个问题,C++ 提供了虚拟继承(virtual inheritance)。在基类被继承时使用 virtual 关键字,可以确保无论通过哪个路径继承,基类都只有一个实例

修改上述代码为使用虚拟继承:

class Base {
public:
    int value;
    Base() : value(0) {}
};

class Left : virtual public Base {};
class Right : virtual public Base {};

class Bottom : public Left, public Right {
public:
    void setValue(int v) {
        value = v; // 现在没有歧义,因为只有一个Base实例
    }
};

在这个修改后的例子中,LeftRight 都通过虚拟继承自 Base。因此,在 Bottom 中只有一个 Base 的实例,setValue 方法可以无歧义地访问 value

2. 访问基类成员

当使用虚拟继承时,可以直接访问基类的成员,而无需通过派生类。这是因为只有一个基类实例,所以直接访问是明确的

3. 设计考虑

在设计类的继承结构时,应尽量避免复杂的菱形继承,因为它可能导致代码难以理解和维护。考虑使用组合、接口或其他设计模式来替代复杂的继承结构

总之,菱形继承是C++中多重继承的一个潜在问题,但通过使用虚拟继承可以有效地解决它

七、多态

1.多态的基本概念

多态直白一点就是多种形态,指的是一种行为会产生多种结果,比如动物的睡觉的行为,如果是马就是站立睡觉,如果是人就是盖着被子躺在床上睡觉,都是睡觉但是会有不同的形态;而我们要学的多态就比较类似,一个统一的接口可以操作不同的对象,从而产生不同的结果

多态主要通过以下两种方式实现:

  • 编译时多态(静态多态):这主要通过函数重载(Overloading)实现。函数重载是指在同一个类中定义多个同名的方法,但这些方法的参数列表不同(参数的数量、类型或顺序不同)。在编译时,编译器会根据方法调用的参数类型、数量和顺序来决定使用哪个方法。这种多态性在编译时就已经确定下来了
  • 运行时多态(动态多态):这主要通过方法重写(Overriding)和向上转型(Upcasting)实现。在父类中定义一个方法,在子类中可以根据需要重写这个方法。当使用父类类型的引用指向子类对象时,如果调用的是被子类重写的方法,那么将执行子类中的方法版本,而不是父类中的版本。这种多态性在运行时才能确定,因此被称为运行时多态
class animal
{
public:
	virtual void sleep()
	{
		cout << "动物睡觉" << endl;
	}
};
class person :public animal
{
public:
	void sleep()
	{
		cout << "人盖着被子睡觉" << endl;
	}
};
class horse :public animal
{
public:
	void sleep()
	{
		cout << "马站着睡觉" << endl;
	}
};
void dosleep(animal& a)//创建一个执行睡觉的函数
{
	a.sleep();
}
int main()
{
	person p;
	dosleep(p);//想执行人在睡觉的行为就需要在父类将睡觉的行为虚化
}

多态满足的条件:

  1. 有继承关系
  2. 子类重写父类中的虚函数

多态的使用:父类指针或引用指向子类对象

2.多态的底层实现

多态本质: 要实现多态我们就要用到虚函数(关键字virtual),虚函数的本质是C++中一种特殊的成员函数,它允许在派生类中重写基类的同名函数,并通过基类的指针或引用在运行时根据对象的实际类型调用相应的函数实现(动态绑定:动态绑定的实现依赖于虚函数表和vfptr指针:在调用虚函数时,程序会首先通过vfptr指针找到虚函数表,然后在虚函数表中找到对应函数的地址,最后执行该函数),从而实现多态性,总结就是当父类的指针或引用指向子类对象时发生多态,这和重载有些类似但是不需要参数列表不同,所以功能更加强大
虚函数实现:
虚函数的实现背后涉及到一个复杂的机制,这个机制通过函数指针变量来实现。具体来说,每个包含虚函数的类都会被编译器生成一个虚函数表(Virtual Function Table,简称VFTable),该表存储了类中所有虚函数的函数指针。每个类的对象(或更准确地说是每个类的实例)都会有一个隐藏的指针(通常称为vfptr),该指针指向该类所对应的虚函数表。这个指针是在对象的构造过程中由编译器初始化的。在声明一个类的时候如果没有成员属性只有非静态成员函数,那么由于分开存储的原因这个类的大小就只有一个字节表示有这个类,但是如果这个函数前加了virtual关键字就会发现有四个字节(x64环境),这就是因为编译器产生了一个虚函数表
继承与重写:
如果派生类重写了基类的虚函数,那么派生类对象的vfptr指针将指向一个不同的虚函数表,该表中存储了派生类虚函数的地址。这样,当通过基类指针或引用调用虚函数时,如果指针或引用实际指向的是派生类对象,那么将调用派生类的虚函数实现。如果不重写基类的虚函数就只会实现继承,派生类就会将基类的全部内容都继承下来

3.多态案例一:计算机类

如果用简单的elseif或者switch结构来实现这个计算器的内容就会导致内容难以修改,比如我只写了加减的功能,后期要添加乘除的功能就必须要修改代码,而如果用多态来实现就会让功能独立,后期想要维护或者添加功能也会方便很多,代码如下(只写了加和乘的功能)

class calculator
{
public:
	virtual int calculate()
	{
		return 0;
	}

	calculator()
	{
		cout << "请输入两个数:";
		cin >> a >> b;
	}
	int a;
	int b;
};
class addcalculate:public calculator
{
public:
	int calculate()
	{
		return a + b;
	}
};
class multiply :public calculator
{
public:
	int calculator()
	{
		return a * b;
	}
};
int  get(calculator& c)
{
	return c.calculate();
}
int main()
{
	addcalculate a;
	cout << get(a);//通过父类引用子类对象
	
	//或者通过父类指针指向子类对象
	calculator* c = new addcalculate;
	cout << c->calculate();//输出3
	delete c;
}

4.纯虚函数和抽象类

在多态中,父类的实现通常是无意义的,主要都是调用子类重写的内容,因此可以将虚函数改成纯虚函数,当类中有了纯虚函数这个类就成为抽象类,抽象类特点:

  1. 无法实例化对象
  2. 子类必须重写抽象类中的纯虚函数,否则这个子类也属于抽象类

纯虚函数语法:virtual 返回值类型 函数名(参数列表)=0;

class base
{
public:
	virtual void func() = 0;//基类的纯虚函数
};
class son :public base
{
public:
	void func()//重写纯虚函数
	{
		cout << "son的func调用" << endl;
	}
};
int main()
{
	son s;
	base* b = &s;
	b->func();
}

5.虚析构和纯虚析构

由于多态的发生依赖于父类指针或引用指向子类对象这就会产生一个问题,当我们对父类进行析构清理的时候如果子类对象有属性开辟在堆区,由于子类应该在自己的析构函数中负责释放这些资源,那么父类指针释放时就无法调用到子类的析构代码(因为如果基类的析构函数没有被声明为虚函数,那么当通过基类指针删除派生类对象时,编译器只会调用基类的析构函数,而不会调用派生类的析构函数),但如果子类的析构函数被正确调用(这依赖于基类的虚析构函数),那么子类中的资源也会得到释放,这就产生了虚析构和纯虚析构函数

虚析构和纯虚析构的共性:

  • 都可以解决父类指针释放子类对象的问题
  • 都需要有具体的函数实现(虽然是虚函数,但是可以用于清理基类的内容,也许有人会说纯虚析构无法实例化需要清理什么内容,需要注意的是只要我们实例化一个子类,那么从父类继承过来的内容也会存在,所以纯虚析构也需要实现)

虚析构和纯虚析构的区别:

  • 纯虚析构也属于特殊的纯虚函数,所以有纯虚析构的类也无法实例化对象

下面就用一个小猫叫的例子来演示纯虚析构和虚析构的用法,但是小猫名字要在子类堆区开辟一块空间进行存储并且用父类指针指向这块空间,然后用纯虚析构和虚析构进行清理操作:

class animal
{
public:
	animal()
	{
		cout << "animal构造函数调用" << endl;
	}
	virtual void speak() = 0;
	virtual ~animal() = 0;
};
animal::~animal()//必须类内声明类外定义
{
		cout << "animal析构函数调用" << endl;
}
class cat:public animal
{
	string *a;
public:
	cat(string a)
	{
		cout << "cat构造函数调用" << endl;
		this->a = new string(a);
	}
	void speak()
	{
		cout << *a<<"在猫叫" << endl;
	}
	~cat()
	{
		if (a != NULL)
		{
			cout << "cat析构调用" << endl;
			delete a;
			a = NULL;
		}
	}
};
int main()
{
	animal *a=new cat("tom");
	a->speak();
	delete a;
}

如果我们不在animal析构函数前面加上virtual关键字,那就会导致cat析构函数无法调用,这会引起内存泄漏的问题;如果将animal基类的析构函数声明为纯虚析构,那么就需要在类外定义

6.多态案例二:组装电脑

在这里插入图片描述
我们将通过使用多态完成这个案例来更深层次地理解面向对象编程地多态性:
首先要创建一个电脑类来进行零件组装和工作,再创建三个零件抽象类来提供不同的厂商生产的零件,用电脑类来接收这三种零件来组装

#include<iostream>  
#include<string>  
using namespace std;  
  
// 定义抽象基类 cpu  
class cpu {  
public:  
    // 纯虚函数,强制派生类实现  
    virtual void calculate() = 0;  
    // 虚析构函数,确保通过基类指针删除派生类对象时,派生类的析构函数被调用  
    virtual ~cpu() {}  
};  
  
// 定义抽象基类 vediocard  
class vediocard {  
public:  
    // 纯虚函数  
    virtual void display() = 0;  
    // 虚析构函数  
    virtual ~vediocard() {}  
};  
  
// 定义抽象基类 memory  
class memory {  
public:  
    // 纯虚函数  
    virtual void storage() = 0;  
    // 虚析构函数  
    virtual ~memory() {}  
};  
  
// 定义 computer 类,包含 cpu、vediocard 和 memory 的指针  
class computer {  
public:  
    // 构造函数,接收三个基类指针  
    computer(cpu* c, vediocard* v, memory* m) : c(c), v(v), m(m) {}  
    // 成员函数,模拟电脑的工作流程  
    void work() {  
        c->calculate();  
        v->display();  
        m->storage();  
    }  
    // 析构函数,负责删除动态分配的对象  
    ~computer() {  
        delete c; c = nullptr;  
        delete v; v = nullptr;  
        delete m; m = nullptr;  
    }  
private:  
    cpu* c;  
    vediocard* v;  
    memory* m;  
};  
  
// 定义 intelcpu 类,继承自 cpu 并实现 calculate 方法  
class intelcpu : public cpu {  
public:  
    void calculate() override { // 使用 override 确保覆盖基类虚函数  
        cout << "intelcpu计算中" << endl;  
    }  
    ~intelcpu() override { // 虚析构函数  
        cout << "intelcpu析构" << endl;  
    }  
};  
  
// 定义 intelvediocard 类,继承自 vediocard 并实现 display 方法  
class intelvediocard : public vediocard {  
public:  
    void display() override {  
        cout << "intelvediocard显示中" << endl;  
    }  
    ~intelvediocard() override {  
        cout << "intelvediocard析构" << endl;  
    }  
};  
  
// 定义 intelmemory 类,继承自 memory 并实现 storage 方法  
class intelmemory : public memory {  
public:  
    void storage() override {  
        cout << "intelmemory存储中" << endl;  
    }  
    ~intelmemory() override {  
        cout << "intelmemory析构" << endl;  
    }  
};  
  
int main() {  
    cout << "第一台电脑开始工作" << endl;  
    // 直接在堆上创建 computer 对象及其依赖对象  
    computer *c1 = new computer(new intelcpu, new intelvediocard, new intelmemory);  
    c1->work();  
    delete c1; // 调用 computer 的析构函数,进而删除内部的对象  
  
    cout << endl;  
  
    cout << "第二台电脑开始工作" << endl;  
    // 分别创建依赖对象,然后创建 computer 对象  
    cpu* c = new intelcpu;  
    vediocard* v = new intelvediocard;  
    memory* m = new intelmemory;  
    computer* c2 = new computer(c, v, m);  
    c2->work();  
    delete c2; // 同样,调用 computer 的析构函数来删除内部对象  
  
    // 注意:在这个例子中,我们没有在 main 函数的末尾显式删除 c、v 和 m,  
    // 因为它们已经被 computer 的析构函数删除了。但在更复杂的情况下,  
    // 如果这些对象在 computer 对象之外还有用途,那么就需要确保它们被正确管理。  
    // 在这里,为了简单起见,我们让它们泄漏了(尽管这不是最佳实践)。  
  
    // 如果要在 main 结束时删除它们,应该这样写:  
    // delete c;  
    // delete v;  
    // delete m;  
  
    // 但由于它们已经被 computer 的析构函数删除了,所以这样做会导致未定义行为(double delete)。  
}

好的长达三万字的类和对象就到此结束了,希望不足之处诸位可以评论区指出

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值