C++ 继承:打破代码冗余,提升开发效率

发布于:2024-10-13 ⋅ 阅读:(56) ⋅ 点赞:(0)

目录

继承的概念和定义

继承的概念

继承的定义

定义格式

继承关系和访问限定符

继承基类成员访问方式的变化 

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

继承中的作用域

派生类的默认成员函数

构造函数

拷贝构造

赋值运算符重载

析构函数

继承与友元

继承与静态成员

菱形继承与菱形虚拟继承

什么是菱形继承

菱形继承的问题

如何解决菱形继承带来的问题

虚继承解决的原理

1. 在菱形继承下

2. 在菱形虚拟继承下 

 3.菱形继承总结

继承的总结和反思

继承和组合

选择继承还是组合

笔试面试题

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

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

3.如何设计一个不能被继承的类?


继承的概念和定义

继承的概念

在学生管理系统的设计中,我们涉及到四个不同的角色:学生、老师、宿管阿姨以及保安大叔。在定义相关类时,我们会发现他们存在一些共同的特征,例如都需要定义成员变量如 name(姓名)、age(年龄)、telephone(电话)等等,以及可能会有一些共同的成员函数。

 

此时,继承机制就发挥了重要作用。我们可以定义一个基类 Person,然后让学生、老师、宿管阿姨和保安大叔这四个类分别继承自 Person 类。通过这种方式,不仅很好地解决了代码重复的问题,实现了类之间的代码复用,而且还反映了现实世界中这些角色都是 “人” 的本质关系,体现了 “is - a” 的逻辑。这种继承关系使得代码结构更加清晰、易于理解和维护。

#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; //姓名
	int _age = 18;   //年龄
};
//继承父类的Person的成员(成员函数+成员变量)都会变成子类的一部分,这里体现出来Student和Teacher复用
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;
}

由监视窗口可知,继承就是除了父亲有这些成员,子类也有这些成员。

继承的定义

定义格式

继承关系和访问限定符

继承基类成员访问方式的变化 

总结:

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

如何记忆这个表格:第3点,继承方式和访问限定符比较取最小值。谁的范围限定的小就取谁。


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

  • 1. 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法    叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
  • 2. 基类对象不能赋值给派生类对象。
  • 3. 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。

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

1. 这个过程是切片或者切割。

2. 指针指向子类,指针类型只能看到子类中父类的一部分。

3. 引用也是同理,不是整个对象的别名,变成你子类对象中,父类那一部分的别名,这个叫赋值兼容规则。

//基类/父类
class Person
{
protected:
	string _name; //姓名
	string _sex; //性别
	int _age;   //年龄
};
//派生类/子类
class Student : public Person
{
public:
	int _No; //学号
};
int main()
{
	Person p;
	Student s;
	//子类和父类之间的赋值兼容规则
	//1.子类对象可以赋值给父类的  对象/指针/引用
	p = s;
	Person* ptr = &s;
	Person& ref = s;
	return 0;
}

2. 基类对象不能赋值给派生类对象 

不能将父类对象直接赋值给子类对象,因为子类可能有父类没有的成员变量和成员函数。

3. 基类的指针可以通过强制类型转换赋值给派生类的指针 (了解)

//基类/父类
class Person
{
protected:
	string _name; //姓名
	string _sex; //性别
	int _age;   //年龄
};
//派生类/子类
class Student : public Person
{
public:
	int _No; //学号
};
int main()
{
	Person p;
	Student s;
	Person* pp = &p;
	pp = &s;
	//这种情况下可行
	Student* ss = (Student*)pp; //这种情况转换时虽然可以,但是会存在越界访问的问题
	return 0;
}

继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。
  5. 当成员函数或者成员变量构造隐藏(重定义)的时候,如果不指定作用域,在自己作用域中,所以优先使用自己的成员变量或者成员函数。
class Person
{
protected:
	string _name = "小李子"; //姓名
	int _num = 111;  //身份证号码
};
class Student : public Person
{
public:
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << "学号:" << _num << endl;  //_num构造隐藏,不指定作用域,就是在自己作用域,所以优先访问自己的成员
		//如果也想访问父类的,怎么办??指定作用域
		//cout << "学号:" << Person::_num << endl;
	}
