C++继承

42 阅读15分钟

 继承的概念

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

需求: 许多子类想共用父类的成员时,为了简化代码(不让每个子类写同样的成员),就有了继承,通过类访问限定符对成员的划分,我们可以将父类的一些成员(成员变量+成员函数)继承到子类,使得子类也可以使用。

例子:对于学校中的学生信息与老师信息,我们需要进行管理,创建Student类管理学生,Teacher类管理老师,对于这两个公有的成员比如学生与老师的姓名,年龄等,我们将其写在另外一个类Member类中作为父类,使得子类可以使用父类的成员,而对于老师的工号与学生的学号不同,我们把它们分别写在各自的类中。

父类/基类

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

protected:
	string _name = "XiaoMing";//姓名
	int _age = 18;//年龄
};

子类/派生类

class Teacher : public Member
{
protected:
	int _jobid;//工号
};

子类/派生类

class Student : public Member
{
protected:
	int _stuid; // 学号
};

父类Member的成员(成员函数+成员变量)都会变成子类的一部分。这里Student和Teacher复用了Person的成员。

继承的定义

定义格式

基类与普通类的格式一致。

派生类与普通类稍有差别,主要是类名后面加上了继承方式和父类,具体定义格式如下。

编辑

 继承关系与访问限定符

对于派生类能访问基类的哪些成员,实际上需要上面的继承方式访问限定符的组合,3×3=9种可能的情况。

这里给出一个顺序:public > protected > private

对于派生类中能否访问基类成员,我们给出这样一条规律:取子类继承方式父类访问限定符较小的那一个作为访问方式。

       注意: 所有的成员都可以被继承,但是继承后的访问关系是有规则的。

例子:

理解:

  • 对于基类的private成员,我们在派生类中无论以什么方式继承都是不可见的(可以被继承但是不能被访问)。
  • 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出protected成员限定符是因继承才出现的
  • 对于基类的public,protected成员,遵循上面那个小顺序:public > protected > private 根据继承方式,选择较小的那个作为访问方式。

总结:

在实际应用中,用的最多的是基类访问限定符是public,protected,派生类的继承方式是public方式。

基类和派生类对象赋值转换——切片

派生类向基类转换

public继承下 派生类对象 可以赋值给 基类的对象/指针/引用,反过来不行 。(其他继承没有这样的特性)

Student s;
Member m1 = s;
Member* m2 = &s;
Member& m3 = s;

这里有个形象的说法叫切片或者切割。意为把派生类中基类那部分切来赋值过去。

它的原理是这样的。

对于不同类型的变量赋值时会进行类型转换,产生临时变量。而对于派生类对象赋值给基类对象时不会产生临时对象,通过切片的方式直接进行赋值。

int i=10;
double d = i;//产生了临时拷贝,进行类型转换
const double& k = i;//所以引用时需要加const

Student s;
Member m = s;//没有产生临时拷贝
//通过切片的方式把s对象中含有的基类成员变量直接赋值给基类对象m
Member &n = s;//是合法的,中间没有产生临时变量,不会出现引用常量
Member *o = &s;

*对于 Member o = &s; 的理解

本质上是对已经实例化对象s的变量的内存访问范围截取。

​编辑

这里并没有创造临时变量,而是仅仅对student类型变量的内存访问范围进行了“截断”,也就是说Member*类型的o变量存的地址就是student类型的s变量的地址,所以o变量和&s都是相同的起始地址,但是它们的类型不同,导致了解引用的内存访问范围不一样。

基类向派生类转换

基本不行,在一些特殊情况下可以

继承中的作用域及隐藏(重定义)的概念

在继承体系中基类派生类都有独立的作用域,就是它们类名形成的作用域。

派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏,也叫重定义

在子类成员函数中,可以使用 基类::基类成员 显式访问。

例子

class Member
{
protected:
	string _name = "XiaoMing";//姓名
	int _age = 18;//年龄
	int _id = 123;
};

class Student : public Member
{
public:
	void Print()
	{
		cout << "_id:" << _id << endl;
		cout << "Member::_id:" << Member::_id << endl;//通过指定类域访问基类变量
	}
protected:
	int _id = 1111; // 学号
};

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

结果:

注意:如果是成员函数在不同作用域,只需要函数名相同就构成隐藏(返回值和参数可以不相同) 。 在实际中在继承体系里面最好不要定义同名的成员

派生类的默认成员函数

构造函数

默认的构造函数

派生类的成员:1.内置类型不做处理 2.自定义类型去调他的构造函数 (和普通的类一样!)。

派生类中的基类成员:去调基类的构造函数(与普通类的区别!)。

例子:

class Member
{
protected:
	string _name;//姓名
	int _age;//年龄
};

class Student : public Member
{
public:

private:
	int _id;
};

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

构造Student对象,对于他自己(派生类)的成员,调用其默认的构造函数,即1.内置类型不做处理(int类型的_id初始化为随机值) 2.自定义类型去调他的构造函数(此处没有)

对于基类的成员(string类型的_name和int类型的_age),去调其基类的构造函数(此处父类仍是默认构造函数)。

显示的构造函数

        在初始化基类成员时不能在派生类中单独初始化,需要在父类构造函数中初始化

显示调用初始化函数

class Member
{
public:
	Member(const char* name = "XiaoMing", int age = 10)
		:_name(name)
		,_age(age)
	{
        cout << "Member()" << endl;
    }
protected:
	string _name;//姓名
	int _age;//年龄
};

class Student : public Member
{
public:
	Student(const char* name, int age ,int id = 10)
		:Member(name, age)
		,_id(id)
	{
        cout << "Student()" << endl;
	}

private:
	int _id;
};

int main()
{
	Student s("XiaoFang",10,100);
	return 0;
}

进行对象实例化,结果为

无论初始化列表顺序如何改变,构造函数构造顺序都是先父后子。

理解: 如果子类初始化时需要父类继承的成员变量时,此时若父类成员还未初始化,则子类初始化出现错误,所以构造顺序一定是先父类后子类。

构造函数构造的顺序:先父后子。

析构函数

class Member
{
public:
	Member(const char* name = "XiaoMing", int age = 10)
		:_name(name)
		,_age(age)
	{
         cout << "Member()" << endl;
    }

    ~Member()
    {
        cout << "~Member()" << endl;
    };
protected:
	string _name;//姓名
	int _age;//年龄
};

class Student : public Member
{
public:
	Student(const char* name, int age ,int id = 10)
		:Member(name, age)
		,_id(id)
	{
         cout << "Student()" << endl;
    }

    ~Student()
    {
        //~Member();错误写法,原因如下
        //子类的析构函数和父类的析构函数构成隐藏关系
		//由于多态原因,析构函数被特殊处理,函数名都被处理成destructor(),同名函数形成隐藏
        
        Member::~Member();//要指定类域;
        cout << "~Student()" << endl;
    };

private:
	int _id;
};

int main()
{
	Student s("XiaoFang",10,100);
	return 0;
}

我们创建一个对象,观察一下

此处我们发现Member被析构两次,原因是父类析构函数会在子类析构后自动调用。

我们在~Student()中显示调用~Member(),在子类自己析构后又自动调用一次父类的析构函数,从而导致析构两次,所以这种析构函数的写法是错误的。

总结: 对于父类的析构函数不用再子类的析构函数中显示调用,子类析构函数结束后父类析构函数自动调用!

继承体系中正确析构函数写法:

class Member
{
public:
	Member(const char* name = "XiaoMing", int age = 10)
		:_name(name)
		,_age(age)
	{
         cout << "Member()" << endl;
    }

    ~Member()
    {
        cout << "~Member()" << endl;
    };
protected:
	string _name;//姓名
	int _age;//年龄
};

class Student : public Member
{
public:
	Student(const char* name, int age ,int id = 10)
		:Member(name, age)
		,_id(id)
	{
         cout << "Student()" << endl;
    }

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

private:
	int _id;
};

int main()
{
	Student s("XiaoFang",10,100);
	return 0;
}

观察结果我们发现析构顺序与构造顺序相反,析构顺序是先子类后父类。

理解: 假设析构先父后子,会存在安全隐患,可能父类成员的资源已经清理,派生类再去访问可能会找不到,或者出现野指针等问题。 

析构函数析构的顺序:先子后父

拷贝构造函数

利用切片原则,先将子类赋值给父类,然后再单独赋值子类剩下的函数。

