一、封装
把客观事物封装成类,而且可以把自己的数据和方法设置为只能让可信的类或者对象操作,对不可信的信息进行隐藏(利用public,private,protected,friend)实现
二、继承
2.1类与类的关系
- has-a :描述一个类由多个部件类构成,一个类的成员属性是另一个已经定义好的类。
- use-a:一个类使用另一个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现。
- is-a:继承,关系具有传递性
2.2继承
让某个类型对象获得另一个类型对象的方法,可以使用现有类的所有功能。有三种继承方法:
2.2.1 实现继承
使用基类的属性,不需要额外功能
2.2.2 接口继承
仅使用属性和方法的名称,子类提供实现的能力
2.2.3 可视继承
子类使用基类外观和实现代码的能力(C++不常用)
2.3 继承机制中的对象之间如何转换?指针和引用之间如何转换?
- 将派生类指针或引用转换为基类的指针或者引用被称为向上类型转换,向上类型转换会自动进行,而且是类型安全的。
- 将基类指针或者引用转换为派生类指针或者引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,向下不知道会对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术,RTTI技术,用dynamic_cast向下类型转换。
2.4 基类
如果想要将某个类用作基类,为什么这个类必须定义而非声明?
派生类中会包含并且使用从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。
什么是虚拟继承?
代码来源阿秀的学习笔记
#include <iostream>
using namespace std;
class A{}
class B : virtual public A{};
class C : virtual public A{};
class D : public B, public C{};
int main()
{
cout << "sizeof(A):" << sizeof A <<endl; // 1,空对象,只有一个占位
cout << "sizeof(B):" << sizeof B <<endl; // 4,一个bptr指针,省去占位,不需要对齐
cout << "sizeof(C):" << sizeof C <<endl; // 4,一个bptr指针,省去占位,不需要对齐
cout << "sizeof(D):" << sizeof D <<endl; // 8,两个bptr,省去占位,不需要对齐
}
B和C虚拟继承A,D公有继承B和C,这种方式是一种菱形继承或者钻石继承。虚拟继承情况下,无论基类被继承多少次,只会存在一个实体。所以D类只会包含一个A类对象,避免重复成员出现,减少了内存使用。 虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr。如果既存在vptr又存在bptr,那么编译器会将其优化,合并为一个指针。
当一个类使用虚拟继承,它的构造函数需要显示调用虚基类的构造函数,同时虚基类的构造函数会在最底层的派生类构造函数中进行调用,而不是在中间类的构造函数中调用。
2.5 抽象基类为什么不能创建对象?
因为抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。
2.6 多继承
什么是多继承
C++允许为一个派生类指定多个基类,这样的继承结构被称为多重继承。
优点
对象可以调用多个基类中的接口。
缺点
派生类所继承的多个基类有相同的基类,派生类对象需要调用这个祖先类的接口方法,就会出现二义性。
解决方法
- 加上全局符确定调用哪一份拷贝。比如pa.Author::eat()调用属于Author的拷贝
- 虚拟继承
2.7 public,private,protected访问权限
图片来源阿秀的学习笔记
2.8 组合与继承的比较
继承有以下缺点:
- 父类内部对子类不可见
- 子类从父类继承的方法在编译的时候就确定了,所以无法在运行期间改变父类继承的方法的行为
- 如果父类的方法做了修改(比如增加一个参数),则子类的方法必须做出相应的修改。父类与子类是一种高耦合,违背了面向对象的思想。
组合
组合就是设计类的时候把要组合的类的对象加入到这个类中作为自己的成员变量。
组合的优点
- 当前对象只能通过所包含的那个对象去调用其方法,所包含的对象的内部细节对当前对象是不可见的。
- 当前对象与包含的对象是一个低耦合的关系
- 当前对象可以在运行的时候动态绑定所包含的对象,通过set方法给所包含的对象赋值。
#include <iostream>
class Base {
public:
virtual void display() = 0;
};
class SubClassA : public Base {
public:
void display() override {
std::cout << "This is SubClassA" << std::endl;
}
};
class SubClassB : public Base {
public:
void display() override {
std::cout << "This is SubClassB" << std::endl;
}
};
class Container {
private:
Base* containedObject;
public:
void setContainedObject(Base* object) {
containedObject = object;
}
void displayContainedObject() {
containedObject->display();
}
};
int main() {
SubClassA objA;
SubClassB objB;
Container container;
// 绑定SubClassA对象到Container中
container.setContainedObject(&objA);
container.displayContainedObject(); // 输出: This is SubClassA
// 绑定SubClassB对象到Container中
container.setContainedObject(&objB);
container.displayContainedObject(); // 输出: This is SubClassB
return 0;
}
组合的缺点
- 容易产生过多对象
- 为了能组合多个对象,必须仔细对接口定义。
三、多态
同一事物表现不同事物的能力。允许将子类类型指针赋值给父类类型指针
多态有两种-重载和虚函数。
3.1 实现多态的两种方法
1、重载(编译时多态)
编译时多态,允许存在多个同名函数,而这些函数的参数表不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数(参数个数或者参数类型不同)
重载是在同一范围定义中的同名成员函数才存在重载关系。重载和函数成员是否是虚函数没有关系
程序中有函数重载的时候函数的匹配原则和顺序是什么?
- 名字查找
- 确定候选函数
- 寻找最佳匹配
重载运算符
只能重载已经有的运算符。有两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数,下标运算符(通常以所访问元素的引用作为返回值,最好定义下标运算符的常量版本和非常量版本)、箭头运算符(解引用通常也是类的成员,重载的箭头运算符必须返回类的指针)必须是成员运算符。引入运算符重载是为了实现类的多态性。
- 示例一
#include <iostream>
using namespace std;
class Complex {
public:
int real, imag;
Complex(int r = 0, int i =0) {real = r; imag = i;}
Complex operator + (Complex const &obj) {
Complex res;
res.real = real + obj.real;
res.imag = imag + obj.imag;
return res;
}
void print() { cout << real << " + i" << imag << endl; }
};
Complex operator - (Complex const &obj1, Complex const &obj2) {
Complex res;
res.real = obj1.real - obj2.real;
res.imag = obj1.imag - obj2.imag;
return res;
}
int main()
{
Complex c1(10, 5), c2(2, 4);
Complex c3 = c1 + c2;
c3.print();//12+i9
Complex c4 = c1 - c2;
c4.print();//8+i1
}
- 示例二
#include <iostream>
using namespace std;
class MyClass {
int arr[5];
public:
MyClass() {
for (int i = 0; i < 5; i++)
arr[i] = i;
}
int operator[](int i) {
return arr[i];
}
};
class PtrClass {
MyClass *ptr;
public:
PtrClass(MyClass *p = NULL) {
ptr = p;
}
MyClass* operator->() {
return ptr;
}
};
int main() {
MyClass obj;
PtrClass ptr(&obj);
cout << obj[2] << endl;//2
cout << ptr->operator[](2) << endl;//2
}
- 解引用
在 C++ 中,解引用运算符 * 用于访问指针指向的变量的值。例如,如果您有一个指向整型变量的指针 p,则可以使用表达式 *p 来访问该变量的值。
#include <iostream>
using namespace std;
int main() {
int x = 5;
int *p = &x;
cout << *p << endl;//5
*p = 10;
cout << x << endl;//10
}
2、虚函数(覆盖)(重写)(运行时多态)
运行时多态。子类重新定义父类的虚函数。在基类的函数前加上virtual关键字,在派生类中重写该函数,运行的时候根据所指对象是基类还是基类还是派生类来调用里面的函数。
注意
- 与基类的虚函数有相同的参数个数
- 与基类的虚函数有相同的参数类型
- 与基类的虚函数有相同的返回值类型
final和override关键字
- override
这个关键字的意思是,这个子类的这个函数是重写父类的,如果不小心打错了名字,编译是不会通过的 - final
不希望某个类被继承或者不希望某个虚函数被重写,可以在类名和虚函数后面添加final关键字。添加final关键字之后如果被继承或者重写,编译器会报错
代码来源阿秀的学习笔记
class Base
{
virtual void foo();
};
class A : public Base
{
void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写
};
class B final : A // 指明B是不可以被继承的
{
void foo() override; // Error: 在A中已经被final了
};
class C : B // Error: B is final
{
};
虚函数底层实现原理
虚表:类中含有virtual关键词时,系统自动生成虚表
虚表指针:含有虚函数的类实例化对象时,对象地址前四个字节存储指向虚表的指针
所以构造函数不能定义为虚函数,因为虚函数对应一个虚函数表,类中存储一个虚表指针,而此时还没有初始化,所以没有虚表指针,无法找到虚表。
虚表的特征
- 全局共享,也就是全局只有一个,在编译的时候就构造完成。
- 虚表类似于一个数组,类对象中存储vptr指针,指向虚函数表。
- 虚表存储的是虚函数的地址即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译的时候就可以确定,虚函数表的大小是在编译的时候确定的,不用动态分配内存空间。
虚函数表存放位置
虚函数表类似于类中的静态成员的变量,静态成员变量也是全局共享。
测试结果显示:虚函数表vtable在Linux中存放在可执行文件的只读数据段(也就是常量区)中;微软编译器将虚函数表存放在常量段。虚函数位于代码区
虚函数实现多态的过程
- 编译器发现基类中有虚函数,自动生成一个虚函数表,这个是一个一维数组,虚表保存虚函数入口地址
- 编译器会在每个对象前四个字节中保存一个虚表指针,构造时,根据对象类型初始化虚表指针。
- 派生类定义对象的时候,程序运行会自动调用构造函数,构造子类对象的时候,先调用父类构造函数,然后才调用子类的构造函数,为子类对象初始化虚表指针,令他指向子类虚表
- 派生类对基类没有重写的时候,派生类虚表指针指向基类的虚表,重写的时候,指向自身的虚表
构造函数或者析构函数能否调用虚函数?
派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类型,同样进入基类析构函数时,对象也是基类类型。所以,虚函数始终仅仅调用基类的虚函数,不能达到多态的效果。
虚表指针和this指针关系
this指针表示对象的地址起始内存地址。即this指针的值,指向了对象起始内存。this指针的值和类第一个成员变量的地址一样。当类有虚函数时候,类的第一个成员变量是一个虚函数指针(_vfpr),根据this指针的值和第一个成员变量地址相同(this指针指向第一个成员变量)。因此当有虚函数时,this指针的值等于虚函数指针的地址(this指针指向虚函数指针)。
虚函数调用过程
- 将this指针转换为int*类型,并解引用得到虚表指针
- 将虚表指针加上offset得到虚函数指针
- 将虚函数指针转换为对应类型,并传入this指针作为参数,调用虚函数
3、重载和重写的区别
- 重写是父类和子类的垂直关系,重载是不同函数之间的水平关系。
- 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求。
- 重写关系中,调用方法根据对象类型决定,重载根绝调用的时候实参表与形参表对应关系来选择函数体
4、隐藏
隐藏指的是子类隐藏父类的函数(还存在),具有以下特征
- 子类函数与父类名称相同,但是参数不相同,父类函数被隐藏
- 子类函数与父类函数名称相同,参数也相同,但是父类函数没有virtual,父类函数被隐藏
- 两个函数参数相同,但是基类函数不是虚函数,和重写的区别在于基类函数是否是虚函数
- 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中
举例一
代码来源C++中函数重载、隐藏、覆盖和重写的区别
#include <iostream>
using namespace std;
void func(char* s){
cout<<"global function with name:"<<s<<endl;
}
class A{
void func(){
cout<<"member function of A"<<endl;
}
public:
void useFunc(){
//func("lvlv");//A::func()将外部函数func(char*)隐藏
func();
::func("lvlv");
}
virtual void print(){
cout<<"A's print"<<endl;
}
};
class B:public A{
public:
void useFunc(){ //隐藏A::vodi useFunc()
cout<<"B's useFunc"<<endl;
}
int useFunc(int i){ //隐藏A::vodi useFunc()
cout<<"In B's useFunc(),i="<<i<<endl;
return 0;
}
virtual int print(char* a){
cout<<"B's print:"<<a<<endl;
return 1;
}
//下面编译不通过,因为对父类虚函数重写时,需要函数返回值类型,函数名称和参数类型全部相同才行
// virtual int print(){
// cout<<"B's print:"<<a<<endl;
// }
};
int main(){
A a;
a.useFunc();
B b;
b.useFunc();//A::useFunc()被B::useFunc()隐藏
b.A::useFunc();
b.useFunc(2);
//b.print();//编译出错,A::print()被B::print(char* a)隐藏
b.A::print();
b.print("jf");
}
程序运行结果
程序执行结果:
member function of A
global function with name:lvlv
B's useFunc
member function of A
global function with name:lvlv
In B's useFunc(),i=2
A's print
B's print:jf
举例二
代码来源C++中的覆盖与隐藏(详细讲解)
class father
{
public:
void show1()
{
cout << "father::show1" << endl<< endl;
}
virtual void show2()
{
cout << "father::show2" << endl << endl;
}
};
class son:public father
{
public:
void show1()
{
cout << "son::show1" << endl<< endl;
}
virtual void show2()
{
cout << "son::show2" << endl << endl;
}
};
int main(){
//基类指针指向派生类对象的时候,基类指针可以直接调用派生类的覆盖函数,也可以通过::调用基类被覆盖的虚函数
//而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数
father f;
son s;
father *pf=&s;
son *ps=&s;
pf->show1(); //father::show1
pf->show2(); //son::show2
return 0;
}
- 因为show1是非virtual函数,调用它的对象类型为静态类型即父类(静态联编),所以调用的是父类的对象
- 但是show2为virtual函数,调用它的对象类型为动态类型即指针指向的类型(动态联编),所以调用的是子类的类型
3.2 虚函数和纯虚函数
虚函数和纯虚函数都是用于实现多态的机制,它们都支持动态绑定的特性,但二者有着一些区别:
- 实现方式不同。虚函数需要在基类中有一个默认的实现,派生类可以选择重载该函数或者使用默认的实现,而纯虚函数没有默认的实现,必须在派生类中进行实现才能使用。
- 使用场景不同。虚函数应被定义为默认的实现和在派生类中重新实现的继承函数,它们让继承树中不同的实现起到相同的作用,提高代码重用性;而纯虚函数则常被用作通用接口或抽象类,提供一种规范的实现方式,以确保子类中的实现得到规范化。
- 不同的初始化方式。派生类中的虚函数在默认没有被重载时和基类中的虚函数都指向同一个实现,这个实现允许在对象构造时调用;而纯虚函数不能在基类中被调用,因为其没有实现,只作为规范的存在。如果执行的代码需要实现纯虚函数,这时只能从派生类构造函数中调用。
- 对于纯虚函数,它们在基类中充当抽象角色。在实际中,纯虚函数常常适用于基类,因为基类其实是一个不能被实例化的抽象类,它只包含一些接口,让继承它的派生类去实现这些接口;而虚函数则适用于一个可以被实例化的类,它通过继承实现了多态性的特性。
- 纯虚函数没有具体的函数体,在虚表中的值为0,而具有函数体的虚函数则是函数的具体地址。
3.3 虚函数的代价
- 每一个带有虚函数的类会产生一个虚函数表,用来存储指向虚成员函数的指针
- 带有虚函数的类的每一个对象会有一个指向虚表的指针,增加对象的空间大小
- 虚函数不能在世内联函数。因为内联函数在编译阶段进行替代,而虚函数在运行阶段才能确定用哪种函数,虚函数不能是内联函数。
3.4 哪些函数不能是虚函数?
- 构造函数:派生类必须知道基类干了什么,才能进行构造;当有虚函数的时候,每一个类有一个虚表,对象有虚表指针,虚表指针在构造函数中初始化
- 内联函数
- 静态函数:静态函数不属于对象属于类,没有this指针,设置为虚函数没有意义
- 友元函数、普通函数:不属于成员函数,不能被继承
3.5 静态绑定和动态绑定
- 静态绑定:绑定的是静态类型(对象在声明时采用的类型,在编译器确定),发生在编译器
- 动态绑定:绑定动态类型(通常是指一个指针或引用目前所指对象的类型,是在运行期决定的),所对应的函数或属性依赖于对象的动态类型,发生在运行期。
建议:绝对不要重新定义继承而来的非虚函数,因为这样重写的时候是没有多态的,这样会给程序留下不可预知的隐患和莫名其妙的bug,而且动态绑定时,要注意默认参数的使用,当缺省参数和virtual函数一起使用的时候要谨慎。文章来源:https://www.toymoban.com/news/detail-434551.html
引用是否可以实现动态绑定
引用在创建的时候必须初始化,在访问虚函数的时候,编译器会根据绑定的对象类型决定要调用哪个函数(只能调用虚函数)。文章来源地址https://www.toymoban.com/news/detail-434551.html
3.6 如何防止一个类被实例化?
- 将类定义为抽象基类或者将构造函数声明为private
- 不允许外部创建类对象,只能在类内部创建对象(static)
到了这里,关于c++面向对象之封装、继承、和多态的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!