870920 Menu

SwingCoder之C++备忘录·11

类和对象

 类是抽象的概念和类型(模型),程序设计的基本单位。C++最本质的核心理念:用类来表示概念、专用的数据结构及一组相关的操作,以发挥某个特定的作用,或具有特定的功能。

 对象是类的实例化结果,是可操作的实体。

 一个类主要由数据成员和成员函数构成(类中可嵌套枚举、结构体和类,可有静态成员),类的所有对象均持有这些数据成员和成员函数。数据成员是类的基本属性,它们构成了该类特有的数据结构。成员函数则是类的行为,这些行为针对数据成员进行各种操作,从而达到某种要求或实现某个功能。

 直白的理解:类的属性(数据成员)代表其数据结构或构成形式(由哪些对象或变量所构成)。类的行为(成员函数)则代表该类能干些啥,能让别的类干些啥,别的类能让它干些啥。属性和行为明确后,该类的功能与作用就确定了,即:它是啥(如果是继承而来的派生类,则扩展或改变了啥。如果作为基类,则为所有派生类提供了啥)。

 类的所有成员函数均具有一个隐含的1参:this指针,代表调用此函数的对象本身。类的const函数,this指针也是const。类的静态函数无this参数。

 如果某个成员函数与类的数据成员无关,则可将之声明为静态成员函数,该函数为类所有,不属于某个具体的对象,亦即所有对象共享同一个静态函数(静态数据成员同理)。如果该函数是public性质的,则可以类外直接调用(需前缀类名和::),而无需事先实例化该类的对象。

 类的数据成员为全局数据(所有位于函数之外的变量和对象均为全局数据),但由于语言机制中的访问控制(private、protected),这些全局数据无法被类外的对象或函数使用(除非友元)。

 类的数据成员的初始化在类的构造函数中完成,数据成员的清理善后由类的析构函数负责,编程时体现为对象的创建与销毁。

 即使一个内无一行代码的空类,编译器也会自动生成4个成员函数:默认的构造函数、拷贝构造函数、赋值运算符重载函数、析构函数(C++ 11新增了移动拷贝构造函数和移动赋值运算符重载函数,见2.2.6):
