目录
继承的概念及定义
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
namespace mzt
{
//将一致的信息单独提取出来,提高程序的复用性
class Person
{
public:
string _name;
int _age;
string _addrss;
string _phone;
};
//子类Teacher 继承父类 Person
class Teacher : public Person
{
private:
int _Jobid;
};
//子类Student 继承父类 Person
class Student : public Person
{
private:
int _stuid;
};
void func()
{
Teacher tea;
Student stu;
}
}
假如现在我们需要设计一个类,学生类和老师类,而他们的工作是不一样的所以工号和学号会不一样,但是他们的基本信息比如:名字、年龄、住址、地址就是会包含的,所以我们可以将这一部门信息提取出来单独放到一个类中,在通过子类继承父类的方式,父类的成员(成员函数 + 成员变量)就都会变成子类的一部分这样子会提高代码的复用性
通过监视窗口我们可以看出teacher类和student类都继承了person类,除了拥有自己本身的成员,并且也都拥有了基类的成员
继承定义
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类
继承关系和访问限定符
继承基类成员访问方式的变化
-
基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
-
基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
-
实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),
public > protected> private。 -
使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
-
在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用
protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
举例拷贝
void func()
{
Person per;
Student stu;
stu._name = "小明";
//_name是一个string类对象编译器会做优化会直接调用构造函数
stu._age = 18;
per = stu; //ok,会发生切片的动作
stu = per;// 不能将父类赋值给子类
}
发生切片动作,将派生类中从基类继承的那一部分赋值过去
举例指针
Person* ptr = &stu; //切片
指针在这里也是一样的虽然他指向了这个对象的地址,但是并不能访问派生类对象的所有成员
举例引用
Person& ref = stu; //切片
引用的底层是类似于指针这样的,所以即使引用子类对象,还是不能访问子类的所有成员
继承中的作用域
1、 在继承体系中基类和派生类都有独立的作用域。
2、 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定 义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
namespace mzt
{
//将重复的信息单独提取出来,提高程序的复用性
class Person
{
public:
Person()
:_age(0)
{
}
void print()
{
cout << "调用了父类的成员函数" << endl;
}
void print1()
{
cout << "你哈珀" << endl;
}
string _name;
int _age;
string _addrss;
string _phone;
};
class Student : public Person
{
public:
Student()
:_stuid(0)
{
_name = "小明";
}
void print()
{
cout << "调用了子类的成员函数" << endl;
}
private:
int _stuid;
};
void func()
{
Student stu;
stu.print();
stu.print1();
}
}
派生类继承基类的成员后,子类和父类中有同名函数,子类会去自己调用自己的成员函数,如果子类没有该成员函数就会去调用父类的成员函数
通过指定作用域访问父类的成员函数
void func()
{
Student stu;
stu.print();
stu.print1();
//通过指定作用域访问父类的成员函数
stu.Person::print();
}
效果:
3、 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
namespace mzt
{
//将重复的信息单独提取出来,提高程序的复用性
class Person
{
public:
Person()
:_val(0)
{
}
void print()
{
cout << _val << endl;
}
int _val = 10;
};
class Student : public Person
{
public:
Student()
:_stuid(0)
{
}
void print(int val)
{
cout << val << endl;
}
private:
int _stuid;
};
void func()
{
Student stu;
stu.print(1);//正常运行
stu.print(); //报错
//通过指定作用域是可以的,编译器会去调用父类的成员函数
stu.Person::print();
}
}
如果父类和子类都拥有同名函数,则父类的成员函数会被隐藏
4、 注意在实际中在继承体系里面最好不要定义同名的成员。
派生类的默认成员函数
派生类对象初始化先调用基类构造再调派生类构造。
派生类对象析构清理先调用派生类析构再调基类的析构
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
class Person
{
public:
int _age;
const char* _str;
protected:
string _name;
};
class Student : public Person
{
public:
private:
int _stuid;
};
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
//父类构造
Person(const char* str = "小明", int age = 18)
:_name(str)
,_age(age)
,_str(str) //这里直接初始化会存在程序崩溃的问题具体细节析构函数详解
{
/*_str = new char[10]; memcpy(_str,str,sizeof(char) * 10);*/
}
//子类构造
Student(const char* str)
:Person(str) //调用父类的构造函数初始化子类从父类中继承下来的那一部分成员
,_stuid(2001) //子类成员直接初始化
{
}
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
//父类拷贝构造
Person(const Person& per)
:_age(per._age)
,_name(per._name)
{
}
//派生类拷贝构造
Student(const Student& stu)
:Person(stu) //调用父类的构造函数初始化父类的一部分,中间是一个切片动作
{
_stuid = stu._stuid;
}
派生类的operator=必须要调用基类的operator=完成基类的复制。
//父类operator=()
Person& operator=(const Person& per)
{
if (&per != this)
{
_age = per._age;
_name = per._name;
}
return *this;
}
//派生类operator=()
Student& operator=(const Student& stu)
{
if (this != &stu)
{
Person::operator=(stu);
//调用父类的operator= 初始化父类的成员,
//父类引用子类对象会有一个切片的动作
//这里必须指定作用域去调用父类的operator=
//因为在继承中同名函数构成隐藏,默认会去调用本类的成员函数
}
return *this;
}
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
//父类的析构函数
~Person()
{
delete _str;
_str = NULL;
cout << "~Person()" << endl;
}
//子类的析构函数
~Student()
{
//显示指定调用析构函数会有问题,
//因为父类的析构函数会被调用两次不可取存在程序崩溃的问题
//Person::~Person();
cout << "~Student()" << endl;
}
当我们把整体的代码写好后,再来看看析构函数
namespace mzt
{
class Person
{
public:
int _age;
const char* _str;
Person(const char* str = "小明", int age = 18)
:_name(str)
,_age(age)
,_str(str)
{
/*_str = new char[10]; memcpy(_str,str,sizeof(char) * 10);*/
}
Person(const Person& per)
:_age(per._age)
,_name(per._name)
{
}
Person& operator=(const Person& per)
{
if (&per != this)
{
_age = per._age;
_name = per._name;
}
return *this;
}
~Person()
{
delete _str;
_str = NULL;
cout << "~Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const char* str)
:Person(str) //调用父类的构造函数初始化子类从父类中继承下来的那一部分成员
,_stuid(2001) //子类成员直接初始化
{
}
Student(const Student& stu)
:Person(stu) //调用父类的构造函数初始化父类的一部分,中间是一个切片动作
{
_stuid = stu._stuid;
}
Student& operator=(const Student& stu)
{
if (this != &stu)
{
Person::operator=(stu);
//调用父类的operator= 初始化父类的成员,
//父类引用子类对象会有一个切片的动作
//这里必须指定作用域去调用父类的operator=
//因为在继承中同名函数构成隐藏,默认会去调用本类的成员函数
}
return *this;
}
~Student()
{
//显示指定调用析构函数会有问题,
//因为父类的析构函数会被调用两次不可取存在程序崩溃的问题
//Person::~Person();
cout << "~Student()" << endl;
}
private:
int _stuid;
};
void func()
{
Student stu("小红");
//拷贝构造
//Student stu1(stu);
赋值,operator=
//Student s1("张三");
//stu1 = s1;
}
}
实际上这个代码会存在程序崩溃的问题,具体的原因是因为在调用析构函数的时候同一块空间被析构了两次
明明并没有在子类析构函数中指定调用父类的析构函数,问题的主要原因是出在父类的构造函数和对象销毁时析构函数执行的时机
class Person
{
int _age;
char* _str; //这里将const修饰改成不叫const
Person(const char* str = "小明", int age = 18)
:_name(str)
,_age(age)
{
//让_str指针指向一个new出来的内存空间,在析构的时候就不会影响
_str = new char[10];
memcpy(_str,str,sizeof(char) * 10);
}
protected:
string _name;
};
对比之前代码
//父类的析构函数
~Person()
{
delete _str;
_str = NULL;
cout << "~Person()" << endl;
}
class Person
{
public:
int _age;
const char* _str;
Person(const char* str = "小明", int age = 18)
:_name(str)
,_age(age)
,_str(str)
//_name和_str指向的是同一块空间都是str,如果是字符串的话并
//不会为两个指针开辟不同的空间,而是让他们指向同一块空间
//在析构的时候子类会被先析构,在调用父类的析构函数,
//这样子类成员_name指向的str已经被delete了,
//父类对象再去析构的时候就会引发程序崩溃
{
/*_str = new char[10]; memcpy(_str,str,sizeof(char) * 10);*/
}
protected:
string _name;
};
修改完程序后我们知道,对象的创建和析构时符合栈的特性的,后进先出,父类先构造,再构造子类,析构的时候是先析构子类,再来析构父类
不想做父类的类
如果想要一个父类不能让子类去继承他,可以把他的构造函数私有化,这样就不能生成对象了
class Person
{
private:
Person()
{
}
//成员变量
};
继承与友元
友元关系不能继承,友元的是能通过在类的内部定义声明一份友元关系就可以无限制的访问该类的成员,但是即使基类中声明了友元关系,但是不代表子类也就必须遵守这份友元关系,也就是说基类友元不能访问子类私有和保护成员
class 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; // 学号
};
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成员实例 。
//继承静态成员
//假设我们设计一个学生类,这个学生是一个人,
//那么就可以把人的信息单独提取出来,而学生的特性是拥有学号
//学生为了毕业会设计毕业项目,而毕业后的学生又是一个单独的类,把这部分信息提取出来
//基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,
//都只有一个static成员实例 。
namespace mzt02
{
class Person
{
public:
Person()
{
_count++;
}
int _age;//年龄
string _name;//姓名
int height;//身高
static int _count;
int Getcout() {
return _count; }
};
class Student : public Person
{
public:
int _stuid; //学号
};
class Graduate :public Person
{
public:
string _Graduatedome;
};
//假设我们需要统计一共创建出了多少个对象
void func()
{
Person per;
Student stu;
Graduate gra;
cout << "一共产生:" << per.Getcout()<< "对象";
}
int Person::_count = 0; //类外初始化静态成员变量
}
通过测试结果我们发现确实,如果父类中拥有了一份static成员,那么无论有多少个子类去继承这个对象,这份static成员只会保留一份
复杂的菱形继承及菱形虚拟继承
单继承概念:一个子类只有一个直接父类时称这个继承关系为单继承
多继承概念:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant
的对象中Person成员会有两份。下面通过监视窗口来观察
代码:
//菱形继承
namespace mzt03
{
class Person
{
public:
string _name;//姓名
};
class Student : public Person
{
public:
int _stuid;//学生号
};
class Tercher : public Person
{
public:
int _terid; //老师工号
};
//假设助教是一个学生,但是又需要干一些老师的工作
class Assistant : public Student , public Person //多继承也要指定继承方式
{
public:
string _course;
};
}
通过窗口可以看到子类有两个直接父类Student和Teacher这两个类,而Student和Teacher又都继承了Person,所以Assistant类对象的成员就会有很多冗余
使用的时候二义性问题
void func()
{
Assistant a;
a._name = "张三"; //二义性
}
导致编译器不知道该使用哪一个成员
解决二义性的问题
void func()
{
Assistant a;
//a._name = "张三"; //二义性
//指定作用域可以结果二义性的问题,但是不能解决数据冗余
a.Person::_name = "张三";
a.Student::_name = "李四";
}
通过指定作用域指明了赋值给谁的问题,编译器能够清晰的认识
数据冗余的解决方案:通过虚继承的方式
//添加virtual 关键字
class Student : virtual public Person
{
public:
int _stuid;//学生号
};
class Tercher : virtual public Person
{
public:
int _terid; //老师工号
};
void func()
{
Assistant a;
//a._name = "张三"; //二义性
//使用virtual关键字既解决了冗余的问题也解决了二义性的问题
a._name = "李四";
a._name = "张三";
}
在继承的过程中只会保留相同一份的成员,而不会无脑的全部继承下来
C++编译器是如何通过虚继承来解决数据冗余和二义性的问题?
1、通过监视窗口已经看不到真实的存在,因为监视窗口被编译器处理过
2、建议使用内存窗口来进行观察
测试代码:
//菱形继承对象模型
namespace mzt04
{
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;
};
void func()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
}
通过菱形继承得到的C++对象模型
从内存监视窗口中可以看出确实是有数据的冗余,虽然_a在不同的作用域,但确实是产生了两份相同的数据,因为类B和类C中都继承了类A,里面都会有一个_a成员
接下来我们在来看以虚继承的方式得到的C++对象模型
//虚继承的对象模型
namespace mzt04
{
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;
};
void func()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
}
解决了数据冗余,在赋值的过程中会发现都是改变类A成员_a的值,第二次赋值会将原本的值给覆盖
使用虚继承的方式通过内存窗口看到的对象模型
子类通过虚继承的方式去继承父类,那么这个父类被成为虚基类,通过对象模型可以看到的是这个虚基类的成员_a总是在最底下的,再看B类的成员部分和C类的成员部分会多一个值,
8c de 74 00,94 de 74 00,其实这是一个虚基表的地址,再通过内存窗口观察,博主是处在32位平台观察的,不同的平台观察的现象也是不一样的
而如果发生了以下的切片行为,就要通过虚基表中偏移量找到公共虚基类A成员,最后将类A的成员赋值过去
D d;
B b = d;
C c = d;
虚继承是为了弥补多继承导致的菱形继承问题,虽然解决了菱形继承的问题,但是也使对象模型变得极为复杂,学习成本会很高,并且也会有一定的效率影响,因为每次想要去访问类A中的成员都需要通过对指针解引用得到距离公共虚基类的偏移量位置,再来计算公共类A成员所处的位置
继承的总结和反思
1、其实多继承就是C++的一个语法复杂的体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以不要设计出菱形继承,多继承还好,否则在复杂度及性能上都有问题。
2、. 多继承可以认为是C++的缺陷之一
继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象,这是一种强关联关系
//比如现实生活中,花和树都是植物,那么植物就会有根也会与枝叶,将这部分
//相同的属性给提取出来单独封装一个类,而花类和树类都能通过public的方式
//继承植物类,拥有他的属性,这样就提高了类的复用性
class botany
{
protected:
string branches;
stirng root
};
class flower : public botany
{
private:
};
class tree : public botany
{
private:
};
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象,这是一种弱关联关系
class flower
{
private:
};
class tree
{
private:
};
//花园里面有花也有树
class garden
{
private:
flower f;
tree t;
};
1、继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。
继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
2、对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
3、实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
总结:
有了多继承就一定会存在菱形继承,菱形继承会伴随着数据冗余和二义性的问题,为了解决数据冗余和二义性的问题,就引入了虚继承,虚继承又会使对象模型变得复杂
文章评论