C++【深入理解继承】

一、继承概念与定义

1、继承的概念:
继承是面向对象程序设计中最重要的一个概念。它允许我们依据另一个类来定义一个类,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样做也达到了重用代码功能和提高执行效率的效果。当创建一个类时,就不需要重新编写新的数据成员和成员函数,只需指定新建的类继承了一个已有的类的成员即可,这个已有的类称为基类,产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构。
继承代表了 is a 关系。例如,学生是人。如下图
在这里插入图片描述

class person {
    
    
public:
    void sleep() 
    void walk()
protected:
string _name = "nza";
int _age = 21;  
    
};
//派生类
class student : public person {
    
    
public:
    void study()
protected:
int _ID;
};
int main()
{
    
    
    Student s;
    s.walk();
   return 0;
}

继承后父类的person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了student复用了person的成员。也可使用监视窗口查看student,可以看到变量的复用。调用walk可以看到成员函数的复用。
2.定义格式:
如上的person就是父类,也称基类,student是子类,也称派生类
3.继承方式:
三种:public继承,protected继承,private继承。
4.访问限定符:
public访问,protected访问,private访问。
5.访问控制和继承:
派生类可以访问基类中所有的非私有成员。因此基类成员如果不想被派生类的成员函数访问,则应在基类中声明为 private。
在这里插入图片描述
一个派生类继承了所有的基类方法,但下列情况除外:
基类的构造函数、析构函数和拷贝构造函数。
基类的重载运算符。
基类的友元函数。
6.继承基类成员访问方式的变化
在这里插入图片描述
一般基本不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
总结:
1、基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
7.继承中的作用域:
在继承体系中基类和派生类都有独立的作用域。
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员来进行显示访问)需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。在实际中在继承体系里面最好不要定义同名的成员。

class P
{
    
    
public:
void fun()
{
    
    
cout << "func()" << endl;
}
};
class S : public P
{
    
    
public:
void fun(int i)
{
    
    
    A::fun();
    cout << "A" <<i<<endl;
}
};
int main()
{
    
    
     S s;
     s.fun(1);
}

S中的fun和A中的fun不是构成重载,因为不是在同一作用域,S中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
8.继承与友元:
友元关系不能继承,就是说基类友元不能访问子类私有和保护成员。
如果一定需要友元关系就要在父类和子类中都声明。
9.继承与静态成员:
如果类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。

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

先看一下继承中对象内存模型:
在这里插入图片描述
派生类会保留基类的所有属性和行为,每一个派生类的实例都包含一份完整的基类实例数据。
再看赋值转换:
派生类对象 可以赋值给基类的对象或基类的指针或基类的引用。这里有个生动的说法叫切片或者切割。意思是把派生类中父类那部分切来赋值过去,但是基类对象不能赋值给派生类对象,而且基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。如下图:
在这里插入图片描述
因为子类的成员数量一定>=父类的成员数量,所以当一个子类赋值给父类时,子类就可以将属于与父类的成员赋值给父类,剩下的成员父类中不含有。其实就是将子类的成员切片出来赋值给父类。如上图,父类A对象中的成员,子类B对象赋值时将自己含有的A中的成员切片赋值给父类A对象。

详细图解和代码:
在这里插入图片描述

class A
{
    
    };
class B : public A
{
    
    };