class MyCalss
{
public:
Myclass() { }
MyClass(const MyClass&) { }
MyClass& operator= (const MyClass&) { }
~MyClass() { }
}
这种情况下,如果类的数据成员是内置类型,则其初始值不可预料。如果数据成员是类对象,则其初始值为该对象的默认构造函数所生成的结果。手工定义以上函数中的任何一个,编译器将不再生成所对应的该函数。如果MyClass类无需拷贝构造函数和赋值运算符重载,则将这两个函数声明在private区中,并且无需实现之(此办法同样适用于构造函数和析构函数)。

 构造函数的主要作用:在初始化列表中初始化基类,初始化本类的数据成员。在函数体内设置数据成员(对象)的初始状态,设置本类的初始状态。构造函数可有多个重载版本,也可以是private性质的,这种情况意味着不能显式创建本类的对象。注:如果本类的数据成员为栈对象且有默认的构造函数,则无需显式初始化之。原始类型的栈变量,需初始化。

 构造函数可以有参数,也可以无参数,无返回值,无返回类型,可重载,其函数名为类名。

 单参构造函数又称为“转换构造”。如无需编译器隐式转换,可在构造函数声明的最开始使用explicit关键字,建议这么做。

 析构函数无参数,无返回值,无返回类型,不能重载,其函数名为类名前加一个“~”。

 所有的类有且只有一个析构函数。如果未显式定义析构函数,编译器将自动生成。客户端使用时,永远无需显式调用类的析构函数。

 如果某个类有虚函数,则其析构函数也必须声明为虚函数。此举将使该类的所有派生类的析构函数也自动成为虚函数,以防止销毁指针时,基类无法正确析构的问题(比如:基类指针实际指向派生类的堆中对象,这种情况下,销毁该指针时,如果基类析构函数未声明为virtual,将导致基类无法被析构)。

 创建对象时,编译器总是先构造基类,而后是本类的数据成员。析构时正好相反:先析构最下层的派生类,而后逐次析构本类的数据成员和基类,最后析构的是本类所继承的最顶层基类。

 析构函数本身并不释放对象所占用的内存空间,它在操作系统回收内存之前被执行。动态分配的堆对象,可在析构函数中显式销毁并释放这些对象所占用的内存空间。

 根据用途和设计目的,类可分为:“值类”、“基类”、“特类”、“异常类”等几种。值类(包括基于RAII技术的工具类)通常有拷构与赋值重载,没有虚函数(析构函数也非虚),总是组合到其他类中,并在栈中实例化对象,String、var、Image和Array、HashMap等各种容器都是典型的值类。可作为基类的类,其析构函数通常是public性质的虚函数,或者protected性质的非虚函数(本类没有使用new创建堆内存并赋值给裸指针的前提下)。基类通常无需拷构与赋值重载,通过虚函数建立接口实现多态,使用时应总是声明指针或引用而非栈对象,而后指向堆中创建的派生类对象。特类一般没有数据成员,也没有成员函数,只包含public静态函数和静态数据,因此无需创建特类的对象,直接类名加::来调用即可。异常类的对象供throw抛出,抛出异常的函数由调用方的try所包裹,而后由catch(异常类&)捕获处理。

 不要在构造函数和析构函数中调用本类的虚函数。因为派生类构造完毕之前,要先构造基类,此时虚函数还未正式成为“虚函数”。而析构本类时,派生类已被析构,此时调用虚函数会出问题。

 函数内部不可以定义函数,但可以在函数内部定义类(局部类),或者将需调用的函数声明为本函数的形参(基于函数指针的函数回调)。

 判断两个对象是否相同最简单的办法是比较两者的内存地址是否相同。赋值运算符重载中,首先就要执行此操作,以防止自赋值,语句是:if (this == &rhs) return *this;

 多个栈对象的销毁顺序与其声明顺序相反,比如:
MyClassA objA;
MyClassB objB;
// 此处先销毁objB,而后再销毁objA。原因:创建对象即内存数据的压栈,而栈结构是后进先出。

类的数据成员为其它类的对象时:

 声明为栈对象:意味着创建本类对象时一并创建该数据成员。即:该对象不可或缺、与本类同生共死。

 声明为引用型对象:不可或缺,不可为空,但无需创建之,也无需销毁之。由别的类负责其生死。

 声明为指针:或许用到,或许用不到(通常在构造本类时将其初始化为nullptr),可推迟到使用时再创建(按需创建),或者由别处传递而来,可随时置为nullptr。需注意空指针调用函数和该成员的销毁问题。使用时偶尔会使用强转语句来赋值,通常强转后需先进行空指针判断。

 如需实现该数据成员的虚函数多态调用,则该数据成员必须声明为引用型或指针。

防止某个类创建栈对象(自动对象),只能new堆对象,流程有三:

 将该类的析构函数声明为private。只要看到这样的非抽象类,就意味着只能使用new来创建其堆对象。否则编译器报错。

 类中写一个deleteThis()自杀式函数,其语句为:{ delete this; this = nullpt; }

 new该类的堆对象之后,如需销毁,则该对象显式调用deleteThis()函数。

防止new堆对象,只能创建栈对象,只需将new和delete运算符声明为private,无须实现:

 void* operator new(size_t size); // 声明为private

 void operator delete(void* address); // 声明为private

使用RAII和作用域锁定/解锁的类最适于这种方式,即:只能使用其栈对象,离开作用域时自动销毁之。

 没有使用虚函数和静态成员的类,无额外的运行时开销,与C代码的结构与效率完全一致。

 对象之间的赋值是“昂贵”的,尽量使用拷贝构造或引用/指针(传址),而不使用直接赋值。

 创建大型数组和对象时,优先使用动态堆内存。数组方面,只要有可能,一律使用指针数组。

 使用堆也是比较“昂贵”的,此时要考虑减少创建行为,使之可复用,而不是用完即删(此即缓存技术的由来)。

 尽量避免重复创建相同的临时性对象,特别是循环结构中。

