上一篇记录了在创建一个类时,首先要考虑这个类的构造函数、拷贝构造函数、拷贝赋值操作、以及析构函数的声明及定义;那么本篇主要说明的是有关类成员的声明及定义;有关类成员声明的工作实际上大多数时候都是在决定类构造函数、拷贝函数及析构函数之前需要考虑的。那么为什么我要把构造函数等作为创建类考虑的第一个因素呢?因为在大多数软件设计的情况下,无论这个软件是一个大型的应用程序还是其中的微小组件,都是先进行概要设计再进行详细设计。而概要设计的核心工作就是给出组件完成什么功能,为了完成目标功能如何与其他组件协同工作,遵守什么样的协定。详细设计才会根据功能以及组件间的协定给出类定义。那么这就意味着概要设计完成后,类的设计者应该已经对需要定义的类与类之间的松耦合关系、类层次结构甚至类应该拥有一些什么样的成员有了一个大致蓝图。而根据上述这三个因素,构造函数、拷贝函数、以及析构函数可以优先考虑。当然不能否认,在具体的类成员(尤其下述的前两种)出来后,可能对构造函数等一族需要进行进一步修改。 这部分工作虽然看上去简单,但是如果视同创建一个类与创建一个类型相同的话,那么这部分工作就变得不是那么简单了。 大致类成员分成四种: 1. 类常量 2. 类变量 3. 类成员函数(读取第2种的) 4. 类成员函数(改变第2种的) 下面我们针对每一种类成员分别说明。 1. 类常量 作为类专属常量,为了将常量的作用域限制于类内,必须让它成为类的一个成员;而为确保此常量至多只有一份实体,你必须让它成为一个static成员:
C++代码 class Cache { private: static const int BUFSIZE = 4196; char buffer[BUFSIZE]; // ... }; 上述只是声明式而不是定义式,如果要取某个类专属常量的地址甚至即使不取其地址时,C++编译器却坚持要看到一个定义式,所以我们必须提供定义式如下: C++代码 const int Cache::BUFSIZE; 这个定义式请放入实现的文件中而非头文件中。因为声明时,类常量获得初始值 ,因此定义时不可以再设初始值。顺带一提,宏定义#define无法创建一个类专属常量,因为#define不重视作用域。一旦宏被定义,它就在其后的编译过程中有效。这表示不仅不能定义类专属常量,而且不能提供任何封装性。 老的编译器也许不支持上述语法,它们不允许static成员在其声明式上获得初始值,另外所谓的"in-class初值设定";也只允许对整数常量进行。那么怎么办呢?可以通过下述的方式进行: C++代码 class Cache { private: static const int BUFSIZE; char buffer[BUFSIZE]; // ... ... }; const int Cache::BUFSIZE=4196; 假如在编译期间需要一个类常量值,例如上述的Cache::buffer的数组声明式中,编译器坚持必须在编译期间知道数组的大小。这时候万一编译器不允许“staic整数型类常量完成in class初值设定”,可使用the enum hack补偿。 C++代码 class Cache { private: enum { BUFSIZE=4196}; char buffer[BUFSIZE]; // ... ... }; 关于enum hack我会详细介绍。 除了类常量外,还有一种是non-static的const成员,这种用const修饰的成员向编译器表达了一个语义约束,表示这个成员不该被改动。当然这个语义约束理解起来并不困难,而编译器会强制实施这项约束。如果类中成员有这样的约束事实存在,那么请一定清晰的告诉编译器,以获得它的帮助。 const可谓多才多艺。它可以用来修饰 (1)global或者namespace作用域中的常量 (2)文件、函数、或者block scope中被声明为static的对象 (3)类内部的static或者non-static的成员变量 (4)指针本身,指针所指对象; C++代码 char greeting[] = "Hello"; char *p = greeting; //non-const ptr, non-const data const char *p = greeting; //non-const ptr, const data char* const p = greeting; //const ptr, non-const data const char* const p = greeting; //const ptr, const data 有人发明的一种指针的读法比较有助于记忆和识别,这种指针读法就是从右往左念。 例如,最后一个p是常量指针指向字符常量;另外,在《The C++ Programming Language》一书中,曾经提及过“引用”可以理解为常量指针,一旦被初始化或者赋值,其指针地址不可更改。 下面要知道const修饰的标识符什么时候被初始化? 实际上const修饰的标识符有两种,一种称为编译器const对象;另一种称为运行时const对象(函数参数为主);编译期const对象是针对编译器而言,如果用于初始化const对象的值在编译期即被确定,则通过类型检查后用这个初始值代替这个const对象本身(听起来好像跟宏#define相似啊):)而对于运行时const对象,其初始化时机和对象本身被创建的时机相同。作为函数参数的const对象(包括任何引用类型)在参数传递生成参数时同时初始化。 2. 类变量 类变量感觉上好像没什么可说的,但是这部分涉及到了OO的三大特性之一——封装。 类变量也称为数据成员,那么在一个类中的数据成员可以用public, protected, private修饰。这也是OO的封装级别,public意味着完全不需要封装,protected意味着派生类可以访问,但并不比public更具有封装性,private表示只有类成员函数以及友元类函数可以访问。原则上,类变量要求用private修饰。 在具体谈到某个数据成员的封装级别之前,我们应该首先考虑这个数据成员是否有必要被封装;换句话说,如果没必要封装,就表示它可以不是该类的数据成员。按照开闭原则和里氏替换原则来说的话,被封装的数据应该是那些变化的数据,而不是那些不变化的数据。 一旦决定某数据是需要被封装在类中的以后,那么就是决定封装级别的时候了。考虑封装级别的时候,就参考下面的引用: 引用 封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量(也就是封装它们),你可以确保class的约束条件总是会获得维护,因为只有成员函数可以影响它们。犹有进者,你保留了日后变更实现的权利。如果你不隐藏它们,你很快会发现,即使拥有class源代码,改变任何public事物的能力还是极端受到束缚,因为那会破坏太多客户代码。Public以为不封装,而几乎可以说,不封装以为不可以改变,特别是对被广泛使用的classes而言。被广泛使用的classes,是最需要封装的一个族群,因为它们最能够从“改采用一个较佳实现版本”中获益。“封装性与当期内容改变时可能造成的代码破坏量成反比” -- 参考《EFFECTIVE C++》条款22, 23. 另外需要考虑成员变量的声明通过采用外覆类型(wrapper types)可以使得用户不易误用。例如:(这个例子摘自《Effective C++》) C++代码 class Date{ public: Date(int month, int day, int year); private: int m, d, y; }; 乍看之下,这个类变量的声明看上去挺合理的。但是Date的客户却不像想象中的那么合理使用这个类;例如,欧洲的客户很容易输入错误的次序传递参数: Date d(30, 12, 2010); 更有可能输入错误的日期Date d(2, 30, 2010);那么怎么防范呢?很多人第一反应是,应该在所有的接口函数加上一些判断语句就可以了。如 C++代码 Date::Date(int month, int day, int year){ if(month>=1 && month <=12) m = month; else throw bad_date(); //... }; 这样,虽然能达到目的,但是不觉得这样一个构造函数已经很丑陋了吗?上面的代码还没有写出可以解决客户容易误用的第二个错误的判断语句。如果再加上那样的判断语句,估计会更丑陋的。那么还有什么更好的方法看上去不那么丑陋吗? C++代码 struct Day{ explicit Day(int d):val(d){} int val; }; struct Month{ explicit Month(int m):val(m){} int val; }; struct Year{ explicit Year(int y):val(y){} int val; }; class Date{ public: Date(const Month& m, const Day& d, const Year& y); private: Year y; Month m; Day d; }; Date d(30, 12, 2010) // error! wrong type Date d(Day(30), Month(12), Year(2010)); // error! wrong type Date d(Month(12), Day(30), Year(2010)); // correct! 针对第二种容易误用的解决方案,我想可以通过ENUM+外覆类型可以得到更好地解决; 那么类变量在声明时,除了考虑其封装性外,还需要考虑其合理范围,尽量避免误用。 3. 成员函数(读取第2种的) 设计这种成员函数时,在C++语言中需要注意和理解三个事项; (1)const 修饰符 (2)inline 的里里外外 (3)避免返回handler指向对象内部成分 (1)如上所述,const多才多艺,但const最具威力的用法就是面对函数声明时的应用;针对一个函数的声明式,const可以和函数的返回值、各参数、函数自身产生关联。但是针对第3种const成员函数而言,主要说明下const主要跟函数返回值和函数本身的两种关联的意义。 I. 令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。 II. 将const实施于成员函数的目的是为了确认该成员函数可作用于const对象身上。这样一来,使得class接口比较容易被理解,二来呢,它们使“操作const对象”成为可能,对于编写高效率代码是个关键,也很重要。(例如,pass-by-reference to const 这一技术的前提是,我们有const成员函数可用来处理取得的const对象) 注意,C++成员函数只因constness不同,可以被重载。这是个非常重要的特性。 (2)inline修饰符语义是对inline函数的每一次调用都以函数本体替换之。那这个语义跟宏的函数定义不一样吗?语义上一样,但是执行上不一样,要比宏更好。为什么呢?很显然这样每一次调用它们时,由于事实上函数已经被函数本体替换,所以不需要蒙受函数调用所招致的额外开销。当然,至此宏也能这样做到;但是inline函数实际上只是向编译器发出这样一个申请,但是申请的结果完全取决于编译器优化的结果。这就好像,你申请美国的过境签证一样,即使万事俱备,也未必会得到审批。 由于inline的语义,有足够的理由可以相信,这样会导致程序产生的目标执行代码会膨胀。如果在一台资源,尤其是内存吃紧的机器上运行目标代码时,这样的代码膨胀会导致你的程序招致内存换页所引起的开销,降低cache命中率。但是如果inline函数本体很小,替换后的结果如果比函数调用更小,那么我们也有足够的理由相信产生的目标代码更小,当然程序执行效率也会很高,也提高了cache的命中率。 那么什么样的函数本体算很小,可以比函数调用更小呢? 至少函数体包含循环语句,或者调用virtual函数,再或者利用函数指针调用都会使得inline的申请遭到拒绝。但是仅仅列出这两个标准,似乎并不是让人很满意的答案。幸运的是,现代编译器大多数都提供了一个诊断级别:如果无法将被申请函数inline化,会发出警告信息。 另一个慎重使用inline便是由于其语义而导致的debug困难。 (3) C++代码 摘自《Effective C++》- 条款28 class Point { public: Point(int x, int y); ... void setX(int newVal); void setY(int newVal); ... }; struct RectData { Point ulhc; //upper left hand corner Point lrhc; //lower right hand corner }; class Rectangle { public: Point& upperLeft() const { return pData->ulhc; } Point& lowerRight() const { return pData->lrhc; } ... private: std::tr1::shared_ptr<RectData> pData; }; 虽然这样可以通过编译,但是却是个逻辑上自相矛盾的错误。一方面upperLeft()和lowerRight()被声明为const成员函数,因为它们的目的只是为客户提供一个得知Rectangle相关坐标点的方法,而不是让客户修改Rectangle。另一方面,两个函数都返回引用指向private数据,使得private的封装形同虚设。 因此: (一)、成员变量的封装性最多只等于“返回其reference”的函数的访问级别。 (二)、const成员函数返回一个引用且引向数据与对象自身有关联,那么函数调用者有机会更改对象内部数据。