详解C++三大特性之继承

发布于:2023-01-19 ⋅ 阅读:(415) ⋅ 点赞:(0)

0.前言

面向对象三大特性:封装、继承、多态

  1. C++Stack类的设计和C Stakc类的设计对比。体现出封装的优势。
  2. 迭代器的设计。
    如果没有迭代器,容器的访问就只能暴露底层结构。
    这也就导致使用复杂,使用成本大大高,使用者必须熟悉底层实现才行。
    迭代器的存在,封装了底层结构,对外提供统一的访问接口,降低使用成本。
  3. stack/queue/priority_queue 的设计 --适配器模式,这也是一种封装

1.继承概念

定义

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

举个例子👇

在一个学校管理系统里面,存在各种各样的人,包括学生、老师、保安、保洁、后勤等
每个人都有各自的姓名、性别、电话、ID等。
在这些类中,有些数据和方法是重复的,有些是独立的。
为了简化代码,就诞生了继承,实现了类层次的复用。

在这里插入图片描述

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
	// protected/private成员对于基类 -- 一样的,类外面不能访问,类里面可以访问
	// protected/private成员对于派生类 -- private成员不能用 protected成员类里面可以用

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

// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。
class Student : public Person
{
public:
	void func()
	{
		Print();
		//_age = 0; // 不可见
	}
protected:
	int _stuid; // 学号
};

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

int main()
{
	Student s;
	cout << sizeof(s) << endl;

	Teacher t;
	//s._age = 0;
	//s.Print();
	//t.Print();

	return 0;
}

Person是父类,也叫基类。Student、Teacher是之类,也叫派生类。

继承方式有3种,访问限定符也有3种。两两组合就有9种情况。

在这里插入图片描述

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

在这里插入图片描述

总结:

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

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

  1. 派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。
    寓意把派生类中父类那部分切下来再赋值过去。
  2. 基类对象不能赋值给派生类对象。
  3. 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast 来进行识别后进行安全转换。

在这里插入图片描述

class Person
{

	//protected:
public:
	string _name;
	string _sex;
	int	_age;
};

// 必须建立在公有继承的基础上
class Student : public Person
{
public:
	int _No; // 学号
};

int main()
{
	Person p;
	Student s;
	s._name = "张三";
	s._age = 18;
	s._sex = "男";

	// 子类对象给父类 对象/指针/引用 -- 语法天然支持,没有类型转换,也就不存在临时对象
	p = s;
	Person& rp = s;
	Person* ptrp = &s;
	cout << s._age << endl; // 18
	cout << p._age << endl; // 18

	// 指向都是同一块空间
	cout << &s << endl; // 012FFAA0
	cout << &rp << endl; // 012FFAA0
	cout << ptrp << endl; // 012FFAA0
	rp._age++;
	ptrp->_age++;
	cout << s._age << endl; // 20
	cout << p._age << endl; // 18

	// 基类不能赋值给之类,即使强转也不行
	//s = (Student)p;

	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
	ptrp = &s;
	Student* ps1 = (Student*)ptrp; // 这种情况转换时可以的。
	ps1->_No = 10;

	ptrp = &p;
	Student* ps2 = (Student*)ptrp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;
	return 0;
}

注意,这种切片必须是建立在公有继承的基础上的。

3.继承中的作用域

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

  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。
    (在子类成员函数中,可以使用 基类::基类成员 显示访问)
    有点类似于就近访问。

// 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; // 999
		// 利用类域访问
		cout << " 身份证号:" << Person::_num << endl; // 111
	}
protected:
	int _num = 999; // 学号
};

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

在这里插入图片描述

调试也可以看到对象s中是存在2个_num的。

  1. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
// 两个fun关系是隐藏
class A
{
public:
	void fun()
	{
		cout << "A::func()" << endl;
	}
};
class B : public A
{
public:
	void fun()
	{
		cout << "B : public A" << endl;
	}
};

