继承的概念和定义
1.作用
通俗的来说,继承就是从类的设计角度避免重复定义方法和数据,进行类角度的复用
//这就是继承
class Person
{
protected:
string _name = "peter";//注意这里不是初始化,初始化是在定义的地方进行,而这里只是声明。这里是缺省值。
int _age = 20;
};
class Student : public Person
{
protected:
int _grade;//年级
};
class Teacher : public Person
{
protected:
int _jobid;//工号
};
在上面的代码中,Student和Teacher就是派生类(子类),Person就是基类(父类),Student和Teacher中有从Person中继承来的_name和_age,也有他们自己的_grade和_jobid
2.定义格式
3.继承关系和访问限定符
总结:
- 基类中的private成员其实也被派生类继承过来了,但是由于访问限定符限制,派生类对象不管是在类外面还是在类里面都不能去访问它。
- 可以把protected看成是专门为继承量身打造的,如果基类成员不想在类外面被访问,但想在派生类中被访问,那就用protected。
- 权限大小是public>protected>private。基类的private成员在派生类中是不可见的,其他成员在派生类中的访访问方式是 Min(基类中的访问限定符,派生类中的继承方式)。
- 实际中最常用的还是public继承,protected/private不推荐使用,因为这俩继承下来的成员只能在派生类中使用,可维护性不强。
基类和派生类对象赋值转换
派生类对象可以赋值给基类的对象、指针、引用,这个过程叫切片(切割)
把派生类从基类中继承来的,一个个再赋值给基类
继承中的作用域
在继承体系中,派生类和基类都有独立的作用域,如果派生类和基类中有同名成员,那么派生类的成员将屏蔽父类对同名成员的访问(说白了就是父类的成员不能用了,如果想用的话要显式指定访问 基类::基类成员),这种情况叫隐藏,也叫重定义。
如果是重名函数的隐藏,只要函数名相同就构成隐藏,参数无所谓。要注意区分好重载和重定义,重载是要在同一个作用域内的。
class Person
{
protected:
string _name = "王铁柱"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
//这里如果不显示指定的话,打印出的是999,派生类的_nume把基类的_num隐藏了
cout << " 学号:" << _num << endl;
}
protected:
int _num = 999; // 这里就构成重定义了。
};
void Test()
{
Student s1;
s1.Print();
};
//要注意 A B不是构成函数重载,因为他们俩不是在同一个作用域内的,
//A B 构成重定义
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);
b.fun();//可以发现这样是用不了的,因为无参的fun是去调的A中的fun,而 AB构成重定义,A中的fun被隐藏了。
b.A::fun();//这样访问是对的。
};
派生类的默认成员函数
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 num)
: Person(name)
, _num(num)
{
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;
};
- 派生类中可以初始化自己的成员,但是从基类继承的那一部分成员,必须使用调用基类的构造函数初始化,不能自己去初始化。如果基类没有默认的构造函数,那必须要在派生类中的构造函数初始化列表阶段进行显式定义。
- 拷贝构造函数与构造函数相似,从基类继承的成员也需要调用基类的拷贝构造函数。
- 派生类的operator=必须调用基类的operator=完成基类的赋值
- 派生类的析构函数会在被调用之后自动调用基类的析构函数清理基类成员,这样才能保证先清理派生类成员再清理基类成员的顺序 。
- 派生类对象初始化先调用基类构造再调用派生类构造
- 派生类对象析构清理先调用派生类析构再调用基类析构
1.构造函数
_num是Student类中的,_name是从Person类中继承来的,所以它不能像下面这样直接初始化,这样是错误的。
//错误写法
Student(const char* name = "张三", int num = 1)
: _name(name)
, _num(num)
{}
派生类中的构造函数可以初始化自己的成员,但是从基类继承的成员要调用基类的构造函数进行初始化。正确写法如下
//正确写法
Student(const char* name = "张三", int num = 1)
: Person(name)
, _num(num)
{}
如果是下面这种情况的话也可以不写从基类继承来的成员的构造函数,它会去自动调用父类的默认构造函数
class Person
{
public:
Person(const char* name = "张三")
: _name(name)
{}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const char* name = "李四",int num = 1)
:_num(num)
{}
protected:
int _num;
};
像下面这样没有构造函数的话,就必须显式指定基类的构造函数了
class Person
{
public:
Person(const char* name )
: _name(name)
{}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const char* name = "李四",int num = 1)
:Person(name)
,_num(num)
{}
protected:
int _num;
};
2.拷贝构造函数
拷贝构造函数是一种特殊的构造函数,所以同理,也需要去显式调用基类的拷贝构造
Student(const Student& s)
:Person(s)//显式调用基类的
,_num(s._num)
{}
3.赋值重载
如果显式指定基类的赋值重载的话,派生类和基类的赋值重载函数名就会相同,构成隐藏,就没办法用基类的赋值重载了。
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);//显式指定基类的赋值重载
_num = s._num;
}
return *this;
}
4.析构函数
所有类中的析构函数名字会被统一处理成destructor,所以派生类的析构函数和基类的析构函数构成隐藏,所以要显式指定基类的析构函数
~Student()
{
Person::~Person();
}
但如果像上面这么写的话,会出现下面这个问题
如果我们不去手动调用它,系统会自动先调用派生类的析构函数然后再调用基类的构造函数。
如果在派生类的析构函数中显式调用的话,会存在基类先析构的问题,这样不符合先析构清理派生类再析构清理基类的顺序,所以我们不去手动调用,让它自己去调用。
~Student()
{
/*Person::~Person();*/
}
继承与友元、静态成员
总结一句话就是:
友元关系不能继承,基类友元不能访问派生类私有和保护成员。
基类定义了static静态成员,则整个继承体系中只有一个这样的成员,无论派生出多少个子类,都只有一个static成员实例。
菱形继承与菱形虚拟继承
单继承:一个子类只有一个直接父类
多继承:一个子类有两个及以上的直接父类
菱形继承是多继承的一种特殊情况
从下图中可以看到,菱形继承有二义性和数据冗余这两个问题。
在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; // 主修课程
};
void Test()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a;
a._name = "peter";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
可以通过上面的代码解决二义性的问题,但是这样治标不治本,还是有两个_name,数据冗余的问题依旧没有得到解决。
所以引入了虚拟继承来解决数据冗余和二义性的问题(虚继承和虚函数关键字都是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; // 主修课程
};
void Test()
{
Assistant a;
a._name = "peter";
}
(注意virtual加的位置)
这样的话里面就只有一个_name了。
虚拟继承解决数据冗余和二义性的原理
用下面这个代码进行举例
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;
}
先看一个现象
没用虚拟继承
用了虚继承的话
用了虚继承之后_a明明只有一个了,按理说d的大小应该是16,那为什么是24呢?
接下来就来看虚拟继承的原理。
如果先把virtual去掉,不用虚拟继承,在内存窗口中可以看到下图。可以看到有两个A
如果加上virtual,使用虚拟继承。
可以看到A就只有一个了,并且放到了最下面,这样就解决了二义性和数据冗余的问题了。(如下图)
而从上面我们得知,使用虚拟继承之后d的大小变为了24,多出来的两个数据就是B和C中第一行的东西(如下图)
这两个是指针,分别指向两个位置,叫虚基表,而这两个指针就叫虚基表指针(如下图)
两个虚基表中分别存了两个值,这两个值是当前位置距离虚基类对象的偏移量(如下图)
从下图中可以看到A的位置是30。第一个画蓝线的地方+20,就到了A的位置。第二个画蓝线的地方+12,就到了A的位置。
当前位置距离基类对象的偏移量 就是这个意思。
那为什么要在虚基表中存偏移量呢?
看下面这个场景
B定义了一个对象b,C定义了一个对象c,D定义了一个对象d。现在要把派生类赋值给基类,这时候就会发生切片。假如要切b,我们不知道b中从基类继承来的a在哪,这个时候我们就可以通过虚基表指针找到虚基表中存的偏移量,然后用自己的地址+偏移量就得到了a的位置,然后一切就行了。
说白了,虚基表的本质就是存放偏移量,然后切片的时候用这个偏移量来找到虚基类的位置然后方便切掉。
问:虚继承是怎么解决二义性和数据冗余的?
答:虚继承将虚基类放到了对象的最后一个位置且只放了一个,(用上面那个图的说法就是这个A既属于B又属于C又属于D),并且还多放了两个指针,这两个指针是虚基表指针,如果有子类赋值给父类的情况的话,这种情况会发生切片,需要找到从父类那继承到的东西然后切掉他,这时候就会用虚基表指针找到虚基表,而虚基表中存放的就是偏移量,用子类的位置+这个偏移量就得到了这个虚基类的位置,然后切掉它就可以了。
继承的总结
C++的语法复杂,多继承就是一个体现,有了多继承,就会有菱形继承,有了菱形继承就一定会有二义性和数据冗余的问题,为了解决这两个问题就要用虚继承,而虚继承的基层就会有虚基表偏移量这些东西,这会导致一定程度上的性能损失,所以可以认为多继承是C++的缺陷之一,一般不建议设计多继承。
在很多面向对象的编程语言中,已经舍弃掉了多进程这个东西。
继承和组合
- public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象
- 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象
- 优先使用对象组合,而不是类继承。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合