防止某个类显式创建对象:将其构造函数声明为private即可,如该类是基类,则声明为protected。

关于引用计数:这种技术有两个巨大的优势:一是数据共享,节省内存,提高效率,多个对象实际是同一个原始对象,指针之间的赋值几乎无成本。二是实现自动垃圾回收,当没有其他对象共享原始对象时,自动销毁之。使用引用计数的场合有二:多个对象可能共享相同的数据;创建和销毁每个对象的开销较大。

关于虚函数表与指向虚表的指针:带有虚函数的类,通过该类所隐含的虚函数表来实现多态机制(用来确定调用基类的成员函数,还是调用派生类的该同名函数),该类的每个对象均具有一个指向本类虚函数表的指针,这一点并非C++标准所要求的,而是编译器所采用的“内部方式”。不同平台、不同编译器厂商所生成的虚表指针在内存中的布局是不同的,有些将虚表指针置于对象内存中的开头处,有些则置于结尾处。如果涉及多重继承和虚继承,情况还将更加复杂。基于此,永远不要做任何假设,永远不要使用memcpy()之类的函数复制对象,而应该使用初始化(构造和拷构)或赋值的方式来复制对象。优先使用构造和拷构,尽量少用或不用赋值运算符(赋值和初始化的性质与内部实现机制是不同的,多了一个构造的步骤)。

关于类的设计:大多数类(特别是功能类和问题域的建模类)总是需要和其它类的对象发生关系或交互,此时应遵循七条设计原则(见2.5.2),优先使用“use-a”(聚合),其次是“has-a”(组合),尽量少用或不用“is-a”(继承)。

关于局部类:函数体内定义的类称为局部类,该类的定义和实现必须以内联的形式写在一起(因为无处可单独写类实现)。局部类不能有静态数据,不能派生子类,但可以继承其他基类。局部类的作用之一是用于实现基类的类型转换。

关于嵌套类:嵌套类(包括嵌套结构体)是独立的类(结构体),与其外围类无关,相当于给外围类增加了一个新的数据类型,该类型如果没有实例化对象,则外围类对象的数据空间中并不包含嵌套类的数据成员。通常,在嵌套类声明或定义之后,外围类中会声明嵌套类的对象,并在本类的构造函数中创建之。也就是说,嵌套类通常不对外(外围类public区中的嵌套类,外部可使用。private区中的嵌套类,外部不可用),仅用于对外围类做功能方面的补充与完善,使外围类拥有更多类型的数据成员,并通过这些数据成员增加自己的实用价值,实现更多的功能。如果嵌套类定义在外围类的public区中,嵌套类的对象也可以被其它类或函数使用,声明其对象的语法为:
外围类::嵌套类 对象名;

嵌套类不拥有外围类的成员,嵌套类的对象无法直接调用外围类的成员函数;外围类也不拥有嵌套类的成员,外围类的对象同样无法直接调用嵌套类的成员函数。即:嵌套类的对象与外围类的对象没有任何关联,各有自己的数据成员和成员函数。当然,静态数据成员和静态成员函数除外,嵌套类可直接使用外围类的静态成员、枚举成员和外围类的类名。嵌套类的名字仅在最近一层的外围类中可见,其他作用域内不可见。

类模板中的嵌套类默认也是类模板,它的模板形参与外围类的模板形参保持一致。

 某个类的嵌套类可以是另外一个类的派生类,甚至可以是外围类的派生类。

 外部可用的嵌套类可成为其它类的基类,即:外部单独声明和定义的类可以继承某个类的嵌套类,并且该派生类与嵌套类的外围类没有任何关系。

 外围类定义嵌套类之前,如果先前向声明class 嵌套类; 那么嵌套类即相当于外围类的友元类,这意味着嵌套类的实现中,可通过外围类的对象直接读写外围类的私有成员。

 外围类的public区或protected区中声明的嵌套类,可被外围类的派生类所使用。外围类的private区中声明的嵌套类,外围类的派生类不可使用。

