[C++] 继承详解

Posted 哦哦呵呵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[C++] 继承详解相关的知识,希望对你有一定的参考价值。

1. 概念及定义

1.1 概念

  继承是面向对象中使代码复用的一种手段,在保持原有特性的基础上进行扩展,并增加新功能,这样产生的类叫做派生类。继承是类涉及层次的代码复用。

1.2 定义

下面看到的Person是父类,也称作基类。Student是子类,也称作派生类。

从左依次向右是:
	  Student: 派生类
	  public: 继承方式
	  Person: 基类
class Student: public Person
{
// 新增内容
public:
	int a;
	int b;
};

注意: 若不加继承关系,默认使用private方式进行继承

1.3 继承关系与访问限定符


1.4 总结

  • 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  • 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  • 基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
  • 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
  • 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

2. 赋值兼容性规则

2.1 规则

  • 可以将子类的对象直接赋值给基类对象,反之不行。
  • 可以让基类指针(引用)指向子类对象,反之不行。
    如果子类可以指向基类,那么子类指针就会把所指向的空间看作子类对象,访问时由于基类中没有存储子类扩展的内容,那么子类访问其独有内容是就会造成访问越界。

注意:

  • 如果子类是public的方式继承基类,子类和基类的关系就可以看作是子类对象是基类的一个对象
  • 成员使用: 子类可以使用基类的成员函数,基类不能使用子类的成员函数
  • 对象模型: 子类存储基类的所有内容,但是基类没有存储子类的独有内容。如果子类可以指向基类,但基类没有存储子类的独有信息,强行去访问时,就会造成访问越界。
  • 基类指针虽然可以指向一个子类对象,但是其只能访问子类对象从基类对象继承下来的内容。
    如果使用基类指针指向子类,那么调用成员函数时,调用的依旧是基类的成员函数

2.2 代码示例

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; err
	
	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
	pp = &sobj
	Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
	ps1->_No = 10;
	
	pp = &pobj;
	Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;
}

3. 作用域

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

  • 如果基类和子类中有相同的成员,并且使用子类访问。子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  • 注意在实际中在继承体系里面最好不要定义同名的成员。

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

成员函数调用过程

4.1 构造函数

  1. 如果基类没有显示定义任何构造函数,则子类也可以不用定义,子类中如果有需求可以自定制构造函数。(子类构造函数初始化列表位置不能直接初始化从基类继承的成员)
  2. 基类定义了构造函数,但是基类构造函数是默认的构造函数,则子类的构造函数也可以不用定义。
  3. 如果基类定义了非默认的构造函数,则子类必须显式定义自己的构造函数,而且在其构造函数初始化列表的位置显示调用基类的构造函数,从而完成从基类继承的成员的初始化。

子类构造函数名()
	: 基类构造函数(参数列表)
	, 初始化子类新增成员 
{...}

对第二点的解释
  子类对象初始化的过程: 1.初始化从基类继承下来的成员,自动调用基类的构造函数。2.初始化自己的新增成员。3.执行构造函数体的内容。
  如果子类没有显式定义构造函数,则编译器会自动生成默认的构造函数,而且在编译器中会对子类生成默认构造函数,并且在初始化列表处调用父类的构造函数。如果子类定义了构造函数,在其初始化列表的位置,基类和子类的新增成员都要进行初始化。

4.2 其它的默认成员函数

  • 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  • 派生类的operator=必须要调用基类的operator=完成基类的复制。
  • 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 派生类对象初始化先调用基类构造再调派生类构造。
  • 派生类对象析构清理先调用派生类析构再调基类的析构,子类析构函数中,在子类析构函数的最后一条有序语句之后,条件调用基类的析构函数。不要自己手动书写,此步是由编译器完成的。否则会造成double free

4.3 代码示例

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 :
	Student(const char* name, int num)
		: Person(name )
		, _num(num )
	{
		cout<<"Student()" <<endl;
	}
	
	Student(const Student& s)
		: Person(s)
		, _num(s ._num)
	{
		cout<<"Student(const Student& s)" <<endl ;
	}
	
	Student& operator = (const Student& s )
	{
		cout<<"Student& operator= (const Student& s)"<< endl;
		if (this != &s)
		{
			Person::operator =(s);
			num = s ._num;
		}
		return *this ;
	} 
	
	~Student()
	{
		cout<<"~Student()" <<endl;
	}
	
protected :
	int _num ; //学号
};
void Test ()
{
	Student s1 ("jack", 18);
	Student s2 (s1);
	Student s3 ("rose", 17);
	s1 = s3 ;
}

5. 继承与特殊成员

  • 友元: 友元关系不能继承,基类友元不能访问子类的私有成员和保护成员,因为友元不是类的成员
  • 静态成员: 可以被继承,但在整个继承体系中,只有一份

6. 继承模型

6.1 单继承

一个子类只有一个基类:

6.2 多继承

一个子类有两个或以上直接父类时称这个继承关系为多继承

对象模型:

6.3 单继承 + 多继承(菱形继承)


对象模型:

通过观察对象模型,可以看出,菱形继承有二义性与数据冗余问题的出现,因为多个子类会继承同一个父类成员,并且如果还有一个孙子同时继承了这两个子类,那么孙子就拿到了两份内容,造成了二义性与冗余。

如何解决
  可以让访问明确化,对二义性的成员加上作用域限定符,但是没有从本质上解决问题。
  从本质上解决问题,必须要使用虚拟继承。让最顶层基类中成员只存在一份。

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

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

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

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

void Test ()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a ;
	a._name = "peter";
	
	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
}

7. 虚拟继承

解决了菱形继承的二义性与数据冗余问题,在上述例子中,最顶层基类成员在派生类中存储了两份而导致,在Student和Teacher的继承Person时使用虚拟继承,就可以解决上述问题。注意:虚拟继承只能在菱形继承中,一般的方式不会采用虚拟继承

7.1 原理

下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。

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;
}


普通的类单继承,虚拟继承:

  • 虚拟继承下与普通继承不同,在模型中是先为派生类,后为基类
  • 同时在派生类的开始会多四个字节,这四个字节指向内存中-一个八个字节的表(虚基表/偏移量表格)
    这个表前四个字节放类自己与自己的偏移量0,后四个字节放基类对象与自己的偏移量
  • 编译器为派生类生成了默认构造函数,作用:在构造函数中必须将对象前4个字节初始化好
  • 访问基类成员的方式:
    a.取出对象前四个字节作为地址, 所在的表(其实是表的首地址)
    b. 再将内容加4作为地址,取地址中的值
    C. 通过值作为偏移量给基类的成员对象赋值
  • 访问派生类自身的成员,直接访问

上述程序中菱形继承的示意图

以上是关于[C++] 继承详解的主要内容,如果未能解决你的问题,请参考以下文章

[C++] 继承详解

C++继承详解

C++继承详解

C++ 继承 详解

C++学习日积月累——继承详解

C++卷积神经网络实例:tiny_cnn代码详解——层间继承关系