目录
前言
继承是C++面向对象的三大特性,它十分的重要同时也掺杂了以前面向对象的大部分知识。
一、继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
假如我们写一个学生管理系统,这个系统中主要有三类人,学生,老师,后勤
他们每一类人又具有各自的属性,这些属性有些是他们三类共有的,也有一些是他们私有的
如他们每个人都有:姓名,年龄,性别。学生又私有宿舍号,班级,学号……
老师有 教学课程,职称…… 后勤有职能……
所以我们将他们共有的部分提取出来,也就是将他们的共有数据和方法都提取到一个类中,这个类叫做基类或者父类。继承体现了类设计定义层次的复用。
1、继承的基本形式
2、继承关系和访问限定符
C++的继承关系和访问限定符设计的十分复杂,它不但有三种类成员访问限定符,还有三种继承关系。
类成员
/
继承方式
|
public
继承
|
protected
继承
|
private
继承
|
基类的
public
成员
|
派生类的
public
成员
|
派生类的
protected
成员
|
派生类的
private
成员
|
基类的
protected
成员
|
派生类的
protected
成员
|
派生类的
protected
成员
|
派生类的
private
成员
|
基类的
private
成
员
|
在派生类中不可见
|
在派生类中不可见
|
在派生类中不可见
|
1、基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它
2、使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。3、 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
例:
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
Student继承了Person的_name,另一个子类Teacher也继承了Person
父子类的_name是不同的。
3、.继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。4. 注意在实际中在继承体系里面最好不要定义同名的成员。
class Person
{
public:
string _name;
};
class Student : public Person
{
public:
string _name;
};
如果我们在父类和子类中定义了同名函数或者是同名变量时,编译不会报错
这时我们通过调试观察一下Student类的内部
我们发现st对象的内部中具有两个_name,当我们不去指定作用域去访问_name时,默认会去访问Student类中的_name,这时继承Person类的_name就被隐藏了
成员函数构成隐藏的条件是,同名且不构成成员函数重写的函数
class A
{
public:
void fun()
{
cout << "fun()" << endl;
}
};
class B
{
public:
void fun(int i)
{
cout << "fun(int i)->" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
b.A::fun();
return 0;
}
这样就能调用B中原本的fun,以及继承自A中的fun函数
我们要调用父类的同名函数就需要显示指定函数作用域
继承可以使用现有类的所有功能,并且无需重新编写原来类的情况下对这些功能进行扩展
继承体系中子类必须要体现出与基类的不同
子类对象可能会比基类对象大,也可能大小不变
继承体现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程
二、
基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。基类对象不能赋值给派生类对象。基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
我们可以想象一下,对于同类指针,引用和赋值没有问题
子类对象可以赋值给父类对象/指针/引用
它既不是隐式类型转换,也不是强制类型转换,它是一种特殊的转换
我们可以通过以下代码来验证
class Person
{
public:
void show(int i)
{
cout << _name << endl;
cout << _age << endl;
}
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
void show()
{
cout << _name << endl;
cout << _age << endl;
cout << _id << endl;
}
protected:
int _id;
};
void test()
{
Student sobj;
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
//可能会出现越界
Student* sp = (Student*) & pobj;
sp->show();
}
以上代码能够正常编译通过,根据我们面向对象的知识,如果是隐式类型转换我们下面的引用会报错,因为隐式类型转换会产生临时对象,临时对象具有常性,我们的引用要加const
强制类型转换就更不可能了,我们根本没有显示转换类型
下面我们强制类型转换的代码可能会出现越界,因为,子类一般会比父类大。
我们再回到切片问题上,我们在上面定义的两个指针,他都是指向同一个位置,只是他们看到的大小是不同的,所谓的切片就是将子类多余的成员变量切掉。
总结:
1、子类对象可以赋值给父类对象/指针/引用
2、基类对象不能赋值给派生类对象
3、基类的指针可以通过强制类型转换给派生类的指针
4、最后强制类型转换虽然可以编译通过,但是会存在越界访问的问题
我们先不考虑虚继承,计算子类的大小的计算方式
既然是类,那么它也会有内存对齐的问题,然后还有考虑父类的内存对齐
三、派生类的默认成员函数
在前面学习类和对象的时候,我们知道,一个类具有6个默认成员函数,默认成员函数对于父类来说跟以前一样。
1、子类编译器默认生成的构造函数
class A
{
public:
A()
{
cout << "A()" << endl;
}
A(const A& a)
:_a(a._a)
{
cout << "A(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=" << endl;
return *this;
}
protected:
int _a;
};
class B : public A
{
public:
protected:
int _b;
};
void test()
{
B b;
B bb(b);
b = bb;
}
我们先将父类A的所有默认生成函数中打印证明调用该函数
我们先调用构造函数
发现它会调用A的默认构造函数
如果我们将构造函数显示的写出来
B(int b = 1)
:A()
{
_b = b;
cout << "B()" << endl;
}
发现它会先调用A的构造函数,然后调用B的构造函数
总结:
1、自己成员,跟类和对象一样。内置类型不处理,自定义类型调用它的默认构造
2、继承父类成员,必须调用父类的构造函数初始化
编译器默认生成的析构函数
1、自己的成员 内置类型不处理,自定义类型调用它的析构函数
2、继承的成员,调用父类析构函数处理
子类是把父类的成员变量当成一个整体看待的,必须调用父类的构造函数
2、子类编译器默认生成的析构函数
析构函数对于子类来说有一点奇特
子类的析构函数跟父类的析构函数构成隐藏
听到这句话,可能很多人都会感到奇怪,为什么子类的析构会和父类的析构构成隐藏?
这是因为后面多态的需要,析构函数名字会统一处理成destructor,这样就会出现隐藏
然后我们显式调用析构函数
然后我们就会出现疑问 A的析构函数比B的析构函数调用次数多,且刚好是二倍
原因是:父类析构函数会被自动调用,子类析构函数后面会自动调用父类析构函数,保证先析构子类,再析构父类,保证栈的后进先出。
因为我们调用析构函数的顺序是不确定的,我们可以先处理子类的再处理父类的,或者先调用父类,然后处理子类。这种情况是不确定的,因此为了应对这种情况,编译器自动调用析构函数,使父类的构造函数顺序与析构顺序刚好相反。
3、子类编译器默认生成的拷贝构造函数
1、自己的成员,跟类和对象一致(内置类型值拷贝,自定义类型调用它的拷贝构造)
2、继承的父类成员,必须调用父类的拷贝构造初始化
4、子类编译器默认生成的=操作符函数重载
同上
5、子类编译器默认生成的取地址符函数重载
这个函数就不区分父类还是子类了,取地址是哪个类型的对象就取出哪个对象类型的地址
四、继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
下面给出正确写法
class B;
class A
{
public:
friend void Display(const A& a, const B& b);
A()
{
cout << "A()" << endl;
}
A(const A& a)
:_a(a._a)
{
cout << "A(const A& a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
A& operator=(const A& a)
{
cout << "A& operator=" << endl;
return *this;
}
protected:
int _a;
};
class B : public A
{
public:
friend void Display(const A& a, const B& b);
B(int b = 1)
:A()
{
_b = b;
cout << "B()" << endl;
}
B(const B& b)
:A(b)
{
_b = b._b;
cout << "B(cosnt B& b)" << endl;
}
B& operator=(const B& b)
{
A::operator=(b);
_b = b._b;
cout << "B& operator=" << endl;
return *this;
}
~B()
{
//A::~A();
cout << "~B()" << endl;
}
protected:
int _b;
};
void Display(const A& a, const B& b)
{
cout << a._a << endl;
cout << b._b << endl;
}
在父类与子类中必须都要声明该函数为友元,才能正确调用该函数,同时要注意,可能要在父类的前面声明子类,如果不这样可能会出现编译错误。
五、继承与静态成员
对于非静态成员,父类的变量和方法会拷贝一份给子类,父类和子类的成员变量地址是不同的,对于静态成员,父类和子类中都是同一个变量。
我们可以通过下面代码测试
class A
{
protected:
int _a;
public:
static int _s;
};
int A::_s = 0;
class C
{
protected:
int _c;
};
class B : public A, public C
{
protected:
int _b;
};
void test()
{
A aa;
B bb;
aa._s++;
cout << bb._s << endl;
cout << &bb._s << endl;
cout << &aa._s << endl;
}
_s是A中的静态变量,我们可以观察_s中的值与_s的地址
我们在开始时将_s初始化为0,然后让它自增,然后打印父类中的_s和子类中的_s的地址
发现他们的地址是相同。
六、如何创建一个不能被继承的类
1、将类的构造函数私有化
class A
{
private:
A()
{}
};
class B : public A
{
};
这种情况在编译时不会发生报错,但是会在运行时报错
2、使用final关键字
final是C++11新增的关键字,它声明之后如果使用该类继承就会在编译时报错
class A final
{
};
class B : public A
{
};
七、复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
菱形继承:菱形继承是多继承的一种特殊情况。
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 test7()
{
Assistant a;
a._name = "张三";
cout << a._name << endl;
}
这样会出现二义性问题,因为Assistant类中会有两个_name,如果这样会出现二义性,编译器无法明确知道访问哪一个。
这不是隐藏的问题(隐藏是父类和子类的问题),这是两个父类之间的问题
如果我们显式的指定访问哪个父类的成员可以解决二义性问题,但是数据冗余的问题无法解决
这时就引出了新的关键字virtual,virtual关键字是用来解决菱形继承的问题的
不过菱形继承十分的复杂
我们可以在teacher和student类的继承时加上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.Student::_name = "张三";
cout << a._name << endl;
}
这就解决了二义性问题
不过这种处理会导致对象模型十分的复杂
我们举一个简单的例子来说明
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;
};
void func(B* ptr)
{
cout << ptr->_a << endl;
}
void test8()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
cout << sizeof(d) << endl;
func(&d);
}
我们定义了菱形虚拟继承,然后我们通过调试来观察现象
我们打开监视和内存窗口,并且&d,同时改变d中的不同成员变量的值
我们发现在变量的间隙中还插入着不同的值,他们是一个指针保存的是一个地址
同时将公共的_a放到了对象的后面
我们再打开一个内存窗口查看指针所指向的内容
我们发现它里面存的是14,它是16进制的数字,我们可以将它换成10进制是20
这个其实是公共变量_a的偏移量或者说叫_a的距离
我们再切回内存窗口1
_a的地址是0x0090F608,对象起始地址是0x0090F5F4,我们将这两个16进制数字相减
发现他们的差值正好是20
这说明虚拟继承的对象模型发生改变,内部存有一个指针,它指向被虚继承的成员的地址的偏移量的地址。虚继承最大的问题就是多了一层偏移量的计算
他就好比deque的[ ]访问一样,需要计算偏移量。这也会导致一定的性能损失
这时又有一个问题,d对象的大小是多少?
答案是24
我们可以先看B类的大小
一个继承自A的_a和它本身的变量_b以及指向_a偏移量的指针,在32位系统下sizeof(B) = 12
同理得sizeof(C) = 12,然后关键点来了,sizeof(D) = 12 + 12 + 4 ?
答案是不是,因为是虚拟菱形继承,它只要在D中有一份_a就可以了,所以在D中也会有一个指针,指向_a所以正确做法是sizeof(D) = 12 - 4 + 12 - 4 + 4 + 4 = 24
前两个-4是去掉B,C类中的指向偏移量的指针,在+4是_d,最后一个+4是加上指向偏移量的指针
。
这个是菱形继承吗?
答案是:是的
那么virtual关键字放在哪里呢?(去掉全部加virtual的情况)
答案是在B和D上加上virtual关键字
D就没什么可说的,主要是在B上加还是在C上加
如果在C中加virtual,表中存放的是B类的成员偏移量,而不是A的,B中包含A,导致冗余
所以要在B上加入。
七、继承总结
多继承算是C++中较为复杂的语法了,有了多继承,就会有菱形继承,有了菱形继承,就会有菱形虚拟继承。
继承与组合