【C++ 09】继承

发布于:2024-09-17 ⋅ 阅读:(240) ⋅ 点赞:(0)

🌈 一、继承的概念及定义

⭐ 1. 继承的概念

  • 继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。
  • 继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
  • 小类可以继承大类的共有特性,让小类不需要再重复定义这些共有的内容,让小类只需要定义出自己所特有的东西即可。
    • 如:学生、老师、工人都可以继承人所共有的特性,猫、狗、老鼠也都可以继承动物所共有的特性。
  • 一般将被继承的类称为父类 / 基类。将继承父类的的类称为子类 / 派生类

举个例子

  • 人类都有自己的名字和年龄,那么学生类就可以继承这一共有的特性而不用重复定义。
  • 让学生类 student 和老师类 teacher 继承人类 person 的共有部分,让这两个子类定义独属于自己的学生 id 和教职工 id。
  • 继承后父类 person 的成员函数和成员变量就会变成子类的一部分。
class person
{
protected:
	string _name = "张三";
	int _age = 18;

public:
	void print()
	{
		cout << "name: " << _name << endl;
		cout << "age: " << _age << endl;
	}
};

// 学生类作为子类继承人类的成员变量和成员函数
class student : public person
{
protected:
	int _stuid;	// 学生 id
};

// 教师类作为子类继承人类的成员变量和成员函数
class teacher : public person
{
protected:
	int _jobid;	// 教师 id
};

int main()
{
	student s;
	teacher t;

	s.print();
	t.print();

	return 0;
}

image-20240908153019733

⭐ 2. 继承的定义

🌙 2.1 定义格式

  • 注意:在继承当中,父类也称为基类,子类是由基类派生而来的,所以子类又称为派生类。
class 子类名 : 继承方式 父类名
{
    // ......
};

🌙 2.2 继承方式和访问限定符

1. 继承方式

  1. public 继承
  2. protected 继承
  3. private 继承

2. 访问限定符

  1. public 访问
  2. protected 访问
  3. private 访问

🌙 2.3 继承父类成员访问方式的变化

  • 父类中被不同访问限定符修饰的成员,以不同的继承方式继承到子类当中后,该成员最终在子类当中的访问方式也会发生变化。
类成员 / 继承方式 子类使用 public 方式继承 子类使用 protected 方式继承 子类使用 private 方式继承
父类的 public 成员 变成子类的 public 成员 变成子类的 protected 成员 变成子类的 private 成员
父类的 protected 成员 变成子类的 protected 成员 变成子类的 protected 成员 变成子类的 private 成员
父类的 private 成员 在子类中不可见 在子类中不可见 在子类中不可见

1. 访问方式的变换规则

  • 种访问限定符的权限大小为:public > protected > private,父类成员访问方式的变化规则如下:
  1. 在父类当中的访问方式为 public 或 protected 的成员,在子类当中的访问方式变为:min(成员在父类的访问方式,继承方式)。
  2. 在父类中的访问方式为 private 的成员,在子类中都不可见。

2. 父类的 private 成员在子类中不可见

  • 无法在子类中访问父类的 private 成员。
  • 父类的 private 成员无论以什么方式被子类继承,子类对象都不能去访问这个 private 成员。
  • 注:实际上使用的一般都是 public 继承,几乎很少使用 protected 和 private 继承,也不提倡使用 protected 和 private 继承,因为使用 protected 和 private 继承下来的成员都只能在子类的类里面使用,实际中扩展维护性不强。

🌙 2.4 默认继承方式

  • 使用继承时也可以不指定继承方式,使用关键字 class 时默认继承方式是 private,而使用 struct 时默认继承方式是 public

举个栗子

  • 在关键字为 class 的子类中,所继承的父类成员的访问方式变为 private。
// 父类
class person
{
protected:
	string _name = "张三";
	int _age = 18;

public:
	void print()
	{
		cout << "name: " << _name << endl;
		cout << "age: " << _age << endl;
	}
};

// class 子类默认以 private 方式继承父类
class Student : person
{
protected:
	int _stuid;
};
  • 在关键字为 struct 的子类中,所继承的父类成员的访问方式变为 public。
