16.关于面向对象
- 面向对象的思想是把整个世界看成是由具有行为的各种对象组成的,任何对象都具有某种特征和行为。
- OOAD把一个对象的特征称为属性、而把其行为称为服务或方法。
- 向一个对象传递参数并调用对应函数,就是在请求其服务。通过一个对象向另一个对象请求服务,发起请求的对象就是“客户机”,提供服务的对象就是“服务器”。这就是“客户机/服务器(C/S)”体系结构模型。
- C++类的封装是一种“信息隐藏”的设计理念。利用public、private和pritected声明哪些数据和函数是可以公开的,哪些是受保护的。让类仅仅公开必须让外界知道的内容,而隐藏其他一切内容。
- 如果A是基类,B是A的派生类,那么B将继承A的数据和函数。
- 如果类A和类B毫不相关,不可以为了使B的功能更多一些而让B继承A的功能和属性。
- 若逻辑上B是A的“一种”,并且A的所有属性和功能对B而言都有意义,则允许B继承A的功能和属性。
- C++支持多重继承,即允许一个类同时具有另外几个类的特点。
- 多重继承的一个比较重要的用途是在已有的接口和实现类的基础上创建自己的接口和实现类。
- 在COM中多重继承被作为定义COM对象类的一种方法:一个COM对象类 可以同时从多个COM接口(Interface)继承。接口隔离原则。
- 若逻辑上A是B的一部分,则不允许B从A派生,而是要用A和其他部分组合出B。
- 虚函数是实现动态特性的手段,声明方法是在基类函数原型之前加上关键字virtual。
- 一旦类的一个成员函数被声明为虚函数,那么其派生类的对应函数也自动称为虚函数。虽然如此,建议在每个派生层次中显式的将它声明为虚函数,即加virtual关键字。
- 很多情况下,定义一些不能实例化出对象的类也是很有用的,这种类叫做抽象类。可以实例化的类叫做实现类。抽象类的唯一目的就是让其派生类继承并实现它的接口方法,所以抽象类也称为抽象基类。
- 如果基类的虚函数声明为纯虚函数,那么该类将被定义为抽象基类。纯虚函数是在声明时将其初始化为0的函数。只有虚函数才可以被初始化为0。
class shape{
public:
virtual void Draw(void) = 0;
};
- 抽象基类的主要用途是“接口与实现分离”,即不仅要把数据成员隐藏起来,而且还要把实现完全隐藏起来,只留一些接口给外部调用。这样即使将来实现改变了,接口仍然可以保持不变。
- 由于抽象基类不能实例化,并且实现类被完全隐藏,所以必须以其他的途径使用户能够获得实现类的对象,比如提供入口函数来动态创建实现类的对象。入口函数可以是全局函数,但最好是静态成员函数。
- 将基类中的一个函数声明为virtual,然后用指向派生类对象的基类指针或引用来调用这个函数,程序会在运行时选择派生类的这个函数而不是基类里的函数,这种特性就是动态绑定,也叫运行时绑定。
- 如果派生类定义了一个与基类的虚函数同名的虚函数,但是参数列表有所不同,编译器就不会认为是对基类虚函数的改写,也就不会发生运行时绑定。所以派生类和基类的同名虚函数必须具有相同的原型(在C++特性中,返回类型其实是可以不同的)
- 所谓C++的多态包含以下三种:
- 经过隐含的类型转换,让一个public多态基类的指针或者引用指向它的一个派生类的对象。
- 通过指针或引用调用基类的虚函数,包括通过指针的反引用调用虚函数。指针或引用指向的派生类的对象会对同一函数调用做出不同的反应。这就是所谓运行时多态。
- 使用dynamic_cast<>和typeid运算符。
- 如果在一个数组中放置一些多态对象,通过一致的接口动态调用它们自定义的虚函数这一做法不成立。因为数组在创建的时候是会分配内存空间的,数组放置多态对象时,编译器并不知道运行时放置的是哪个派生类对象,所以只能放置基类对象,调用的函数也只是基类的函数。
- 所以,不要再数组中直接存放多态对象,而是换之以基类指针或者基类的智能指针。
- 如果确实需要使用多态数组,最好使用STL容器配合普通指针或者智能指针。
- 非静态数据成员被存放在每一个对象体内作为对象的专有数据成员;
- 静态数据成员被提取出来放在程序的静态数据区作为该类所有对象共享,仅存在一份;
- 静态和非静态成员函数最终都被提取出来放在程序的代码段中并为该类的所有对象共享,因此每一个成员函数其实只存在一份代码实体。
- 类内嵌套的各种类型(typedef、clas、struct、union、enum等)与放在类外面定义的类型除了作用域不同外没有本质区别。
- 构成对象本身的只有数据,任何成员函数都不隶属于任何一个对象,非静态成员函数与对象的关系其实是绑定,绑定的中介就是this指针。
- 派生类继承基类的非静态数据成员,并作为自己对象的专用数据成员;
- 派生类继承基类的非静态成员函数,并可以像自己的成员函数一样访问;
- 为每一个多态类创建一个虚函数指针数组vtable,该类的所有虚函数(继承自基类的或者新增的)的地址都保存在这张表里;
- 多态类的每一个对象(如果有)中都安插一个指针成员vptr,其类型为指向函数指针的指针,它总是指向所属类的vtable,也就是说:vptr当前所在的对象是什么类型的,那么它就指向这个类型的vtable。vptr是C++对象的隐含数据成员。
- 如果基类已经插入了vptr,则派生类将继承并重用该vptr;
- 如果派生类是从多个基类继承或者有多个继承分支(从所有根类开始算),而其中若干继承分支上出现了多态类,则派生类将从这些分支中的每个分支上继承一个vptr,编译器也将为它生成多个vtable,有几个vptr就生成几个vtable,分别与它的多态基类对应。
- vptr在派生类对象中的相对位置不会随着继承层次的逐渐加深而改变,并且现在的编译器一般都将vptr放在所有数据成员的最前面。
- 为了支持RTTI,编译器会为每一个多态类创建一个type_info对象,并把地址保存在vtable中的固定位置(一般为第一个位置)(这条跟编译器有关,没有硬性规定)
- 总结一下:从一个派生类对象入手,可以直接访问到基类的数据成员;不论派生层次有多深,派生类对象访问基类对象的数据成员和成员函数时,与访问自己的数据成员和成员函数没哟效率差异;当基类定义发生改变时,派生类必须重新编译才能使用;派生类新增数据成员和继承来的数据成员按照对象的构造顺序来组合,并且每层新增数据要么统一放在基类子对象前面,要么统一放在后面;虚函数访问需要经过vptr的间接寻址,因此存在一定的额外开销。
- 一个虚函数如果在当前class中是第一次出现(如果在其某一个基类中已经出现,则不能算式第一次),则将其地址插入到该class的每一个vtable的尾部
- 如果派生类改写了基类的虚函数,则这个虚函数的地址在派生类vtable中的位置与它在其基类vtable中的位置一致,而与它在派生类中声明的位置无关。
- 派生类没有改写的基类虚函数被继承下来并插入派生类的vtable中(与该虚函数所在基类对应下来的那个vtable),且在派生类vtable中的位置与在基类vtable中的位置相同。
- 建议尽量避免在构造函数和析构函数中调用虚函数。
17.关于对象的初始、拷贝和析构
- 不要再构造函数内做与初始化对象无关的工作,不要再析构函数中做与销毁一个对象无关的工作。构造函数和析构函数应该做能够满足正常初始化和销毁一个对象的最少工作量。
- 不显式声明的话,C++编译器会自动为其生成四个public inline的默认函数,即默认构造函数、默认拷贝构造函数、析构函数和默认赋值函数。
- 默认拷贝构造函数和默认赋值函数都是采用按成员拷贝的默认方式实现的。当类中含有指针成员时,两个对象对应的指针成员将指向相同的对象。
- 对象的初始化就是在对象创建的同时使用初值直接填充对象的内存单元,因此不会有数据类型转换等中间过程,也不会有临时对象产生;而赋值则是在对象创建后后任何时候都可以调用而且可以多次调用的函数,由于调用的是等号运算符,因此可能需要进行类型转换,即可能会产生临时对象。
- 最好为每个类显式地定义构造函数和析构函数,即使它们暂时空着,尤其是当类含有指针成员或引用成员的时候。
- 构造函数的另一个用途是给一些可能存在的隐含成员(如vptr)创造一个初始化的机会,否则虚拟极值将不能保证实现。
- 在构造函数体内来初始化数据成员本质上并不是真正意义上的初始化,而是赋值。真正的初始化是使用“初始化列表”来进行的。初始化列表位域构造函数参数表之后,在函数体{}之前。
- 如果类存在继承关系,派生类可以直接在其初始化列表里调用基类的特定构造函数以向它传递参数,因为我们不能在初始化对象时访问基类的数据成员。
- 类的非静态const数据成员和引用成员只能在初始化列表里初始化,因为它们只存在初始化语义,而不存在赋值语义。
- 当使用成员初始化列表来初始化话数据成员时,这些成员真正的初始化顺序并不一定跟你在初始化列表中安排的顺序一致,编译器是按照它们在类中的声明顺序来依次初始化的。
- 最好按照数据成员的声明顺序来书写初始化列表:
(1) 调用基类的构造函数,向它们传递参数;
(2) 初始化本类的数据成员(包括成员对象的初始化);
(3) 在函数体内完成其他的初始化工作。 - 如果一个类没有基类,那么它的构造过程仅仅把自己的数据成员初始化就可以了。如果一个类是派生类,它的构造函数将首先调用它们各自基类的构造函数,直到最根类。因此,任何一个对象总是首先构造最根类的子对象,然后逐层向下扩展,直到把整个对象构造起来。
- 析构会严格按照对象构造相反的次序进行,该次序是唯一的。
- 对于非静态的局部对象,它们的构造函数、赋值函数和析构函数的调用时机为:
(1) 在程序执行到该对象的定义处时,创建对象并调用相对应的构造函数;
(2) 如果在定义对象时没有提供初始值,则会暗中调用默认构造函数;如果没有默认构造函数,则该对象不会自动初始化。
(3) 如果在定义对象时提供了初始值,则会暗中调用类型匹配的带参数的构造函数(包括拷贝构造函数)。如果没有定义这样的构造函数,编译器可能报错。
(4) 当程序执行到该对象的生存域结尾处时,暗中调用它的析构函数。 - 对于静态局部对象,它们的构造函数、析构函数调用时机为:
(1) 在程序执行到该对象的定义处时,创建对象并调用相对应的构造函数;
(2) 如果在定义对象时没有提供初始值,则会暗中调用默认构造函数;如果没有默认构造函数,则自动初始化为0。
(3) 如果在定义对象时提供了初始值,则会暗中调用类型匹配的带参数的构造函数(包括拷贝构造函数)。如果没有定义这样的构造函数,编译器可能报错。
(4) 直到main()结束后才会调用析构函数。 - 对于全局对象,它们的构造函数、析构函数调用时机为:
(1) 在程序进入main()之前自动调用它们相应的构造函数来初始化,但是初始化的顺序不确定。
(2) 如果在定义对象时没有提供初始值,则会暗中调用默认构造函数;如果没有默认构造函数,则自动初始化为0。
(3) 如果在定义对象时提供了初始值,则会暗中调用类型匹配的带参数的构造函数(包括拷贝构造函数)。如果没有定义这样的构造函数,编译器可能报错。
(4) 直到main()结束后才会调用析构函数。 - 对于类的静态数据成员对象,构造和析构的时机等同于全局对象的情况。
- 对于对象引用,其初始化和销毁都不会调用构造函数和析构函数。
- 当调用new运算符动态创建对象时,自动调用类型匹配的构造函数
- 动态创建对象时,当接受返回地址的指针为静态局部指针变量时,构造初始化过程等同于静态局部对象的情况。
- 动态创建对象时,当接受返回地址的指针为非静态局部指针变量时,构造初始化过程等同于非静态局部对象的情况。
- 当调用delete运算符删除对象时,自动调用对象的析构函数
- 对象赋值时,调用类型匹配的operator=重载函数,因此如果没有定义这样的赋值函数,编译器可能会报错。
- 构造函数分为三类:默认构造函数、拷贝构造函数和其他带参数的构造函数
- 默认构造函数要么没有参数(参数列表为void),要么所有参数都有默认值。
- 不能同时定义一个无参数的构造函数和一个参数全部有默认值的构造函数,会产生二义性。
- 拷贝构造函数第一个参数为本类对象的引用、const引用、volatile引用或const volatile引用,并且没有其他参数,或者其他参数都有默认值。
- 拷贝构造函数必须使同类对象的引用,而不能是对象值。
- 如果没有显式地定义默认构造函数却定义了带参数的构造函数,那么后者的存在就会阻止编译器产生前者,于是类就没有默认构造函数。此时如果定义该类型对象就会导致编译错误。
- 一般来说,重载构造函数的行为都差不多,必然存在重复代码片段。当需要定义多个构造函数时,应设法把其中相同的任务代码片段抽取出来并定义一个非public的成员函数,然后在构造函数中调用它。
- 拷贝构造函数和拷贝赋值函数容易混淆。拷贝构造函数是在对象被创建并用另一个已经存在的对象来初始化它的时候调用的,而赋值函数只能把一个对象赋值给另一个已经存在的对象。
- 如果实在不想编写拷贝构造函数和拷贝赋值函数(即不想复制对象),又不想让别人用编译器自动生成默认函数,可以把拷贝构造函数和拷贝赋值函数声明为private,并且不去实现它,但是不能像纯虚函数那样置0。
- 如果把默认构造函数声明为private,而把其他带参数的构造函数声明为public,就可以强制用户使用带参数的构造函数。
- 基类的构造函数、析构函数和赋值函数都不能被派生类继承。
- 派生类的构造函数应在其初始化列表里显式的调用基类的构造函数,除非基类构造函数不可访问。
- 如果基类是多态类,必须把基类的析构函数定义为虚函数,这样就可以动态绑定,否则有可能造成内存泄漏。即如果基类的析构函数不是虚函数,则派生类对象的内存单元有可能不会释放。
- 在编写派生类的赋值函数时,注意不要忘记对基类数据成员的重新赋值,可以通过调用基类的赋值函数来实现。