软件小设计(txt+pdf+epub+mobi电子书下载)

作者:董向阳

出版社:电子工业出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

软件小设计

软件小设计试读:

前言

计算机编程诞生至今,理论和实践的发展越来越丰富,各种编程语言也如雨后春笋般不断出现,不断壮大,不断消亡;随之而来的就是各种介绍计算机程序设计的书籍不断涌现。

在这些著作中,不仅有介绍编程语言的书,如初级的语言规范介绍、中级的语言特性应用、高级的设计模式和软件架构的实现,还有介绍编程技巧的书,如对象的设计、算法的分析、重构的实施、框架的应用,等等。这些书大部分都十分经典,内容集中在某一个具体的方面,就好比一颗颗珍珠零散地放在我们的案头上。

对于我这样从程序员过来的人来说,从入行的那天起,就不断地有高人给我推荐这些由浅入深的经典到不能再经典的书籍,于是我不断地采购这些书籍。 每当手里拎起这些沉重的书时,我就会深深地体会到知识的厚重。当然,从这些作者流畅而严谨的述说中,我还是逐渐地学习到了程序设计的各种知识。

在这样的情况下,有一天,在思考之余,我的脑中忽然有了一些大胆的想法:能否有一本书把这些经典的知识串起来,形成一个完整的体系呢?并且能否在这些理论和实践的基础上,再深挖一下设计本来的面目呢?良好的设计是否真的像那些高深的书籍描述的那样是那么高不可攀,必须要经过多年的修炼才能谈及呢?良好的设计是否真的需要那几十本厚重的书才能描述清楚吗?

为了回答这些问题,我决定写些什么,这是本书的写作初衷。有时想想,觉得我还真的有点不自量力,但是反过来想想,我又觉得,不妨就当抛砖引玉吧。我真心希望这本拙劣的小书能引起真正想学习编程技术的读者们的思考,能自己找到这些问题的答案。

本书将从最基本的面向对象编程开始,探讨面向对象设计中的基本概念,比如对象、元素、继承、多态等;然后,会探讨面向对象设计的基本原则;随后,会在这些思想和原则的基础上分析设计的基本流程及可能遇到的各种问题。在最后一部分,我们会脱离面向对象的束缚,直面设计的自然面貌。

书中的例子大部分都使用C#来实现,一部分例子使用Java和JavaScript来实现,不过好在代码都很简单,喜爱其他语言的读者看起来应该也是毫无压力的。

不过请大家注意,这本书里没有严密的论证,也没有严谨的定义,更没有高深的理论,因而还请大家抱着怀疑的态度阅读本书吧。如果你期望读到的是一本严谨、严密、高深的设计类书籍,那么这本书可能不适合你!

此外,现在大家经常会谈到“互联网思维”。在互联网时代,知识被快速地创建出来,然后飞速地累积和传播。本书的部分资料和图片,是通过搜索引擎直接找到并使用的,根本无法了解其最初的来源在哪里,因此书中如果引用了哪位仁兄的大作但是没有注明,还请见谅!你可以直接联系我,我会把你的作品及有关情况加到引用列表中。对于本书来说,我决定按照“互联网思维”的玩法来对待,就是你可以在引用本书观点的时候,说明引用自本书,当然不引用也没关系,这取决于你自己的选择,我只在意思想的传播。

最后,感谢我的老婆周花梅和我两个可爱的女儿董雨婷和董雨佳,正因为有了她们的爱和鼓舞,我才能够坚持完成本书!第1章设计概论

记者:“金庸先生,你认为在你的小说中,哪一位主角的武功最高强?”

金庸:“很大可能是张无忌。张无忌集各家之长,应该比较全面。”1.1 面向对象程序设计

今天与人闲谈中无意间提到了“倚天屠龙记”中的张无忌,这是一个普通的甚至性格有点软弱的主角,性格复杂,很多人读完小说以后觉得他一点都不像一个英雄人物,与乔峰乔大侠、郭靖郭大侠等相比,简直是差得太远了。回来之后细细回味思索一番,除了张教主是不是英雄这个问题外,我意外地发现他的学艺之路竟然颇有启发性,想来金庸大师也是哲学方面的高才啊。下面让我们一起来回忆一下张教主的学艺之路,并结合金庸小说中的各种成名绝技,看是否有值得借鉴之处。1.1.1 面向对象思想——任督二脉

打通任督二脉,是修习高等武术的基础和必经步骤,大家看过武侠小说的都知道,也基本都见识过了。之所以是基础,道理很简单,如果任督二脉不通,真气流转不流畅,当然就不能发挥自己体内最大的能量了。就好比一条非常细小的水渠,怎么能承受得了陡然暴发的洪水。这一关通常看似简单,但是非常有难度,即使是主角兄弟们,也只是会“意外”通过,一般都是借助了外力,比如武功高强的师傅、能力超凡的道具、不可思议的奇遇,等等。对于张教主来说,这一关在说不得大师的乾坤布袋中顺带通过了,凶险万分但非常轻松。对于大部分普通人来说,这一关可能终生都无法逾越,所以武功怎么练也无法达到上层境界。

