C++进阶:继承C++为什么要引入继承 | 继承概念及定义 | 基类和派生类对象赋值转换 | 继承中的作用域 | 派生类的默认成员函数 | 继承与友元/静态成员 | 复杂的菱形继承及菱形虚拟继承

Posted 跳动的bit

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++进阶:继承C++为什么要引入继承 | 继承概念及定义 | 基类和派生类对象赋值转换 | 继承中的作用域 | 派生类的默认成员函数 | 继承与友元/静态成员 | 复杂的菱形继承及菱形虚拟继承相关的知识,希望对你有一定的参考价值。

文章目录

【写在前面】

在 C++ 初阶专栏 ➡ 类和对象一文中,我们提出了面向对象的三大特性 —— 封装、继承、多态。但在 C++ 初阶专栏中涉及到的只是封装,而这里我们直接以封装和继承作为 C++ 进阶专栏中的敲门砖。

我们说过 C++ 是大佬从 C 发展出来的,最开始的 C++ 叫做 C With Class,就是在 C 的基础上增加了类。经过 C++ 初阶的学习,我们知道了 C++ 中类,就是为了对标并解决 C 的缺陷,比如构造、析构等。

在 C With Class 时,在类的设计层面,C++ 还面临一个问题,假设:

  我们发现每个角色都有公共的信息,如果在每个类中都写一份,构造和析构时会对每个冗余的信息都处理一次,这是设计层面上的困境。

面对这种困境,不谈继承,如何解决 ❓

  我们把公共的信息提取出来封装成一个类,每个角色创建时就调用。这样就完成类层面的复用,这里 C 也是支持的。

这种复用有什么缺陷 ???

  没错,它确实可以解决代码冗余的困境,但是它肯定有缺陷,不然 C++ 就不会有继承了。这里的缺陷在于 Student 里复用 Person 时,并不好访问 Person 的成员,因为一般都会设置为私有。所以 C++ 中就衍生了继承。

一、继承的概念及定义

💦 继承的概念

继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继承是类设计层次的复用。

#include<iostream>
#include<string>
using namespace std;

class Person

public:
	void print()
	
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	
protected:
	string _name = "DanceBit";
	int _age = 18;
;

class Student : public Person

protected:
	int _stuid;
;

class Teacher : public Person

protected:
	int _jobid;
;

int main()

	Student s;
	s.print();

	Teacher t;
	t.print();

	return 0;

  其中 Person 就叫做父类或基类;Student、Teacher 叫做子类或派生类。Student、Teacher 继承了 Person,Student、Teacher 中就拥有了 Person 的成员变量和成员函数,所以可以 s.print()、t.print(),但是对于一个类对象,它只存储成员变量,成员函数存储于公共代码区。所以继承的本质是类级别的复用。

  这里一继承就把父类的所有东西都继承了,这样也不好。好比皇帝不想干了,让年纪较小的太子继位,但并不是所有的事都交给太子来决策,而是重要的事要给皇帝请示。所以对于继承来说,我们需要能灵活控制,针对不同场景,我们可以部分继承、半继承、全继承、暂时不继承。

💦 继承定义

1、定义格式

  Student 是子类或派生类;Person 是父类或基类;public 是继承方式;C++ 把这块区分后,它的继承方式有 3 种。

2、继承关系和访问限定符

  Student 继承 Person,Person 里的成员变量在 Student 中到底是什么样的访问方式,它是由这里的继承方式和原来的访问限定符所决定的。

3、继承基类成员访问方式的变化
类成员/继承方式public继承ptotected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见
  • 实际从上面的表格总结会发现,基类的私有成员在子类都是不可见的。其余我们认为派生类中的访问方式是参照类成员原来的访问方式和继承方式,我们认为它们的权限关系是 public > protected > private,那么派生类最终的访问方式是取 Min(访问方式,继承方式)。
  • 在类和对象中我们说过,想被访问的成员设置为 public,不想被访问的成员设置为 private 或 protected。我们当时说 private 和 protected 的区别到了继承才能体现,体现如下。
#include<iostream>
#include<string>
using namespace std;

class Person

public:
	void print()
	
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	
protected:
	string _name = "DanceBit";
private:
	int _age = 18;
;

class Student : public Person

	void f()
	
		_name = "dancebit";
		print();
		//_age = 20;
	
protected:
	int _stuid;
;

class Teacher : protected Person

	void f()
	
		_name = "dancebit";
		print();
		//_age = 20;
	
protected:
	int _jobid;
;

class Other : private Person

	void f()
	
		print();
		_name = "dancebit";
		//_age = 20;
	
protected:
	int _jobid;
;