// 父类
class person
{
protected:
	string _name = "张三";
	int _age = 18;

public:
	void print()
	{
		cout << "name: " << _name << endl;
		cout << "age: " << _age << endl;
	}
};

// struct 子类默认以 public 方式继承父类
struct Student : person
{
protected:
	int _stuid;
};

🌈 二、父类和子类对象赋值转换

  • 相近类型之间可以进行赋值类型转换,父子类对象也属于是相近类型,也可以进行赋值转换。
  • 子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用,这种做法被称之为切片,意思是将子类中属于父类的那部分切出来赋值过去。
  • 父类对象不能赋值给子类对象。
  • 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用,但是必须是父类的指针是指向子类对象时才是安全的。

举个栗子

  • 对于如下父类及其子类,父子类对象能够赋值和不能够赋值的情况如下:
// 父类
class Person
{
protected:
	string _name;	// 姓名
	string _sex;	// 性别
	int _age;		// 年龄
};

// 子类
class Student : public Person
{
public:
	int _stuid;		// 学号
};
  1. 子类对象可以切片赋值给父类的 对象 / 指针 / 引用。
Student sobj;
Person pobj = sobj;	// √ 子类对象赋值给父类对象
Person* pp = &sobj;	// √ 子类对象赋值给父类指针
Person& rp = sobj;	// √ 子类对象赋值给父类引用
  1. 父类对象不能赋值给子类对象。
Student sobj;
Person pobj;

sobj = pobj;		// × 父类对象不能赋值给子类对象
  1. 父类对象的指针可以通过强制类型转换赋值给子类的指针。
Student sobj;
Person pobj;

Person* pp = &sobj;
Student* ps1 = (Student*)pp; 	// 这种情况转换是可以的。
ps1->_stuid = 10;

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

⭐ 1. 子类对象赋值给父类的 对象 / 指针 / 引用

  • 子类对象可以通过切片将属于父类的那部分内容赋值给父类。
  1. 子类对象赋值给父类对象

image-20240908184319028

  1. 子类对象赋值给父类指针

image-20240908184408479

  1. 子类对象赋值给父类引用

image-20240908184510839

🌈 三、继承中的作用域

  1. 在继承体系中,父类子类都有自己独立的作用域
  2. 如果子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况被称之为隐藏,也叫做重定义
  3. 实际在继承体系中,最好不要定义同名的成员

举个栗子

  • 对于如下代码,访问父类的 _num 成员时会访问到子类的 _num 成员。
// 父类
class Person
{
protected:
	int _num = 111;
};

// 子类
class Student : public Person
{
public:
	void fun()
	{
		cout << _num << endl;
	}

protected:
    // 将继承过来的值为 111 的 _num 重定义成 999
	int _num = 999;	
};

int main()
{
	Student s;
	s.fun();	
	return 0;
}

image-20240908192003912

使用域作用限定符访问父类成员

  • 如果想要访问到父类的 _num 的内容,可以使用域作用限定符 :: 进行指定访问。
// 父类
class Person
{
protected:
	int _num = 111;
};

// 子类
class Student : public Person
{
public:
	void fun()
	{
        // 使用域作用限定符指定访问 Person 类的 _num 成员
		cout << Person::_num << endl;
	}

protected:
	int _num = 999;	
};

int main()
{
	Student s;
	s.fun();	
	return 0;
}

image-20240908192824778

如果是成员函数的隐藏,只需要函数名相同即可构成隐藏

// 父类
class Person
{
public:
	void fun(double x)
	{
		cout << "Person: " << x << endl;
	}
};

// 子类
class Student : public Person
{
public:
	void fun(int x)
	{
		cout << "Student: " << x << endl;
	}
};

int main()
{
	Student s;
	s.fun(2024);			// 会直接调用子类中的 fun 函数
	s.Person::fun(3.14);	// 指定调用父类当中的 fun 函数

	return 0;
}

image-20240908194841857

🌈 四、子类的默认成员函数

image-20240908200506115

