当前位置: 首页 > article >正文

【C++】详细讲解继承(上)

 C++面向对象的三大特性:封装,继承,多态。现在我们就介绍一下继承。

1.继承的概念及定义

1.1 继承的概念

        继承机制是⾯向对象程序设计使代码可以 复⽤ 的最重要的⼿段。我们前面接触到的都是 函数 层次的复用,遇到过的 层次的复用有模板,而继承是 类层次 的一种新的复用
        继承允许我们在 保持原有类特性的基础上进⾏扩展 ,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派⽣类(或子类)。

 假如现在我们模拟校园环境,设计老师(Teacher)和学生(Student)两个类。老师和学生都有姓名、电话、地址、年龄等成员变量,都有身份认证相关成员函数。

class Student //学生
{
public:
	void identity() //身份认证
	{
		//...
	}

protected:
	string _name; //姓名
	size_t _age;  //年龄
	string _add;  //住址
	string _tel;  //电话
};
class Teacher  //老师
{
public:
	void identity() //身份认证
	{
		//...
	}

protected:
	string _name; //姓名
	size_t _age;  //年龄
	string _add;  //住址
	string _tel;  //电话
};

学生特有的变量是学号和学习相关的成员函数。

class Student //学生
{
public:
	void identity() //身份认证
	{
		//...
	}
	void study() //学习
	{
		//...
	}

protected:
	string _name; //姓名
	size_t _age;  //年龄
	string _add;  //住址
	string _tel;  //电话

	string _stuid;//学号 
};

老师特有的变量是职称和教书相关的成员函数。

class Teacher  //老师
{
public:
	void identity() //身份认证
	{
		//...
	}
	void teaching() //教书
	{
		//...
	}
protected:
	string _name; //姓名
	size_t _age;  //年龄
	string _add;  //住址
	string _tel;  //电话

	string _title;//职称
};

但是我们会发现这样设计的两个类重复的地方特别多,显得很冗余。

那我们把公共的信息提取出来,放在一个Same类里面,Student和Teacher这两个类复用这个类,就不用重复定义了。

class Same
{
public:
	void identity() //身份认证
	{
		//...
	}
protected:
	string _name; //姓名
	size_t _age;  //年龄
	string _add;  //住址
	string _tel;  //电话
};

复用这个Same类,就是继承它,怎么继承?写法如下。

//学生
class Student : public Same
{
public:
	void study() //学习
	{
		//...
	}

protected:
	string _stuid;//学号 
};
//老师
class Teacher : public Same 
{
public:
	void teaching() //教书
	{
		//...
	}

protected:
	string _title;//职称
};

这就是继承的意义。接下来我们细说一下继承。

1.2 继承的定义

1.2.1 定义的格式

前面定义的Same类就是一个父类,也称作基类;Student类是子类,也称作派生类。

 继承方式有三个:public继承、private继承、protected继承。

1.2.2 继承基类成员访问⽅式的变化

由上面的表我们可以观察到:

  • 基类private成员在派⽣类中⽆论以什么⽅式继承都是不可⻅的。这⾥的不可⻅是指基类的私有成员还是被继承到了派⽣类对象中,但是语法上限制派⽣类对象不管在类⾥⾯还是类外⾯都不能去访问它。
  • 基类private成员在派⽣类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派⽣类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。(这里也体现出private和protected的区别)
  • 实际上⾯的表格我们进⾏⼀下总结会发现,基类的私有(private)成员在派⽣类都是不可⻅。基类的其他成员在派⽣类的访问⽅式取权限小的:public > protected > private
如果我们不写继承方式,使⽤关键字 class 时默认的继承⽅式是 private ,使⽤ struct 时默认的继承⽅式是 public ,不过最好显⽰的写出继承⽅式。

在实际运⽤中 ⼀般使⽤都是public继承 ,⼏乎很少使⽤protetced/private继承,也不提倡使⽤
protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实
际中扩展维护性不强。

1.3 类模板的继承

之前我们用适配器模式写过栈和队列【C++】栈和队列的模拟实现(适配器模式)

这里我们还可以用 继承来实现栈和队列,以栈stack为例。
template<class T>
class stack : public std::vector<T> //继承vector
{

};

然后再在stack里面实现相关接口。

template<class T>
class stack : public std::vector<T> //继承vector
{
public:
	void push(const T& x)
	{
		vector<T>::push_back(x);
	}
	void pop()
	{
		vector<T>::pop_back();
	}
	const T& top()
	{
		return vector<T>::back();
	}
	bool empty()
	{
		return vector<T>::empty();
	}
};