protected:
	int _num = 999; //学号
};
//当父类和子类同时有同名成员的时候,子类的成员隐藏了父类的成员。(隐藏/重定义)
int main()
{
	Student s;
	s.Print();
	return 0;
}

这里我们来看一个例题

A、重载  B、重写  C、重定义(隐藏)   D、编译不通过

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		cout << "func(int i )->" << i << endl;
	}
};
int main()
{
	B b;
	b.fun(1);   //func(int i )->1
	//指定去调用父类的
	b.A::fun();  //func()
	return 0;
}

1. 相信大家会误以为是重载,因为函数名相同,参数不同,构成了重载,可是重载的前提条件是在同一块作用域中。

2. 这里是隐藏,变量只要是变量名相同就是隐藏,函数只要是函数名相同就是隐藏,不需要参数相同,参数个数多一个或者少一个也是隐藏。

3. 子类会隐藏父类的同名成员。同名成员包含: 成员变量和成员函数,函数名相同就构成了隐藏。虽然隐藏了父类的,不是说不见了, 而是可以指定作用域进行调用。


派生类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会帮我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢?

  • 1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。

  • 2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

  • 3. 派生类的operator=必须要调用基类的operator=完成基类的复制。

  • 4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

  • 5. 派生类对象初始化先调用基类构造再调派生类构造。

  • 6. 派生类对象析构清理先调用派生类析构再调基类的析构。

  • 7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(后面多态的内容)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系。

构造函数

在派生类中,构造函数是先调用基类的构造函数,再去调用派生类的构造函数,

先构造的后析构,后构造的先析构,所以先析构派生类,再析构基类。

先构建通用的部分(基类),再构建特化的部分(派生类)是符合这种层次结构的自然顺序。就像建造一座大楼,先打好基础(基类构造),然后再建造上层结构(派生类构造)。

析构的时候也是同理,先清理上层结构(派生类结构),在清理根基(基类结构)。

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()
	{
		cout << "Student()" << endl;
	}
	~Student()
	{
		cout << "~Student()" << endl;
	}
protected:
	int _stuid; //学号
};

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

派生类和我们以前学的普通类有区别吗???

普通类中会自动生成默认的成员函数,内置类型不做处理,针对自定义类型会做处理

所以我们可以总结出结论是:派生类要初始化分成两个部:一部分是父类的,一部分是自己。不能自己去初始化父类的那部分,父类这部分一定是调用父类完成,不调自己也会去调用默认的构造函数。

构造的时候父类调用父类的构造,析构的时候也是一样,父类调用父类的析构,自己的也会去调用自己的。

 注意点:

这里的初始化分开了,父类的调用父类的完成初始化,子类的调用子类的完成初始化,

所以在子类中利用子类初始化父类是错误的。

在 C++ 的继承体系中,基类和派生类有各自独立的作用域。当创建派生类对象时,应该明确区分基类部分和派生类部分的初始化。

基类的成员变量应该由基类的构造函数进行初始化,派生类不能直接去初始化基类的成员变量。同样,派生类的成员变量由派生类的构造函数进行初始化。

如果在派生类中尝试用类似 “_name(name)” 这样的方式去初始化一个看似是继承自基类的成员变量,实际上是错误的做法。  

正确的做法:

1. 如果派生类Student的构造函数中没有显式调用基类的构造函数,而基类又提供了带默认参数的构造函数,那么在创建Student对象时,会自动调用基类的这个默认构造函数,将基类部分初始化为默认值 “peter”。

class Person
{
public:
	Person(const char* name = "peter")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; //姓名
};

//派生类
class Student : public Person
{
public:
	Student(const char* name, int stuid)
		:_stuid(stuid)
	{}
protected:
	int _stuid; //学号
};
int main()
{
	Student s("jack", 1);
	return 0;
}

2. 如果基类没有提供默认构造函数,那么在派生类的构造函数中就必须显式地调用基类的某个构造函数来完成基类部分的初始化,否则会导致编译错误。 

class Person
{
public:
	Person(const char* name)
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; //姓名
};