⭐ 1. 子类中如何生成默认成员函数

  1. 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员
    • 如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表中阶段显示调用。
  2. 子类的拷贝构造函数必须调用父类的拷贝构造完成父类的拷贝初始化
  3. 子类的赋值重载必须要调用父类的赋值重载完成对父类的复制
  4. 子类的析构函数会在被调用完后,自动调用父类的析构函数清理父类成员
    • 这样才能保证子类对象先清理子类成员,再清理父类成员的顺序。
  5. 子类对象在构造时,会先调用父类构造再调用子类构造
  6. 子类对象在析构时,会先调用子类析构再调用父类析构

举个栗子

  • 以 Person 类作为父类。
// 父类
class Person
{
private:
	string _name;

public:
	// 构造函数
	Person(const string& 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;
	}

	// 析构函数
	~Person()
	{
		cout << "~Person()" << endl;
	}
};
  • 用 Student 类去继承 Person 类,子类 Student 中的默认成员函数如下:
// 子类
class Student : public Person
{
private:
	int _stuid; // 学号

public:
	//构造函数
	Student(const string& name, int stuid)
		: Person(name)	// 调用父类的构造函数初始化父类的那一部分成员
		, _stuid(stuid)	// 初始化子类的成员
	{
		cout << "Student()" << endl;
	}

	// 拷贝构造
	Student(const Student& s)
		: Person(s)			// 调用父类的拷贝构造函数完成对父类成员的拷贝构造
		, _stuid(s._stuid)	// 拷贝构造子类的成员
	{
		cout << "Student(const Student& s)" << endl;
	}

	// 赋值重载
	Student& operator=(const Student& s)
	{
		cout << "Student& operator=(const Student& s)" << endl;
		
		if (this != &s)
		{
			Person::operator=(s);	// 调用父类的赋值重载完成对父类成员的赋值
			_stuid = s._stuid;		// 完成子类成员的赋值
		}
		return *this;
	}

	// 析构函数
	~Student()
	{
		// 子类的析构函数会在被调用完成后再自动调用父类的析构函数
		cout << "~Student()" << endl;
	}
};

⭐ 2. 编写子类的默认成员函数时的注意事项

  1. 子类和父类的赋值重载函数会因为函数名相同而构成隐藏,因此在子类中调用父类的赋值重载时,需要使用域作用限定符指定调用父类的赋值重载函数
  2. 由于多态的原因,任何类的析构函数的函数名都会被统一处理为 destructor(),因此子类和父类也会因为函数名相同从而构成隐藏,也要使用域作用限定符指定调用父类的析构函数
  3. 在子类的拷贝构造和赋值重载中调用父类的拷贝构造和赋值重载的传参方式是一个切片行为,都是将子类对象直接赋值给父类的引用。

🌈 五、继承与友元

  • 友元关系不能继承,即父类的友元可以访问父类的私有和保护成员,但不能访问子类的私有和保护成员。

举个栗子

  • Display 函数是父类 Person 的友元函数,但不是子类 Student 的友元,即 Display 无法访问子类 Student 中的私有和保护成员。
class Student;

class Person
{
public:
	// 声明 Display 函数是父类 Person 的友元函数
	friend void Display(const Person& p, const Student& s);

protected:
	string _name;
};

class Student : public Person
{
protected:
	int _stuid;
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;	// 可以访问
	cout << s._stuid << endl;	// 无法访问
}

int main()
{
	Person p;
	Student s;

	Display(p, s);

	return 0;
}
  • 如果想让 Display 函数也能够访问子类 Student 中的私有和保护成员,必须让子类 Student 也将 Display 声明成友元函数。
class Student : public Person
{
public:
	// 声明 Display 函数是子类 Student 的友元函数
    friend void Display(const Person& p, const Student& s);

protected:
	int _stuid; 
};

🌈 六、继承与静态成员

  • 父类如果定义了一个 static 静态成员,则整个继承体系里就只能有一个该静态成员,无论派生出多少个子类,都只能有这一个 static 成员实例。