关于类的分类:弄清类的本质与分类,在系统架构、分析设计、类库使用、复用重构等方面大有裨益。

 具体类:亦可称为“终点类、功能类”,容易理解,能够直接使用,可有基类,也可为独立类。

 抽象类:一组类(类族)的蓝图和规范,必须多态使用,必须派生子类,子类须实现基类的纯虚函数,基类的析构函数必须为虚

 接口类:用于简化、完善、修饰或增强另一个类(或函数库)的封装体(代理),提供更直观或一致的接口。接口类的另一个重要作用是:可为类的实现提供多个版本,这些版本具有一致的接口,根据不同的环境、条件或操作平台在编译期或运行期进行有针对性的调用,可为敏感和重要部分提供保护,而对用户隐藏细节。这种情况下,该类通常只有一个头文件,但可能有多个源文件,根据某些条件,在编译时确定仅哪个源文件参与编译。

 节点类:继承体系中的“中间类”,有虚函数,但非纯虚,可作为基类派生子类,也可直接使用,析构函数为虚函数。直接使用时,往往作为未来扩展功能的方案,为有可能编写的子类留下了余地。节点类通常提供了protected数据成员和public成员函数,没有或仅有少量的private成员。C++面向对象编程时,节点类的使用相当普遍(继承而来的虚函数依然是虚函数,基类虚析构,子类亦然)。

 域类:对问题域中的现实对象(操作、行为、概念、术语、规则等)进行面向对象建模,以类的形式来描述这些现实对象。这种类直接模拟和反应了编程问题域的真实情况,通常很难或无需复用,大多数情况下无需拷构和赋值重载,比如某款软件的主界面类、产生混响效果的数据计算类等等。注意:域类可以是具体类,也可以是抽象类或节点类。

 应用类:可复用的、具有普适性的问题域建模或抽象概念/功能,比如JUCE类库中的Time时间类、UnitTest单元测试类、CriticalSection互斥类、各种GUI控件类等等。

 集合与容器:通常包括底层的数据结构和插入、追加、删除、查找、排序等基本算法,含与之相关的迭代器和适配器类。常见的集合与容器有动态数组、链表、哈希表、二叉树等等。也可代表特殊的域类,比如音轨面板容器类、持有和存储音块的集合类。集合与容器类可采用基于泛型编程的类模板和通用接口技术来实现,但特定问题域的容器通常更倾向于采用基于特定类型的继承和多态技术来实现,这种情况下,可继承基于类模板实例化而得到的基类。

关于类库(class library):类库不同于程序框架(Application Framework),尽管二者都是一组类的集合,均符合高度可复用的软件工程原则。类库的概念大于框架的概念,相对来说更加底层,它的使用和扩展更加灵活,几乎有无数种编程模式和可能性。类库中的类可能有、也可能没有任何关系与约束,而框架则更加具体,有预定义的关系、要求和规则,通常仅限于特定平台及该框架所能表示的应用范围之内,很难或不便于扩展,灵活性稍差。框架可由一到多个类库所写成。

使用类库是C++ 编程的重中之重。大部分时间,程序员通过选择适当的、最接近问题域的已有类,采取聚合、组合、继承与多态等手段实现对类库的功能性扩展,从而快速完成具体问题的应用级编程。但万事皆有两面,依赖类库编程的缺陷有四:一是需要大量的时间和精力来学习类库本身,二是有些底层实现很难弄懂其根本原理和来龙去脉,三是提高效率的同时也意味着全盘引入类库中原有的Bug,一旦修改类库的源码,再次更新该类库后,修改结果将全部丢失,四是必须忍受和适应该类库特有的编码风格和命名规范,包括晦涩难懂或错综复杂的思维模式和实现技巧。