//派生类
class Student : public Person
{
public:
	Student(const char* name, int stuid)
		:Person(name)  //显示调用父类的,就像构造一个匿名对象
		,_stuid(stuid)
	{}
protected:
	int _stuid; //学号
};
int main()
{
	Student s("jack", 1);
	return 0;
}

构造函数小结:

如果基类提供了默认构造函数(即无参构造函数或所有参数都有默认值的构造函数),并且在派生类构造函数中不需要以特定的方式初始化基类部分时,编译器会自动调用基类的默认构造函数,这种情况下就不需要显式调用。

但是,如果基类没有默认构造函数,或者需要以特定的参数来初始化基类部分,那么就必须在派生类构造函数的初始化列表中显式调用基类的构造函数。


拷贝构造

拷贝构造和构造函数同理, 父类调用父类的完成,子类没有写就调默认的完成浅拷贝(值拷贝),但是通常情况下我们都需要自己手动写拷贝构造。

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;
	}
protected:
	string _name; //姓名
};

//派生类
class Student : public Person
{
public:
	Student(const char* name, int stuid)
		:Person(name)  //显示调用父类的,就像构造一个匿名对象
		,_stuid(stuid)
	{}
protected:
	int _stuid; //学号
};
int main()
{
	//构造s1
	Student s1("jack", 1);
	//拷贝构造,父类调用父类的完成,子类没有写就调默认的完成浅拷贝(值拷贝)
	Student s2(s1);
	return 0;
}

假设我们要自己写拷贝构造???

这样初始化也是不行的,必须显示的去调用父类的,那么怎么调呢?

  • 当执行Student(const Student& s)拷贝构造函数时:
    • 首先,会调用基类Person的拷贝构造函数来初始化派生类对象中的基类部分。这里通过Person(s._name)来调用基类的拷贝构造函数,将参数s的基类部分(即s_name成员变量值)传递给基类的拷贝构造函数,创建一个临时的基类对象来初始化派生类对象中的基类部分。这种方式会导致对象切片问题,因为只传递了基类部分的值,而丢失了派生类特有的部分。
    • 接着,会初始化派生类自己的成员变量_stuid,使用参数s_stuid成员变量值进行初始化。

正确的写法:

把子类对象传给父类,会发生切片。

以上方式存在问题,正确的做法应该是通过Person(s)来调用基类的拷贝构造函数,将整个派生类对象s传递给基类的拷贝构造函数(子类传给父类),这样可以确保基类部分被正确地拷贝初始化。

//拷贝构造
Student(const Student& s) 
	:Person(s) //会发生切片,子类可以给父类。这里类似于匿名对象传参
	, _stuid(s._stuid)
{
	cout << "Student(const Student& s)" << endl;
}

赋值运算符重载

赋值也是同理,父类调用父类完成赋值,子类在子类中完成赋值。

需注意的是:在继承中,赋值的时候,遇到同名函数,隐藏了同名函数 operator= ,当调用的时候需要指定作用域

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)
	{
		if (this != &p)
			_name = p._name;
		cout << "Person& operator=(const Person& p) " << endl;
		return *this;
	}
	//析构
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; //姓名
};

//派生类
class Student : public Person
{
public:
	//构造
	Student(const char* 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;
	}
	//赋值
	//s1 = s3
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			Person::operator=(s);  //父类调用父类的完成初始化
			_stuid = s._stuid;   //子类完成子类的初始化
		}
		cout << "Student& operator=(const Student& s) " << endl;
		return *this;
	}
protected:
	int _stuid; //学号
};


int main()
{
	Student s1("jack", 1);
	//赋值
	Student s3("rose", 2);
	s1 = s3;
	return 0;
}

析构函数

析构函数与前面三者比较,是一个特殊。

当我们显示写析构函数的时候:

~Person~Student 不构成隐藏啊,函数名不同。 但是这里构成隐藏。

子类的析构函数和父类的析构数构成隐藏,因为他们的名字会被编译器统一处理成destructor (跟多态有关系),都会处理成这个,那么它们就是隐藏关系。

既然是隐藏关系,我们就需要指定作用域。

纠正后的代码: 

//析构
~Student() 
{
	Person::~Person();
	cout << "~Student()" << endl;
}

还是不对劲啊,父类析构了两次,去掉第一个析构,先定义的后析构,后定义的先析构。

