C++之继承

一、继承的基本理解

1.继承的概念

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

继承是类设计层次的复用

简单图示:

在这里插入图片描述

一个简单例子:

// 父类
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}

protected:
	string _name = "Peter";  // 姓名
	int _age = 20;           // 年龄
};

// 继承后父类Person的成员(成员函数+成员变量)都会变成子类的一部分
// 这里体现出了Student和Teacher复用了Person的成员。
// 可以通过监视窗口查看Student和Teacher对象
// 可以看到变量的复用。调用Print可以看到成员函数的复用。

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

// 子类
class Teacher : public Person
{
protected:
	int _jobid;  // 工号
};

int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();

	return 0;
}

查看监视窗口:
在这里插入图片描述

2.继承的定义

定义格式:
在这里插入图片描述

继承关系和访问限定符:

在这里插入图片描述


派生类继承基类成员后,基类成员在派生类中的访问方式:

  • 基类的私有成员在派生类中都是不可见的。
  • 基类的其他成员在子类的访问方式 == min(基类成员的访问限定符,派生类的继承方式),public > protected > private 。

用一张表来描述,就是:

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

继承影响的是继承下来的基类成员,跟子类成员无关,不要混淆。

说明:

  1. 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见,是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制了派生类对象不管是在类内还是类外都不能去访问它。换言之,如果父类有一个成员,不想让子类使用,可以在父类中把该成员定义为 private 。
  2. 基类 private 成员在派生类中是不能被访问。如果基类成员不想在类外被直接访问,但需要在派生类中能访问,就定义为 protected 。可以看出 protected 成员限定符是因继承才出现的。
  3. ① 关于继承方式,在实际运用中一般都是使用 public 继承,几乎不使用 protected/private 继承,也不提倡使用 protected/private 继承,因为 protected/private 继承下来的成员都只能在派生类的类内使用,实际中扩展维护性不强。
    ② 关于访问限定符,在实际运用中一般都是使用 public/protected ,几乎不使用 private 。
    换言之,实际中最常见的是,父类成员使用 public/protected 修饰,子类的继承方式使用 public 继承。9 种关系里面只用了 2 种。
  4. 使用 class 时默认的继承方式是 private ,使用 struct 时默认的继承方式是 public ,不过最好还是显示地写出继承方式,这样更直观。

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

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割,寓意把派生类中父类那部分切来赋值过去,仅限于 public 继承

  • 基类对象不能赋值给派生类对象。
    基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但这很危险,存在越界访问的风险,不要这么用。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Identification)的 dynamic_cast 来进行识别后进行安全转换。

测试代码:

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

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

int main()
{
	Person p;
	Student s;

	// 子类对象可以赋值给父类的对象/父类的指针/父类的引用
	// 赋值兼容 -> 切割/切片
	// 这里不存在类型转换,是语法天然支持的行为
	p = s;
	Person* pp = &s;
	Person& rp = s;

	// 父类对象不能赋值给子类对象
	//s = p;  s = (Student)p;  // 怎样都不行
	// 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用
	// 但这很危险,存在越界访问的风险,不要这么用
	Student* ps = (Student*)&p;
	//ps->_No = 1;  
	Student& rs = (Student&)p;
	//rs._No = 2;   

	return 0;
}

将上述代码图示:在这里插入图片描述

三、继承中的作用域

  • 在继承体系中基类和派生类都有独立的作用域
  • 子类和父类中若有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏 / 重定义(成员函数的隐藏,只需要函数名相同就构成隐藏,跟参数无关)。若要在子类中访问被隐藏的父类同名成员,需要指明父类类域显示访问。
  • 实际中,在继承体系里面最好不要定义同名的成员。

说明:
 ① 类的成员,包括成员变量和成员函数。
 ② 构成隐藏 / 重定义之后,到底是操作父类还是子类的成员,本质上还是要看作用域,跟同名的局部变量和全局变量类似,都是就近原则。

测试代码1:

// Student的_num和Person的_num构成隐藏/重定义关系
// 可以看出这样的代码虽然能跑,但是非常容易混淆
class Person
{
protected:
	string _name = "小李子";  // 姓名
	int _num = 111;          // 身份证号
};

class Student : public Person
{
public:
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << _num << endl;          // 就近原则
		cout << Person::_num << endl;  // 若要访问父类同名成员,需要指明类域
	}

protected:
	int _num = 999;  // 学号
};

int a = 0;

int main()
{
	int a = 1;
	cout << a << endl;    // 此处访问的是局部变量a,因为就近原则 
	cout << ::a << endl;  // 要想访问全局变量a,需指明作用域

	Student s;
	s.Print();

	return 0;
}

测试代码2:

