面向过程编程: 一种以执行程序操作的过程或函数为中心编写软件的方法。程序的数据通常存储在变量中,与这些过程是分开的。所以必须将变量传递给需要使用它们的函数。缺点:随着程序变得越来越复杂,程序数据与运行代码的分离可能会导致问题。例如,程序的规范经常会发生变化,从而需要更改数据的格式或数据结构的设计。当数据结构发生变化时,对数据进行操作的代码也必须更改为接受新的格式。查找需要更改的所有代码会为程序员带来额外的工作,并增加了使代码出现错误的机会。
面向对象编程: 以创建和使用对象为中心。一个对象(Object)就是一个软件实体,它将数据和程序在一个单元中组合起来。对象的数据项,也称为其属性,存储在成员变量中。对象执行的过程被称为其成员函数。将对象的数据和过程绑定在一起则被称为封装。
面向对象编程将数据成员和成员函数封装到一个类中,并声明数据成员和成员函数的访问级别(public、private、protected),以便控制类对象对数据成员和函数的访问,对数据成员起到一定的保护作用。而且在类的对象调用成员函数时,只需知道成员函数的名、参数列表以及返回值类型即可,无需了解其函数的实现原理。当类内部的数据成员或者成员函数发生改变时,不影响类外部的代码。
面向对象的三大特性:
隐藏对象的属性和实现细节,仅对外公开接口和对象进行交互,将数据和操作数据的方法进行有机结合。封装的目的:增强安全性和简化编程,使用者不必了解具体的实现细节,而只是通过外部接口及特定的访问权限来使用类的成员(函数是封装的一种形式)。保护或防止代码/数据被无意破坏。
封装解决的问题: 一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
访问权限限定符:
继承可以使得子类具有父类的各种属性和方法,而不需要再次编写相同的代码。在子类继承父类的同时,可以重新定义某些属性,并重新某些方法,即覆盖父类的原有属性和方法,使其获得与父类不同的功能。
目的: 实现代码复用,是实现多态的必要条件;保持原有类特性的基础上进行扩展,增加新功能。
继承解决的问题: 继承最大的一个好处就是代码复用。还可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。
类继承和对象组合是复用的两种最常用的技术。
继承: 白箱复用,继承是Is a 的关系,如Student继承Person,则说明Student is a Person。
优点: 容易进行新的实现;易于修改或扩展那些被复用的实现。
缺点: 基类内部细节对子类可见,在一定程度上破坏了封装性;子类和父类高度耦合,修改父类的代码,会直接影响到子类。继承层次过深过复杂,就会导致代码可读性、可维护性变差。
组合: 黑箱复用,组合是has-a关系,也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
优点: 封装性好,当前对象只能通过被包含对象的接口来对其进行访问,被包含对象的内部细节对外是不可见;当前对象与包含的对象是一个低耦合关系,如果修改被包含对象的类中代码不需要修改当前对象类的代码,代码维护性好;当前对象可以在运行时动态的绑定所包含的对象。
缺点: 容易产生过多的对象;为了能组合多个对象,必须仔细定义接口。
组合比继承更具灵活性和稳定性,所以在设计的时候优先使用组合。只有当下列条件满足时才考虑使用继承:
编译器处理虚函数表:
(1)单继承无虚函数覆盖的情况:
基类的虚函数表:
派生类的虚函数表:
(2)单继承有虚函数覆盖的情况:
派生类的虚函数表:
(3)多继承无虚函数覆盖的情况:
class Derive : public Base1, public Base2, public Base3
派生类的虚函数表:(基类的顺序和声明的顺序一致)
(4)多继承有虚函数覆盖的情况:
派生类的虚函数表:
多继承: 是指从多个直接基类中产生派生类。
多重继承容易出现的问题: 命名冲突和数据冗余问题,菱形继承。
对于派生类中继承的的成员变量 var1 ,从继承关系来看,实际上保存了两份,一份是来自基类 Base2,一份来自基类 Base3。因此,出现了命名冲突。
解决方法 1:声明出现冲突的成员变量来源于哪个类
void set_var1(int tmp) { Base2::var1 = tmp; } // 这里声明成员变量来源于类 Base2,当然也可以声明来源于类 Base3。
解决方法 2:虚继承
使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即使间接基类中的成员在派生类中只保留一份。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。
实现方式:在继承方式前面加上 virtual 关键字。
解决方法一: 借助 final 关键字,用该关键字修饰的类不能被继承。
class Base final { }; class Derive: public Base{ // error: cannot derive from 'final' base 'Base' in derived type 'Derive' };
解决方法二: 借助友元、虚继承和私有构造函数来实现。
template <typename T> class Base{ friend T; private: Base(){ cout << "base" << endl; } ~Base(){} }; class B:virtual public Base<B>{ //一定注意 必须是虚继承 public: B(){ cout << "B" << endl; } }; class C:public B{ public: C(){} // error: 'Base<T>::Base() [with T = B]' is private within this context };
具体原因:虽然 Base 类构造函数和析构函数被声明为私有 private,在 B 类中,由于 B 是 Base 的友元,因此可以访问 Base 类构造函数,从而正常创建 B 类的对象;
B 类继承 Base 类采用虚继承的方式,创建 C 类的对象时,C 类的构造函数要负责 Base 类的构造,但是 Base 类的构造函数私有化了,C 类没有权限访问。因此,无法创建 C 类的对象, B 类是不能被继承的类。
注意:在继承体系中,友元关系不能被继承,虽然 C 类继承了 B 类,B 类是 Base 类的友元,但是 C 类和 Base 类没有友元关系。
**这里采用虚继承的原因是:**直接由最低层次的派生类构造函数初始化虚基类。这是因为在菱形继承中,可能会存在对虚基类的多次初始化问题,为了避免出现该问题,在采用虚继承的时候,直接由最低层次的派生类构造函数直接负责虚基类 类的构造。如果不加virtual的话,在构造函数的顺序中,每个类只负责自己的直接基类的初始化,所以还是可以生成对象的。加上了virtual之后,C直接负责Base类的构造,但是Base类的构造函数和析构函数都是private,C无法访问,所以不能生成对象。
可以简单概括为“一个接口,多种方法”,即用的是同一个接口,但是效果各不相同,多态有两种形式的多态,一种是静态多态,一种是动态多态。
多态解决的问题: 多态特性能提高代码的可扩展性和复用性。多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else 语句等等。
C++多态分为:编译时多态性(静态多态)和运行时多态性(动态多态)。
编译时多态和运行时多态的区别:
时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。
动态多态的条件:
重载、重写、隐藏
重写和重载的区别:
隐藏和重写,重载的区别:
C语言能实现运行时多态吗?
C实现多态就仿照C++,具体实现要用到 结构体(虚函数表就是一个元素为虚函数指针的结构体) + 函数指针(记录函数名对应的函数地址)。这样处理存在一个缺陷就是:父子各自的函数指针之间指向的不是类似C++中维护的虚函数表而是一块物理内存,如果模拟的函数过多,就会难以维护。
定义一个函数为虚函数,不代表函数为不被实现的函数。定义它为虚函数是为了允许用基类的指针来调用子类的这个函数。虚函数必须实现,如果不实现,编译器将报错。虚函数声明如下:
virtual ReturnType FunctionName(Parameter);
虚函数带来的好处就是: 可以定义一个基类的指针, 其指向一个继承类, 当通过基类的指针去调用函数时, 可以在运行时决定该调用基类的函数还是继承类的函数。虚函数是实现运行时的多态(动态绑定)/接口函数的基础。
虚函数在c++中的实现机制: 用虚(函数)表和虚(表)指针。编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这种数组成为虚函数表(virtual function table, vtbl)。虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。 虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。
虚函数表相关知识点:
那些函数不能定义为虚函数?
定义一个函数为纯虚函数,才代表函数没有被实现(即没有定义)。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的派生类必须重写这个函数以实现多态。纯虚函数声明如下:
virtual void function()=0;
引入纯虚函数的目的: 为了安全,因为避免任何需要明确但是因为不小心而导致的未知的结果,提醒子类去做应做的实现;为了效率,不是程序执行的效率,而是为了编码的效率。
纯虚函数的意义: 让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法。继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,该派生类依然是抽象类,不能实例化对象。
在虚函数和纯虚函数的定义中不能有static标识符。 原因:被static修饰的函数在编译时候要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。
包含纯虚函数的类是抽象类,抽象类不能定义实例,只能创建它的派生类的实例,但可以声明指向实现该抽象类的具体类的指针或引用。抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。
(1)抽象类的定义:称带有纯虚函数的类为抽象类。
(2)抽象类的作用:为派生类提供基类,定义了一个接口,派生类将具体实现在其基类中作为接口的操作。
(3)使用抽象类时注意:a、抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。b、抽象类是不能定义对象的。c、抽象类不能用作参数类型、函数返回类型或显式转换的类型。
如果一个类里面只有纯虚函数,没有其他成员函数和数据成员,就是接口类。好的接口定义应该是具有专一功能性的,而不是多功能的,否则造成接口污染。如果一个类只是为实现了这个接口的中一个功能,但是却不得不去实现接口中的其他方法,就叫接口污染。
接口有如下特性:
接口除了可以包含方法之外,还可以包含属性、索引器、事件,而且这些成员都被定义为公有的。除此之外,不能包含任何其他的成员,例如:常量、域、构造函数、析构函数、静态成员。一个类可以继承多个接口和类(包括抽象类)。
接口是引用类型的,类似于类,和抽象类的相似之处有三点:
抽象类和接口的区别:
既然有抽象类,为什么要用接口呢?
接口带来的最大好处就是避免了多继承带来的复杂性和低效性,并且同时可以提供多重继承的好处。接口和抽象类都可以体现多态性,但是抽象类对事物进行抽象,更多的是为了继承,为了扩展,为了实现代码的重用,子类和父类之间体现的是is-a关系;接口则更多的体现一种行为约束,一种规则,一旦实现了这个接口,就要给出这个接口中所有方法的具体实现,也就是说实现类对于接口中所有的方法都是有意义的。
在设计类的时候,首先考虑用接口抽象出类的特性,当你发现某些方法可以复用的时候,可以使用抽象类来复用代码。简单说,接口用于抽象事物的特性,抽象类用于代码复用。
析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。