例子:

class Member
{
public:
	Member(const char* name = "XiaoMing", int age = 10)
		:_name(name)
		,_age(age)
	{}
    ~Member()
    {
        cout << "~Member()" << endl;
    };
protected:
	string _name;
	int _age;
};

class Student : public Member
{
public:
	Student(const char* name, int age ,int id = 10)
		:Member(name, age)
		,_id(id)
	{

	}
//----------------------------------------------------------------------------
	Student(const Student& s)
		:Member(s)//利用赋值转换的原则(切片原则)将派生类成员切片给基类进行赋值
		,_id(s._id)
	{}
//----------------------------------------------------------------------------
    ~Student()
    {
        cout << "~Student()" << endl;
    };

private:
	int _id;
};

int main()
{
	Student s("XiaoFang",10,100);
	Student s1(s);
	return 0;
}

赋值重载函数

class Member
{
public:
	Member(const char* name = "XiaoMing", int age = 10)
		:_name(name)
		,_age(age)
	{}
//-----------------------------------------------------------
	Member& operator=(const Member& p)
	{
		if (this != &p)
		{
			_name = p._name;
			_age = p._age;
		}
		return *this;
	}
//-----------------------------------------------------------
    ~Member()
    {
        cout << "~Member()" << endl;
    };
protected:
	string _name;
	int _age;
};

class Student : public Member
{
public:
	Student(const char* name, int age ,int id)
		:Member(name, age)
		,_id(id)
	{

	}
	Student(const Student& s)
		:Member(s)//利用赋值转换的原则(切片原则)将派生类成员切片给基类进行赋值
		,_id(s._id)
	{}
//----------------------------------------------------------
    Student& operator=(const Student& s) 
	{
		if (this != &s)
		{
			Member::operator=(s);//这里构成了隐藏,所以要想调用父类的,需要指明类域
			_id = s._id;
		}

		return *this;
	}
//----------------------------------------------------------
    ~Student()
    {
        cout << "~Student()" << endl;
    };

private:
	int _id = 10;
};

int main()
{
	Student s("XiaoFang",10,100);
	Student s1 = s;
	return 0;
}

总结

派生类的默认成员函数规则跟普通类的规则一致,唯一不同的是,不管是构造/析构/拷贝,多的是基类那一部分,基类部分调用基类那一部分对应函数去完成。

继承与友元

友元关系不能继承,基类友元不能访问子类私有和保护成员。 (爸爸的朋友不是孩子的朋友)

class Student;
class Member
{
public:
	//friend void print(const Member& m, const Student& s);
    //友元不能继承(不能继承下去,成为子类的友元),无法获取子类的成员
protected:
	string _name;
	int _age;
};

class Student:public Member
{
public:
    friend void print(const Member& m, const Student& s);
private:
	int _id;
};
void print(const Member& m,const Student& s)
{
	cout << m._name << endl;
	cout << s._id << endl;
}

int main()
{
    print();
    return 0;
}

继承与静态成员

基类定义了static静态成员,则整个继承体系中只有一个这样的成员,子类只能继承访问权。(子类中不会再产生一份拷贝,原因是static静态成员不是在对象中的,是在静态区的)

菱形继承

单继承:一个子类只有一个直接父类。

多继承:一个子类有两个或两个以上的直接父类。

菱形继承:在多继承的基础上,被同一个类继承的两个类,他们又继承自同一对象。

有了多继承就可能出现菱形继承,菱形继承会产生二义性,空间浪费

产生的问题:Student类和Teacher类继承Member类的成员,具有相同的成员(作用域不同),Assistant类又继承Student类和Teacher类,这样Assistant类就有两个相同的成员但是作用域不同,所以可以通过不同的作用域访问这两个相同的成员),造成了变量二义性,数据浪费。

解决菱形继承——虚继承

        为了解决这个问题,引入关键字virtual加在产生同一个成员变量的类中,这个时候B对象实例化时没有保存A类成员,而是通过一个机制指向A类成员

例子:

关于虚继承的底层原理

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 dd;
	dd.B::_a = 1;
	dd.C::_a = 2;
	dd._b = 3;
	dd._c = 4;
	dd._d = 5;
	return 0;
}