// B中的func和A中的func不构成函数重载,因为不是在同一作用域
// B中的func和A中的func构成隐藏,成员函数满足函数名相同就构成隐藏
class A
{
public:
	void func()
	{
		cout << "func()" << endl;
	}

	void f()
	{
		cout << "f()" << endl;
	}
};

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

int main()
{
	B b;
	b.func(1);
	//b.func();   // 编译报错,因为父类A的func被隐藏了
	b.A::func();  // 若想调用被隐藏的父类同名成员函数,必须指明类域
	b.f();

	return 0;
}

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

先抛开继承不谈,类的构造函数和析构函数的行为分别是:
 ① 构造函数:先根据成员变量的声明次序在初始化列表中顺序完成成员变量的初始化,再执行函数体内的语句。
 ② 析构函数:先执行函数体内的语句,再根据成员变量的声明次序逆序完成成员变量的析构。

也就是说,类的构造和析构保证符合栈的后进先出

加入继承后,可以把继承下来的父类理解成子类的一个自定义类型成员变量,且在成员变量中的声明次序是顺序第一位。

测试代码1:

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;
	}
	
	~Person()
	{
		cout << "~Person()" << endl;
	}

protected:
	string _name;  // 姓名
};

class Student : public Person
{
public:

protected:
	int _num = 1;  // 学号
	string _s = "hello world";
};

// 派生类的重点的四个默认成员函数,我们不写,编译器默认生成的:

// 我们不写默认生成的派生类的构造和析构:
// a.父类继承下来的(调用父类默认构造和析构处理)   b.自己的(按普通类处理)
// 我们不写默认生成的派生类的拷贝构造和operator=:
// a.父类继承下来的(调用父类拷贝构造和operator=) b.自己的(按普通类处理)

// 总结:继承下来的调用父类处理,自己的按普通类处理

int main()
{
	Student s1;
	Student s2(s1);
	Student s3;
	s1 = s3;

	return 0;
}

测试代码2:

class Person
{
public:
	Person(const char* name)
		: _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;
		//delete[] _ptr;
	}

protected:
	string _name;  // 姓名
	//int* _ptr = new int[10];
};

class Student : public Person
{
public:
	Student(const char* name = "张三", int num = 4)
		:Person(name)
		, _num(num)
	{}

	// s2(s1)
	// 其实可以不写,默认生成的就可以了
	Student(const Student& s)
		:Person(s)  // 传参时将子类对象赋值给父类的引用,即切片
		,_num(s._num)
	{}

	// s2 = s1
	// 其实可以不写,默认生成的就可以了
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			Person::operator=(s);  // 传参时将子类对象赋值给父类的引用,即切片
			_num = s._num;
		}

		return *this;
	}

	// 析构函数名字会被统一处理成destructor()
	// 那么子类的析构函数跟父类的析构函数就构成隐藏
	// 所以,要调用父类的析构函数,需要指明父类类域
	~Student()
	{
		//Person::~Person();
		//delete[] _ptr;
	}
	// 但是子类析构函数结束时,会自动调用父类的析构函数(保证符合栈的后进先出)
	// 所以我们自己实现子类析构函数时,不需要显式调用父类析构函数
	// 否则会调用两次父类析构函数,可能导致运行出错

protected:
	int _num;  // 学号
	//string _s = "hello world";
	//int* _ptr = new int[10];
};

// 什么情况下必须自己写子类的默认成员函数?
// 1.父类没有默认构造,需要显式写构造
// 2.若子类有资源需要释放,就需要显式写析构
// 3.若子类存在浅拷贝问题,就需要自己实现拷贝构造和赋值重载解决浅拷贝问题

// 若必须写子类的默认成员函数,如何写?
// 1.父类成员,调用父类的对应构造、拷贝构造、operator=和析构处理
// 2.自己成员,按普通类处理

// 总结:继承下来的调用父类处理,自己的按普通类处理

int main()
{
	Student s1;
	Student s2(s1);
	Student s3("Jack", 19);
	s1 = s3;

	return 0;
}

说明:

  1. 派生类对象初始化:一定是先调用基类构造再调用派生类构造。
  2. 派生类对象析构:一定是先调用派生类析构再调用基类析构。
  3. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名统一进行特殊处理,处理成destructor(),所以父类析构函数不加 virtual 关键字的情况下,子类析构函数和父类析构函数构成隐藏关系。

五、继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。

测试代码:

class Student;

class Person
{
	friend void Display(const Person& p, const Student& s);
public:

protected:
	string _name;  // 姓名
};

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

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	//cout << s._stuNum << endl;  // 编译报错,友元关系不能继承
}

int main()
{
	Person p;
	Student s;
	Display(p, s);

	return 0;
}

六、继承与静态成员

若基类定义了 static 成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。

测试代码:

