C++类间的 “接力棒“ 传递:继承(上)

发布于:2025-04-04 ⋅ 阅读:(19) ⋅ 点赞:(0)

本篇将开启 C++ 三大特性中的继承篇章,继承是一种派生类能够复用基类的代码,同时还能添加自己特有的属性和方法,或者对基类的方法进行重写。这种机制可以提高代码的复用性和可维护性

1.什么是继承?

1.1 继承的概念

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

✏️举个例子:

  • 学生和老师都有的共同点(Person): 年龄,性别,名字等
  • 学生特有的(Student): 学号,专业,宿舍号
  • 老师特有的(Teacher): 职工号,职称

共同点就相当于一个基底,称他为基类或者父类,在基类的基础上拓展出来的各种各样的角色称他为派生类或者子类,这样一个拓展的过程就叫继承,所以继承的本质是一种复用

1.2 继承的语法

Person 是父类,也称作基类。Student 是子类,也称作派生类

其语法为:

在这里插入图片描述

表示 Studentpublic 继承于 Person,那么这个继承方式和类内部的 public 有何区别?

在这里插入图片描述

🚩类内部的 public 这一类的叫访问限定符,表示访问时类内部的变量函数等是以何种方式被访问,只使用访问限定符时 privateprotected 是没有区别的

  • private:成员被声明为 private 后,只能在类的内部被访问和调用,类外部及派生类都无法直接访问
  • protected:类内部可以访问,类的派生类也可以访问,但类外部不能访问

🚩派生类后跟的 public 这一类叫继承方式

🚩那么继承最重要的就是访问限定符和继承方式的组合,组合起来决定了基类成员在派生类中的访问属性

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的 public 成员 派生类的 protected 成员 派生类的 private 成员
基类的protected成员 派生类的 protected 成员 派生类的 protected 成员 派生类的 private 成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

实际上面的表格我们进行一下总结会发现,public > protected > private,基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),特别的基类的私有成员在子类都是不可见,而不是 private

🔥值得注意的是:

  1. 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它

  2. 基类 private 成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出的

  3. 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是public,不过最好显示的写出继承方式

  4. 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced / private 继承,也不提倡使用 protetced / private 继承,因为 protetced / private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强

2.基类和派生类的转换机制

在这里插入图片描述

Student 是子类,Person 是父类

因为子类包含了父类的内容,且子类其实是父类的一种特殊类型,存在天然的类型兼容性,所以只能子类赋值给父类,且中间不存在类型转换,是以切割 / 切片的形式

为什么说不存在类型转换?举个例子:

class Parent 
{
public:
    int x;

    Parent()
    { }
};

class Child : public Parent 
{
public:
    int y;

    Child()
    { }
};

int main() 
{
    Child child;
    Parent parent;
    cout << "Size of Child: " << sizeof(child) << endl;
    cout << "Size of Parent before assignment: " << sizeof(parent) << endl;
    parent = child;
    cout << "Size of Parent after assignment: " << sizeof(parent) << endl;
    return 0;
}

可以使用 sizeof 运算符获取父类和子类对象的大小,然后将子类对象赋值给父类对象后,再获取父类对象的大小,比较赋值前后父类对象大小是否发生变化,如果是切片,父类对象大小不会改变,因为只是复制了子类中父类部分的成员

在这里插入图片描述

🔥值得注意的是:

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

class Person
{
protected:
	string _name; // 姓名
	string _sex;  // 性别
	int _age; // 年龄
};

class Student : public Person
{
public:
	int _No; // 学号
};

void Test()
{
	Student sobj;
	
	// 1.子类对象可以赋值给父类对象/指针/引用
	Person pobj = sobj;
	Person* pp = &sobj;
	Person& rp = sobj;

	//2.基类对象不能赋值给派生类对象
	sobj = pobj;

	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
	pp = &sobj
	Student * ps1 = (Student*)pp; // 这种情况转换时可以的。
	ps1->_No = 10;

	pp = &pobj;
	Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;
}

第三种情况的第二种,ppPerson 类型的指针,它指向一个 Person 对象。接着把 pp 强制转换为 Student* 类型并赋值给 ps2。虽然语法上允许这样转换,但实际上 pobj 只是 Person 对象,它并没有 _No 这个成员变量。当执行 ps2->_No = 10; 时,程序会尝试在 pobj 对象的内存区域之后写入 _No 的值,这就造成了越界访问,可能会改写其他重要的数据,从而引发未定义行为