int main()
{
	B b;
	// 调用之类自己的
	b.fun(); // B : public A
	b.A::fun(); // A::func()

	return 0;
};
// 这2个fun关系仍然是隐藏
// 这里不是重载,函数重载要求在同一个作用域
class A
{
public:
	void fun()
	{
		cout << "A::func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		cout << "B : public A" << endl;
	}
};

int main()
{
	B b;
	// 调用之类自己的
	b.fun(1); // B : public A
	b.A::fun(); // A::func()

	return 0;
};

注意:这里不是重载,函数重载要求在同一个作用域。

  1. 注意在实际中在继承体系里面最好不要定义同名的成员。

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

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

构造函数

  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; //学号
};

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

子类啥也不写,也会去调用父类的构造函数完成初始化。
在这里插入图片描述

子类构造函数原则:调用父类构造函数初始化继承自父类的成员,自己再初始化自己的成员
针对自己的成员:内置类型不做处理,自定义类型调用相应的构造函数完成初始化。

同理,析构、拷贝构造、赋值重载也是类似这样。

注意:父类的成员就必须调用父类的构造函数完成初始化,想通过子类自己的初始化是行不通的。
在这里插入图片描述

在这里插入图片描述

要想真正完成对子类继承自父类的那部分成员的初始化,需要在初始化列表给父类的构造函数传参。

class Student : public Person
{
public:
	Student(const char* name = "", int num = 0)
		: Person(name)
		, _num(num)
	{
		cout << "Student(const char* name = "", int num = 0)" << endl;
	}
protected:
	int _num; //学号
	//string _address;
};

int main()
{
	Student s1("李四", 1);
	return 0;
}

在这里插入图片描述

如果这样写:
在这里插入图片描述

依旧也是先调用父类的构造函数完成name的初始化。
因为初始化列表的顺序是与成员变量声明的顺序有关,可以认为从父类继承的成员始终在子类自身的成员的上面先声明。
因此才去先完成父类的成员的初始化。

拷贝构造

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

对于子类自己的,内置类型不做处理,自定义类型去调用相应的拷贝构造完成初始化。

class Student : public Person
{
public:
	Student(const char* name = "", int num = 0)
		: Person(name)
		, _num(num)
	{
		cout << "Student(const char* name = "", int num = 0)" << endl;
	}
protected:
	int _num; //学号
	string _address = "西安";
};

int main()
{
	Student s1("李四", 1);
	Student s2(s1);
	return 0;
}

在这里插入图片描述

同样,自己在初始化列表不能完成拷贝构造的初始化的,必须借助父类的拷贝构造。

在这里插入图片描述

class Student : public Person
{
public:
	Student(const char* name = "", int num = 0)
		: Person(name)
		, _num(num)
	{
		cout << "Student(const char* name = "", int num = 0)" << endl;
	}

	Student(const Student& s)
		: Person(s) // 切割,子类的对象可以传给父类的引用
		, _num(s._num)
	{}
protected:
	int _num; //学号
	//string _address = "西安";
};

int main()
{
	Student s1("李四", 1);
	Student s2(s1);
	return 0;
}

赋值重载

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

赋值重载亦是如此:

Student& operator=(const Student& s)
{
    if (this != &s)
    {
        operator=(s); //同样是切割,子类对象传给父类引用
        _num = s._num;
    }
    return *this;
}
int main()
{
    Student s1("李四", 1);
    Student s2(s1);
    Student s3("王五", 2);
    s1 = s3;
    return 0;
}

注意,这么写是会崩溃的,栈溢出,不断的去回调子类自己的operator=。
必须调用父类的operator=才行

在这里插入图片描述

Student& operator=(const Student& s)
{
    if (this != &s)
    {
        //operator=(s); //同样是切割,子类对象传给父类引用
        // 注意,这么写是会崩溃的,operator= 不断重复调用自己,会造成栈溢出
        // 这里需要调用父类的operator=,需要加上类域
        Person::operator=(s);
        _num = s._num;
    }
    return *this;
}