int main()

	Student s;
	s.print();
	//s._name;
	//s._age;

	Teacher t;
	//t.print();
	//t._name;
	//t._age;

	Other o;
	//o.print();
	//o._name();
	//o._age();
	
	return 0;

📝 说明:

  • Student 中 f 函数里能访问 _name 的原因是因为访问限定符限制的是类外的人。

  • 任何继承方式继承的私有成员都是不可见,它指的是基类的私有成员,虽然还是被继承到了派生类对象中,但语法限制了不管在类里类外都不能访问,也就是说对象的物理空间上存在,但是类里类外都不能使用。注意区分非继承的私有成员,它是类里可以使用,类外不能使用。

  • 在继承之前,我们以前说想被访问的设置为公有,不想被访问的设置为私有。在继承之后,对于 public 继承:想让子类或类外访问的把成员设置为 public;想让子类访问,但不想让类外访问的把成员设置为 protected;不想让子类访问,也不想让类外访问的把成员设置为 private;所以在继承中,一个类,尽量不要使用 private,因为 private 在子类中不可见,尽量用 protected。这里就可以看出保护成员限定符是因继承而出现的。

  • private 和 protected 成员对于父类是一样的,都是在类里可以访问,类外不可以访问;区别在于,public 继承后,对于子类,private 成员不可见,protected 成员可以在类里访问,类外不能访问。

  • 使用关键字 class 时默认的继承方式是 private;使用关键字 struct 时默认的继承方式是 public。不过最好显示的写出继承方式。其实这块设计时应该强制写出继承方式比较好,不写就报错,就如构造函数的内置类型不处理、访问限定符不一定需要写等。这些都是由于早期设计时,在这方面考虑的不是很周到,也没有经验借鉴,同时 C++ 是向前兼容的,所以就有了我们现在所看到的 C++ 很多细细小小的坑。我们也不能站在现在的时代去指责历史,就像你不能跟你爸说:爸,你当初要是好好学习,奋斗一番事业,我现在就不会在这码字了。

  • 当时 C++ 设计时,考虑的很健全,但在健全的同时,也增加了学习的成本,且这种成本价值不大,因为实际中常用的只有基类的 public 成员和 public 继承方式、基类的 protected 成员和 public 继承方式,几乎很少使用 protected/private 继承和 private 访问限定符,当然也不提倡使用,因为实际中扩展维护性很差。所以后来的好多语言把这块内容简化了,这也就是 C++ 相对其它语言难学的原因,本质就是有些地方考虑的比较复杂,但是这也没办法,因为 C++ 是早期吃螃蟹的人。这里想说的是虽然 C++ 设计的比较复杂,但是我们要往简单去理解。所以对于 C++ 的一些小缺陷小细节的地方,我们应该谦虚的、包容的去学习,不是说它恶心就厌恶它。

二、基类和派生类对象赋值转换

#include<iostream>
using namespace std;

class Person

public:
	/*void f()
	*/
protected:
	string _name;
	string _sex;
	int _age;
;
//class Student : protected Person
class Student : public Person

public:
	int _No;
;

int main()

	Person p;
	Student s;

	p = s;//父类对象 = 子类对象
	Person* ptr = &s;//父类指针 = 子类指针
	Person& ref = s;//父类引用 = 子类

	//s = p;//子类对象 = 父类对象,err
			//子类指针 = 父类指针,ok,但是最好用dynamic_cast,因为这样才是安全的,这里的安全指的是它会去识别父类的指针,如果是指向父类,这个转换就失败,如果是指向子类,这个转换就成功。
			//引用同指针

	return 0;

  • 我们都知道同类型的对象赋值是可以的,那父子类呢 ❓

    首先我们得知道父子类为啥要支持赋值呢,究其原因是它们之间存在一些强关联的关系,子类几乎包含父类的成员。

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。这是在 public 继承的前提,因为如果是 public 继承,那么继承下来的成员的访问限定符是不变的;如果是 protected 或 private 继承,那么继承下来的成员的访问限定符可能会改变,进而导致不支持子类对父类赋值,因为可能存在类型转换。

  • 基类对象不能赋值给派生类对象。

  • 但是基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。(ps:这个后面会谈,这里先了解一下)。

  • 这就完了 ?当然不是,这里是需要和下面的派生类的默认成员函数一起理解。

三、继承中的作用域

#include<iostream>
#include<string>
using namespace std;

class Person

protected:
	string _name = "dancebit";
	int _num = 111;
;

class Student : public Person

public:
	void Print()
	
		cout << "姓名: " << _name << endl;
		cout << _num << endl;
		cout << Person::_num << endl;
	
protected:
	int _num = 999;
;

class A

public:
	void fun()
	
		cout << "fun()" << endl;
	
