C++继承机制详解(单继承,多继承,菱形继承,虚继承)
文章目录
继承概念继承的定义继承的语法格式继承方式
继承方式的汇总赋值兼容规则基类对象和派生类对象的赋值转换
继承中的作用域派生类的默认成员函数继承与友元继承与静态成员菱形继承虚继承虚继承语法格式虚继承的原理
继承的总结继承和组合
二次修订于date:2024:3:22 C++作为一门面向对象的语言,具有面向对象的三大特性,封装,继承,多态(inheritance, encapsulation, polymorphism),那么下面就是关于继承的详解。
继承概念
继承的定义
继承的核心是代码的复用,继承机制就是面向对象语言对于类保持原来的结构,并对其进行扩展的重要手段。在继承里有这样两个类,基类(父类),派生类(子类)。
继承就是子类继承父类,子类的结构相当于是在父类的结构上面的一个扩展。 继承是类在设计层次的复用。
继承的语法格式
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
//person是这里的父类,student和teacher都是这里的子类
//子类继承父类之后子类的结构内会有父类的可见的成员变量和成员函数
//因此在子类对象里面也可以访问到Print,同样也可以访问到从父类继承下来的成员变量
//这里是成员函数的复用和成员变量的复用
class Student : public Person
{
protected:
string _stuid = "202111050974"; // 学号
};
class Teacher : public Person
{
protected:
string _jobid = "202122090987"; // 工号
};
int main()
{
Student s;
Teacher t;
s.Print();
t.Print();
return 0;
}
这是继承语法的基本格式。派生类名后面加一个冒号( :)继承方式,加上继承基类的名称。
继承方式
继承方式有三种,就是与访问限定符的那三个是一样的 继承方式是可以省略的,派生类是class默认private继承,派生类是struct默认public继承。
继承方式的汇总
三种不同继承方式决定了继承后父类的成员函数与成员变量在子类中属于什么类型的成员,到底是public还是protected亦或者private下面这个表格详细给出。
类成员/继承方式public继承protected继承private继承public成员子类的public成员子类的protected成员子类的private成员protected成员子类的protected成员子类的protected成员子类的private成员private成员在子类中不可见在子类中不可见在子类中不可见
1.比较特殊的就是private成员被继承之后,这些成员在子类中存在(已经继承下来了)但是在子类中是不可见的,不可见就是在子类中访问不到父类的private成员,只有父类自己这个类里面可以访问到自己的private成员。 2.继承可以总结一个规律就是权限的缩小。public > protected > private。继承到子类里的成员属于什么类型取决于继承方式和这些成员以前在父类里面是什么类型。最终由两种类型的权限小的那个决定。
3.如果想要基类的成员在类外访问不到,但是在继承关系的子类中可以访问到,那么就将父类的这个成员设置成protected类型。可以看出protected限定符是因继承才出现的。
**4.**省略继承方式,派生类是class默认private继承,struct默认public继承。 5.实际中public继承使用最多。protected和private继承很少用,也不提倡使用。因为继承下来的对象只能在类内访问,也增加了维护的复杂度。
注: 父类成员大多定义为:public成员和protected成员 子类常用的继承方式:public继承
赋值兼容规则
必须是public继承才可以使用
基类对象和派生类对象的赋值转换
赋值规则: 1.派生类对象可以赋值给基类对象,基类指针,基类引用 2.基类对象不可以赋值给派生类对象(强转也不行) 3.基类对象的指针,引用可以通过强制类型转换赋值给派生类的指针变量和引用,但是存在安全问题(越界访问)。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(多态的时候详细介绍)
派生类赋值给基类,叫做赋值转换,也叫做切片、切割
其实为什么派生类对象可以赋值给基类对象很好理解,因为派生类不仅有从父类继承下来的成员,还具有自己的成员。因此赋值给父类的时候只需要舍弃子类的成员即可。因此也叫做切割或者切片。如下图
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void Print()
{
cout << "student_id:"<<_stuid << endl;
}
protected:
string _stuid = "202111050974"; // 学号
};
class Teacher : public Person
{
protected:
string _jobid = "202122090987"; // 工号
};
int main()
{
Student s;
Teacher t;
Person p;
//s = (Student)p;//父类赋值给子类对象是不可的。
//子类对象赋值给父类对象,指针,引用完全没有问题。
p = s;
p.Print();
Person& ref = s;
ref.Print();
Person* ptr = &s;
ptr->Print();
//////////////////////////////////
Student* ps1 = (Student*)&p;
//ps1->Print();//这里访问的是Student里面的Print造成了越界导致程序崩溃。
//引用经过强转之后也是可以从父类赋值给子类
Student& rs1 = (Student&)p;
return 0;
}
子类赋值给父类的时候完全没有问题,不发生类型转换。相当于将子类中父类的那一部分切割出来赋值给父类对象。
不发生类型转换就代表中间没有临时变量产生。
父类对象直接赋值给子类时会报错。只有父类的指针或父类的引用经过强制类型转换之后才可以赋值给子类的指针和引用。但是容易发生越界访问。 如果是父类的private成员,子类public继承之后,虽然private成员不可见,但是可以切片赋值父类,赋值后父类这个对象的private成员依然是有效的。
继承中的作用域
1.父类和子类都有各自独立的作用域 2.父类的成员和子类的成员同名的时候,子类的成员会屏蔽父类成员的直接访问,这种情况叫做隐藏或者重定义,如果要访问父类被隐藏的成员需要 基类::基类成员名 显示访问(限定作用域) 3.父类和子类中只要是同名的函数就构成隐藏,不构成重载,因为重载必须是两个函数在同一个作用域,并且参数的类型或者是个数不同,父类和子类是两个不同且独立的作用域,所以不构成重载 4.尽量不要在继承这里定义同名的成员。 5.待了解了多态后我们可以知道,基类和派生类中同名的函数,不是构成隐藏就是构成多态。
class Person
{
public:
int test = 1;
};
class Student : public Person
{
public:
int test = 2;
};
class Teacher : public Person
{
public:
int test = 3;
};
int main()
{
Person p;
Student stu;
Teacher tea;
cout << p.test << endl;
cout << stu.test << endl;
cout << tea.test << endl;
return 0;
}
这里定义了三个同名的变量,子类的test和父类的test构成隐藏,所以直接访问的时候只能访问到自己这个类的test,打印结果是 1 2 3 想要访问父类里面的test只能显式的指定如下图(限定作用域)
派生类的默认成员函数
什么是默认成员函数,就是不写编译器会自动生成 默认构造函数是不用传参就可以调用的构造函数
派生类默认成员函数的规则是: 1.编译器自动生成的构造函数和析构函数:对于父类会先调用父类的默认构造函数,对于子类不处理。析构函数会自动调用父类的析构函数 2.operator=函数也是一样的,对父类部分会调用父类的operator=完成父类成员的赋值,对于子类成员默认使用值拷贝,也就是浅拷贝。 3.拷贝构造函数对于父类的成员调用父类的拷贝构造,对于子类成员,是浅拷贝,值拷贝。 **总结:**继承下来的成员编译器自动调用父类的默认成员函数处理,子类的成员按照普通类的基本规则
两个问题:
什么时候需要自己写成员函数?子类的成员函数如何写?
先看代码:
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
Person(const Person& x)
{
cout << "Person(const Person& x)" << endl;
}
Person& operator=(const Person& x)
{
cout << "Person& operator=(const Person& x)" << endl;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
int test = 1;
};
class Student : public Person
{
public:
int test = 2;
};
class Teacher : public Person
{
public:
int test = 3;
};
int main()
{
Person p;
Student stu1;
Teacher tea1;
Student stu2(stu1);
Teacher tea2;
tea2 = tea1;
return 0;
}
运行结果: 编译器默认生成的子类的默认成员函数会自动调用父类的默认成员函数
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
Person(const Person& x)
{
cout << "Person(const Person& x)" << endl;
}
Person& operator=(const Person& x)
{
cout << "Person& operator=(const Person& x)" << endl;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
int test = 1;
};
class Student : public Person
{
public:
Student()
:Person()
{
cout << "Student()" << endl;
}
//显示调用父类拷贝构造,传子类对象,切片
Student(const Student& st)
:Person(st)
{
cout << "Student(const Student& st)" << endl;
}
Student& operator=(const Student& st)
{
Person::operator=(st);
cout << "Student& operator=(const Student& st)" << endl;
return *this;
}
//注意析构代码是错误的(这里是有坑的,注意下面的解释)
~Student()
{
Person::~Person();
cout<<"~Student()"< } int test = 2; }; int main() { Student stu1; Student stu2(stu1); Student stu3; stu3 = stu1; return 0; } 在构造和拷贝构造函数中需要先在初始化列表里面调用父类的构造或拷贝构造。调用父类拷贝构造可以直接传子类对象,这就是前面说到的赋值转换。 对于operator=这种函数没有初始化列表,就在函数体内调用,因为父类和子类的赋值运算符重载的这个函数名是一样的。所以构成了隐藏,子类的会隐藏掉父类的。因此要指定作用域显式调用父类的operator= 为什么说析构函数哪里是错误的呢? 运行结果: 这里三个对象却调用了六次基类的析构,现在这个函数没有崩溃是因为成员里面没有资源需要清理,一旦有资源需要清理,这段代码就会因为对同一块空间释放多次造成程序崩溃。 那么这里为什么会调用六次基类的析构函数呢? 子类构造函数调用的时候会在初始化列表内先构造出父类的成员,然后构造出子类的成员,因为系统的栈是后进先出的。所以在析构的时候就需要子类的成员先析构,因此编译器在这里做了处理,在子类析构函数结束的时候会自动调用父类的析构函数,保证栈的先进先出。 如图所示结构,析构的时候需要先析构子类成员 ~Student() { cout << "~Student()" << endl; } 这才是析构的正确代码,在子类析构内,不需要主动调用父类的析构。 其实上面的析构代码还表现出一个问题 ~Student() { Person::~Person(); cout<<"~Student()"< } 父子类析构函数,函数名是不同的,明明不构成隐藏为什么调用父类的析构还需要显式指定作用域呢?并且不指定就会报错。 答案是:在编译阶段,析构函数的名字会被统一替换成destructor( )(ps:多态领域的知识)因此,子类和父类的析构函数就构成隐藏了。 继承与友元 友元关系不能继承。父类的友元函数或者友元类与子类没有任何关系。 继承与静态成员 静态成员是可以继承的,子类和父类共享的是同一个静态成员。 class Person { public: Person() { ++k; } int test = 1; static int k; }; int Person::k = 0; class Student : public Person { public: int test = 2; }; class Teacher : public Person { public: int test = 3; }; int main() { Student stu1; cout << Student::k << endl; Student stu2; cout << Student::k << endl; Person p; cout << Person::k << endl; return 0; } 运行结果显示子类对象和父类对象都是共用同一个静态成员。 菱形继承 菱形继承是多继承里面的一种特殊情况。 上面的代码使用的都是单继承。就是子类只有一个直接继承的父类。 **多继承:**一个子类有两个及以上的直接继承的父类 菱形继承就是三层继承,一个类继承的两个直接父类,这两个直接的父类又同时继承了一个父类。如下图 这个就是菱形继承。 菱形继承有一个问题,就是最下面的派生类,相当于拥有两份最上面的基类的成员。 这就引出了菱形继承的两大问题:数据冗余和二义性 数据冗余就是Assistant类同时拥有两份Person类的成员 二义性就是在Assistant类的对象访问继承下来的Person类的成员时出现访问不明确问题,比如Person类里面的_name此时编译器就会不知道是访问Teacher里面的_name还是访问Student里面的_name 这就是二义性。 代码演示: 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 a1; //这里会报错 cout << a1._name << endl; return 0; } 这就是二义性的问题。可以同过显式指定作用域来解决,如下 int main() { Assistant a1; cout << a1.Student::_name << endl; cout << a1.Teacher::_name << endl; return 0; } 但是数据冗余问题需要特殊解决了。数据冗余不解决不行吗,反正就多了一个string成员,没有多大的空间浪费,在这段代码这里确实是这样的,但是万一基类有一个int arr[10000]的成员呢?这时候数据冗余就会狼粪很多空间。 虚继承 虚继承就是用来解决菱形继承里面的数据冗余和二义性的方案 虚继承语法格式 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; // 主修课程 }; 在中间这一层类的继承方式前面加上了一个virtual。这种继承方式就是虚继承 要注意必须是直接继承基类的类这里加上virtual 还有一种情况 这种情况下,virtual也是添加在直接继承基类的这一层这里,主要是要明白那两个类被同时继承后会存在数据冗余和二义性。这里B和C同时直接继承了A。所以基类B和C或者他们的子类如果被同一个类继承就会出现数据冗余问题,解决问题就该在这个源头这里解决。 虚继承的原理 虚拟继承之后对象模型就不一样了。 虚继承是如何解决数据冗余和二义性呢? 主要是通过虚基表来解决 注意:虚继承是解决数据冗余和二义性的方案,虚继承也只能用在这里。 下面来看一下,没有使用虚继承的这段代码在内存中是如何存储的。 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; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; } 在这内存图中可以看到,d这个对象的内部分区,因为D类继承的时候先继承的是B类,所以B类先出现在低地址处,然后是C类成员。最下面才是D类对象自己的那一部分。可以看到B类和C类的分区里面都有一个A类的成员_a,造成了数据冗余,这就是没有虚继承的D类对象的内存分布。 下面代码和内存演示虚继承如何解决数据冗余和二义性 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; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; } 这里的B类分区和C类分区里面的第一个位置存放的是一个地址,这个地址指向的就是一个虚基表 虚基表里面的第二个数据存放的是从B分区的第一个位置到公共部分_a的地址偏移量。 当然虚基表的第一个位置的数值其实也是一个偏移量,这个是虚函数的偏移量,在多态领域会学到虚函数。这里不需要关注。 有了虚基表的偏移量,我们在d对象中的B部分或者C部分找_a就可以通过偏移量计算找到那个公共地址处的_a 那么B和C部分为什么要存一个虚基表地址保存偏移量去找_a呢?我们我是不是可以默认_a就在那个固定的位置,每次找a都在哪里呢?答案是不可以 如果是下面这种情况,我们必须要知道偏移量。 D d; B b = d; C c = d; D d1; B* ptr= & b; ptr->_a; ptr = &d1; ptr->_a; //B*类型的指针无法得知自己指向的时B类型对象还是D类型对象,所以不能知道_a的具体问题, //这时候就需要通过访问虚基表拿到偏移量然后通过基址+偏移量的方式访问_a; 就是切片,这时候我们需要将d里面的B分区和公共部分a切割给b对象,这时候就需要我们只需将b的那一部分切割过去,再通过偏移量计算a的位置,一同切割过去即可。 这是虚继承的内存图解。 继承的总结 继承可以说是C++语法复杂的一个方面,因为有了多继承,所以就会存在菱形继承,为了解决菱形继承又有了菱形虚拟继承。不仅底层实现复杂,对性能也有影响。所以一般不能设计出菱形继承。大多数情况都是使用单继承。像是java只有单继承。 继承和组合 开始的时候说过,继承就是一种代码的复用方式,而组合就是代码的另一种复用方式。 继承和组合之间是不同的复用方式 继承是is-a,所有的子类都是父类 组合是has-a的关系,子类里面有父类。 //轮胎 class Tire{ protected: string _brand = "Michelin"; // 品牌 size_t _size = 17; // 尺寸 }; class Car{ protected: string _colour = "白色"; // 颜色 string _num = "陕ABIT00"; // 车牌号 Tire _t; // 轮胎 }; 这里的这段代码就是has-a的关系。汽车里面包含了轮胎。 组合的语法形式就是在一个类的成员里面包含另一个类的对象。 一般来讲推荐使用组合不使用继承。但是还是要看情况 如果类之间的关系是is-a的关系就使用继承,has-a就使用组合。两种都可以优先使用组合。 组合优于继承的原因 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,**基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。 ** 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。若类之间的关系可以用继承也可以用组合,就用组合。 组合的耦合性低也是一种相对的说法,比如如果基类的成员都是公有成员,那么组合和继承就差不多了,修改成员都是会很大程度影响到派生类。 对于黑箱和白箱,在软件测试方面还有黑箱测试和白箱测试。 黑箱测试就是根据软件的功能依次进行测试,测试人员看不到内部代码的实现。 白箱测试是测试人员可以看到内部实现代码,可以根据代码实现有针对性的进行测试。找出代码的缺陷。一般来说白箱测试是比黑箱测试更加严格的。