目录
🏆一、继承的概念及定义
1.1继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
那么说了这些,可能还是不太清楚继承到底是如何在类层面复用的,我简单举个例子。我们学校后勤由不同部门的人员组成,有食堂阿姨、保安大叔、管理人员等。他们都有自己的工号、电话、地址等,那么如果我们把他们录入计算机系统,就有几个类,每个类成员变量有名字、年龄、地址、电话等。如果每个类都这样设计,是否过于冗余呢?我们为了自己方便,可否这样设计,就是设计一个基类(父类),让其余类继承,是否更加方便呢?
Student和Teacher都继承Person这个类,这样我们会方便不少。继承正是基于这样的理念被设计诞生。
1.2继承定义
1.2.1定义格式
这里Person是基类,Student是子类,也称派生类。而public是继承方式。
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18;
};
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;
}
通过调试,我们可以观察到,继承后基类Person的成员(成员函数+成员变量)都会变成子类的一部分。这里就体现了继承类设计层面的复用,即Student和Teacher复用了Person的成员。通过控制台的显示,我们看到成功调用基类中Print函数。
那么,上面的继承方式public只是其中一种,还用两种继承方式。
1.2.2继承关系和访问限定符
这三种继承方式有何不同呢?
1.2.3继承基类成员访问方式的变化
1、基类private成员在派生类中无论以何种方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2、基类private成员在派生类中是不能被访问的,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就将这部分private限定的成员定义为protected,可以看出保护成员限定符是因继承才出现的。
3、除了基类的private成员,其余基类成员,若以protected方式继承,则基类public成员变成派生类的protected成员,以private方式继承,则基类public和protected成员变成派生类的private成员。以public方式继承,则public和protected成员原封不动继承下来。
4、使用关键字class时默认的继承方式是private,使用struct默认的继承方式是public,不过最好显式地写出继承方式。
5、在实际应用中一般使用都是public继承,几乎很少使用protected/private继承,也不提倡使用protected/private继承,因为protected/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
🏆二、基类和派生类对象赋值转换
1、派生类对象可以赋值给基类的对象/基类的指针/基类的引用。因为基类中不包含派生类中除基类外的其他成员,所以这种继承不是完全继承,而是切割,意思是把派生类中基类那部分切来赋值过去。
2、基类对象不能赋值给派生类对象。
3、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是不建议这种做法,它是不安全的。
我们可以对上述这种特性做进一步的解读,我们可以看到,派生类赋值给基类是不存在类型转换的,因为它是切片,不产生临时变量,它是不同于内置类型的转换,不会产生临时变量,可以做到引用。
🏆三、继承中的作用域
1、继承体系中基类和派生类都有独立的作用域。
2、基类和派生类中有若同名成员,则派生类将屏蔽基类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(若想访问基类同名成员,可以使用基类::基类成员显式访问)。
3、需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4、注意在实际中在继承体系里面最好不要定义同名的成员。
①对成员变量的隐藏
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111;// 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 999; // 学号
};
void Test()
{
Student s1;
s1.Print();
};
我们看到,在默认情况下,打印的是派生类Student的成员变量_num,对基类中和派生类同名的成员变量_num进行了隐藏,我们必须显式指明调用基类中的_num才能调用成功。
②对成员函数的隐藏
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" << i << endl;
}
};
void Test()
{
B b;
b.fun(10);
};
int main()
{
Test();
return 0;
}
派生类B和基类A中的fun()函数同名,构成隐藏,在默认调用下,是调用派生类B中的fun()函数,当显式指明时才会调用基类A中的fun()函数。
🏆四、派生类的默认成员函数
默认成员函数是指我们不写,编译器会自动生成,那么在继承中,派生类的几个成员函数是如何生成的呢?
1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
2、派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。
3、派生类的operator=必须要调用基类的operator=完成基类的赋值。
4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5、派生类对象初始化先调用基类构造再调派生类构造。
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)
,Person(name)
{
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 s3("rose", 17);
s1 = s3;
}
通过验证我们可以发现,确实是满足上述性质。
🏆五、继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
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; }
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; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
}
🏆七、复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
多继承:一个子类有两个或以上直接子类时称这个继承关系为多继承。
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:菱形继承有数据冗余和二义性的问题。
比如说上面派生类Assistant继承了Student和Teacher,而Student和Teacher都继承了Person,这会导致Assistant中有两份Person:
这样不仅数据冗余,而且容易产生二义性,需要指明访问的是哪个父类的成员:
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。
虚拟继承解决数据冗余和二义性的原理
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员。
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;
}
我们通过调试会发现数据冗余的问题。
这里需要注意的是,如果使用的是vs2019的编译器,那么可能看不到数据冗余的情景,针对菱形继承,它做了优化。
上面就是一个简单的菱形虚拟继承的内存对象成员模型。我们这里可以分析出D对象中将A放到了最下面,而这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的是偏移量。B和C通过偏移量可以找到下面的A。
我们可以看到虚基表存偏移量确实是为了可以找到公共基类。我们可以看到B中虚基表存储的偏移量是0x00000014,而B的地址为0x12FFC64,偏移量加上B的地址确实是公共基类A的地址0x012FFC78.
下面是上面的Person关系菱形虚拟继承的原理解释:
既然菱形继承这么复杂,一般不建议我们使用,而在STL库中却有使用它的实例:
我们的iostream设计的就是菱形继承。
🏆八、继承的总结与反思
1、继承有很多优点,多继承也是很不错的设计,但是菱形继承就比较复杂。而C++语法复杂,很多都体现在了一些语法设计有缺陷的地方,但是没办法,毕竟C++是摸石头过河。后来很多语言都借鉴C++没有多继承,比如Java。一般不建议设计出多继承,一定不要设计出菱形继承,否则在复杂度及性能上都有问题。
2、继承和组合
·public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
·组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
·优先使用对象组合,而不是类继承。
·继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用。术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对子类可见。但是继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
·对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用,因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先选用对象组合有助于你保持每个类被封装。
·实际中尽量多去使用组合。组合耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态的前提也必须要继承。