举个例子

  • 在父类 Person 中定义了静态成员变量 _count,虽然 Student 和 Graduate 都继承了 Person,但整个继承体系中只能有一个 _count 静态成员。
  • 如果在父类 Person 的构造和拷贝构造中让 _count 自增,就能通过 _count 来获已经实例化的 Person、Student 以及 Graduate 对象的总个数。
    • 由于子类在调用构造前会先调用父类的构造,因此只要知道调用了多少次 Person 的构造,即可知道这三个类的对象的总个数。
// 父类
class Person
{
public:
	Person()
	{
		_count++;
	}
	Person(const Person& p)
	{
		_count++;
	}

protected:
	string _name;			// 姓名

public:
	static int _count;		// 统计人的个数。
};

int Person::_count = 0;		// 静态成员变量必须在类外进行初始化

// 子类
class Student : public Person
{
protected:
	int _stuNum;			// 学号
};

// 子类
class Graduate : public Person
{
protected:
	string _seminarCourse;	// 研究科目
};

int main()
{
	Student s1;
	Student s2(s1);
	Student s3;
	Graduate s4;

	cout << Person::_count << endl;		// 4
	cout << Student::_count << endl;	// 4
	cout << Graduate::_count << endl;	// 4
	
	return 0;
}

image-20240909100745420

🌈 七、继承的方式

⭐ 1. 单继承

  • 一个子类只有一个直接父类时,称这种继承关系为单继承。

image-20240909101934946

⭐ 2. 多继承

  • 一个子类有两个或两个以上直接父类时,称这种继承关系为多继承。
    • 如:助教既可以是大学生的老师的同时也可以是博导的学生。

image-20240909102428269

  • 事物往往会具有多种特性,因此多继承本身不是什么大问题,但是由多继承延伸出来的菱形继承却会导致很多问题。

⭐ 3. 菱形继承

  • 菱形继承属于多继承的一种特殊情况,这种继承方式会出现数据冗余二义性的问题。

image-20240909102508124

1. 菱形继承会导致发生数据冗余和二义性问题

  • 对于上述菱形继承的模型,在实例化出 Assistant 对象后,会出现数据冗余问题,在访问成员时会出现二义性问题。
// 父类
class Person
{
public:
	string _name;			// 姓名
};

// 单继承自 Person 类的子类
class Student : public Person
{
protected:
	int _num;				// 学号
};

// 单继承自 Person 类的子类
class Teacher : public Person
{
protected:
	int _id;				// 职工编号
};

// 多继承自 Student 和 Teacher 类的子类
class Assistant : public Student, public Teacher
{
protected:
	string _major_course;	// 主修课程
};

int main()
{
	Assistant a;
	a._name = "peter";		// 二义性:无法明确知道要访问哪一个_name

	return 0;
}
  • 数据冗余问题:Student 和 Teacher 都继承自 Person 类,它们都拥有 Person 的成员,再让 Assistant 多继承自 Student 和 Teacher 会导致 Assistant 拥有两份 Person 类的成员。
  • 二义性问题:Assistant 对象多继承自 Student 和 Teacher 类,而 Student 和 Teacher 类都单继承自 Person 类,因此 Student 和 Teacher 类中都有含有 _name 成员,如果直接访问 Assistant 对象的 _name 成员,就不知道要访问的到底是 Student 还是 Teacher 的 _name 成员了。

image-20240909110159266

2. 解决二义性问题

  • 对于这种情况,可以使用域作用限定符让 Assistant 对象访问指定父类的 _name成员。
a.Student::_name = "张同学";
a.Teacher::_name = "张老师";
  • 这种方法虽然能够解决二义性,但由于 Assistant 对象中始终存在着两份 Person 类的成员,因此并不能解决数据冗余的问题,此时就需要用到菱形虚拟继承了。

🌈 八、菱形虚拟继承

  • 虚拟继承可以解决菱形继承的二义性和数据冗余的问题。

    • 如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决问题。
  • 虚拟继承只在菱形继承中使用,不要在其他地方使用。

⭐ 1. 虚拟继承格式

class 子类名 : virtual 继承方式 父类名
{
    // ......
};

⭐ 2. 虚拟继承样例

image-20240909111456461

// 父类
class Person
{
public:
	string _name;			// 姓名
};