为什么不需要显示的去调用父类的析构

当派生类对象被销毁时,析构函数的调用顺序是先调用派生类的析构函数,然后自动调用基类的析构函数。

为什么去掉第一个析构,因为第一个是自己手写的析构,当我们执行到子类的析构的时候,由于手写了一个父类的析构,先去调用父类的析构,然后完成子类的析构,最后自动调用父类的析构,所以不需要手动写父类的调用

为什么会自动调用父类的析构函数呢?

1. 资源释放顺序:对象销毁时,会自动调用父类的析构函数,因为这样才能保证先析构子类,再析构父类。

2. 避免重复释放:如果显式调用父类析构函数,会导致重复释放资源。

析构函数小结:

前三者成员需要显示调用,析构函数不需要显示,为了保证析构的顺序。

隐藏不是说不见了,优先调自己,要去找父类,指定作用域。


继承与友元

友元关系不能被继承,友元是各自的友元。

1. class Person中声明了一个以Student为参数的友元函数Display。如果没有对Student进行前向声明,编译器在处理class Person时,由于不知道Student是什么类型,会报错。

2. Display无法访问 _stuNum 成员,因为是保护成员。

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
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;
}
void main()
{
	Person p;
	Student s;
	Display(p, s);
}

继承与静态成员

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

class Person
{
public:
	Person()
	{
		++_count;
	}
public:
	string _name; //姓名
	static int _count; //统计人的个数
};
int Person::_count = 0;
class Student : public Person
{
protected:
	int _stuNum;  //学号
};
int main()
{
	Person p;
	Student s;
	p._name = "jack";
	s._name = "rose";
	p._count = 1;
	s._count = 2;
	Person::_count++;
	cout << Person::_count << endl;
	cout << Student::_count << endl;
	return 0;
}


菱形继承与菱形虚拟继承

什么是菱形继承

有一个共同的祖父类。然后有两个父类,这两个父类都继承自这个祖父类。接着有一个子类,这个子类同时继承自这两个父类。这样的结构就构成了菱形继承。

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

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

菱形继承:菱形继承是多继承的一种特殊情况

菱形继承的问题

菱形继承的问题从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份。

虽然显示指定父类的时候可以解决二义性的问题,但是数据冗余的问题依旧没有解决

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; //主修课程
};
int main()
{
	//这样会有二义性无法明确知道访问的是那一个,name
	Assistant a;
	a._name = "peter";  //这里不能这样写

	//需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	//a.Student::_name = "xxx";
	//a.Teacher::_name = "yyy";
	return 0;
}

如何解决菱形继承带来的问题

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承,关键字virtual,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。virtual关键字和多态的 virtual 没有半毛钱关系。

class Person
{
public:
	string _name; //姓名
};
class Student : virtual public Person
{
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
protected:
	int _id; //职工编号 
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};
int main()
{
	Assistant a;
	a._name = "peter";
	return 0;
}

这个_name 就像一个别名,就是让继承的Person只存在一份

虚继承解决的原理

虚继承解决了数据冗余和二义性,为什么加个虚继承就解决了呢???

1. 在菱形继承下

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

求下面对象的大小

在没有虚继承的情况下,下面代码中 D类占 20 个字节,因为 B 和 C 各自有一个 int,又各自继承了一个 int,B 和 C 继承后有两个int,把 B 和 C 的各自的 2 个int 都继承下来,最后D 有一个int,总共 5 个 int,再内存对齐一下就是20个字节

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;
	cout << sizeof(d) << endl;
	return 0;
}

内存对象模型(对象在内存中是怎么存的?)

由下图观察可知,在没有使用虚继承的情况下,类D的对象中存在数据冗余(类B和类C各自包含一份类A的成员变量_a),并且在访问_a时会产生二义性(不清楚到底是访问类B继承的_a还是类C继承的_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 d;
	cout << sizeof(d) << endl;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5; 
	return 0;
}

2. 在菱形虚拟继承下 

我们加上虚继承,本来大小是20,解决了数据冗余和二义性,大小不应该是 16 吗,怎么变成了24,这是什么情况???

 

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;
	cout << sizeof(d) << endl;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5; 
	return 0;
}

 虚继承解决的原理