(注意:基类是类模板时,需要指定⼀下类域, 否则编译报错:error C3861: “push_back”: 找不到标识符   相关的错误。)

这里stack的实现就是用了继承来实现。这个代码还可以进行改进,如下。

#define CONTAINER std::vector //宏
template<class T>
class stack : public CONTAINER<T> //继承
{
public:
	void push(const T& x)
	{
		CONTAINER<T>::push_back(x);
	}
	void pop()
	{
		CONTAINER<T>::pop_back();
	}
	const T& top()
	{
		return CONTAINER<T>::back();
	}
	bool empty()
	{
		return CONTAINER<T>::empty();
	}
};

宏替换,就可以改变stack的底层逻辑,可以换成list,deque。

2.父类和子类对象赋值兼容转换

public继承 的派⽣类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引⽤ 。这⾥有个形象的说法叫 切⽚ 或者 切割 。寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分。

比如说现在有 Student类的指针ptr1Same类指针ptr2,ptr1赋值给ptr2,就是派生类(子类)对象赋值给基类(父类),ptr2只会指向ptr1中基类有的部分。

引用同理。

Student st;//子类对象
Same sa = st; //子类对象赋值给父类对象
Same* psa = &st;//子类对象赋值给父类指针
Same& rsa = sa; //子类对象赋值给父类引用

(这里所有的赋值都不会产生临时变量,因为子类直接做了切片给父类)

父类(基类)对象不能赋值给子类(派生类)。

Student st;//子类对象
Same sa; //父类对象
sa = st;//子类赋值给父类(可以,做切片)
st = sa;//父类赋值给子类(不可以)
子类的 指针或者引⽤可以 通过强制类型转换赋值给父类的指针或者引⽤。但是必须是基类的指针
是指向派⽣类对象时才是安全的。(等以后细说)

3.继承中的作用域

3.1 隐藏规则

下面的类基类和派生类有一个同名的成员变量是_name。

class Same //基类
{ 
protected:
	string _name = "123";
	size_t _age; 
};
class Teacher : public Same //派生类
{
public:
	void Print()
	{
		cout << _name << endl;
	}
protected:
	string _name = "456";
};

那我们在派生类中访问_name的时候到底访问的是哪个?

int main()
{
	Teacher t;
	t.Print();
	return 0;
}

 结果是显示派生类(子类)里的_name。

如果我们就想访问基类(父类)里的_name,可以直接用 基类::基类成员 显⽰访问,如下。

class Teacher : public Same
{
public:
	void Print()
	{
		cout << Same::_name << endl; //指定作用域访问
	}
protected:
	string _name = "456";
};

  • 在继承体系中基类和派⽣类都有独⽴的作⽤域
  • 派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  • 注意在实际中在继承体系⾥⾯最好不要定义同名的成员

3.2 相关练习

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		cout << "func(int i)" << i << endl;
	}
};

int main()
{
	B b;
	b.fun(10);
	b.fun();
	return 0;
};

答案: B.隐藏

为什么不是重载?因为函数构成重载的要求是两个函数在同一作用域。而父类和子类有独立的作用域。

 

答案: A.编译报错 

如果是下面这样调用,没有传参

int main()
{
	B b;
	b.fun();
	return 0;
};

先看子类里的fun,子类里的fun是需要传参的,匹配不成功,会不会继续去父类里找?不会,子类把父类同名函数隐藏了,直接报错。

如果传参,像下面这样,就会调用子类里的fun函数。

int main()
{
	B b;
	b.fun(10);
	return 0;
};

如果就是想要父类里的fun函数,直接指定定义域调用。

int main()
{
	B b;
	b.fun(10);
	b.A::fun(); //指定
	return 0;
};

 

4.派生类的默认成员函数 

4.1 四个常见的默认成员函数

4.1.1 默认构造

我们不写,编译器默认生成的构造函数的行为:

1.对内置类型->是否初始化是不确定的。

2.对自定义类型->调用默认构造

3.继承父类成员看作一个整体对象,要求调用父类的默认构造

  • 派⽣类的构造函数必须调⽤基类的构造函数初始化基类的那⼀部分成员
  • 如果基类没有默认的构造函数,则必须在派⽣类构造函数的初始化列表阶段显⽰调⽤
  • 派⽣类对象初始化调⽤基类构造派⽣类构造。

 比如说Same类是基类,我们显示写它的默认构造,Student类为派生类,不显示写。