该代码描述的是这样的场景:

我们观察其内存地址: 

观察发现其确实多储存了两份_a(B::_a与C::_a)。

在B类与C类中加入virtual关键字。 

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 dd;
	dd.B::_a = 1;
	dd.C::_a = 2;
	dd._b = 3;
	dd._c = 4;
	dd._d = 5;
	return 0;
}

我们观察其内存地址:  

在两个蓝色框中我们发现下一行分别存储B类_b和C类_c的值,而上一行储存的是地址,由于我的是小端机,则_b存储的地址是0x001c7bdc,_c存储的地址是0x001c7be4 ,找到这两个地址,我们发现它们下一个地址存储了一个十六进制的值,这个值是偏移量。

再将这个偏移量加回原来存储地址的地址,我们发现它们的结果都指向了A类的地址

B类和C类都通过偏移量表去找到公共的A类。这个时候B或C对象实例化时没有保存A类成员,而是通过一个机制指向A类成员

总结:D类继承自B和C,因此D对象的内存布局中,会包含属于B类的成员部分(即B切片)和属于C类的成员部分(即C切片),还包含D自身新增的成员,以及虚继承带来的公共A部分。

类D的对象的内存布局(虚继承下):
+---------------------+
| B切片(B类的成员)   |  <-- 类D对象的指针指向这里
+---------------------+
| C切片(C类的成员)   |
+---------------------+
| D自身新增的成员      |
+---------------------+
| 公共A的成员(虚基类)|  <-- B、C通过指针指向这里
+---------------------+

关于对类D进行切片后的类B对象

D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;

B* pb = d;//对类D对象进行切片

切片后的类B对象仍然可以访问到基类A的成员变量(通过偏移量找),逻辑自洽。

举例: C++ 标准库中IO 流类的继承结构(菱形继承)

  • ios:作为 IO 流的核心基类
  • istream:通过虚继承从ios派生,是输入流的基类,提供字符 / 数据的输入操作(例如cinistream的实例)
  • ostream:通过虚继承从ios派生,是输出流的基类,提供字符 / 数据的输出操作(例如coutostream的实例)
  • iostream:同时继承istreamostream。

回顾:关于访问结构体成员的本质

对象中多个成员,存储的顺序是按照声明顺序(从上到下)确定的

  1. 第一个成员的内存偏移量为 0(直接从结构体的起始地址开始存储
  2. 后续每个成员,默认会紧跟在前一个成员的 “逻辑末尾地址” 之后(注意:不是物理末尾,因为存在内存对齐
#include <stdio.h> 
// 声明顺序:id → name → score 
struct Student { 
    int id; // 第1个成员 
    char name[6]; // 第2个成员 
    double score; // 第3个成员 
};

内存中默认排布顺序:id(先存)→ name(中间存)→ score(后存),绝不会出现name排在id前面的情况。

例子

p_stu→score 找到对应内存范围的过程,本质是地址定位 + 类型确定范围。

step1:获取结构体的起始地址

p_stu是一个struct Student*类型的指针变量,它的核心作用就是存储结构体stu的起始内存地址(即&stu的值)。

step2:计算score成员的偏移量

编译器在编译代码时,会根据结构体成员的声明顺序内存对齐规则,提前计算好每个成员相对于结构体起始地址的偏移量(字节数),这个偏移量是固定的,不会在运行时改变。

对于score成员:

  • 前面的id占 4 字节,name占 6 字节;
  • 为了满足double类型的内存对齐(偏移量需是 8 的整数倍),name后面会填充 6 字节;
  • 因此score的偏移量 = 4(id) + 6(name) + 6(填充) = 16 字节(可通过offsetof(struct Student, score)获取)。

简单说:score在结构体中,距离结构体起始地址往后偏移了 16 个字节。

step3: 计算score成员的起始内存地址

有了结构体起始地址(来自p_stu)和score的偏移量,就能通过加法得到score的起始地址:

score起始地址 = p_stu存储的结构体起始地址 + score的偏移量

step4: 根据score数据类型,确定内存范围

当找到score的起始地址后,它的内存范围由自身的数据类型(double)决定

  • double类型在系统中固定占 8 字节;
  • 因此**score的内存范围 = score起始地址 ~ score起始地址+7**