本文将介绍C++面向对象中三大特性之一的继承,并详细介绍多继承引发的菱形继承及其解决方法。
就像现实世界中子辈继承父辈遗产一样,面向对象的语言一样有继承,这是面向对象程序设计使代码可以复用的最重要的手段。
举个例子,老师和学生都是人,都有人的属性,有姓名,年龄,性别等等公共属性,而如果设计这些对象时在类中都定义相关变量,就会造成数据冗余。所以我们可以将这些共同属性统一为一个类Person,再让Student和Teacher两个类去继承Person即可,表现到代码则为:
class Person
{
public:
void Print()
{
cout << "姓名" << _name << endl;
cout << "年龄" << _age << endl;
}
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
void f()
{
Print();
cout << "学号" << _stuID << endl;
}
private:
string _stuID;
};
class Teacher : public Person
{
public:
void f()
{
Print();
cout << "教工号" << _teachID << endl;
}
private:
string _teachID;
};
从上面我们可以知道继承的定义格式:
继承关系与访问限定符:
继承基类成员访问方式的变化:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成 员 |
基类的protected成 员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成 员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
·上面的表格简单来说就是除了private成员,其他成员访问权限为继承方式和访问限定符中较小的那个权限;其次,这里的不可见是指成员依旧被派生类继承了,但只是派生类对象无论在类中或类外都不能对其访问。
·但是在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
·使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
对于基类和派生类对象的赋值类型转换,简单来说就是将派生类中属于基类的部分付给基类对象,同样作用于指针和引用。这种行为被称为切片或切割。
但是这种赋值只能是派生类赋给基类,而基类对象不能赋给派生类,基类的指针可以通过强制类型转换赋值给派生类,但是需要使用后面介绍的dynamic_cast
来进行识别后进行安全转换。
int main()
{
Person ps;
Student sd;
//1.子类对象可以赋值给父类对象/指针/引用
ps = sd;
Person* pps = &sd;
Person& rps = sd;
//2.基类对象不能赋值给派生类对象
sd = ps;//error
//3.基类的指针可以通过强制类型转换赋值给派生类的指针
//最好用dynamic_cast进行转换,这样才安全
Student* psd = (Student*)&ps;
return 0;
}
继承中子类和父类有各自独立的作用域,而在这之上如果子类和父类有同名的函数,函数就会构成隐藏(重定义)的关系。这样如果在子类中要调用父类同名的函数,需要指明作用域来显式调用。
class Person
{
public:
void f(int age)
{
cout << "姓名" << _name << endl;
cout << "年龄" << _age << endl;
}
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
void f()
{
Person::f(32);//需显式调用f函数
cout << "学号" << _stuID << endl;
}
private:
string _stuID;
};
注意,这里的同名函数为隐藏关系,而不是重载关系,因为重载需要在同一个作用域下才行。
·一般子类的构造函数会调用基类的默认构造函数,如果基类没有默认构造函数,那么需要在子类构造函数的初始化列表中显式调用基类构造函数:
class Person
{
public:
Person(string name, int age)
{
_name = name;
_age = age;
}
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
Student(string name, int age, string stuID)
:Person(name, age)//基类没有默认构造函数,则子类需要显式调用
, _stuID(stuID)
{
cout << "Student(string name, int age, string stuID)" << endl;
}
private:
string _stuID;
};
同样的,拷贝构造函数和赋值重载函数也要显式调用基类的拷贝构造与赋值重载函数:
//Person
Person(const Person& p)//拷贝构造函数
: _name(p._name)
: _age(p._age)
{
cout<<"Person(const Person& p)" <<endl;
}
Person& operator=(const Person& p)//赋值重载函数
{
cout << "Person operator=(const Person& p)" << endl;
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
//Student
Student(const Student& s)
: Person(s)
, _stuID(s ._stuID)
{
cout<<"Student(const Student& s)" <<endl ;
}
Student& operator=(const Student& s)
{
cout << "Student& operator = (const Student& s)" << endl;
if (this != &s)
{
Person:: operator=(s);
_stuID = s._stuID;
}
}
析构函数有点特殊,由于编译器会将析构函数的名字处理成destructor,因此派生类和基类的析构函数会构成隐藏关系,故若要派生类要调用基类的析构函数,那么需要显式调用。
~Person()
{
cout << "~Person()" << endl;
}
~Student()
{
Person:: ~Person();
cout << "~Student()" << endl;
}
但如果像上面在派生类中显式调用基类的析构函数,则会出现如下结果:
这里创建一个Student类的对象,发现会调用两次Person的析构函数,这是因为一般基类的初始化会优先于派生类,而根据先初始化的后析构这个原则,所以编译器会默认在派生类的析构函数调用结束后调用基类的析构函数。
继承关系并不会将基类的友元也继承下来,如果子类要使用父类的友元,则子类自己也要将其定义为友元。
class Person
{
public:
friend void Print();
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
friend void Print();//基类的友元不会继承
protected:
string _stuID;
};
void Print()
{
cout << "friend" << endl;
}
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
这很好理解静态成员全局只存在一个,整个继承体系共享。
class Person
{
public:
static int _count;
protected:
string _name;
int _age;
};
int Person::_count = 0;
class Student : public Person
{
protected:
string _stuID;
};
int main()
{
Student s1;
s1._count++;
cout << "人数: " << Person::_count << endl;
Student s2;
s2._count++;
cout << "人数: " << Person::_count << endl;
Student s3;
s3._count++;
cout << "人数: " << Person::_count << endl;
Student s4;
s4._count--;
cout << "人数: " << Person::_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()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a;
a._name = "peter";//error
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "张三";
a.Teacher::_name = "李四";
return 0;
}
对于菱形继承需要在中间继承关系使用虚拟继承,如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
class Student : virtual public Person
{
protected:
int _num; //学号
};
class Teacher : virtual public Person
{
protected:
int _id; // 职工编号
};
注意只有在菱形的中间使用虚拟继承才能解决,否则仍有错误。
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型。
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;
}
首先来看不进行虚拟继承的情况:
可以看到没有虚拟继承的情况下,d中的成员连续排列出现了B和C中的_a是两个不同的值,但实际上一个人几乎不会有两个名字,所以这就出现了数据冗余。
接着我们来看看虚拟继承后的内存情况:
可以看到此时的_a只有一份了,但是在B和C中出现了意义不明的两块数据,这是什么意思呢?可以看到这两块内存存储的应该是地址,那么我们再调出这两块内存看看:
可以看到,这两块内存存放的是两个数字20和12,并且回过头一看正好是B和C中两块内存区域到存储_a的内存偏移量,这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。
由此可见,使用了虚拟继承后,就可以解决菱形继承导致的问题。
C++在有了多继承后,语法就变得复杂了许多,这是C++最初设计时的小瑕疵,但是我们以后世的眼见来看,也要去吸收和反思。
有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。这也是Java等语言没有实现多继承这个功能的原因。
·我们把public继承称为is_a的关系,也就是说每个派生类对象都是一个基类对象。比如,学生和老师是人,老鼠和猫是动物等等。
·而我们把组合称为has_a的关系,假设B组合了A,每个B对象中都有一个A对象。比如说车和轮胎就是组合关系,车组合了轮胎,即每一个车对象中多有一个轮胎对象。
·继承是一种白箱复用,也就是说基类中的成员对派生类可见;而组合是一种黑箱复用,即组合关系对象的内部细节不可见。
·由于继承中基类和派生类的依赖关系很强,耦合性很高,也就是说他们之间的独立性相对较低,不便于维护。
·而组合的类之间没有较强的依赖关系,耦合性较弱,也即他们之间的独立性较高,便于维护。
·因此,实际中能用组合就尽量使用组合,而对于is_a的关系,需要用继承可以使用,而类之间可以用组合则用组合,分清情况即可。