上一篇记录了在创建一个类时,首先要考虑这个类的
构造函数、拷贝构造函数、拷贝赋值操作、以及
析构函数的声明及定义;那么本篇主要说明的是有关类成员的声明及定义;有关类成员声明的工作实际上大多数时候都是在决定类构造函数、拷贝函数及析构函数之前需要考虑的。那么为什么我要把构造函数等作为
创建类考虑的第一个因素呢?因为在大多数软件设计的情况下,无论这个软件是一个大型的应用程序还是其中的微小组件,都是先进行概要设计再进行详细设计。而概要设计的核心工作就是给出组件完成什么功能,为了完成目标功能如何与其他组件协同工作,遵守
什么样的协定。详细设计才会根据功能以及组件间的协定给出类定义。那么这就意味着概要设计完成后,类的设计者应该已经对需要定义的类与类之间的松耦合关系、类层次结构甚至类应该拥有一些什么样的成员有了一个大致蓝图。而根据上述这三个因素,构造函数、拷贝函数、以及析构函数可以优先考虑。当然不能否认,在具体的类成员(尤其下述的前两种)出来后,可能对构造函数等一族需要进行进一步修改。
这部分工作虽然看上去简单,但是如果视同创建一个类与创建一个类型相同的话,那么这部分工作就变得不是那么简单了。
大致类成员分成四种:
1. 类常量
2. 类变量
3. 类成员函数(读取第2种的)
4. 类成员函数(改变第2种的)
下面我们针对每一种类成员分别说明。
1. 类常量
作为类专属常量,为了将常量的作用域
限制于类内,必须让它成为类的一个成员;而为确保此常量至多只有一份实体,你必须让它成为一个static成员:
class Cache {
private:
static const int BUFSIZE = 4196;
char buffer[BUFSIZE];
// ...
};
上述只是声明式而不是定义式,如果要取某个类专属常量的地址甚至即使不取其地址时,C++编译器却坚持要看到一个定义式,所以我们必须提供定义式如下:
const int Cache::BUFSIZE;
这个定义式请放入实现的文件中而非
头文件中。因为声明时,类常量获得初始值 ,因此定义时不可以再设初始值。顺带一提,
宏定义#define无法创建一个类专属常量,因为#define不重视作用域。一旦宏被定义,它就在其后的编译过程中有效。这表示不仅不能定义类专属常量,而且不能提供任何封装性。 老的编译器也许不支持上述语法,它们不允许static成员在其声明式上获得初始值,另外所谓的"in-class初值设定"也只允许对整数常量进行。那么怎么办呢?可以通过下述的方式进行:
class Cache {
private:
static const int BUFSIZE;
char buffer[BUFSIZE];
?// ... ...
};
const int Cache::BUFSIZE=4196;
假如在编译期间需要一个类常量值,例如上述的Cache::buffer的数组声明式中,编译器坚持必须在编译期间知道数组的大小。这时候万一编译器不允许“staic整数型类常量完成in class初值设定”,可使用the enum hack补偿。
class Cache {
private:
enum { BUFSIZE=4196};
char buffer[BUFSIZE];
// ... ...
};
关于enum hack我会
详细介绍。
2. 类变量
类变量感觉上好像没什么可说的,但是这部分涉及到了OO的三大特性之一——封装。
类变量也称为数据成员,那么在一个类中的数据成员可以用public, protected, private修饰。这也是OO的封装级别,public意味着完全不需要封装,protected意味着派生类可以访问,但并不比public更具有封装性,private表示只有类成员函数以及友元类函数可以访问。
在具体谈到某个数据成员的封装级别之前,我们应该首先考虑这个数据成员是否有必要被封装;为了日后类的可维护性不受到其他使用这个类的代码的限制,被封装的数据应该是那些极其易变化的数据,而不是那些不需要变化的数据。同时类也应提供改变数据的函数
接口。这样只要类不改变这些接口,那么其他使用该类的代码就不要做出改变,缩小了影响范围。在这样的前提下,类自身可以自由地提供相同接口但不同实现的函数定义。
另外需要考虑成员变量的声明通过采用外覆类型(wrapper types)可以使得用户不易误用。例如:(这个
例子摘自《Effective 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);那么怎么防范呢?很多人第一反应是,应该在所有的接口函数加上一些判断语句就可以了。如
Date::Date(int month, int day, int year){
if(month>=1 && month <=12)
m = month;
else
throw bad_date();
//...
};
这样,虽然能达到目的,但是不觉得这样一个构造函数已经很丑陋了吗?上面的代码还没有写出可以解决客户容易误用的第二个错误的判断语句。如果再加上那样的判断语句,估计会更丑陋的。那么还有什么更好的方法看上去不那么丑陋吗?
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种的)
再讲3和4之前,比如要知道C++的函数签名signature的语义。续。。。