int  main ()
{
    
    
  B sobj ;
 // 1.子类对象可以赋值给父类对象/指针/引用
  A pobj = sobj ;
  A* pp = &sobj;
  A& rp = sobj;
 
 //2.基类对象不能赋值给派生类对象
  sobj = pobj;
 
  // 3.基类的指针可以通过强制类型转换赋值给派生类的指针
  pp = &sobj
  B* ps1 = (B*)pp; // 这种情况转换是可以的。
 
  pp = &pobj;
  B* ps2 = (B*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
}

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

(1)构造函数

基类中的成员函数可以被继承,可以通过派生类的对象访问,但这仅仅只是对于普通函数,对于基类的构造函数和析构函数时不能被继承的。派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
下面的代码中Person有默认构造函数,则在子类的构造函数中,会先调用父类的默认构造函数完成父类成员的初始化,然后在调用子类的构造函数初始化子类的成员。

class Person
{
    
    
public:
	//父类构造函数
	Person(string name = "父类")//父类有默认构造函数
		:_name(name)
	{
    
    
		cout << "Person()" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
    
    
public:
	//子类构造函数
	Student(string name, int num)
		:_num(num)
	{
    
    
		cout << "Student()" << endl;
	}
protected:
	int _num;
};

int main()
{
    
    
	Student s("nza", 1);

	return 0;
}

如果父类没有默认构造函数,不传参就无法初始化,则需要在子类的构造函数中显式调用父类构造函数完成父类成员的初始化,可以使用初始化列表来完成构造函数的任务,其中初始化列表可以使用基类的构造函数来完成和填充派生类中的数据的初始化。如下:

class Person
{
    
    
public:
	//父类构造函数
	Person(string name)//如果不传参无法初始化
		:_name(name)
	{
    
    
		cout << "Person()" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
    
    
public:
	//子类构造函数
	Student(string name, int num)
		: Person(name)//调用父类构造函数初始化
		, _num(num)
	{
    
    
		cout << "Student()" << endl;
	}
protected:
	int _num;
};

int main()
{
    
    
	Student s("nza", 1);

	return 0;
}

总之构造函数的调用顺序是按照继承的层次自顶向下,从基类再到派生类。不管初始化列表的顺序如何,派生类构造函数总是先调用基类的构造函数,再执行其他代码。

(2)拷贝构造函数

和构造函数的思想一样,派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。子类中调用父类的拷贝构造时,直接传入子类对象即可,父类的拷贝构造会通过“切片”拿到父类的那一部分。

class Person
{
    
    
public:
	//父类构造函数
	Person(string name = "父类")//父类有默认构造函数
		:_name(name)
	{
    
    
		cout << "Person()" << endl;
	}
	Person(const Person& p)
		:_name(p._name)
	{
    
    
		cout << "Person(const Person& p)" << endl;
	}

protected:
	string _name;
};
class Student : public Person
{
    
    
public:
	//子类构造函数
	Student(string name, int id)
		:_id(id)
		, Person(name)
	{
    
    
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		: Person(s)//直接传s,通过切片拿到父类的部分
		, _id(s._id)
	{
    
    
		cout << "Student(const Student& s)" << endl;
	}
protected:
	int _id;
};

int main()
{
    
    
	Student s("nza", 1);
	Student s2(s);//拷贝构造
	return 0;
}

(3)赋值重载

子类的operator=必须要显式调用父类的operator=完成父类的赋值。因为函数名相同构成隐藏,所以需要显式调用。

class Person
{
    
    
public:
	//父类构造函数
	Person(string 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;
	}

protected:
	string _name;
};
class Student : public Person
{
    
    
public:
	//子类构造函数
	Student(string name, int num)
		:_num(num)
		, Person(name)
	{
    
    
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		: Person(s)//直接传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;
	}
protected:
	int _num;
};

int main()
{
    
    
	Student s("nza", 1);
	//Student s2(s);//拷贝构造
	Student s3("kd", 7);
	s = s3;//赋值重载
	return 0;
}

在这里插入图片描述

(4)析构函数

和构造函数类似,析构函数也不能被继承。在创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类的构造函数,再执行派生类的构造函数,在销毁派生类对象时,析构函数的执行顺序和继承顺序正好相反,即先执行派生类析构函数,再执行基类的析构函数。
在上面的代码分别加入如下析构代码:

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

结果:
在这里插入图片描述
结果和前面讲的一致,派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。

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

(1)菱形继承

先说单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
在这里插入图片描述
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
在这里插入图片描述
菱形继承:菱形继承是多继承的一种特殊情况。
在这里插入图片描述
看如下代码:

class A
{
    
    
public:
	string _name; // 姓名
};
class B : public A
{
    
    
protected:
	int _num; //学号
};
class C : public A
{
    
    
protected:
	int _id; // 职工编号
};
class D : public B, public C
{
    
    
protected:
	string _majorCourse; // 主修课程
};
void Test()
{
    
    

	D d;
	d._name = "peter";
	
}

main函数里面这样写会有二义性无法明确知道访问的是哪一个。如图:
在这里插入图片描述

这时候换成下面的代码即需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决。如下代码和图:

    D d;
	d.B::_name = "nza";
	d.C::_name = "kd";

在这里插入图片描述

从上面面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在D的对象中A成员会有两,会造成歧义,比如说正常一个人的身份就是一份。
这就是菱形继承,由于最底层的派生类继承了两个基类,同时这两个基类有又继承的是一个基类,而会造成最顶部基类的两次调用,会造成数据冗余及二义性问题。数据冗余性的本质是空间浪费,多存了;二义性是不知道要访问谁。

(2)菱形虚拟继承解决数据冗余和二义性的原理

(2.1)看现象和方法

用虚继承来解决,在继承方式前加上virtual,先解决刚才的冗余问题。
在这里插入图片描述如上我们解决了菱形继承的两个问题,也就是加virtual形成虚拟菱形继承。那么原理是怎样的?

(2.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;
	return 0;
}

没加virtual,内存里观察到可以看到数据冗余:
在这里插入图片描述
加了virtual:如图:
在这里插入图片描述

在这里可以看到,这里D对象中将A放到的了对象组成的最下面。
这个A同时属于B和C,那么B和C如何去找到公共的A?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量,通过偏移量可以找到下面的A。如下图,B通过指找到偏移量,加上20字节正好到了A地址处,同理C也是。
在这里插入图片描述
这里虚继承要解决数据冗余性和二义性,把它们放到了一个公共的位置A,A属于B也属于C。

(2.3)关于菱形虚拟继承的一些问题

为什么不直接存A的地址?
因为把它们指向一个表不仅能存偏移量,还要存其他的东西的偏移量,还要解决其他问题。
所需空间变大了吗是亏还是赚?
解决数据冗余性是要两个指针的成本,如果类A变大,是8字节不亏不赚,如果是16字节转8字节。如果小,也不完全亏,至少解决问题了,没有节省也没有浪费太多,而且成本永远是固定的8字节。而指向的空间几乎可以忽略不计,一个类可能会有多个对象,但是它们还是指向一个表。
基类有多个成员就有多个指针?
并不是,如果在基类A中增加成员,不需要增加指针,派生类各自还是只存一个,因为编译器通过偏移量找到第一个位置,按声明顺序算,简单来说就是挨着的,通过找到第一个位置,按顺序可能需要内存对齐或者直接跳过多个字节,来找到后面的位置。如图:
在这里插入图片描述

(2.5)继承总结

多继承就是C++语法中的一个复杂体现,也是缺陷之一,因为有多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,更不要设计出菱形继承,否则在性能以及复杂度上会很棘手。

五、继承与组合

(5.1)概念

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象,比如B继承A,B都是一个A。组合是一种has-a的关系。假设D组合了C,每个D对象中都有一个C对象。

(5.2)继承与组合比较

如图:
在这里插入图片描述

对象继承:它允许根据基类的实现来定义派生类的实现。这种通过生成派生类的复用被称为白箱复用。“白箱”是相对可视化的,在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,继承就像跟团旅游中的团体旅游,受限制于自由,如上图,B可以直接用A的两个成员,但是A改动保护可能会影响B,耦合度高,即关联度高。
对象组合:是类继承之外的另一种复用选择。复杂的功能可以通过组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称为黑箱复用,也就是被组合对象的内部细节是不可见的。组合类之间没有很强的依赖关系,优先使用对象组合有利于保持每个类被封装,组合像是跟团旅游中的自由旅游,如上图,D可以直接用C的一个成员(public),间接用另外一个成员(通过访问public中的func来间接访问protected中的成员),C改动保护和私有成员基本不会影响D,耦合度低,即关联低

(5.3)结论

因为互相影响越小越好,优先使用对象组合,而不是类继承,这就符合软件工程的一个重要思想:低耦合,高内聚 。
但是继承也是非常重要的,有些关系就适合继承那就用继承,而且要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

猜你喜欢

转载自blog.csdn.net/m0_59292239/article/details/130099147