class Same //基类
{
public:
	Same(const char* name = "peter")
		: _name(name)
	{
		cout << "Same()" << endl;
	}
protected:
	string _name;
};
class Student : public Same  //派生类
{
public:
	//没有显示写默认构造,编译器自己生成
protected:
	int _num;
	string _add;
};

按照规则,_num是内置类型,可能初始化也可能不初始化;_add是自定义类型,调用string自己的初始化;继承还要调用父类的默认构造,所以_name应该被初始化为peter。

 假如我们不显示写基类的默认构造,就必须在派生类显示调用。

class Same //基类
{
public:
	Same(const char* name) //此时基类没有默认构造
		: _name(name)
	{
		cout << "Same()" << endl;
	}
protected:
	string _name;
};
class Student : public Same
{
public:
	Student(const char* string, int num, const char* add)
		:Same(string) //在初始化列表阶段显示调用
		, _num(num)
		,_add(add)
	{}
protected:
	int _num;
	string _add;
};

然后我们传参,就可以了。

int main()
{
	Student st("张三", 0, "Chain");
	return 0;
}

4.1.2 拷贝构造

1.对内置类型 -> 值拷贝

2.对自定义类型 -> 调用自己的拷贝构造函数

3.对于继承成员看作一个整体对象,要求调用父类的拷贝构造

  • 派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。
class Same //基类
{
public:
	Same(const char* name = "peter") //默认构造
		: _name(name) 
	{
		cout << "Same()" << endl;
	}

	Same(const Same& p) //拷贝构造
		: _name(p._name)
	{
		cout << "Same(const Same& p)" << endl;
	}

protected:
	string _name;
};
class Student : public Same //派生类
{
public:
	Student(const char* string, int num, const char* add)
		:Same(string) //在初始化列表阶段显示调用
		, _num(num)
		,_add(add)
	{}
    //拷贝构造一般不用自己写
protected:
	int _num;
	string _add;
};

一般情况下,派生类(子类)默认生成的拷贝构造就够用了,不用自己写,如果有需要深拷贝的资源,才需要自己写。

int main()
{
	Student st1("张三", 0, "Chain");
	Student st2(st1);
	return 0;
}

_num是内置类型,进行值拷贝;_add是自定义类型string,调用string自己的拷贝构造;_name是父类继承成员,调用父类Same的拷贝构造。

如果我们要在派生类(子类)中显示地写拷贝构造,写法如下。

Student(const Student& s) //显示地写子类的拷贝构造
	:Same(s)//父类的拷贝构造
	,_add(s._add)
	,_num(s._num)
{
	//假设里面是深拷贝资源的拷贝逻辑
}

Same是父类,Same后面的括号里应该传父类的对象,但是我们没有父类的对象,只有子类的对象s,为什么可以直接传s过去?

这里用到的就是前面说过的  子类和父类对象赋值兼容转换  。我们要拷贝父类的那一部分,就要把父类的那一部分拿出来,我们把子类对象s传给父类Same,Same的拷贝构造函数是引用传参

这里引用的就是子类对象中切出来的父类的那一部分

4.1.3 赋值运算符重载

  • 派⽣类的operator=必须要调⽤基类的operator=完成基类的复制。
  • 需要注意的是派⽣类的operator=隐藏了基类的operator=,所以显⽰调⽤基类的operator=,需要指定基类作⽤域。

我们先实现一个基类(父类)的赋值重载。

class Same //基类
{
public:
	Same(const char* name = "peter") //默认构造
		: _name(name)
	{
		cout << "Same()" << endl;
	}

	Same(const Same& p) //拷贝构造
		: _name(p._name)
	{
		cout << "Same(const Same& p)" << endl;
	}

	Same& operator=(const Same& p) //赋值重载
	{
		cout << "Same& operator=(const Same& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}

protected:
	string _name;
};
class Student : public Same //派生类
{
public:
	Student(const char* string, int num, const char* add)
		:Same(string) //在初始化列表阶段显示调用
		, _num(num)
		,_add(add)
	{}
	
    //没有实现operator=

protected:
	int _num;
	string _add;
};

operator=和拷贝构造差不多,我们不在派生类(子类)中显示地写赋值重载时,编译器自动生成的就够用。

int main()
{
	Student st1("张三", 0, "Chain");
	Student st2("李四", 1, "LA");
	st1 = st2;
	return 0;
}

也是内置类型值拷贝,自定义类型调用自己的operator=,继承父类成员看作整体,调用父类的operator=。