int main()
{
    Student s1("李四", 1);
    Student s2(s1);
    Student s3("王五", 2);
    s1 = s3;
    return 0;
}

析构函数

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

如果我们显示去写,反而编译不通过。

在这里插入图片描述

// 父子类的析构函数存在隐藏关系,虽然他们看上去名称不同
// 但由于的多态的需要,析构函数名被统一处理成destructor()
// 因此构成了隐藏关系
~Student()
{
    //~Person();
    cout << "~Student()" << endl;
}

如果非要显示调用呢?加上类域就行了,表明要调用的是父类的析构函数。

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

但是如果显示调用了,又出现问题了。

在这里插入图片描述

只有3个对象,但是Person的析构却调用了6次。
这是因为编译器在起作用了,为了保证析构顺序是先子类后父类,因此子类析构完成之后会自动调用父类的析构。
因此不显示调用才是正常的。

在这里插入图片描述

源代码

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 = 0)
		: Person(name)
		, _num(num)
	{
		cout << "Student(const char* name = "", int num = 0)" << endl;
	}

	Student(const Student& s)
		: Person(s) // 切割,子类的对象可以传给父类的引用
		, _num(s._num)
	{
		cout << "Student(const Student& s)" << endl;
	}

	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			//operator=(s); //同样是切割,子类对象传给父类引用
			// 注意,这么写是会崩溃的,operator= 不断重复调用自己,会造成栈溢出
			// 这里需要调用父类的operator=,需要加上类域
			Person::operator=(s);
			_num = s._num;
		}
		cout << "Student& operator=(const Student& s)" << endl;
		return *this;
	}

	// 父子类的析构函数存在隐藏关系,虽然他们看上去名称不同
	// 但由于的多态的需要,析构函数名被统一处理成destructor()
	// 因此构成了隐藏关系
	~Student()
	{
		// 不需要显示调用
		//~Person();
		//Person::~Person(); 
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
	//string _address = "西安";
};

int main()
{
	Student s1("李四", 1);
	Student s2(s1);
	Student s3("王五", 2);
	s1 = s3;
	return 0;
}

设计不能被继承的类

很简单啊,让父类的构造函数私有就行。

class A
{
public:
	A CreatObject()
	{
		return A();
	}
private:
	A()
	{}
};

// 父类A的构造函数私有化后,B就无法构造对象了
class B : public A
{

};

int main()
{
	//B bb;
	A aa = A::CreatObject();
	return 0;
}

在这里插入图片描述

class A
{
public:
	static A CreatObject()
	//A CreatObject()
	{
		return A();
	}
private:
	A()
	{}
};

// 父类A的构造函数私有化后,B就无法构造对象了
class B : public A
{

};

int main()
{
	//B bb;
	A aa = A::CreatObject();
	return 0;
}

编译器在编译一个普通成员函数时,会隐式地增加一个形参 this,并把当前对象的地址赋值给 this,所以普通成员函数只能在创建对象后通过对象来调用,因为它需要当前对象的地址。而静态成员函数可以通过类来直接调用,编译器不会为它增加形参 this,它不需要当前对象的地址,所以不管有没有创建对象,都可以调用静态成员函数。

5.继承与友元

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

class Student; // 必须前置声明Student
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; // 姓名
};
class Student : public Person
{
public:
	//friend void Display(const Person& p, const Student& s); // 必须在父类子类都声明友元才行
protected:
	int _stuNum; // 学号
};

// Display是父类的友元,但不是子类的友元。友元关系不能继承下来,因此不能访问子类的成员
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;
}

在这里插入图片描述

6. 继承与静态成员