class Person
{
public:
	Person() 
	{
		++_count; 
	}

protected:
	string _name;  // 姓名

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

int Person::_count = 0;

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

class Graduate : public Student
{
protected:
	string _seminarCourse;  // 研究科目
};

int main()
{
	Person p;
	Student s;
	Graduate g;

	cout << Person::_count << endl;
	cout << Student::_count << endl;
	cout << Graduate::_count << endl;

	cout << &Person::_count << endl;
	cout << &Student::_count << endl;
	cout << &Graduate::_count << endl;

	return 0;
}

七、复杂的菱形继承及菱形虚拟继承

1.继承关系

  • 单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
    在这里插入图片描述

  • 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
    在这里插入图片描述

  • 菱形继承:菱形继承是多继承的一种特殊情况。
    在这里插入图片描述

2.菱形继承存在数据冗余和二义性的问题

菱形继承存在数据冗余二义性的问题。

比如下面的对象成员模型构造:
在这里插入图片描述
在 Assistant 对象中 Person 成员会有两份,若要访问 _name ,是访问从 Student 继承过来的还是访问从 Teacher 继承过来的呢?所以说 Assistant 存在数据冗余和二义性的问题。

class Person
{
public:
	string _name;  // 姓名
	int _a[10000];
};

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

class Teacher : public Person
{
public:
	int _id;  // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;  // 主修课程
};

int main()
{
	Assistant a;
	a._num = 1;
	a._id = 2;
	
	//a._name = "张三";  // 编译报错,对_name访问不明确
	//指明父类类域,可以解决二义性的问题
	a.Student::_name = "小张";
	a.Teacher::_name = "张老师";

	//但是数据冗余的问题无法解决,万一数据很大就会浪费掉很多空间
	cout << sizeof(a) << endl;
	
	return 0;
}

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

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

比如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决问题。

class Person
{
public:
	string _name;  // 姓名
	int _a[10000];
};

class Student : virtual public Person   // 虚拟继承 
{
public:
	int _num;  // 学号
};

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

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse;  // 主修课程
};

int main()
{
	Assistant a;
	// 虚拟继承可以解决菱形继承的数据冗余和二义性的问题
	a.Student::_name = "小张";
	a.Teacher::_name = "张老师";
	a._name = "张三";

	cout << sizeof(a) << endl;

	return 0;
}

4.虚拟继承解决菱形继承数据冗余和二义性的原理

为了研究虚拟继承的原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型。

测试代码1:不使用虚拟继承。

class A
{
public:
	int _a;
};

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

class C : public A
//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;
}

下图是菱形继承的内存对象成员模型:这里可以看到数据冗余。在这里插入图片描述


测试代码2:使用虚拟继承。

class A
{
public:
	int _a;
};

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

//class C : public A
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;
	d._a = 0;
	
	return 0;
}

下图是菱形虚拟继承的内存对象成员模型:这里可以分析出 D 对象中将 A 放到了对象组成的最下面,这个 A 是 B 和 C 的公共基类,那么 B 和 C 如何去找到公共的 A 呢?这里是通过了 B 和 C 里的两个指针,各指向一张表。这两个指针叫虚基表指针,这两张表叫虚基表。虚基表中存有偏移量,通过偏移量可以找到 A 。
在这里插入图片描述
A 是被虚拟继承的基类,称之为虚基类。B 或 C 需要找 A ,就要通过虚基表中的偏移量进行计算。
此时的 A 既不属于 B ,也不属于 C ,而是属于 D 。

八、继承的总结和反思

  1. 很多人说 C++ 的语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,最好不要设计出菱形继承。
  2. 多继承可以认为是 C++ 的缺陷之一,后来的很多语言都没有多继承,如 Java 。
  3. 继承和组合
    ① public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
    组合是一种 has-a 的关系。假设 B 组合了 A ,每个 B 对象中都有一个 A 对象。
    继承和组合都能达到复用的目的。
    优先使用对象组合,而不是类继承
    ③ 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
    ④ 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
    ⑤ 实际尽量多去用组合,因为组合的耦合度低,代码维护性好。有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,也可以用组合,优先用组合。
// BMW和Benz与Car构成is-a的关系
class Car 
{
protected:
	string _colour = "白色";    // 颜色
	string _num = "粤A XXX00";  // 车牌号
};

class BMW : public Car 
{
public:
	void Drive() { cout << "好开-操控" << endl; }
};

class Benz : public Car 
{
public:
	void Drive() { cout << "好坐-舒适" << endl; }
};
// Car和Tire构成has-a的关系
class Tire 
{
protected:
	string _brand = "Michelin";  // 品牌
	size_t _size = 17;           // 尺寸
};

class Car 
{
protected:
	string _colour = "白色";     // 颜色
	string _num = "粤A XXX00";  // 车牌号
	Tire _t;                   // 轮胎
};
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值