【C++】面向对象编程的三大特性:深入解析继承机制
C++语法 | 相关知识点 | 可以通过点击 | 以下链接进行学习 | 一起加油! |
---|---|---|---|---|
命名空间 | 缺省参数与函数重载 | C++相关特性 | 类和对象-上篇 | 类和对象-中篇 |
类和对象-下篇 | 日期类 | C/C++内存管理 | 模板初阶 | String使用 |
String模拟实现 | Vector使用及其模拟实现 | List使用及其模拟实现 | 容器适配器Stack与Queue | Priority Queue与仿函数 |
模板进阶-模板特化 |
本文将深入解析面向对象编程的三大核心特性,特别是对继承机制的详细探讨。通过对这些概念的优化理解,帮助读者更好地掌握面向对象编程的精髓。
🌈个人主页:是店小二呀
🌈C语言专栏:C语言
🌈C++专栏: C++
🌈初阶数据结构专栏: 初阶数据结构
🌈高阶数据结构专栏: 高阶数据结构
🌈Linux专栏: Linux
🌈喜欢的诗句:无人扶我青云志 我自踏雪至山巅
文章目录
- 一、前文
- 二、继承
- 2.1 继承概念
- 2.2 继承关系和访问限定符
- 2.3 继承基类成员访问方式间变化
- 2.4 基类的private成员
- 2.4.1 公共接口间接使用基类的私有变量
- 2.5小结
- 2.6 基类和派生类对象赋值转换
- 2.6.1 派生类赋值基类
- 2.6.2 基类对象不能赋值给派生类对象(建立在public继承)
- 2.6.3 强制基类赋值派生类
- 2.7 继承中作用域
- 2.7.1 继承体系相关知识
- 2.7.2 继承体系的隐藏
- 2.7.2.1 成员变量隐藏
- 2.7.2.2 成员函数隐藏
- 2.8 派生类的默认成员函数
- 2.8.1 默认构造函数
- 2.8.2 拷贝构造
- 2.8.3 赋值运算符重载
- 2.8.4 析构函数(有坑)
- 2.8.5 小结
- 2.9 继承与友元
- 2.8 继承与静态成员
- 三、菱形继承及菱形虚拟继承
- 3.1 继承分类
- 3.2 菱形继承问题
- 3.3 虚继承(菱形虚拟继承)
- 3.3.1 虚继承概念
- 3.3.2 虚拟继承解决数据冗余和二义性的原理
- 3.3.3 多继承指针偏移特性
- 四、继承总结和反思
- 4.1 设计继承建议
- 4.2 继承和组合
- 4.3 低耦和与高内聚
- 五、笔试面试题
- 六、继承是子类拷贝一份父类数据吗?(重点)
一、前文
面向对象编程的三大特性:封装、继承和多态。
- 封装通过将数据和方法封装在对象中,提高了数据的安全性和代码的可维护性。
- 继承允许新类从现有类继承属性和方法,实现代码复用和扩展。
- 多态则通过统一的接口实现不同的行为,提高了代码的灵活性和扩展性。
封装:
- 数据和方法放到一起,把想给访问定义成公有,不想给你访问定义成私有和保护
- 一个类型放到另一个类型里面,通过typedef成员函数调整,封装另一个全新的类型相当于武器库
二、继承
2.1 继承概念
- 继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。
- 它允许一个类(称为子类或派生类)从另一个类(称为父类或基类)继承属性和方法
- 每个派生类对象都是一个特殊的基类对象,派生类在基类的基础上进行扩展,增加新功能
- 继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
- 以前我们接触的复用都是函数复用,继承是类设计层次的复用
using namespace std;class Person
{public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}private:string _name = "peter";//名字int _age = 18;//年龄
};class Student : public Person
{protected:int _stuid;//学号
};class Teacher : public Person
{protected:int jobid;//工号
};
int main()
{Student s;Teacher t;s.Print();t.Print();return 0;
}
从代码中可以看出Person是父类或基类,Student是子类或派生类
2.2 继承关系和访问限定符
2.3 继承基类成员访问方式间变化
【注意】:圈出来的是实践中最常见的设计方式,主要了解这部分。
2.4 基类的private成员
基类private成员在派生类中无论以什么方式继承都是不可见的,这里的不可见是指基类的私有成员还是被继承到了派生类中,但是语法上限制派生类对象不管在类里面还是在类外面都是不能去访问
class Person
{public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}protected:string _name = "peter";private:int _age = 18;
};class Student : public Person
{public:void func(){cout << _name << endl;cout << _age << endl;}protected:int _stuid;
};
按照上面的意思,Student继承了Person属性和方法,但是由于_age是属于私有成员是不可见且不能被访问。
2.4.1 公共接口间接使用基类的私有变量
class Student : public Person
{public:void func(){//间接的使用Print();}protected:int _stuid;
};
由于基类private成员在派生类中式不能被访问的,如果基类成员不想在类外直接被访问,但是需要在派生类中能访问。这样子可以将基类private成员定义为protected成员,可以看出来保护成员限定符是因继承才出现的
思考:
- 类可以使用关键字class或者struct,他们只是默认的继承方式不同,为什么不使用struct呢?
解释:
- struct默认继承方式和访问限定符都是公有的
- class默认继承方式和访问限定符都是私有的
- 最好函数显式写出继承方式,偷偷摸摸的不好!
2.5小结
- 基类的私有成员在子类都是不可见。基类的其他成员在访问方式(取最小的权限)== Min(成员在基类的访问限定符、继承方式)public > prrotected > private
- 在实际中一般常用的是public继承,而不是protected/private继承,也不提倡使用protected/private继承,因为protected/private继承下来的成员都是只能在派生类的类里面使用,实际中扩展维护性不强
2.6 基类和派生类对象赋值转换
2.6.1 派生类赋值基类
每个派生类对象都是一个特殊的基类对象,派生类在基类的基础上进行扩展,增加新功能
class Person
{protected:string _name;string _sex;int _age;
};class Student : public Person
{public:int _No; //学号
};
派生类对象可以赋值基类的对象/基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。(将派生类中父类那部分切来赋值过去)。
int main()
{Student sobj;//子类对象可以赋值给父类对象Person pobj = sobj;//子类对象可以赋值给父类指针Person* pp = &sobj;//子类对象可以赋值给父类引用Person& rp = sobj;
}
2.6.2 基类对象不能赋值给派生类对象(建立在public继承)
int main()
{Student sobj;Person pobj = sobj;Person* pp = &sobj;sobj = pobj;
}
基类是不能直接赋值给派生类,因为可能派生类包含了基类没有的额外属性和方式,而且基类是不具备这些属性和方式,对此不能直接赋值。
2.6.3 强制基类赋值派生类
基类的指针或者引用可用通过强制类型转化赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
int main()
{Student sobj;Person pobj = sobj;Person* pp = &sobj;1.0//pp存放这子类实例的地址,再进行强转pp = &sobj; // 这种情况转换时可以的。Student* ps1 = (Student*)pp; ps1->_No = 10;2.0//pp存放父类实例的地址pp = &pobj;// 这种情况转换时虽然可以,但是会存在越界访问的问题Student* ps2 = (Student*)pp; ps2->_No = 10;
}
注意:
以上提前需要满足是公有继承
切割/切片是赋值兼容,编译器进行特殊处理,不产生临时对象,将子拷贝给父。内置类型值拷贝,自定义类型调用拷贝构造。
2.7 继承中作用域
2.7.1 继承体系相关知识
在继承体系中基类和派生类都有独立的作用域
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的访问,这种情况叫隐藏(重定义)-在子类成员函数中,可以使用 基类::基类成员 显式访问
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构造隐藏
注意在实际中在继承体系里面最好不要定义同名的成员,很容易混洗
2.7.2 继承体系的隐藏
提起作用域,就会想到域起到名字隔离的作用,不同的域可以有同样的名字,但是同一个域不能有同一个名字(函数重载比较特殊,但是变量不可以)
- 全局/局部变量 - 影响生命周期
- 类/命名空间域 -不影响生命周期
2.7.2.1 成员变量隐藏
class Person
{
public:private:string _name = "xiaoming";
};
class Student : public Person
{
public:void Print(){cout << _name << endl;}
private:string _name = "zhangsan";
};
int main()
{Student st;st.Print();//张三return 0;
}
具体说明:
- 关于上述代码,Student继承Person,导致Student中有两个_name。由于继承不是将父类数据拷贝一份给子类,实际上Student只有一个 _name还有一个是父类的。
- 这里父类和子类 _name构成隐藏关系,虽然代码可以跑,但是容易混洗,在继承体系中尽量不要定义同名的成员。
- 这里会默认优先访问子类的 _name,如果想要访问父类中该成员,可以使用指定类域限定符。如果子类中没有该成员,将会去父类中查找
2.7.2.2 成员函数隐藏
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和A中的fun不是构成重载,因为不是在同一作用域。B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
2.8 派生类的默认成员函数
2.8.1 默认构造函数
按照继承体系,子类中成员可以分为三类:子类内置类型成员、子类自定义类型成员以及父类成员,父类成员可以看出一个自定义类型的整体,这三类成员都会走初始化列表。
//父类
Person(const char* name = "peter"):_name(name){cout << "Person()" << endl;}//子类
Student(const char* name, int num):Person(name),_num(num){cout << "Student(int num)" << endl;}
具体说明:
在初始化步骤中,内置类型不进行处理,自定义类型会调用构造构造,父类也会调用自身的拷贝构造。
需要注意:
初始化列表中是否显式初始化父类成员,不影响父类成员走初始化列表,这里父类成员将调用复用自身的构造函数,需要考虑自身参数匹配的问题。这边建议父类类中显式写个全缺省构造函数,类的调用父类构造函数初始化,自己初始化自己的变量,也可以避免参数对应不上,父类尽量都走初始化列表。
容易错误的地方:
- 不能单独对父类成员进行初始化,而是对父类这个整体,参数需要对应上。
- 在创建
Student
对象时,首先使用name
参数来构造Person
部分的对象。这个过程并不会产生匿名对象,而是直接在Student
对象中嵌入的Person
部分。
2.8.2 拷贝构造
具体说明:
- 切割/切片是赋值兼容,编译器进行特殊处理,不产生临时对象将子拷贝给父。内置类型值拷贝,自定义类型调用拷贝构造,父类复用父类的拷贝构造
2.8.3 赋值运算符重载
//父类
Person& operator=(const Person& p)
{cout << "Person& operator=(const Person& p)" << endl;if (&p != this) _name = p._name;return *this;
}
//子类
Student& operator= (const Student& s)
{cout << "Student& operator= (const Student& s)" << endl;if (this != &s){Person::operator=(s);_num = s._num;}return *this;
}
具体说明:
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,隐藏。对此这里按照父类复用父类的赋值运算符重载的话,需要使用指定类域限定符直接到父类中调用
2.8.4 析构函数(有坑)
//父类
~Person()
{cout << "~Person()" << endl;
}
//子类
~Student()
{Person::~Person();cout << _name << endl;cout << "~Student()" << endl;
}
坑点:
问题一
- 子类析构里面如果显式写父类的析构就有问题,按照规定构造是先父后子,析构是先子后父,为了析构顺序是先子后父,子类析构函数结束后会自动调用父类析构,如果出现在子类析构中显式调用父类析构,无法保证先子后父的规定。
问题二:
- 同时如果显式调用父类析构,那么父类对象的内存空间已经被释放,此时再去访问父类成员将会导致未定义的醒为或者程序崩溃。并且在调用完子类的析构会自动调用再次调用父类的析构,这种行为是未定义的,可能会导致内存泄漏或者程序崩溃。
子类的析构也会隐藏父类,根据多态的需要,析构函数名字会被统一处理成destructor
2.8.5 小结
- 派生类的构造函数必须调用基类的构造函数(复用)初始化基类的那一部分成员,如果基类没有默认的构造函数,那么必须在派生类构造函数的初始化列表显式调用
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
- 派生类的operator=必须调用基类的operator=完成基类的复制
- ↑↑↑以上都是说子类继承父类的那一部分,需要父类调用复用自己默认函数
- 派生类的析构函数会在调用完成后完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序
- 派生类对象初始化先调用基类构造再调用派生类构造
- 派生类对象析构清理先调用派生类再调用基类的析构
2.9 继承与友元
友元关系不能继承,也就是说基类友元一般不能访问子类私有和保护成员
class Student;
class Person
{public:friend void Display(const Person& p, const Student& s);protected:string _name; // 姓名
};class Student : public Person
{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);
}
父类的友元不一定是子类的友元(父亲的朋友不一定是你的朋友),意味着友元关系不能继承。如果想要访问子类,可以添加友元。这里编译器是从上到下扫描的,所以需要先声明Student类。
2.8 继承与静态成员
**基类定义了static静态成员,则整个继承体系里面只有一个这样的成员,无论派生多少个子类,都只有一个static成员实例。**对此静态成员属于当前类,也属于基类。
那么利用该特性,子类的构造都需要调用父类的构造,可以统计父类有多少个派生类。
class Person
{public :Person () {++ _count ;}protected :string _name ; // 姓名public :static int _count; // 统计人的个数。
};int Person :: _count = 0;class Student : public Person
{protected :int _stuNum ; // 学号
};
class Graduate : public Student
{protected :string _seminarCourse ; // 研究科目
};
void TestPerson()
{Student s1 ;Student s2 ;Student s3 ;Graduate s4 ;cout <<" 人数 :"<< Person ::_count << endl;Student ::_count = 0;cout <<" 人数 :"<< Person ::_count << endl;
}
三、菱形继承及菱形虚拟继承
3.1 继承分类
单继承:当一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:属于多继承的一种特殊情况
3.2 菱形继承问题
从上面可以看出来,菱形继承有数据冗余和二义性的问题,在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";
}
Assistant的对象中Person成员会有两份,那么如果单纯a._name = "peter"
会产生二义性,编译器无法明确知道访问的是哪一个直接父类的成员。
解决办法:使用指定类域限定符访问
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
不足之处:需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
3.3 虚继承(菱形虚拟继承)
3.3.1 虚继承概念
对此引入虚继承这个概念,可以用于解决菱形继承的二义性和数据冗余的问题。
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)
3.3.2 虚拟继承解决数据冗余和二义性的原理
这里需要借助内存窗口观察对象成员的模型,调试窗口有时为了方便观察进行了调整,导致结果不准确。
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;
}
从下列两个模拟来看,一个是没有虚继承导致数据冗余和二义性,一个是完成虚继承解决了数据冗余和二义性
具体说明:
- 第一种,这里B和C类都存储了一个变量_a,导致数据冗余和二义性。
- 第二种,这里可以看出D对象中将A放到了对象组成的最下面,这个_a同时属于B和C,修改这个 _a同样会影响到其他类的 _a。
- 这里我们需要知道B和C如何去找到公共的A,这里是通过B和C的两个指针(就是属于x类内存中第一个存储的地址),去指向一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量(十六进制)可以找到下面的_a。
注意:
- 内存存储的顺序是按照声明顺序或者继承顺序存储的。
3.3.3 多继承指针偏移特性
继承的指针偏移特性,为了支持动态绑定和多态性。多继承的情况下,由于不同的父类可能有不用的虚函数表,因此需要通过指针偏移来正确访问不同父类的虚函数表。这种指针偏移通常由编译器自动生成的,确保访问父类的成员或者调用父类的虚函数时,能够正确地定位到父类对象内存位置。
四、继承总结和反思
4.1 设计继承建议
一般不建议设计出多继承,一定不要设计出菱形继承,不然在复杂度及性能上都有问题。实践中可以设计多继承,但是切记不要设计菱形继承,因为太复杂,容易出现各种问题
4.2 继承和组合
- public继承:是一种is-a的关系,也就是说每个派生类对象都是一个基类对象
- 组合:是一种has-a的关系,假设B组合了A,每个B对象中都有一个A对象
继承允许你根据基类的实现派生类的实现。这种通过生成派生类的复用通常被称为为白箱复用(white-box reuse),术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合性度高
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组合或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以"黑箱"的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先实现对象组合有助于你保持每个类被封装
实践尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
组合比如:
class A
{
private:int _ a;
}//组合
class B
{private:A _aa;int _b;
}
4.3 低耦和与高内聚
- 低耦和:类和类之间、模块与模块之间关系不那么紧密,关联不高
- 高耦合:类和类之间、模块与模块之间关系很紧密,关联很高
五、笔试面试题
问题:
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承、解决数据冗余和二义性的?
- 继承和组合的区别?什么时候用继承?什么时候用组合?
回答:
菱形继承是指在面向对象编程中的一种继承关系,其中一个列同时继承两个不同的类,而这两个类又都继承自同一个父类,形成了一个菱形的继承结构。
数据冗余和二义性问题,并且还有代码维护困难、不稳定性
菱形虚拟继承是C++中用来解决菱形继承问题的一种机制,派生类通过虚拟继承来自共同基类,被虚继承的基类只会在继承层次结构中存在一份实例,而不会出现多次复制
继承和组合是面向对象编程中两种不同的关系模式,继承是一种"is-a"关系,表示的是类之间的一种分类关系,即子类是父类的一种特殊形式,而组合是一种"has-a"关系,表示一个类包含另一个类作为基一部分,但是它们之间不具有层次关系。
当存在明确的"是一个is-a"关系,且子类需要继承父类的行为和属性时,使用继承,当需要在不同的类之间建立层次关系时,使用继承。
当对象之间存在包含关系,一个对象包含另一个对象作为其一部分时,使用组合。当对象之间的关系更加动态,或者不需要建立明确的层次关系时,使用组合。
六、继承是子类拷贝一份父类数据吗?(重点)
在继承中,子类和父类之间是一种逻辑关系,子类没有真正地拥有一份父类代码的副本,而是通过继承机制在需要时访问父类的功能。继承的这种特性使得子类不仅可以直接使用父类已有的功能,还可以通过重写(override)或扩展(extend)来修改或增加功能,而这一切都是通过共享父类的代码实现的,而不是通过复制代码实现的。
继承的实现是在运行时,通过类之间的关系来实现的。子类通过引用父类的成员,而不是复制父类的代码。因此,子类并没有物理上拥有父类的代码,而是通过继承关系“共享”父类的代码。
因此,继承不是拷贝,它是一种更高级的代码复用机制,通过类的层次结构来共享行为,而不是简单地复制粘贴代码。这样不仅可以减少代码重复,还可以通过多态和重写机制来增强代码的灵活性和可扩展性。
以上就是本篇文章的所有内容,在此感谢大家的观看!这里是店小二呀C++笔记,希望对你在学习C++语言旅途中有所帮助!