// 虚拟继承自 Person 类的子类
class Student : virtual public Person
{
protected:
	int _num;				// 学号
};

// 虚拟继承自 Person 类的子类
class Teacher : virtual public Person
{
protected:
	int _id;				// 职工编号
};

// 多继承自 Student 和 Teacher 类的子类
class Assistant : public Student, public Teacher
{
protected:
	string _major_course;	// 主修课程
};

int main()
{
	Assistant a;
	a._name = "peter";		// 无二义性

	return 0;
}
  • 解决了二义性的问题:此时可以直接访问 Assistant 对象的 _name 成员,之后就算指定访问 Assistant 的 Student 父类和 Teacher 父类的 _name 成员,访问到的都是同一个结果。

image-20240909112423983

  • 解决了数据冗余的问题:打印 Assistant 的 Student 父类和 Teacher 父类的 _name 成员的地址时,显示的也是同一个地址。

image-20240909112623464

⭐ 3. 虚拟继承位置

  • 很多时候菱形继承并不一定就是一个标准的菱形,虚拟继承要放置在直接继承自会出现数据冗余部分的类上。
    • 如:上述代码中,Person 类中的成员会出现数据冗余,virtual 就要加在直接继承 Person 类的子类当中。

image-20240909113735549

⭐ 4. 虚拟继承原理

  • 为了研究虚拟继承原理,现在实现一个简单的菱形继承继承体系,再借助内存窗口观察对象成员的模型

1. 未使用虚拟继承时的内存分布情况

image-20240909160942324

class A
{
public:
	int _a;
};

class B : public A
{
public:
	int _b;
};

class C : public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}
  • D 类对象包含 2 份 A 类的成员,出现了数据冗余和二义性问题。

image-20240909162716468

2. 使用了虚拟继承时的内存分布情况

image-20240909163159717

class A
{
public:
	int _a;
};

class B : virtual public A
{
public:
	int _b;
};

class C : virtual public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}
  • D 类对象的各个成员在内存中的分布情况如下:

image-20240909171456525

  • D 类对象中属于 A 类的 _a 成员被放在了最后,而在原来存放属于 B 和 C 类的两个 _a 成员的位置变成了指针。
  • B 和 C 的这两个指针被称作虚基表指针,它们分别指向一张虚基表,此时就是通过 B 和 C 的这两个指针找到各自对应的虚基表。
  • 虚基表中存储的是所有从父类继承过来的成员的偏移量,通过起始地址 + 指定的 _a 成员的偏移量能够找到最下面的 A 类的 _a。
    • 如:B 的虚基表指针指向的虚基表中存储的 _a 偏移量是 14 (16进制),让 B 的起始地址 5C + 14 就能找到位于 70 处的 _a 的地址。
  • 即 B 和 C 通过自身的起始地址 + 指定成员 _a 的偏移量,最终都能够找到 A 类的 _a 成员。

🌈 九、继承笔试面试题

1. 什么是菱形继承?菱形继承的问题是什么?

  • 菱形继承是多继承的一种特殊情况,多个个子类继承同一个父类,而又有子类同时继承这多个个子类,我们称这种继承为菱形继承。
  • 菱形继承因为子类对象当中会有多份父类的成员,因此会导致数据冗余和二义性的问题。

2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的问题?

  • 菱形虚拟继承是指在会发生数据冗余的类的直接子类使用虚拟继承 virtual 的继承方式,菱形虚拟继承对于 D 类对象当中重复的 A 类成员只存储一份,然后采用虚基表指针和虚基表使得 D 类对象当中继承的 B 类和 C 类可以找到自己继承的A类成员,从而解决了数据冗余和二义性的问题。

3. 继承和组合的区别?什么时候用继承?什么时候用组合?

  • 继承是一种 is-a 的关系,而组合是一种 has-a 的关系。
  • 如果两个类之间是 is-a 的关系,使用继承;如果两个类之间是 has-a 的关系,则使用组合。
  • 如果两个类之间的关系既可以看作 is-a 的关系,又可以看作 has-a 的关系,则优先使用组合。

网站公告

今日签到

点亮在社区的每一天
去签到