3.继承中的作用域

在继承体系中基类和派生类都有独立的作用域

class Person
{
protected:
	int _num = 111;
};

class Student : public Person
{
public:
	void Print()
	{
		cout << _num << endl;
	}
protected:
	int _num = 999;
};

void Test()
{
	Student s1;
	s1.Print();
};

这里调用 Print 输出的 _num 是多少?根据前面所学有关作用域的知识可知,编译器遵守就近原则,这里优先输出子类类域里的 _num

如果在 Print 局部域里也有 _num 的话,就优先输出局部域 _num;如果想要输出父类的_num 的话,就需要指定类域(Person::_num

这里 C++ 对这种情况取了个名字叫隐藏/重定义:子类和父类有同名成员,子类的成员隐藏了父类的成员(只要函数名相同就构成重定义

具体作用域分析可以回顾前文:

传送门:C++命运石之门代码抉择:C++入门(上)

🔥值得注意的是: 假设子类有个 func(int i),父类有个 func(),这里构成的是重定义,而不是重载,因为重载的前提条件是在同一作用域,同一作用域下就需要根据函数名修饰规则进行区分,虽然只要函数名相同就会进行修饰,但是继承的这种情况根据域的不同就能进行区分了,实际上函数名修饰规则起不到很大作用,因此是重定义,而不是重载

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

在这里插入图片描述

4.1 构造函数

class Person
{
public:

	Person(const char* name = "peter")
		: _name(name)
	{
		cout << "Person()" << endl;
	}

protected:
	string _name;
};

class Student : public Person
{
public:

	Student(const char* name = "张三", int id = 0)
		:Person(name)
		,_id(id)
	{ }

protected:
	int _id;
};

子类的构造函数只能构造自己的变量,想要构造继承来的父类变量,必须像 Person(name) 这样显示调用父类的构造函数来调用

🔥值得注意的是:

  • 也可以不写 Person(name) 来显示调用,那么就需要调用父类的默认构造函数,即父类的构造函数必须有缺省参数
  • 派生类初始化列表先初始化父类,再初始化子类

4.2 拷贝构造函数

class Person
{
public:

	Person(const char* name = "peter")
		: _name(name)
	{
		cout << "Person()" << endl;
	}

	Person(const Person& p)
		: _name(p._name)
	{
			cout << "Person(const Person& p)" << endl;
	}

	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}

protected:
	string _name;
};

class Student : public Person
{
public:

	Student(const char* name = "张三", int id = 0)
		:Person(name)
		, _id(id)
	{}

	Student(const Student& s)
		: Person(s)
		, _id(s._id)
	{
		cout << "Student(const Student& s)" << endl;
	}

	Student& operator = (const Student& s)
	{
		cout << "Student& operator= (const Student& s)" << endl;
		if (this != &s)
		{
			Person::operator =(s);
			_id = s._id;
		}
		return *this;
	}

protected:
	int _id;
};

同理,拷贝构造的初始化顺序及初始化机制和构造函数基本一致

但是拷贝构造是如何传Person的对象来拷贝构造的呢?

其实直接传子类对象即可,因为前面说过,子类对象可以赋值给父类引用,直接切割就行了

🔥值得注意的是:

  • 当不显式写 Person(s) 时,会调用父类的构造函数初始化父类变量
  • Person::operator =(s)Person:: 必须写,不然根据就近原则,这里构成重定义,子类 operator= 会一直调用自己,造成死循环

4.3 析构函数

	~Student()
	{
		Person::~Person();
	}

根据初学经验,一般析构函数我们会写成这样,保证父类和子类都能被析构,这里显式调用父类析构要加 Person:: 是因为在底层,父类和子类的析构都会被统一处理成 destructor 构成重定义(这部分会在多态部分详细解释

但其实这种调用方法是错误的,我们不应该显式调用父类析构,父类析构其实是会被自动调用的,因为必须保证先子后父的调用。如果先析构了父类,那么此时的子类额外的部分可能处于不一致或未定义的状态

✏️比如: 有可能先把父类析构了,但是子类还在访问父类的内容;但是把子类先析构了,父类是不会去访问子类的内容的,就不会造成访问未定义的情况

~Student()
 {
 	cout<<"~Student()" <<endl;
 }

所以这里不需要显式调用,子类完成析构之后就会自动析构父类


希望读者们多多三连支持

小编会继续更新

你们的鼓励就是我前进的动力!

请添加图片描述