基类定义了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()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	Person s;

	// 任何一个类或者类的对象都可以访问静态成员
	cout << " 人数 :" << Person::_count << endl; //人数 :5
	cout << " 人数 :" << Student::_count << endl; //人数 :5
	cout << " 人数 :" << s4._count << endl; //人数 :5
	cout << " 人数 :" << s._count << endl; //人数 :5
	cout << " 人数 :" << s2._count << endl; //人数 :5

	// 访问的是同一个,地址也就是相同的
	cout << &Person::_count << endl; // 00D1E3DC
	cout << &Student::_count << endl; // 00D1E3DC
	cout << &s4._count << endl; // 00D1E3DC
	cout << &s._count << endl; // 00D1E3DC
	cout << &s2._count << endl; // 00D1E3DC

	return 0;
}

7.菱形继承及菱形虚拟继承

概念

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

问题

菱形继承会产生数据冗余和二义性的问题,例如在Assistant的对象中,Person的成员会有2份。

class Person
{
public:
	string _name; // 姓名
	int _a[10000];
};
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()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a;
	a._name = "peter";
	return 0;
}

在这里插入图片描述

虽然可以利用类域解决二义性,但是还是会有数据冗余,非常浪费空间。

class Person
{
public:
	string _name; // 姓名
	int _a[10000];
};
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()
{
	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";

	cout << sizeof(a) << endl; //80092
	return 0;
}

在这里插入图片描述

加上virtual就可以解决数据冗余的问题。

class Person
{
public:
	string _name; // 姓名
	int _a[10000];
};
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()
{
	//加了virtual 虚拟继承,2个问题都解决了
	Assistant a;
	a._name = "peter";
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
	cout << sizeof(a) << endl; //40072
    
	return 0;
}

在这里插入图片描述

原理

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

在这里插入图片描述

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

在这里插入图片描述

缺点

那虚拟菱形继承有什么缺点吗?

有的,虽然解决了数据冗余和二义性的问题,但也付出了对象存储的巨大代价。

在这里插入图片描述

这样保留的距离A位置的偏移量的指针有什么用呢?

比如如果要进行切片,编译器就可以通过偏移量计算A的位置。

B b;
C c;
D d;

// 切片时,把把子类和父类都有的赋值过去,自然要找到A才行。
b = d; // 切片时,要找到A的位置,通过B里面的指针0x003BFBB4,找到偏移量20,然后0x003BFBB4 + 20就找到A了 再切片赋值过去。
c = d;

// 
B* ptrb = &d; // 指向的是0x003BFBB4,借助偏移量就能找到_a了。
ptrb->_a = 1; //

虚基表与虚基表指针

上面的两个指针,称为虚基表指针,他们指向了两个表,叫做虚基表。虚基表中存的是偏移量,通过这个偏移量可以在内存中找到 A 的位置进行访问。

🔺 注意: 虚基表中第一个位置是为后面的多态虚表预留的存偏移量的位置,第二个位置才是当前类对象存储距离公共虚基类的偏移量的位置。

菱形继承就没有这么麻烦,菱形继承中基类A的对象_a直接存储在各自的子类中。

也许会有人说,既然都是同一个_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;

	B b;
	b._a = 10;
	b._b = 20;

	B* ptr1 = &d;
	B* ptr2 = &b;

	cout << ptr1->_a << endl;
	cout << ptr2->_a << endl;
	cout << ptr1->_b << endl;
	cout << ptr2->_b << endl;

	return 0;
}

在这里插入图片描述

举个例子👇

class Base1 
{ 
public: 
    int _b1; 
};

class Base2 
{ 
public: 
    int _b2; 
};

class Derive : public Base1, public Base2 
{ 
public: 
    int _d; 
};

int main()
{
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    return 0;
}

// 请判断:
A.p1 == p2 == p3
B.p1 < p2 < p3
C.p1 == p3 != p2
D.p1 != p2 != p3

分析: p1和p2虽然都是其父类,但在子类内存模型中,其位置不同,所以 p1和p2 所指子类的位置也不相同,因此 p1 != p2,由于 p1 对象是第一个被继承的父类类型,所有其地址与子类对象的地址 p3 所指位置都为子类对象的起始位置,因此 p1 == p3,所以C正确

8.继承和组合