在使用虚继承后,虽然从概念上说最终在内存中只有一份 _a 的实例,但在代码层面,由于继承关系的存在,看起来BCD类中似乎都有 _a

实际上,当你通过d.B::_a这样的方式去访问时,编译器会通过特定的机制(通常是通过虚函数表中的指针和偏移量)找到实际的唯一一份_a 的内存位置。这只是一种语法上的表现形式,让程序员可以在不同的继承路径下以熟悉的方式去访问继承自基类的成员变量,但实际上并不是真正地在BCD类中各自存在独立的_a成员变量。

所以,虽然看起来多处有_a,但实际上只有一份实例,这是虚继承为了解决菱形继承问题而采用的特殊内存布局和访问机制所导致的表象。

如下图,以及通过放大再次观察

 

 

相信到这里,会发现,这里解决了数据冗余和二义性,原来是占 20字节,现在反而变大了,因为现在多了两个指针(虚基表指针)

  1. 两个虚基表指针,用于解决虚继承带来的二义性和确保只有一份基类实例。
  2. 一个int类型的_a,这是从虚基类A继承来的唯一一份成员变量。
  3. 一个int类型的_b,来自类 B
  4. 一个int类型的_c,来自类 C
  5. 一个int类型的_d,来自类 D 自身。

所以,最终类D的对象占用的空间会比没有虚继承时更大,因为增加了两个虚基表指针。但通过这种方式解决了菱形继承中的数据冗余和二义性问题。

如果没有使用虚继承来解决数据冗余问题,那么在继承关系中数据会被重复继承下来,这将导致严重的数据冗余现象,极大地浪费空间。

例如,当存在一个包含 1000 个元素的数组_a时,在没有虚继承的情况下,由于多重继承可能会导致多个父类中的_a被重复继承到子类中,造成大量的空间浪费。而使用虚继承后,能够确保只有一份_a被继承到最终的子类中,从而节省了 4000 字节的空间。虽然使用虚继承会多付出 8 字节的代价来处理虚继承的机制,但从整体来看,显然是利大于弊的。这是因为节省的 4000 字节空间在很多情况下对程序的性能和资源利用有着重要的影响,而多付出的 8 字节相对来说影响较小。在对内存使用较为敏感或者需要处理大规模数据结构的场景中,虚继承的这种优势更加明显。

 3.菱形继承总结

1. 以前直接赋值就可以了,现在需要找到地址,到虚基表里面找到偏移量,通过偏移量找到里面的_a,再取值。

2. 解决原理是:把公共的虚基类的成员放到最下面了,并且存了两个指针,这两个指针指向虚基表,虚基表里面存的偏移量来找这个成员的位置。

3. 什么时候会利用虚基表呢???

比如切片的时候,或者说 B 要找_a,或者 C 要找_a, 就把这个 _a 放到公共的部分。

所以实际中,不到万不得已的情况下,不要把类设计成菱形继承。


继承的总结和反思

有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。

继承和组合

继承

继承是一种建立在 “is-a” 关系上的机制,即子类是一种特殊的父类。通过继承,子类可以继承父类的成员变量和成员函数,实现代码的复用。

优点:子类可以直接使用父类的代码,减少了重复编写代码的工作量,继承使得代码结构更加清晰,易于理解和维护。

缺点:强耦合性。子类与父类紧密耦合,父类的任何变化都可能影响到子类。这可能导致代码的脆弱性,增加了维护的难度。

// Car 和 BMW, Car和 Benz构成is-a的关系
class Car {
protected:
	string _colour = "白色";  // 颜色
	string _num = "赣A6666";
	// 车牌号
};
class BMW : public Car 
{
public:
	void Drive() 
	{ 
		cout << "好开-操控" << endl; 
	}
};
class Benz : public Car 
{
public:
	void Drive() 
	{ 
		cout << "好坐-舒适" << endl; 
	}
};

组合

组合强调的是 “has-a” 关系,即一个对象拥有其他对象。

优点:低耦合性。组合中的对象之间是松散耦合的,一个对象的变化不会直接影响到其他对象。这使得代码更加灵活,易于维护。