;

class B : public A

public:
	void fun(int i)
	
		cout << "fun(int i)" << endl;
	
;

void Test1()

	Student s;
	s.Print();


void Test2()

	B b;
	b.fun(10);
	//b.fun();
	b.A::fun();


int main()

	//Test1();
	Test2();

	return 0;

  • 在继承体系中基类和派生类都有独立的作用域,这意味着可以定义同名的成员,就像 STL 中 list 有 push_back,vector 也有 push_back。

    同时也就意味着现在有一个矛盾点是 Student 里有 2 个 _num,我们在访问 _num 时是访问基类的还是派生类的 ❓

      根据我们之前知道的全局变量和局部变量相同,局部优先的特性,我们可以猜测是这里是派生类优先。它们都有一个特性,那就是优先在自己的作用域查找。

  • Test1() 中,当派生类和基类有同名成员变量时,派生类成员变量会屏蔽基类成员变量,以此不能直接访问基类成员变量。这种情况叫做隐藏或重定义。如果想访问基类成员变量需要指定基类成员变量所在的作用域。

  • Test2() 中,当派生类和基类有同名成员函数 fun,但是参数不同时,它们之间存在什么关系 ❓

      首先 A 类的 fun 和 B 类的 fun 一定不构成函数重载,因为以前说过函数重载必须是在同一作用域,而我们刚说基类和派生类是不同的作用域。

      这里规定对于成员函数,构成隐藏的关系只需要函数名相同即可,而不用关心参数、返回值。所以这里 A 类的 fun 和 B 类的 fun 构成的就是隐藏,如果想访问基类中的 fun 需要指定其所在作用域。

  • 注意在实际中继承体系里面最好不要定义同名的成员变量和函数。这里其实也是 C++ 在设计时不好和复杂的地方,但是你也不能说如果同名就报错。就像北京有一个叫张三的,贵州也有一个叫张三的,这当然没有问题;但是同一个家庭不能大哥叫张三,二哥也叫张三,因为你要访问张三,你就要指定一个规则,如默认张三就是大哥、小张三就是二哥。C++ 中不能完全禁止同名的成员,因为一定会存在同名的隐藏关系,本章以及多态会碰到这样的场景。

四、派生类的默认成员函数

我们之前在类和对象部分中学习了 6 个默认成员函数,“ 默认 ” 的意思就是指我们不写,编译器会帮我们自动生成。那么在派生类中,这几个成员函数是怎么生成的呢。

✔ 测试用例一:

#include<iostream>
#include<string>
using namespace std;

class Person

public:
	//Person(const char* name = "dancebit")
	Person(const char* name)
		: _name(name)
	
		cout << "Person(const char* name = \\"dancebit\\")" << endl;
	
	Person(const Person& p)
		: _name(p._name)
	
		cout << "Person(const Person& p)" << endl;
	
	Person& operator=(const Person& p)
	
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	
	~Person()
	
		cout << "~Person()" << endl;
	
protected:
	string _name;
	int a;
;
class Student : public Person

public:
	Student(const char* name, int id, const char* address)//推荐
		//: _name(name)//err,父类继承下来是一个整体
		: Person(name)
		, _id(id)
		, _address(address)
	
	
	//Student(const char* name, int id, const char* address)//不推荐
	//	: _id(id)//初始化列表阶段会先调用父类的默认构造
	//	, _address(address)
	//
private:
	int _id;
	string _address;
;