基本上,任何用过面向对象语言(如Java、C++、C#等)的同学们张口就能说出面向对象的三大特征:继承、封装、多态。但是又有几个人曾试图深层次地了解过这6个字背后的含义呢?

面向对象思想是软件工程发展史上的伟大里程碑,它提供了软件设计与开发的新思路:那就是一切皆是对象。这种思想体现了人们对世界的重新认识。这种认识体现到实际的行动中,就是使用对象去表述世界发展的一切活动。为了生动描述事物之间特性的传承性,“继承”就诞生了;为了描述事物之间相同特性基础上表现出来的差异性,于是“多态”就被创造出来;为了描述事物的完整性和相对封闭性,“封装”就提上了日程,细节从此不需要再去关注。这是面向对象世界观的三大基本特征,也是面向对象设计的核心根基,只有深刻理解了它们,时时刻刻记住它们,把它们深深地融入到你的日常活动中去,你的神功才会一日千里。1.1.2 面向对象设计原则——九阳神功

当你能做到任何时候、任何场合,眼中只有对象的时候,那么恭喜你,你的任督二脉已经打通!你可以学习任何一门高深的内功了。为什么先学习内功呢?这个大家也都非常熟悉,没有内功的配合,任何高深的招式都是浮云。所以要想天下无敌,必须要练就一身深厚的内功,比如九阳神功。

对象的三大特征全都有了,表明在你眼中一切都应该是对象了,你已经是面向对象了,但是并不是说这就是好的面向对象了。

如何评判一个系统是好的面向对象呢?一般是参照“SOLID”标准。单一职责原则(SRP):做一个专一的人做好并且只做好一件事,这条原则其实不仅仅适用于对象,同样适用于函数、变量等一切编程元素。当然,在商业模式中,将一件事做到极致就是成功,笔者觉得也还是成立的。开放封闭原则(OCP):改造世界大部分不是破坏原来的秩序在对象世界中,添加新的功能一般意味着新的对象,一个好的设计也意味着这个新的修改不要大幅度波及现有的对象。这一条理解起来简单,实施起来却最是困难。无数的模式和解耦方法都是为了达到这个目的而诞生的。里氏替换原则(LSP):长大后,我就成了你父类使用的地方,子类也可以使用。这一条希望子类不要破坏父类的接口成员,一旦破坏了,就如同人与人之间破坏合同一样,有时候会很糟糕。接口分离原则(ISP):不要一口吃成胖子接口不要过于庞大,繁杂的东西是难以理解、难以扩展、难以修改的。这一条的目的与单一职责原则类似,不过是更加强调了接口的逻辑一致性和简易性。依赖倒置原则(DIP):抽象的艺术才有生命力高层与底层组件之间都应该依赖于抽象的组件。这一条深刻揭示了抽象的生命力,抽象的对象才是最有表达能力的对象,因为它通常是“无形”的,可以随时填充相关的细节。

除了这几个基本的设计原则外,还有一些衍生原则,掌握它们,你将能更好地面向对象。迪米特法则:尽量不与无关的类发生关系。对象之间联系越是简单,则越是容易管理。好莱坞法则:不要调用我,让我调用你。电影中常说,单线联系最安全,就是这样。多使用组合,少使用继承复用的手段除了继承这种强约束手段外,组合这种弱耦合的关系更加灵活。实现依赖于抽象这一点一般也称为面向接口编程,保证了对象之间关系稳定。

修炼完这些原则,你已经神功初成了,那是不是活动活动手脚,就可以大干一场了呢?1.1.3 模式——乾坤大挪移

当张无忌学会了九阳神功下山以后,他已经身负绝世内功了,但是不知大家发现了没有,他居然连最普通,只会些三脚猫功夫的毛贼都打不过,他只能依靠阿蛛的招式,来借力反击,只是这样谈何天下无敌呢。不过当他学会了乾坤大挪移以后,那真的是如同凤凰涅 ,瞬间完成了超级赛亚人变身。这个道理似乎也很简单,光有高深的内功,但是没有运转自如、充分发挥内功的招式,同样也是不成的。招式,那可是前辈们经验的总结,是精华啊。学会了这个,你才有决胜天下的资本。

世间原本没有模式,使用的人多了,才有了模式。模式是经验的总结,是“巨侠”们的心血。大型模式如架构模式(分层、MVC、PAC、黑板、中间人、反射、管道、微核、REST架构等),它们描述了系统大骨架的构建过程;中等规模的模式如设计模式(GOF 23种模式、POSA中的各种设计模式),它们描述了子系统中组件的构建过程;小的模式如各种语言中的编程实践(C#中的IDispose模式、C++中的Counted Point)模式等,它们描述了解决语言中特定问题的实施方案。这些模式合起来就是面向对象程序设计过程中针对特定问题的乾坤大挪移神功。1.1.4 重构——太极拳

如果一定要评出金庸小说中的三大巅峰神功,那么太极拳必能入选。练习太极拳是需要超凡的悟性的,太极拳重在以意运转,不能有丝毫外在强加的约束,这一点看过原著的同学们都很清楚。自从太极拳问世后,人们才真正体会到了什么叫做“以柔克刚”!

重构是精心打磨、持续雕琢代码的过程,是任何资深码农的必备技能。重构不是无目的的,重构是一种在不改变代码行为的前提下,改善代码可读性、可扩展性的过程。

之所以需要重构,就是因为代码也是符合事物发展规律的,也有一个从出生到成长,从强壮到衰败,从衰败到腐烂的过程,而且是循序渐进的,不知不觉地就从好变烂了。毫无疑问,并不是所有的烂代码都是一次写成的,也许最初的代码设计是很好的,但是一旦被多个人修改过以后,就变坏了,很多人对此都深有体会。代码总是在所有人的共同“努力”下写烂的。

如果说上面提到的那些技术都有迹可循、规则性比较强的话,那么相对来说,重构的技术要相对柔和一点,没有那么强烈的约束条款,而且虽然也有像介绍“代码坏味道”的伟大著作(《重构》一书)诞生,但是,通常人们脑海中对于重构的冲动,从没有像使用模式那么强烈,根本不会想到重构会是那么的重要,就如同书中的那些高人们根本不会想到如此缓慢而且无固定招式的太极拳会是那么强悍一样。重构与太极拳一样,进行到多大程度和画多大多小的圈,都无法明确衡量,它们都是真正的“意识流”。1.1.5 抽象与组合——独孤九剑

要说金庸小说中最厉害的武功,笔者认为既不是乾坤大挪移,也不是太极拳,更不是葵花宝典,而是独孤九剑。那是什么境界啊?九剑破遍天下任何拳脚、气功、暗器、十八般兵器,这才叫“无敌”。当然,这个武功不是随便是谁都能练得成的,也就是像令狐冲那样智慧超群,洒脱异常,具备一定内功(后期更辅助以吸星大法),武功又杂的小哥才能练;而且这个练习过程一般也比较长,据风清扬介绍,令狐老兄刻苦练习十几年后方能与东方不败一战。

我们再看看该神功原创者独孤大侠一辈子兵器的变迁:从利剑到重剑,到木剑,再到无剑。大家发现了没有,到了最后抛开一切招式、内功,融会贯通,水乳交融以后,才是真正的超凡入圣,这个时候也才发现原来一切又回到了起点:原来武功就是这么简单,就是锻炼与发挥人体的最大能量,随便地一挥一洒就有无与伦比的威力。返璞归真才是武者的终极、青铜门后面的永恒(出自盗墓笔记一书)。

不管是面向过程,还是面向对象,不管是面向组件,还是面向服务,归根结底只不过是人们对世界的抽象方式和抽象的粒度不同而已,把抽象出来的东西组合起来,形成一定的规范和流程,依靠这些去解决实际的需求,这就是编程。抽象与组合,才是终极的艺术。1.2 面向过程与面向对象

前面我们提到了面向过程和面向对象的概念,作为计算机编程理论发展过程中最为重要的两种思想,在正式接触设计细节之前,我们绝对有必要先来弄清楚一下它们的来龙去脉。

计算机之所以出现,就是为了帮助人们解决科学计算中人类无法承担的大量的繁重计算,这也是为什么我们称之为“计算机”的原因,所以初期的程序也基本都是集中在科学计算的相关方面。对于科学计算来说,问题解决起来就简单了,基本就是把数据放到一个统一的地方,然后把计算过程分为几步,每步使用一个函数去操作那些数据,最后用主函数把这些小函数串起来调用一遍就得到结果了。这个过程其实就像小品中说的:“请问,把大象装进冰箱需要几步?”答案是:“分为三步,第一步打开冰箱门,第二步把大象塞进去,第三步把冰箱门关上。”

随着计算机技术的不断提高,计算机逐渐被广泛用于解决各个领域的问题,于是计算机处理的问题越来越复杂,程序的业务流程也越来越庞大,因而实现业务的函数也越来越大,越来越多。于是程序设计开始不得不先使用最主要的外层函数来实现空的流程外壳,然后逐步细化每个外层函数来生成更多的内层的函数,直到最内层的函数完成功能,设计结束,如图1-1所示。图1-1 程序设计流程

这些最外层的意义丰富的函数就叫做模块,这就是模块化的思想。模块化和函数构成了面向过程(Procedure Oriented)编程的主旋律;从上往下,步步细化,以函数为编程的基本单元来组织整个软件的运行流程,这么做简单粗暴,但是相当有效。

这个思路一直高效运作,直到计算机处理问题的复杂程度超出了函数的能力范畴,人们才不得不重新考虑编程的基本单元。

就好比一个国家开始的时候,1毛钱能买很多东西,大部分时候1毛钱就够用了,1块钱的东西算是比较贵的,如果没有1块钱,也只需要掏出10个1毛钱就可以了,所以主要印刷1毛钱就行了。但是随着物价越来越高,很多东西都是以10块为起卖价了,甚至还有一些东西已经卖到100块,这个时候如果还是以1毛钱为支付的主要钱币,那么买100块的东西,乖乖,你要带着1000张1毛钱去买东西,这该多么可怕啊,于是大家不得不改为以印刷10块钱为主。这种新10块钱的主流纸币在编程理论中就是“对象”。

我们回过头来看面向过程的过程,通过前面的分析,我们知道函数的本质就是计算的步骤,代表的是一种行为。这些函数使用的数据集中存放在一个地方,需要的时候去拿就可以了。这个过程是以函数作为最基本的逻辑单元构建出来的整个业务系统,这里处处都透出全局的味道,函数是全局的,数据是全局的。但是随着问题越来越复杂,有些数据很明显需要控制起来,不再是所有函数都能访问的,而且很多函数之间越来越表现出很强的关联性,于是一个更加强大的逻辑单元,第一次将程序的两个基本要素——数据和算法,结合起来的超级元素“对象”诞生了,设计思路迈出了关键的一步,如图1-2所示。图1-2 面向对象面向对象(Object Oriented)不是抛弃面向过程,而是在过程的基础上,把数据加入抽象的范畴,理解这一点至关重要。

面向对象仍然是基于模块化基础上的思考方式,只不过描述业务逻辑的功能单元不再是代表单一行为的函数,而是行为和数据的混合体——“对象”。相比面向过程,面向对象最主要的进化就是把问题领域中的事物给对象化了,对象包括属性与行为。在这个抽象的过程中,为了描述私有性,封装来了;为了描述传承性,继承来了;为了描述差异性,多态来了。

人类世界的发展在继续,计算机的发展也在继续,渐渐地,人们发现在对象的使用过程中,总是有些东西是贯穿多个对象的,比如记日志,基本上每个核心调用都可能用到,其他的还有像安全性检查、登录状态检查、性能检查等,相对来说,它们虽然不是核心的业务逻辑,但却是系统不可缺少的步骤,这些离散的特性使得我们并不能用继承的方式去复用这些功能,于是立足于解决这种问题的编程理念——面向方面编程出现了。

面向方面编程将这些重复的但是无法通过简单继承来复用的功能称为方面,将方面抽取出来后创建专门的对象来表示它们,然后在编程中通过各种静态注入(代理),或者动态注入(反射)的方式来融合到原来的对象体系中实现复用。所以,方面是对象的一种补充,而没有从基本概念上做出新的突破。面向方面(Aspect Oriented)不是替代面向对象的,而是作为对象编程的有益补充而存在。

接下来,随着分布式系统的大范围应用,软件分布的范围也在逐渐扩大,于是模块可能不在一起了,有些模块被单独放置在某个地方,供其他模块,特别是某些远程模块调用;也有可能公司业务重组,有些模块被保留供别的业务使用,总之,这些模块可统称为服务,如图1-3所示。图1-3 面向服务面向服务(Service Oriented)也不是替代面向对象的,它是在对象的基础上,提供了更大粒度的抽象。

面向服务研究的重点在于模块的分布式部署和使用,目前基于XML这种中间格式的SOAP协议或者REST开发方式最为热门,它们解决了不同实现的系统(异构系统)之间的分布式开发。所以服务仍然是对象的补充,并没有从基本概念上做出关键突破。

回顾上面这个不断发展、不断创新的过程,我们发现:(1)科技的进步来源于实际的需求,请时刻记住“实际的需求”,因为这一点是很多理论的基石,比如敏捷开发、简单设计、重构等。(2)量变带来质变,最重要的就是过程到对象的突破,大数据和云计算是新的量变,这次量变什么时候引发质变呢?大家拭目以待,当然也有可能由你来突破这关键的一点。(3)设计的思路从来都没变,仍然还是模块化,层层细化或者反过来。(4)面向对象仍是设计的核心,它包含了其他面向XX的思想。1.3 设计的宏观面貌

软件设计听起来很高大上,很抽象,一般人闻之变色,但其实,软件设计的过程就是软件开发的过程,也就是从客户需求分析到构建软件系统的过程。设计的产物就是开发的产物,就是代码本身;俗话说得好,代码即设计。

面向对象设计的宏观面貌描述的就是设计的过程。

设计的过程有很多种,比如瀑布模型、原型模型、迭代模型、统一过程模型、极限编程、敏捷开发,等等,这么多的开发理论,读下来可以搞得你晕头转向,这是本书第一次提到它们,也差不多是最后一次,因为不管它们的定义和流程有多少差异,基本都可以归结为以下几个方面。1.3.1 开发模式:自顶向下和自底向上

方式1:从上往下、步步细化

自顶向下的开发模式是不断地将相对复杂的大问题分解为相对简单的小问题,找出每个问题的关键、重点所在,然后用精确的思维定性、定量地去描述问题,最终完成软件的过程。该模式的核心本质是“不断分解”,直到每个问题都变成比较简单的编程问题。

这是大部分模型采用的正统开发模式,采用这种模式比较稳健,开发的过程很容易控制。

以开发一个网络爬虫获取各大网页的新闻为例,自顶向下的设计过程大致如表1-1所示。表1-1 自顶向下的设计过程分解过程过程描述分解结果得到主程序类,有一个入启动爬虫程序,输入需要口方法(如main方法),方第一次分解获取的网址,运行后获得法内描述了最为粗糙的程新闻序流程将主要的获取新闻的流程继续分解:将每个网站的得到爬虫类,有一个公共爬虫存起来放到集合中,第二次分解的方法,调用以后可以获然后一次调用各自的爬取取到新闻并存入数据库中方法,得到新闻后存到数据库中每个网站的网页可能模板是不同的,需要重载爬虫类的各个虚方法,得到各得到各个爬虫子类,数据第三次分解个网站对应的爬虫子类;库操作类存放数据库功能移到数据库类中考虑数据库的不同,实现第四次分解含有各种操作细节的数据得到数据库子类库子类

上面是一个简单问题的自顶向下的分析过程,其实大部分讲软件设计的书都会讲到很多的实例,但本质上都是这样一个不断分解、不断细化的过程。

方式2:从下往上、步步抽象

自底向上的开发模式是先编写出基础程序段,然后再逐步扩大规模、不断补充和升级某些功能,最终构造出软件的过程。该模式的核心本质是“不断归纳”,直到形成稳定的系统。

这是新兴的模型们推荐的灵活的开发模式,采用这种模式比较灵活,系统应对变化的能力相对较强。但由于设计是从最底层开始的,所以如果设计经验不足的话,难以保证总体设计的最佳性。

还是以开发一个网络爬虫为例,自底向上的设计过程如表1-2所示。表1-2 自底向上的设计过程归纳过程过程描述归纳结果启动程序,抓取新浪的新主程序类,新浪爬虫类, 第一次归纳闻,写到MongoDB中MongoDB操作类新增抓取网易新闻的爬第二次归纳虫,由于代码有些重复,爬虫基类抽取爬虫基类考虑需要支持MySQL,添加操作MySQL的数据库第三次归纳数据库访问基类类,由于代码有些重复,抽取数据库基类

这就是一个典型的自底向上的开发过程,本质上就是不断归纳、不断抽象的过程。实际上在现代设计中,有很多的大型项目都是混合使用自顶向下法和自底向上法的,因为自顶向下可以确定系统大的架构方向,自底向上可以灵活调整系统的实现细节。1.3.2 开发方式:迭代

不管是采用自顶向下,还是自底向上,如果缺少迭代这种方式,都是万万不行的。注意到上面描述两种模式中的“不断”这个词了吧,“不断”就代表多次重复,就是迭代。迭代若用图来描述则如图1-4所示。图1-4 迭代

这幅图也代表了软件开发宏观的全貌,从需求到设计,到实现,到测试,到部署运行,再到反馈,再回到新的需求,如此这般,就是现在大家普遍认同的软件设计的通用流程。1.3.3 开发结果:模块化

虽然现在模块的规模和承载模块的部件都在发生着变化,但是模块化仍然是面向对象设计的核心,也是开发和最终部署的构件。

好了,至此设计宏观的讨论结束,这些步骤切实有效,在后面的章节中,我们将不断地见到它们的身影。1.4 设计的微观世界

面向对象设计是基于面向过程设计发展而来的,对象是数据和行为的结合体。在面向对象设计的微观世界中,我们研究的就是构成设计的各种元素和粒子,即函数和对象。在讨论对象之前,我们先讨论一下行为的载体:函数。1.4.1 函数

函数还用讨论吗?我们从学习编程的第一天就开始用了,多简单啊。

在开始讨论之前,你能先回答一个问题么:“为什么编程的行为要用函数来表达?”也就是:“为什么需要函数?”

其实“数据结构”第一节课就告诉我们:程序=数据+算法。算法就是行为,算法是用函数来表示的,所以我们离不开函数。这也是编程最基本的一个认知了。函数是表示行为、描述算法的代码,函数是面向对象设计最基础的一环。这么说固然正确,但是可以展开来说。

函数存在的目的有以下几个。

1.分解复杂算法

自顶向下的设计理念告诉我们设计要从上向下,步步细化。细化的是什么?细化的是算法,每个函数的实现就是一个子算法。函数的第一个作用就是分解算法,简化流程。

还记得如何把大象装进冰箱吗: static void Main() { OpenDoor(); PutElephant(); CloseDoor(); }

这里的每个子函数都可能又划分成多个更小的函数。

2.信息隐藏

函数的第二个作用就是隐藏实现细节,也称为“信息隐藏”,更专业一点的称呼叫“封装”。

比如,上面例子中OpenDoor函数的实现可能是这样的: void OpenDoor() { // 1.走到冰箱前 // 2.握住门把手 // 3.使劲拉开 }

这里的每一个细节都被隐藏起来了,别人根本不用关注,只要知道这样门可以打开就行了。

其实,实现细节也包含两个部分:数据和算法。数据就是局部变量,算法就是实现逻辑。信息隐藏的同时也是信息的保护。

3.复用

函数的另一个重要功能就是复用,很多人甚至认为这是函数的唯一用途,或者说是最主要的用途,其实并不是。

看下面这个打印数值的例子: class Program { static void Main(string[] args) { int year = 2015; Print(year, ConsoleColor.Red); year = 2016; Print(year, ConsoleColor.Blue); Console.ReadLine(); } static void Print(int value, ConsoleColor color) { Console.ForegroundColor = color; Console.WriteLine(value); } }

打印整数值的逻辑很多时候都用得上,所以可以复用。

4.应对变化

函数调用只提供一个名字,而实现细节被隐藏到了函数体中。所以只要函数的签名不变,函数体内的修改并不会波及其他代码。因而函数提供了应对变化的手段,把可能变化的逻辑放到函数中封装起来,这是函数的重要用途。

比如下面这个Print函数: static void Print(int value, ConsoleColor color) { Console.ForegroundColor = color; Console.WriteLine(value); }

如果哪一天我们觉得不输出到控制台,而是输出到文件中,那么,我们只要修改Print本身就可以了,不会波及其他地方。

5.提供注释

函数的名字本身就是最好的说明,当我们考虑给一段代码写注释的时候,就是可以使用函数的信号。 void OpenDoor();

这个签名比注释是不是好很多?而且有编译保证正确性,不像注释,写错了也没人知道,而且相关逻辑更改了以后一般人也不会更新注释。

综上所述,其实函数是编程设计中最常用的抽象方式之一,属于粒度较小的元素,这是面向对象设计的基础。比函数稍大一点的抽象是对象,它在函数的基础上加入了数据,下面我们就讨论一下对象。1.4.2 对象

对象是具有状态和操作的编程实体。对象具有状态,一个对象用数值来描述它的状态。对象具有操作,用于改变对象的状态,操作就是对象的行为。对象实现了数据和操作的结合,使数据和操作集合于统一体中。

面向对象的基本哲学是认为世界是由各种各样具有自己的运动规律和内部状态的对象所组成的,不同对象之间的相互作用和通信构成了完整的现实世界。因此,人们应当按照现实世界这个本来面貌来理解世界,直接通过对象及其相互关系来反映世界。这样建立起来的系统才能符合现实世界的本来面目。在这个哲学体系中,最为基础也最为重要的概念就是面向对象的三大特征:封装、继承和多态。

1.封装

没错,“封装”,就是前面讨论函数功能的那个“封装”。手法是一样的,只不过目标换成了对象。封装是信息隐藏的手法。

封装确定了对象与外界交互的稳定方式,这些稳定的成员叫做契约或者接口。封装提供了一个良好的合作基础,只要接口不变,则双方互不干扰,同上面函数的封装意义一样,把可能变化的逻辑使用对象封装起来,是应对变化最直接的手段之一。

在面向对象语言中,对象基本都是由类来实例化的,类就是对象的模子,它定义了对象的一切。

看大象这个类: class Elephant { private string m_name; public Elephant(string name) { m_name = name; } public virtual void Run() { Console.WriteLine("我跑得比较慢..."); } }

很简单,典型的对象的影子,成员有数据(name),有行为(Run),它们被封装到了一起。

2.继承

还记得上面函数的复用吗?继承就是对象级别的代码复用的重要手段,但不是唯一手段,还有一种手段叫组合。继承和组合是代码复用的两种最基本的手法。

看猛犸象的类: class Mammoth : Elephant { protected int m_age; public Mammoth(string name, int age) : base(name) { m_age = age; } public virtual void Howling() { Console.WriteLine("我还会吓人..."); } public override void Run() { Console.WriteLine("我可是飞象哦..."); } }

它也是大象,有名字,还有了年龄数据,也能跑,还会吓人。它在大象数据和行为的基础上,扩展了新的数据和行为。

3.多态

继承提供了复用,但是个体是有差异性的。多态就是用于在统一的接口这个框中描述个体的差异性,多态是继承这种手法的延续。

假设猛犸象跑得很快,那么它就不能用原来大象的行为了,需要表现差异: public override void Run() { Console.WriteLine("我可是飞象哦..."); }

好吧,还是看更实际点的例子吧。

在.NET框架中,我们需要一个根对象Object,来提供每个对象都需要的接口功能,如ToString、GetHashCode、GetType、Equals等。同样的,在实战中,在一个软件系统中,我们也需要使用一个对象去描述软件内所用到的实体类都需要的接口功能,我们通常也需要设计这样一些基类,比如可以存储的对象的基类、显示数据的对象的基类、接收和处理用户输入的对象的基类等,例如下面的代码: public class ModelBase { public virtual void Save() { } public virtual void Load() { } } public class ViewBase { public virtual void Show() { } public virtual void Hide() { } } public class ControllerBase { public virtual void Render() { } }

当需要用到实际的类的时候,我们就可以从这些类来继承,获得这些接口方法的默认实现。如果实现的细节不同于这些默认的实现,那么就可以重写这些方法,也就是多态的体现。

综上所述,如果把重载当作函数的多态的话,我们对比一下函数的功能和对象的特征,你会觉得除了抽象粒度大小以外,还真没什么不同。

来看一下对比关系,如表1-3所示。表1-3 函数和对象比较功效函数对象分解复杂问分解复杂算法的步骤为细分解复杂的对象为多个简题化的步骤单的对象隐藏信息隐藏算法细节隐藏对象实现细节复用代码复用函数的代码复用对象的数据和行为函数签名稳定,细节变化对象接口稳定,实现细节应对变化波及范围有限变化波及范围有限对象名具有良好的注释作提供注释函数名具有良好注释作用用

这就是面向对象设计微观世界中最主要的粒子的特性。如果说现实世界中的粒子是围绕万有引力来转的话,那么设计世界中的粒子就是围绕信息隐藏、复用、变化来转的,这是本书将要讨论的重点。1.5 小结

本章我们粗略回忆了一下编程设计发展的一个历程,然后在此基础上探讨了设计的宏观面貌和微观组成,理解了这些,虽然好像还是什么高级的工作也做不了,但是在这个过程中,你的面向对象设计的任督二脉就已经打通了,带着这些懵懵懂懂的理解,接下来就可以修炼高级的内功。在后面的历练中,随着你修行的增强,这些抽象的概念将逐步变得清晰和明了,而这就是你以后横行天下的基础。就犹如一栋大厦的地基一样,虽然朴实无华,但是不可或缺。第2章设计原则

张无忌练成九阳神功后,本身所积蓄的力道已是当世无人能及。——金庸《倚天屠龙记》2.1 通用原则

程序设计技术发展至今,对象的概念始终贯穿于设计过程的始终,广义的对象包括了参与设计的所有编程元素,比如变量、函数、类、组件、服务等。在程序设计理论中,对象的设计基本上就等同于设计的全部。在对象设计的进化过程中,渐渐地就有了一些通用的规则。在这些规则中最重要的一个就是KISS原则。2.1.1 KISS原则

KISS原则是英语“Keep it Simple and Stupid”的首字母缩写,也有人称“懒人原则”。KISS原则是指在设计当中应当注重简约的原则,同时,这个原则在商业书信、设计电脑软件、动画、工程上都有着广泛的运用。这个原则的原文其实还有其他许多版本,但是流传最广的说法还是:“Keep It Simple & Stupid”和“Keep It Short & Simple”。不管是哪种说法,放到程序设计中来讲,都指的是“软件设计要保持简单”。

就像人们常说美食要“色香味”俱全,这里面的第一步就是“色”,也就是看起来要好,设计也不例外,作为设计的最终产物——代码,更不例外,一个好的设计(代码)首先看起来要好,KISS原则就可以演绎为设计中描述代码的外观特点的几个小原则,我称之为“代码之形”。这些小原则非常重要,也最容易为人所忽略,我认为它们是一切程序设计的起点和基础,后面我们还会不断地提到它们。2.1.2 代码之“形”

我记得一位很有名气的程序员说过:任何程序员都能写出机器能懂的代码,只有最优秀的程序员才能写出人能懂的代码。人能懂的代码必须能满足下面几个条件。

1.简单

这一原则非常简单,也非常重要,任何难以维护和扩展的代码基本都不是简单的代码,敏捷开发提倡简单设计,越是简单的设计,越是简单的代码,越是容易维护,越是容易重构,问题也相对越少。

当然,简单并不等同于代码越少越好,功能越少越好,这里的简单通常指的是逻辑处理上应该用简单直接的设计来满足目前的需求。

在重构理论中,也非常强调逻辑简单,比如少用复杂的运算符和复杂的逻辑条件,每个循环争取只做一件事,等等。

在编程理论中有一个很重要的概念叫做圈复杂度,它是用来衡量函数的复杂性的。

函数圈复杂度的计算方法很简单:每个函数圈复杂度起始值为1,代表有一个路径执行完函数,每遇到一个if/case/for/while/and(逻辑与运算,如&&)/or(逻辑或运算,如||)就增加1,这样计算的最终结果就是函数的圈复杂度。

比如下面这个函数: static void Main(string[] args) { if (args.Length == 0) { return; } for (int i = 0; i < 100; i++) { Console.Write('A'); } Console.ReadKey(); }

这个函数是个很简单的函数,按照上面的规则来算,它的复杂度只有3 。

在一些资料中,计算圈复杂度的算法略有变化,那就是case部分,无论case有多少个,圈复杂度始终就加1,而不是像上面那样有多少个case就加多少个1,这样计算我觉得更好一点。

不管使用哪种算法,结论都是一样的:圈复杂度值越大,说明函数越复杂,越有存在缺陷的可能,也越有重构的必要。

个人认为把函数的圈复杂度控制在10以内是比较好的做法。

对于对象的设计,有一种观点我觉得很对,那就是:如非必要,请远离各种设计模式。

这个观点就如同另外一个观点一样智慧:如非必要,请不要试图优化代码,提高效率。

我记得曾有位仁兄使用了23种设计模式实现了不同的“Hello World”,从学习角度,我还是很佩服其想象力的;但是从实用角度,我只能表示“呵呵”了。

2.大小合适

圈复杂度小的代码不一定就简单(为什么呢?),所以代码逻辑简单的另一个很重要的指标就是代码数量要大小适中。当小伙伴们看见内容有1000行代码的函数和内容只有10行代码的函数感觉绝不会是一样的!

小通常意味着简单。个人认为把函数的行数限制在50行以内,类的方法限制在30以内是很好的做法,超过这个数目,就有重构的必要。当然你可以因地制宜,制定适合你团队的数目,这个没有关系,关键是要执行下去。

有一种很有市场的观点就是:如果小函数很多的话,调用堆栈就会很长,这样执行的效率就不会很高。

针对这种观点,我的想法是:且不说现在硬件的性能每年都在提升,就说现在的编译器已经足够智能了,小函数大多数情况下都在编译时内联了,执行的效率损失几乎是微乎其微。而且从实际情况来说,大部分项目失败的原因中,除了对效率要求特别高的项目(我们大部分项目都不属于这一类),因为效率原因失败的是少之又少。

另一种观点是:控制函数行数的话,意味着要拆分很多的小函数,小函数太多了也不好管理,还是不要拆分了。

确实,这个问题是真实存在的问题,可能每个人都会遇到,但是我觉得通过使用注释标记(特别是C#中有region)还是可以很好管理的。而且相对于小函数带来的可读性和易维护,这点损失还是值得的。

所以,我的观点是尽情通过重构去控制函数的大小吧,无须多虑。

3.命名易懂

这条原则无论怎样强调都不为过,原因你懂的。对象命名的好坏程度直接反映了一个码农编程水平的高低,对我来说,命名是一个人的基本功,而不仅仅是程序员的基本功。

曾有人说过,好的程序,每个变量、函数、类的名字都非常讲究,整体搭配的也都非常合适,阅读这样的程序,就像阅读一篇优美的散文一样,让人赏心悦目。

我想任何人看到下面两种代码的感觉都会不一样: // 不好的命名 var s = "Frank"; // 比较好的命名 var user_name = "Frank"; // 或者 var userName = "Frank";

上面不推荐的那种写法对于for循环中的循环变量int i来说问题还好,大家已经约定俗成了。如果放到一般的变量上,这种做法就不是很好了。

在国内的程序员中,还有一种特殊的命名方式,这里我不得不说一下,这里先看例子: var mingzi = "Frank"; var nianling = 30;

大家能看出点什么来吗?没错,是拼音,很多程序员当词汇量不足的时候,很容易养成使用拼音的习惯,这里严重批评一下,代码不是一个人的,别人也要用的。

说到命名,不得不提一下著名的匈牙利命名法。

在《软件随想录:程序员部落酋长Joel谈软件》一书中,作者将匈牙利命名法分为两种,流行的并且被废掉的叫“系统型匈牙利命名法则”,这种命名法将变量类型加到了变量名字前面,老实说确实没什么意义,特别是在现代编辑器中。

另一种被推荐的叫做“应用型匈牙利命名法则”,就是把变量的应用场景加到变量的名字前面。

比如在页面开发中,直接从用户输入得到的Name字符串可以起名叫:usName。其中,us代表unsafe,表示这个字符串是用户输入的,没有经过编码处理,可能是不安全的。而经过编码的Name字符串可以起名叫sName,其中s代表safe,表示这个字符串经过了编码处理,是安全的。

谈到命名的规则,就是为了说明下面这个息息相关的问题:代码错误检查。

代码错误检查也是一个经典的话题,如何让代码的错误提前暴露出来,而不是发布后由客户去发现,这是个问题。

在我们的代码中,很多元素是必须要贯穿很多行的,比如在某些函数中,定义变量和末次使用变量的地方可能相差几十行: var usName = getName(); action1(usName); // 此处省略20行... sName = usName; // 此处省略10行... document.write(sName);

由此可见,Joel提出的“应用型匈牙利命名法则”还是相当有用的,比如中间那行: sName = usName;

我们很容易就会从变量名发现这行代码存在安全性威胁。

当然,符合上面这些原则的代码通常不是一次写就的,即便是非常优秀的程序员,开始写的程序也很可能存在瑕疵,不过他们高人一筹的地方就在于代码能工作之后,他们不会停下来,而是不断地打磨自己的代码,让它们简单、易懂、灵活。2.2 核心原则

由于对象设计的核心是类,所以下面的原则也基本都是讨论类的设计问题,其他类型的元素都比较简单,基本上符合这里列出的原则。

前面我们分析了对象设计的通用原则,这里我们将重新温习一下对象设计的核心原则: SOLID原则。几乎所有的设计模式都可以看到这些原则的影子。2.2.1 单一职责原则(SRP):做一个专一的人

单一职责原则的全称是Single Responsibility Principle,简称SRP。SRP原则的定义很简单:不能存在多于一个导致类变更的原因,简单地说就是一个类只负责一项职责。

让一个类仅负责一项职责,如果一个类有多于一项的职责,就是比较脆弱的设计。因为一旦某一项职责发生了改变,需要去更改代码,那么就有可能会引起其他职责改变。所谓牵一发而动全身,这显然是我们所不愿意看到的,所以我们会把这个类分拆开来,由两个类来分别维护这两个职责,这样当一个职责发生改变、需要修改时,不会影响到另一个职责。做且只做好一件事,这条原则其实不仅仅适用于对象,也同样适用于函数、变量等一切编程元素。在商业模式中,将一件事做到极致就是成功。

随便举个例子,如果大家有深入研究过迭代器的思想的话,那其实就是把存储数据和遍历数据的职责分开了,集合只负责实现存储数据的功能,而迭代器完成遍历数据的功能。

再说一个我看到过的例子:说有一个辅助类CommonUtil,在这里面提供了所有不能归入其他模块的辅助方法,它的结构如下: public class CommonUtil { #region Canvas Helpers public void M1() { } //... #endregion #region Screen Helpers public void M2() { } //... #endregion #region Size Helpers public void M3() { } //... #endregion #region Data Helpers public void M4() { } //... #endregion }

你觉得这个类写得怎么样?

这里面放进了各种不同类型的辅助方法,每当模块有辅助方法需要找地方放的时候,人们都不自觉地找到了这个类,于是这个类在每个Release中都不断有新成员加入,于是最终变成了一个庞然大物。当使用的时候,光看函数列表就已经让人眼花缭乱了。

我的想法是,为什么不拆分成4个小类,每个类专门负责某一类型的辅助功能呢?2.2.2 开放封闭原则(OCP):改造世界大部分不是破坏原来的秩序

开放封闭原则的全称是Open Closed Principle,简称OCP, 该原则的定义是:软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。

这条原则是所有面向对象原则的核心。

软件设计所追求的第一个目标就是封装变化、降低耦合,而开放封闭原则正是对这一目标的最直接体现,其他原则或多或少都是为了这个目标而努力。例如,Liskov替换原则就是要求我们实现最佳的、正确的继承层次,这样就能保证不会违反开放封闭原则。

软件设计所追求的第二个目标就是重用,这个是继承机制的核心动力。通常来说,抽象的东西最稳定,最不容易变化,所以抽象与继承是实现开闭原则强大的工具,但不是唯一的工具,后面我们会说到实现开闭原则的另一个更加强大、更加灵活的工具:组合。

一言以蔽之,继承与组合是封装变化、降低耦合的不二法门。能否合理地使用继承和组合是体现一名码农水平高低的又一标准。

在实际的代码中,添加新的功能一般意味着新的对象,一个好的设计也意味着这个新的修改不要大幅度波及现有的对象。这一条理解起来最简单,实施起来却是最困难。无数的模式和解耦方法都是为了达到这个目的而诞生的。

看一个经典的例子: public class Component { public enum Status { None, Installed, Uninstalled } Status m_status = Status.None; void Do() { switch (m_status) { case Status.None: Console.WriteLine("Error..."); break; case Status.Installed: Console.WriteLine("Hello!"); break; case Status.Uninstalled: Console.WriteLine("Error..."); break; default: break; } } }

我们这里定义了一个组件,用户动态加载,加载完以后程序就可以用了。为了处理方便,我们给组件定义了一些状态,在不同的状态下,这个组件有不同的行为,于是就有了上面的代码:enum定义状态,函数中使用switch实现路由。

使用switch分支是一种经典的做法,当组件的状态类型不存在变化的可能时,该段代码无可挑剔,堪称完美。

但在实际项目中,过了一段时间后,我们发现组件的状态不够,比如说我们需要处理组件还未配置时的行为,于是我们在枚举中加了一个状态:Configured,然后在switch中加了一个分支。

又过了一段时间后,我们发现还需要处理组件还未初始化时的行为,于是我们在枚举中又加了一个状态:Initialized,然后在switch中又加了一个分支。

至于以后是否还需要别的状态,我们目前不得而知。

上面这个行为是严重违反开闭原则的,那么如何改进呢?使用我们最强大的工具吧:使用继承/组合封装变化点。

这里我们分析一下,该组件存在变化的地方就是组件的状态,这是一个变化点,对于变化点不要手软,封印它。 public class ComponentStaus { public virtual void Do() { } } public class ComponentNone : ComponentStaus { public override void Do() { Console.WriteLine("Error..."); } } public class ComponentInitialized : ComponentStaus { public virtual void Do() { Console.WriteLine("Hello!"); } } public class Component { ComponentStaus m_status = new ComponentNone(); public void ChangeStatus(ComponentStaus newStatus) { m_status = newStatus; } public void Do() { m_status.Do(); } }

在上面的例子中,我们发现了变化点,然后抽象出一个基类放在那里,最后使用继承机制,让子类去演绎变化。当我们需要添加新的状态Configured的时候,我们只需添加一个新的子类ComponentConfigured,让它从ComponentStaus继承,并重写Do方法即可。使用的时候,在合适的时机(如事件处理中),把该子类的实例传给Component就可以了,当然也有可能是Component自己处理事件或方法时自己修改该状态实例。

能看到开闭原则的影子吗?当然,不要妄想对修改完全封闭,这是不可能的,就像组件之间零依赖是不可能的一样。2.2.3 里氏替换原则(LSP):长大后,我就成了你

里氏替换原则全称是Liskov Substitution Principle,简称LSP,它的定义是:任何基类可以出现的地方,子类一定可以出现。

LSP原则是继承复用的基石,只有当派生类可以替换掉基类,且软件的功能不受影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。

LSP原则保证了继承的正确实现。它希望子类不要破坏父类的接口成员。一旦破坏了,就如同人与人之间破坏合同一样,有时候会很糟糕。

这个原则看起来很容易,但是却很容易和现实中的概念混淆。下面看一个经典的小例子:长方形与正方形问题。

在我们小学学数学的时候,就知道正方形是特殊的长方形,于是写代码的时候,自然的正方形类就继承自长方形了,代码如下: public class Program { static void Main(string[] args) { Rectangle rect = new Rectangle(); rect.setWidth(100); rect.setHeight(20); Console.WriteLine(rect.Area == 100 * 20); Rectangle squ = new Square(); rect.setWidth(100); rect.setHeight(20); Console.WriteLine(squ.Area == 100 * 20); } } class Rectangle { public double m_width; public double m_height; public virtual void setWidth(double width) { m_width = width; } public virtual void setHeight(double height) { m_height = height; } public double Area { get { return m_width * m_height; } } } class Square : Rectangle { public override void setWidth(double width) { m_width = width; m_height = width; } public override void setHeight(double height) { m_width = height; m_height = height; } }

很显然输入的不是两个True,根本原因就在于正方形只有长的概念,而没有长方形所期望的宽的概念,所以长方形中定义了正方形根本没有的东西,也就是说,长方形不应该是正方形的基类。

试读结束[说明:试读内容隐藏了图片]

下载完整电子书

若在网站上没有找合适的书籍,可联系网站客服获取,各类电子版图书资料皆有。

客服微信:xzh432

登入/注册
卧槽~你还有脸回来
没有账号? 忘记密码?