class A{};

// 继承
class B : public A{};

class C{};

// 组合
class D {
	C _c;
};
  • public继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象
  • 组合是一种 has-a 的关系。假设B组合了A,每个B对象中都有一个A对象
    举个例子:
    人 <- 学生 动物 <- 狗
    车 -> 轮胎 头 -> 眼睛
  • 优先使用对象组合,而不是类继承

《极限编程》(Extreme programming)的指导原则之一是“只要能用,就做最简单的”。一个似乎需要继承的设计常常能够戏剧性地使用组合来代替而大简化,从而使其更加灵活。因此,在考虑一个设计时,问问自己:“使用组合是不是更简单?这里真的需要继承吗?它能带来什么好处?”

继承和组合的比较:

面向对象系统中功能复用的两种最常用技术是类继承和对象组合(object composition)。正如我们已解释过的,类继承允许你根据其他类的实现来定义一个类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,父类的内部细节对子类可见。

对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。

​ 继承和组合各有优缺点。类继承是在编译时刻静态定义的,且可直接使用,因为程序设计语言直接支持类继承。类继承可以较方便地改变被复用的实现。当一个子类重定义一些而不是全部操作时,它也能影响它所继承的操作,只要在这些操作中调用了被重定义的操作。

​ 但是类继承也有一些不足之处。首先,因为继承在编译时刻就定义了,所以无法在运行时刻改变从父类继承的实现。更糟的是,父类通常至少定义了部分子类的具体表示。因为继承对子类揭示了其父类的实现细节,所以继承常被认为“破坏了封装性” 。子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。当你需要复用子类时,实现上的依赖性就会产生一些问题。如果继承下来的实现不适合解决新的问题,则父类必须重写或被其他更适合的类替换。这种依赖关系限制了灵活性并最终限制了复用性。一个可用的解决方法就是只继承抽象类,因为抽象类通常提供较少的实现。

​ 对象组合是通过获得对其他对象的引用而在运行时刻动态定义的。组合要求对象遵守彼此的接口约定,进而要求更仔细地定义接口,而这些接口并不妨碍你将一个对象和其他对象一起使用。这还会产生良好的结果:因为对象只能通过接口访问,所以我们并不破坏封装性;只要类型一致,运行时刻还可以用一个对象来替代另一个对象;更进一步,因为对象的实现是基于接口写的,所以实现上存在较少的依赖关系。

对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小规模,并且不太可能增长为不可控制的庞然大物。另一方面,基于对象组合的设计会有更多的对象 (而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。

这导出了我们的面向对象设计的第二个原则:优先使用对象组合,而不是类继承。

  • 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-boxreuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
    比如:C对象公有成员D可以直接使用,C对象保护成员D也可以直接使用。
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的(也就是不能访问私有成员,一般只能访问接口函数)。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
    比如:C对象公有成员D可以直接使用,C对象保护成员D不可以直接使用。
  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
  • 模块间关系要尽量保证高内聚,低耦合

9.继承的总结和反思

  • 继承的语法设计偏复杂,但我们用的角度要简单一点。
  • 尽量就用公有继承,父类成员就设置公有和保护就好了。
  • 自己设计继承尽量避开隐藏。
  • 默认成员函数,有关父类的成员要调用父类的相关函数完成。
  • C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
  • 虽然虚继承解决了菱形继承的坑,但是也付出了一定的代价:
    对象模型更加复杂,学习成本更高了
    有一定的效率影响。

尾声

🌹🌹🌹

写文不易,如果有帮助烦请点个赞~ 👍👍👍

Thanks♪(・ω・)ノ🌹🌹🌹

😘😘😘

👀👀由于笔者水平有限,在今后的博文中难免会出现错误之处,本人非常希望您如果发现错误,恳请留言批评斧正,希望和大家一起学习,一起进步ヽ( ̄ω ̄( ̄ω ̄〃)ゝ,期待您的留言评论。
附GitHub仓库链接

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到