显然,“方法1”比“方法2”的效率要高,运行的更快。
但是,从现在的程序设计角度来看,“方法2”更高级。
原因很简单:
(1)功能模块分割清晰——易读;
(2)也是最重要的——易维护。
程序在设计阶段的时候,就要考虑以后的维护问题。
比如现在是实现了在屏幕上的输出,也许将来某一天,你要修改程序,输出到打印机上、输出到绘图仪上;也许将来某一天,你学习了一个新的高级的排序方法,由“冒泡法”改进为“快速排序”、“堆排序”。
那么在“方法2”的基础上进行修改,是不是就更简单了,更容易了?
!
这种把功能模块分离的程序设计方法,就叫“结构化程序设计”。
当然这个程序更进一步可以定义两个函数Sort()和Print(),用来排序和打印。
3.面向对象的设计方法
随着程序的设计的复杂性增加,结构化程序设计方法又不够用了。
不够用的根本原因是“代码重用”的时候不方便。
面向对象的方法诞生了,它通过继承来实现比较完善的代码重用功能。
面向对象的设计方法和思想,其实早在70年代初就已经被提出来了。
其目的就是:
强制程序必须通过函数的方式来操纵数据。
这样实现了数据的封装,就避免了以前设计方法中的,任何代码都可以随便操作数据而因起的BUG,而查找修改这个BUG是非常困难的。
那么你可以说,即使我不使用面向对象,当我想访问某个数据的时候,我就通过调用函数访问不就可以了吗?
是的,的确可以,但并不是强制的。
人都有惰性,当我想对i加1的时候,干吗非要调用函数呀?
算了,直接i++多省事呀。
呵呵,正式由于这个懒惰,当程序出BUG的时候,可就不好捉啦。
而面向对象是强制性的,从编译阶段就解决了你懒惰的问题。
巧合的是,面向对象的思想,其实和我们的日常生活中处理问题是吻合的。
举例来说,我打算丢掉一个茶杯,怎么扔那?
太简单了,拿起茶杯,走到垃圾桶,扔!
注意分析这个过程,我们是先选一个“对象”------茶杯,然后向这个对象施加一个动作——扔。
每个对象所能施加在它上面的动作是有一定限制的:
茶杯,可以被扔,可以被砸,可以用来喝水,可以敲它发出声音......;一张纸,可以被写字,可以撕,可以烧......。
也就是说,一旦确定了一个对象,则方法也就跟着确定了。
我们的日常生活就是如此。
同时,面向对象又能解决代码重用的问题——继承。
我以前写了一个“狗”的类,属性有(变量):
有毛、4条腿、有翘着的尾巴(耷拉着尾巴的那是狼)、鼻子很灵敏、喜欢吃肉骨头......方法有(函数):
能跑、能闻、汪汪叫......如果它去抓耗子,人家叫它“多管闲事”。
好了,狗这个类写好了。
但在我实际的生活中,我家养的这条狗和我以前写的这个“狗类”非常相似,只有一点点的不同,就是我的这条狗,它是:
卷毛而且长长的,鼻子小,嘴小......。
于是,我派生一个新的类型,叫“哈巴狗类”在“狗类”的基础上,加上新的特性。
好了,程序写完了,并且是重用了以前的正确的代码——这就是面向对象程序设计的好处。
我的成功只是站在了巨人的肩膀上。
当然,如果你使用VC的话,重用最多的代码就是MFC的类库。
4.组件(COM)技术
有了面向对象程序设计方法,就彻底解决了代码重用的问题了吗?
答案是:
否!
硬件越来越快,越来越小了,软件的规模却也越来越大了,集体合作越来越重要,代码重用又出现的新的问题。
具体表现在1、用C++写的类,不能被其它语言重用(不能跨语言);2、重用代码只能处于源代码级而不是二进制级,这样就会暴露更多的细节,这是违背封装性原则的;3、一个类的设计人员走了,那他留下的将不是一笔财富,而是一堆非常难于维护的代码......COM的设计方法,就是解决上面问题的一个方式(在本文中不会涉及COM的知识)。
二.类的设计原则
1.类代表一个概念或实体,即使非程序员也能理解得了这些概念与实体
类是一个对象,它实际上就是对现实生活中物品的模拟,在类的设计中,越接近现实生活的设计越容易被理解。
要保证这点,首先类的名字一定要有它的含义。
例如我们要定义一个类来表示“虎”,那么对于这个类的名字,我们定义为ABC就非常不合适了,可能对于类的设计人员知道“ABC==虎”(当类多了之后,有可能连他自己也不清楚了),但对于其他人来讲,必须通过研究这个类的实现细节以及他的属性才能知道这是一个“虎”类。
其次,对于某个确定下来的类,它所拥有的属性和方法必须符合它本身的真实特性。
classCTiger
{
public:
voidRun();//虎可以跑
voidJump();//虎可以跳
voidFly();//虎可以飞?
?
?
,这将给类的使用者带来困惑
protected:
intm_iWeigh;
};
也许你说,你所描述的虎是动画或者是神化故事中的虎,它当然可以飞,那么,这时候你应该考虑到继承:
classCTiger
{
public:
voidRun();//虎可以跑
voidJump();//虎可以跳
......//普通虎的一些动作
protected:
intm_iWeigh;
};
classCHeroTiger:
publicCTiger
{
public:
voidFly();//神化中的虎可以飞
......//神化中的虎的其它特殊技能
};
2.尽量使类简单,使它能够被一般的程序员所了解
在C的程序设计中,会要求尽量函数设计的简单、清晰,一个函数完成的功能尽可能的单一。
对于C++程序设计,这一条规则同样适用,参看下面的例子,它表示一个Computer类:
classCComputer
{
public:
constintGetCPUType(int*piCPUType)const;
constintGetCPUFrequency(int*piCPUFrequency)const;
constintGetCPUPrice(int*piCPUPrice)const;
constintGetMonitorType(int*piMonitorType)const;
constintGetMonitorSize(int*piMonitorSize)const;
constintGetMonitorPrice(int*piMonitorPrice)const;
......
private:
intm_iCPUType;//CPU型号
intm_iCPUFrequency;//CPU主频
intm_iCPUPrice;//CPU价格
intm_iMonitorType;//显示器型号
intm_iMonitorSize;//显示器尺寸
intm_iMonitorPrice;//显示器价格
......//计算机其它零件的属性
};
在上面的Computer类中,每一个零件的每一个属性都作为类的属性单独存在,这样给人的感觉非常凌乱。
并且,对于某些计算机,有一些零件并不是必须的(比如说某些服务器根本不需要显示器)。
这样,如果需要对Computer进行定制的话,就要将某个不需要的零件的属性一个一个的去掉,这将是一件非常繁琐的工作,尤其在这些属性排列不规则的时候。
这时候,我们需要将类进行细化。
classCCPU
{
public:
constintGetType(int*piType)const;
constintGetFrequency(int*piFrequency)const;
constintGetPrice(int*piPrice)const;
private:
intm_iType;
intm_iFrequency;
intm_iPrice;
};
classCMonitor
{
public:
constintGetType(int*piType)const;
constintGetFrequency(int*piFrequency)const;
constintGetPrice(int*piPrice)const;
private:
intm_iType;
intm_iFrequency;
intm_iPrice;
};
classCComputer
{
public:
constintGetCPU(CCPU*pCPU)const;
constintGetMonitor(CMonitor*pMonitor)const;
......
private:
CCPUm_CPU;//CPU
CMonitorm_Monitor;//显示器
......//计算机其它零件的属性
};
这样的一个类结构将使结果看起来清晰的多,并且也更接近于实际情况,毕竟组装机更加物美价廉。
3.充分利用封装性来增加类自身的可靠性,使用起来才能更加可靠
1)封装全局变量
如果有人问我什么样的Bug最难查,我可能回答不出,因为难查的Bug实在太多了,但是和全局变量有关的Bug绝对是最难查的Bug之一。
在全局变量满天飞的年代,你可能根本分辨不出那些地方对变量进行了修改(也许有些修改根本是无心的)。
如果出现了一个问题,你必须将所有用到全局变量的地方都设上一个断点,不停的F5,F5......,多么庞大的工作量!
当然,你可以将所有全局变量封装起来,但你必须做一个人为的规定使所有人都只能从固定的接口进行数据的读写操作,这是一个考验编程素养的事情。
其实,在C++年代,你完全可以将这些规定交给编译器去做:
classCGlobalDataSet
{
public:
CGlobalDataSet();//初始化所有的全局变量
voidGetGlobalData1(int*piData1)const;
voidSetGlobalData1(constint&iData1);
voidGetGlobalData2(int*piData1)const;
voidSetGlobalData2(constint&iData2);
private:
intm_iGlobalData1;//全局变量1
intm_iGlobalData2;//全局变量2
};
在上面这个例子里面,将全局变量封装在一个类里面,并且作为类的私有成员存在,这样做的好处:
a、所有全局变量被定义为私有,可以避免无意间更改全局变量,因为你无法直接访问到它。
b、变量的读/写操作都有唯一的接口,你不可能在读的时候对变量进行更改,同样你也不可能在写变量的时候取得变量的值,这一点由编译器保证。
c、因为读/写有唯一的接口,也使排错更加容易
注:
对于全局变量的读/写操作,在实现的时候应该注意进行保护
2)封装实现细节
对于普通的类(相对于上面提到的单纯封装数据的类),也应该在设计的时候考虑到封装性,将没有必要暴露给外面的属性或者方法定义为Private,同时定义一些共有的接口和外部进行交互,将类本身的实现细节隐藏起来。
classCApple
{
public:
voidGetCost(int*piCost)const;
voidGetWeigh(int*piWeigh)const;
voidGetColor(int*piColor)const;
private:
intm_iColor;//色泽
intm_iWeigh;//重量
intm_iCost;//售价
intm_iPrimeCost;//成本价
intm_iPlantingTech;//栽培技术
};
对于一个苹果来讲,色泽、重量、售价、成本价以及栽培技术都是它的属性,但是对于前三个属性,我们可以也必须让买家知道,所以对这三个属性提供了对外的接口,以供用户选择;但是对于成本价以及栽培技术,这可能都是商业秘密,不应该暴露给客户知道(无商不奸-_-!
!
)。
4.通过继承建立类族,这样多态性就会有用武之地
请想象一下下面的场景:
一个宴会大厅,需要邀请3个国家的领导人用餐,这三个国家分别是中国、俄国和美国。
中国人可能更喜欢中餐,俄国人喜欢吃饭的时候喝点酒,而美国人可能更喜欢牛排,也就是说,三个国家的人各有自己的用餐方式。
作为主办方,不太可能把大厅开3个门分别用来接待不同的客人,最通常的做法是只有一个大门作为入口,等客人进去之后可以按照自己的方式用餐。
下面我们将这个场景模拟出来:
classCHuman
{
public:
virtualvoidEat()const
{
//默认的进餐方式
}
};
classCChinese:
publicCHuman
{
public:
voidEat()const
{
//中餐的吃法
}
};
classCRussian:
publicCHuman
{
public:
voidEat()const
{
//吃饭前会来点伏特加
}
};
classCAmerican:
publicCHuman
{
public:
voidEat()const
{
//西餐的吃法
}
};
//餐厅的入口,不区分是哪个国家的人
voidEntrance(CHuman*pHuman)
{
//每个国家的人用自己的方式吃饭,这个由多态来保证
pHuman->Eat();
}
//下面是用餐的过程
voidmain()
{
//中国人进入大厅
CChineseChinese;
Entrance(&Chinese);
//俄罗斯人进入大厅
CRussianRussian;
Entrance(&Russian);
//美国人进入大厅
CAmericanAmerican;
Entrance(&American);
}
在上面的例子里面,每个国家的人通过相同的入口进入大厅,然后按照各自不同的习惯进餐,这多么有序啊!
5.类的接口设计原则
对于一个对象来讲,它应该有自己的属性,同时也应该有自己的方法,也就是提供给外界,用于对属性进行读/写操作的函数,这就是所谓的接口。
由于接口是对象和外界进行交互的唯一通道,所以接口的设计应该尽量满足一下的原则:
a、接口的命名一定要有其明确的意义,并与其真正提供的功能相一致。
“接口一定要实现其功能”,这是一个最基本的要求。
并且,这个接口的名字一定要可以体现出此接口完成的功能,这样才不会给使用这个接口的用户带来困惑。
必要的情况下,如果接口的命名不足以解释接口的功能,需要增加相应的注释来进行说明。
classCApple
{
private:
intm_iColor;
};
对于上面的这个CApple类,如果我们想提供一个接口用来取得苹果的Color信息,那么intGetColor(int*piColor)const是个不错的选择,因为从名字上我们可以看出它实现的功能,并且它够简单。
b、接口应该尽量简单,它的功能应该尽量单一
考虑到下面的类:
classCDataSet
{
#defineMaxDataCnt20
public:
intSortAndPrint();
private:
intm_Dataist[MaxDataCnt];
};
在这个类里提供了一个接口,这个接口包含了两个功能:
对数据进行排序
将排序后的数据输出。
它也许在某些场合是适用的,但是在绝大多数的程序中可能不会考虑在排序的同时进行输出。
这就是接口过于复杂造成的。
可以考虑将两个功能分为两个接口:
classCDataSet
{
#defineMaxDataCnt20
public:
intSort();
intPrint()const;
private:
intm_Dataist[MaxDataCnt];
};
将“排序”和“打印”接口分开来提供给类的使用者,将控制权交给外界,这样,使用者可以根据自己的需要进行组合使用。
并且,接口的功能越单一,接口的实现越容易维护,类的扩充也越方便。
c、接口应该尽量隐藏实现细节
一个接口的实现细节是我们不希望暴露的,并且它往往也不是类的使用者所关心的,多于的信息可能还会给使用者带来困惑。
另一方面,过多的暴露实现细节也不利于对接口进行维护。
下面是我们实现了一个类
classCDataSet
{
#defineMaxDataCnt20
public:
//冒泡排序
intSortByBubble();
private:
intm_Dataist[MaxDataCnt];
};
从它的接口我们可以看出它是一个排序算法,并且采用的方法是冒泡排序法。
当这个类使用了一段时间之后,我们发现这个排序算法效率不够高,需要变更为快速排序法。
这时我们有两种解决方案:
1)增加一个接口intSortByQuick()
用户可以选择继续使用原来的排序算法或者新的排序算法,不过我想大多数用户的选择都是后者(谁不希望程序的运行效率更高呢?
)。
这样,绝大多数用户都会更改其用户代码从而使用新的排序算法。
2)将原来的接口变更为intSortByQuick()
在这种方案中,强制所有的用户更改其用户代码以适用新的算法。
稍作考虑就可以知道以上两种方法都是不可取的。
第一种方法给了用户选择权,但是照此发展下去类的体积将会越来越庞大;第二种方法将会彻底的失去用户的信任(谁愿意不断的改变代码呢?
)
如果在接口设计的时候隐藏了实现的细节,这个问题将迎刃而解:
classCDataSet
{
#defineMaxDataCnt20
public:
intSort();
private:
intm_Dataist[MaxDataCnt];
};
对于用户来讲,这个接口只是一个排序的算法,在我们更改其实现细节的时候也不用变更客户代码,我想这个是用户更容易接受的。
d、除非特别简单的功能,所有的接口都应该定义返回值。
几乎所有的函数在执行的过程中可能发生例外。
忘了从哪本书上看过一个例子,一个非常非常简单的函数,它所有的出口竟然有23处之多。
当然我们不太可能回避所有的问题,但应该在函数内部出错的时候返回给客户足够的信息。
classCDataSet
{
#defineMaxDataCnt2
//出错信息
enumeReturnCode
{
FAILURE=-1,
SUCCESS=FAILURE+1,
FAIL_MEMORY=SUCCESS+1,
FAIL_INPUT=FAIL_MEMORY+1
};
public:
//此接口向DataList中加入一个正数
intAddPositive(intiInputData)
{
//如果输入是负数,提示用户输入错误
if(iInputData<0)
{
returnFAIL_INPUT;
}
//如果DataList已满,提示用户Memory不足
if(m_iDataCnt>=MaxDataCnt)
{
returnFAIL_MEMORY;
}
m_Dataist[m_iDataCnt++]=iInputData;
returnSUCCESS;
}
private:
intm_Dataist[MaxDataCnt];
intm_iDataCnt;
};
上面的类的接口AddPositive向用户提供了足够的错误信息,这样做的