int main()

	//Student s1;
	Student s2("DANCEBIT", 1, "China");

	return 0;

  • 对于子类的构造函数,我们不写,编译器会默认生成。它针对 a) 内置类型成员不处理,除非声明时给了缺省值; b) 自定义类型成员,调用它的默认构造函数; c) 继承的父类成员作为一个整体,调用父类的默认构造函数;

  • 父类里写了默认构造函数、子类里没写构造函数,我们只定义了 Studnet 的对象,没有定义 Person 的对象,但是这里却调用了 Person 的构造和析构,这里是子类里默认生成的构造函数调用的,同时也看到了这里设计时没有把继承下来的父类成员混淆到自己的内置和自定义类型成员中。这里继承下来的父类成员会作为一个整体调用它的默认构造函数;内置类型不处理 (除非声明时给了缺省值);自定义类型会调用它的默认构造函数。注意严格来说是先处理父类继承下来的,内置类型和自定义类型可以认为是平等的。

  • 如果父类没有默认构造函数,那么想对父类的成员进行初始化,使用子类默认生成的构造函数是不行的,因为子类默认生成的构造函数要去调用父类的默认构造函数,而父类没有默认构造函数,所以需要自己实现子类构造函数 (Student s1 + 子类全缺省默认构造函数 || Student s2(“DANCEBIT”, 1, “China”) + 子类全缺省默认构造函数/构造函数)。要注意父类是作为一个整体,调用父类的构造函数初始化,对于构造函数我们自己实现是有价值的。

  • 如果父类使用默认生成的构造函数 (注意测试时需要将拷贝构造一起注释掉,因为拷贝构造也是构造),子类的构造函数不调用父类,当然也调用不了父类,它是在子类的初始化列表中调用的,可以看到父类的 _name 依然能初始化,因为 _name 是 string 类型的,它会去调用 string 的默认构造函数初始化。这里对于编译器默认生成的或无参的构造函数在子类就不能显示的初始化了,但是对于全缺省的依然可以显示的初始化。

  • 如何设计出一个不能被继承的类 ❓

      构造函数设计成私有,就可以认为这个类不能被继承了,因为子类要初始化父类继承下来的成员一定要去调用父类的构造函数,而构造函数私有则意味着父类的构造函数在子类中不可见,这里就可以看到 private 还是有点使用的价值的,但也只是在 C++98 中,因为在 C++98 中如果想做到一个不能被继承的类,只能将构造函数私有,但是这样不定义子类对象和不调用父类的构造函数是不会报错的,注意可能是由于编译器检查严格的原因,就算不定义子类对象,在子类中显示的调用了父类的构造函数也会报错,所以你会发现 C++98 这种方式不够彻底和直观。

      在多态中,我们会介绍 C++11 中的关键字 final 用于替代 C++98 中的方式。

✔ 测试用例二:

#include<iostream>
#include<string>
using namespace std;

class Person

public:
	Person(const char* name = "dancebit")
		: _name(name)
	
		cout << "Person(const char* name = \\"dancebit\\")" << endl;
	
	Person(const Person& p)
		: _name(p._name)
	
		cout << "Person(const Person& p)" << endl;
	
	Person& operator=(const Person& p)
	
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	
	~Person()
	
		cout << "~Person()" << endl;
	
protected:
	string _name;
	int a;
;
class Student : public Person

public:
	Student(const char* name, int id, const char* address)
		: Person(name)
		, _id(id)
		, _address(address)
	
	Student(const Student& s)
		//: Person(s)//切片行为
		//, _id(s._id)
		//, _address(s._address)

		: _id(s._id)//不显示的调用父类的拷贝构造
		, _address(s._address)
	

private:
	int _id;
	string _address;
;

int main()

	Student s1("DANCEBIT", 1, "China");

	Student s2(s1);

	return 0;

  • 对于子类的拷贝构造,我们不写,编译器会默认生成。它针对 a) 内置类型成员完成值拷贝; b) 自定义类型成员,调用它的拷贝构造; c) 继承的父类成员作为一个整体,调用父类的拷贝构造;

  • 子类写了拷贝构造,子类就要显示的调用父类的拷贝构造,这里把子类对象里父类的那一部分取出来,本质就是切片行为 (这里把子类对象 s2 传给父类的引用,而父类仅仅使用了 _name)。

    但是实际上这里的拷贝构造没必要自己实现,因为这里一般情况下默认的拷贝构造就足够了,但是如果子类中有一个指针指向一块动态开辟的空间,存在深浅拷贝问题时就需要自己实现。

  • 如果子类中的拷贝构造不显示的调用父类的拷贝构造,那么便不会调用父类的拷贝构造,而是调用了默认的构造函数,因为拷贝构造也是构造,构造函数规定在初始化列表阶段,如果你不调用自定义类型,那就调用它的默认构造。

✔ 测试用例三:

#include<iostream>
#include<string>
using namespace std;

class Person

public:
	Person(const char* name = "dancebit")
		: _name(name)
	
		cout << "Person(const char* name = \\"dancebit\\")" << endl;
	
	Person(const Person& p)
		: _name(p._name)
	
		cout << "Person(const Person& p)" << endl;
	
	Person& operator=(const Person& p)
	
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	
	~Person()
	
		cout << "~Person()" << endl;
	
protected:
	string _name;
	int a;
;

class Student : public Person

public:
	Student(const char* name, int id, const char* address)
		: Person(name)
		, _id(id)
		, _address(address)
	
	Student& operator=(const Student& s)
	
		if (this != &s)
		
			_id = s._id;
			_address = s._address;
			//operator=(s);//切片行为,err
			Person::operator=(s);//切片行为
		
		return *this;
	
private:
	int _id;
	string _address;
;

int main()

	Student s1("DANCEBIT", 1, "贵阳市");
	Student s2("DanceBit", 2, "北京市");
	s1 = s2;

	return 0;