缺点:组合需要更多的代码来管理和协调多个对象之间的关系,可能会导致代码的复杂性增加。与继承相比,组合的代码复用性可能会稍低一些,因为需要手动管理对象的组合和调用。

// Tire 和 Car构成has-a的关系
class Tire {  //轮胎
protected:
	string _brand = "Michelin";  // 品牌
	size_t _size = 17;    // 尺寸
};

class Car {
protected:
	string _colour = "白色";
	string _num = "赣A6666";
	Tire _t;    //轮胎
};

继承和组合的理解

子类继承父类后范围通常会变大,子类具有父类的特性同时还可以扩展自身的特性。就像车可以看作是一个特殊的 “子类”,如果从继承的角度,车继承了轮胎(父类)的某些属性(比如轮胎的一些基本物理特性),但这种关系可能会导致车这个子类与轮胎这个父类强耦合,一旦轮胎的某些特性发生变化,车可能会受到较大影响。

而组合也是车和轮胎的关系,车包含轮胎,它们之间的关系更加灵活。车可以在运行时更换不同类型的轮胎,而不会对车的其他部分造成太大影响。这种组合的方式降低了类之间的耦合度,使得系统更加易于维护和扩展。每个组件(轮胎)可以独立地进行开发、测试和修改,而不会对包含它的整体(车)产生不必要的影响。

选择继承还是组合

在选择继承还是组合时,需要考虑以下几个因素:

  1. 关系的本质:如果两个类之间存在 “is-a” 关系,那么继承可能是更合适的选择。如果是 “has-a” 关系,那么组合更合适。
  2. 代码的灵活性:如果需要更高的代码灵活性和可维护性,组合可能是更好的选择。如果对代码的复用性要求较高,并且继承关系比较稳定,那么继承可能更合适。
  3. 代码的复杂性:如果代码的复杂性较高,组合可能会使代码更加难以理解和维护。在这种情况下,可以考虑使用继承来简化代码结构。

总之,继承和组合都是重要的代码复用方式,在实际编程中需要根据具体情况选择合适的方式。有时候,也可以结合使用继承和组合,以充分发挥它们的优势。


笔试面试题

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

(1)  有一个共同的祖父类。然后有两个父类,这两个父类都继承自这个祖父类。接着有一个子类,这个子类同时继承自这两个父类。这样的结构就构成了菱形继承。

(2)  数据冗余和二义性。

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

(1) 菱形虚拟继承是在菱形继承的基础上,通过使用虚继承的方式来避免数据冗余和二义性问

题。在菱形虚拟继承中,BC类以虚拟继承的方式继承自A类,而D类再继承自BC类。

(2)

解决数据冗余

  • 在虚继承中,派生类对象中只包含一份虚基类的成员变量副本。所以在菱形虚拟继承中,类D的对象中只会有一份从类A继承来的成员变量,避免了数据冗余。

解决二义性

  • 虚继承使得类的继承关系更加明确。在菱形虚拟继承中,对于类D中从类A继承来的成员,编译器可以明确地知道只有一份实例,不存在二义性的问题。
  • 当类D需要访问类A的成员时,编译器可以通过特定的机制(通常是通过虚函数表中的指针和偏移量)准确地找到唯一的一份实例,避免了二义性。

3.如何设计一个不能被继承的类?

构造函数私有化,或者析构函数私有化。

B继承了A,私有的子类都不可见,而子类的构造函数又必须去调父类的构造函数完成初始化

class A
{
private:
	A()
	{}
};
class B : public A
{};

int main()
{
	B b; //B无法生成对象
	return 0;
}

在这段代码中,B类无法生成对象是因为其基类A的构造函数被声明为私有的。

当创建派生类对象时,首先会调用基类的构造函数来初始化从基类继承来的部分。但在这个例子中,由于A的构造函数是私有的,所以在B的构造过程中无法访问A的构造函数,从而导致编译错误,使得B无法生成对象。

A 是一个不能被继承的类。

或者析构函数设置成私有也是可以的。

当一个类被继承时,子类在其生命周期结束时需要调用基类的析构函数来正确清理从基类继承的部分。如果基类的析构函数是私有的,那么子类将无法访问这个析构函数,从而导致编译错误,阻止了继承。