 如果有需要深拷贝的资源,才需要自己实现。自己实现的话,写法如下。

Student& operator=(const Student& s)
{
	if (this != &s)
	{
		Same::operator=(s);//显示调用基类的赋值重载
		_num = s._num;
		_add = s._add;
		//深拷贝逻辑
	}
	return *this;
}

显示调用父类(基类)的operator=时,要指定类域,因为同名函数子类的会把父类的隐藏,屏蔽基类对同名成员的直接访问,如果不指定类域,会造成栈溢出。

4.1.4 析构

  • 派⽣类的析构函数会在被调⽤完成后⾃动调⽤基类的析构函数清理基类成员。
class Same //基类
{
public:
	Same(const char* name = "peter") //默认构造
		: _name(name)
	{
		cout << "Same()" << endl;
	}
	Same(const Same& p) //拷贝构造
		: _name(p._name)
	{
		cout << "Same(const Same& p)" << endl;
	}
	Same& operator=(const Same& p) //赋值重载
	{
		cout << "Same& operator=(const Same& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}

	~Same() //析构
	{
		cout << "~Same()" << endl;
	}
protected:
	string _name;
};

派生类(子类)默认生成的析构函数就够了,如果有需要资源释放的时候才需要自己实现。 

  • 编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派⽣类析构函数和基类析构函数构成隐藏关系

所以我们如果需要在子类(派生类)自己写析构函数时,不可以像下面这样。 

~Student()
{
	~Same(); //错误的写法

	//资源释放逻辑...
}

既然构成隐藏,调用就需要指定类域调用。

~Student()
{
	Same::~Same(); //正确的写法

	//资源释放逻辑...
}

但是我们会发现,下面的代码明明只有两个对象,却调用了4次析构函数。

而调用析构次数太多会出问题。

  • 对于析构函数,在子类(派生类)显示写时,里面不需要显示调用父类(基类)的析构可以认为这是一个规定,为了保证析构顺序是先子后父,子类析构函数之后,会自动调用父类的析构。
~Student()
{
	//资源释放逻辑...
	//自动调用父类析构
}

 

 

4.2 实现一个不能被继承的类

⽅法1:将基类的构造函数 私有 ,派⽣类的构成必须调⽤基类的构造函数,但是基类的构成函数私有化以后,派⽣类看不⻅就不能调⽤了,那么派⽣类就⽆法实例化出对象。
⽅法2:C++11新增了⼀个 final 关键字,final修改基类,派⽣类就不能继承了。
class Same final //基类加final后不可被继承
{
public:
	//成员函数
protected:
	string _name;
};

本次分享见到这里,我们下篇见~


http://www.kler.cn/a/516106.html

相关文章:

  • C语言操作符(上)
  • vue3+elementPlus之后台管理系统(从0到1)(day3-管理员管理)
  • Flutter:自定义Tab切换,订单列表页tab,tab吸顶
  • 【Mac】Python相关知识经验
  • ipad和macbook同步zotero文献附件失败的解决办法
  • 第五篇 vue3 ref 与 reactive 对比
  • 【网络协议】【http】【https】ECDHE-TLS1.2
  • 领域驱动设计(DDD)四 订单管理系统实践步骤
  • 【时时三省】(C语言基础)格式化输入输出函数
  • 2025.1.21——六、BUU XSS COURSE 1
  • P1115 最大子段和
  • 人工智能在音频、视觉、多模态领域的应用
  • 踏浪而行,2024年技术创作的星光轨迹
  • c++瓷砖
  • 4. LwIP_网络数据包管理
  • Spring AI Document
  • 消息队列篇--原理篇--常见消息队列总结(RabbitMQ,Kafka,ActiveMQ,RocketMQ,Pulsar)
  • IoTDB结合Mybatis使用示例(增删查改自定义sql等)
  • electron打包报错解决
  • ThinkPHP 8模型与数据的插入、更新、删除
  • 转换模型到 bfloat16 精度之前需要做的检查工作,不然模型报错给你看
  • Java学习教程,从入门到精通,JDBC创建数据库语法知识点及案例代码(99)
  • SpringBoot读取配置优先级顺序是什么?
  • 【记录自开发的SQL工具】工具字符拼接、Excel转sql、生成编码、生成测试数据
  • verilog笔记1
  • jmeter中对接口进行循环请求后获取相应数据