一、前言
面向对象的三大特性:封装,继承,多态
封装 | 将对象的属性和方法封装在一个对象,形成一个独立的实体 |
继承 | 子类继承父类的属性和方法 |
多态 | 不同类的对象对通一消息做出不同的反映。(当一个父类引用指向一个子类对象,调用的方式回事子类的方法,这就多态性的体现) |
二、封装
2.1 概念:
它指的是将数据和操作数据的方法捆绑在一起,形成一个独立的单元,这个单元就是类。在封装中,数据被视为类的内部状态,而方法则用于操作这些数据。
一般情况:假设有多种需求的情景下,按照一般情况,可能需要多个普通函数。
封装:将多种需求按照它们之间的共性加以区分,然后将这些需求分别封装在不同的类中。
2.2 达成效果:
-
提高代码的可维护性:将功能按照其共性进行分类和封装,使得代码结构更清晰、更易于理解和维护。
-
提高代码的复用性:当有多个需求具有相似的实现逻辑时,将它们封装在同一个类中,可以避免重复编写代码,提高代码的复用性。
-
降低耦合度:不同功能的实现被封装在不同的类中,它们之间相互独立,降低了代码之间的耦合度,使得代码更加灵活和易于扩展。
2.3 举个例子:
A需要一个功能,输入年龄和姓名,就会打印出”某某某的年龄是多少“,功能由B开发。
这个功能A不需要知道是怎么写的,只要知道是怎么用,用来干什么的就行。
B写好功能实现代码,封装成类,给A一个使用的接口(相当于实例化对象或者是调用函数),那么A就可以用了
class person {
public:
person(int m_age, string m_name) :age(m_age), name(m_name) {}
void printf() {
cout << name << "的年龄是" << age << endl;
}
private:
int age;
string name;
};
int main(){
person person1(18, "王小明");
person1.printf();
return 0;
}
三、继承
3.1 概念:
通过继承,一个类可以基于另一个类的属性和方法来创建新的类,从而实现代码的重用和扩展。
3.2 达成效果;
-
代码重用:通过继承基类,子类可以继承基类中已有的属性和方法,避免了重复编写相似的代码,提高了代码的重用性。
-
统一接口:基类定义了通用的接口,子类在继承基类的同时,可以保持统一的接口,便于管理和使用。
-
扩展性:子类可以根据具体需求进行定制和扩展,可以覆盖基类中的方法或添加新的方法,从而满足不同的需求。
3.3 例子 菱形继承
一个爷爷基类 class animal,一个儿子类 class yang继承了class animal,一个女儿类 class tuo继承了 class animal,一个孙子类 class yangtuo继承了class yang和class tuo
错误示范:
在下面这种情况下,yang继承了animal的变量m_age,tuo继承了animal的变量m_age。yangtuo继承了yang和tuo,所以此时它有双份的m_age(yang::m_age和tuo::m_age)。但一个yangtuo应该只有一份m_age才对。
class Animal {//虚基类
public:
int m_age;
};
class yang :public Animal {};
class tuo :public Animal {};
class yangtuo :public yang, public tuo {};
//这里在main函数中实例化一个yangtuo
yangtuo yangtuo1;
//yangtuo1.m_age=20 这句话会报错
//只有加上作用域
yangtuo1::yang.m_age=20;
yangtuo1::tuo.m_age=18;
//这样不会报错,但yangtuo1会有两个年龄,一个是yang作用域下的,一个是tuo作用域下的
以上继承称为菱形继承(在多重继承中,如果派生类继承了两个及以上具有共同基类的类,就会出现菱形继承问题)
解决办法:通过虚继承(虚继承避免了重复继承同一个基类所带来的内存空间浪费,因为共同的基类只会在继承体系中保留一份实例。)
class Animal {//虚基类
public:
int m_age;
};
class yang :public virtual Animal {};//虚继承
class tuo :public virtual Animal {};//虚继承
class yangtuo :public yang, public tuo {};
//这样实例化的yangtuo就只有一份年龄了
yangtuo yangtuo1;
yangtuo1.m_age=18;
四、多态
4.1 概念
多态是面向对象编程中的一个重要概念,指的是同一个方法调用,在不同的对象上可以表现出不同的行为。它使得程序可以根据当前对象的类型来调用不同的方法,从而实现了代码的灵活性和可扩展性。
多态的实现通常依赖于继承和方法重写。当子类继承父类并重写了父类的方法时,父类引用指向子类对象时,调用的方法将会是子类中重写的方法,而不是父类中的方法。
4.2 达成效果
-
灵活性:多态使得程序可以根据对象的实际类型来调用相应的方法,使得代码更加灵活,可以适应不同的对象类型。
-
可扩展性:通过继承和方法重写,可以很容易地添加新的子类,并重写父类的方法,从而实现新的行为,而不需要修改已有的代码。
-
简化代码:多态可以使得代码更加简洁和清晰,因为同样的方法调用可以在不同的对象上表现出不同的行为,减少了代码的重复和冗余。
-
增加代码的可读性和可维护性:多态使得代码更加易于理解和维护,因为它使得对象的行为和方法调用更加直观和自然。
4.3 例子
一个基类class animal ,一个子类class dog 继承animal
错误示范:
class animal {
public:
void speak() {
cout << "动物在说话" << endl;
}
};
class dog :public animal {
public:
void speak() {
cout << "小狗在说话" << endl;
}
};
void dospeak(animal & animal1) {
animal1.speak();
}
int main(){
dog dog1;
dospeak(dog1);//这样调用结果输出是”动物在说话“
}
在main函数中实例化了一个dog类的对象dog1,并将它传入到dospeak函数中,但是由于参数类型是animal类型的引用,因此会发生早期绑定,也称为静态绑定。这意味着编译器在编译时就决定了要调用哪个函数,而不是在运行时根据对象的实际类型来决定。在这种情况下,虽然传递给dosepak()函数的对象是dog类的对象dog1,但此时调用的还是animal类中的speak()函数。
使用visual_studio的工具-》命令行-》开发者powershell选项,打开到源文件目录下
可以看出当前animal类中的大小为size(1),是一个空类的大小,为一个字节,因为类的大小与类的数据成员,继承,虚函数,对齐和填充(与静态成员和普通类成员函数无关)
可以看出此时dog类中中的大小也是1个字节
解决办法:通过虚函数,将基类的speak函数加上virtual关键字(其它子类中的speak函数前面可以加上virtual也可以不加virtual)
此时在dospeak()函数中实现动态绑定(Dynamic Binding):虚函数调用在运行时进行绑定,这意味着函数的调用是根据对象的实际类型来确定的,而不是根据指针或引用的类型。这种动态绑定使得程序在运行时能够适应对象的实际状态,而不是限定于静态类型的限制。
void virtual speak() {
cout << "动物在说话" << endl;
}
..
int main(){
dog dog1;
dospeak(dog1);//这样调用结果输出是”小狗在说话“
}
可以看到此时animal的size大小变为4(一个虚函数指针的大小为4字节,在不同的系统,可能不同),此时出现一个vfptr(虚函数指针),指向下面的虚函数表vftable,可以看到表中有一个函数animal::speak
再看一下继承类dog中,由于继承了基类animal,size尺寸也变为4,它也有一个虚函数指针vfptr,
指向下面的虚函数表vftable,里面有一个函数dog::speak;(这是子类重写了父类中的函数,要是不重写呢,看看最后一张图)
这里是子类dog没有重写父类中的speak函数,展开的虚函数表,此时由于继承了父类,子类的大小还是size(4),但是由于子类没有重写父类中的函数,所以虚表中的函数继承的是父类中的speak函数animal::speak
tip:
动态多态满足条件
1、有继承关系(这里的class dog继承class animal)
2、子类重写父类的虚函数(比如:父类和子类都有void speak()函数),且返回值,返回类型,参数类型,参数列表一致)
五,构造函数和析构函数可以是虚函数嘛
5.1 构造函数不可以是虚函数
构造函数不能是虚函数。这是因为在对象的构造过程中,虚表(vtable)尚未构建,因此无法进行虚函数的动态绑定。此外,虚函数需要通过虚表来进行调用,而在构造函数执行期间,虚表尚未建立。
5.2 析构函数可以是虚函数
错误示范:
父类指针在调用析构函数时,不会调用子类的析构函数,导致子类的内存没有释放,造成内存泄露
(new string是在堆区分配内存用于存储字符串数据的动态分配形式。在C++中,new操作符用于在堆上分配内存,返回一个指向分配内存的指针。堆上的数据通常需要手动释放这块内存以避免内存泄漏。)
class animal {
public:
animal() {
cout << "animal构造函数在调用" << endl;
}
~animal() {
cout << "animal虚析构函数在调用" << endl;
}
void virtual speak() {
cout << "动物在说话" << endl;
}
};
class dog :public animal {
public:
string* m_name;
dog(string name) {
cout << "dog构造函数在调用" << endl;
m_name = new string(name);
}
~dog() {
cout << "dog析构函数在调用" << endl;
delete m_name;
m_name = nullptr;
}
void speak() {
cout << *m_name << "小狗在说话" << endl;
}
};
int main(){
animal *animal1=new dog("tom");
animal1->speak();
delete animal1;
return 0;
}
解决方法:虚析构
在基类中(animal)的析构函数前加上virtual,此时animal中的析构函数称为虚析构函数,这样在析构基类时也会调用派生类的析构函数。
virtual ~animal() {
cout << "animal虚析构函数在调用" << endl;
}
tip:
1 虚构函数或纯虚析构就是用来解决通过父类指针释放子类对象
2 如果子类中没有堆区数据 可以不写为虚析构或者纯虚析构
3 拥有纯虚析构的类也属于抽象类
六、 纯虚函数
6.1 概念
纯虚函数是在C++中的一种特殊的虚函数,它在基类中声明但没有提供实现。纯虚函数的声明形式是在函数声明的结尾加上 "= 0"
virtual 返回值类型 函数名称(参数类型 参数值)=0;
- 纯虚函数的主要作用是定义一个接口,要求派生类必须提供对应的实现。
- 这种机制使得基类能够定义通用的接口,而具体的实现则由派生类负责完成。
- 纯虚函数使得基类可以成为一个抽象类,不能被实例化,只能被用作其他类的基类。
6.2 达成效果
-
实现接口规范:纯虚函数在基类中只提供了函数的声明,而没有提供具体的实现。这样可以强制要求派生类必须提供对应的实现,从而确保派生类都遵循了基类的接口规范。
-
促进多态性:通过基类的指针或引用调用纯虚函数,可以在运行时根据实际对象类型来选择调用哪个派生类的函数。这样可以实现多态性,使得程序更加灵活和可扩展。
-
提供默认实现:有时候基类中可以为纯虚函数提供一个默认的实现,如果某个派生类不需要重新实现这个函数,可以直接使用基类中的默认实现。这样可以减少代码的重复编写。
-
抽象基类:基类中包含纯虚函数的类通常被称为抽象基类,它主要用于定义接口和行为规范,而不是提供具体的实现。抽象基类可以作为其他具体类的接口规范,有助于组织和管理程序的类结构。
6.3 举个例子
在基类中定义一个虚函数,那么这个类就成为抽象类(像是定义了一个总体的规范)。并且在子类中必须对这个虚函数进行重写(重写的函数必须具有与基类函数相同的函数签名(即函数的名称、参数列表和常量属性),通过重写虚函数,派生类可以根据自身的需要覆盖基类的实现),否则子类也成为了抽象类(抽象类不能实例化对象)
class base {
public:
virtual void speak() {} ;
};
class son :public base {
public:
void speak() {
cout << "我是儿子" << endl;
}
};