当前位置: 首页 > 科技观察

重拾面向对象的软件设计

时间:2023-03-22 14:19:45 科技观察

你还在用面向对象的语言写面向过程的代码吗?他提出了日心说,驳斥了以地球为宇宙中心的天体论。由于思想极其超前,直到半个世纪后,开普勒·伽利略等人通过后来的研究才逐渐认识到并确立了当时哥白尼思想的超前性。巧合的是,同样的故事也发生在软件工程领域。半个世纪前,KristenNygaard发明了Simula语言,现在被公认为世界上第一种明确实现面向对象编程的语言。他提出了基于类的编程风格,建立了“一切皆对象”的面向对象语言。学说的“终极思想”,但在当时也不被认可。PeterNorvig在DesignPatternsinDynamicProgramming中驳斥了这一点,并表示我们不需要任何面向对象。半个世纪后,RobertC.Martin、BertrandMeyer、MartinFowler等人再次确认和升华了面向对象的设计理念。编程思想的演变不是一蹴而就的,而是在本世纪迅速发展起来的。2.编程思想的演变从1950年代冯·诺依曼创造出第一台计算机开始,至今只有短短的70年。从第一门计算机语言FORTRAN到今天我们常用的C++、JAVA、PYTHON等,计算机语言的演化速度远远超过我们使用的任何自然语言。从最早的面向机器,到面向过程,再到我们今天使用的面向对象。不变的是编程的目的,变的是编程的思想。1.面向机器的计算机是一个01的世界,最早的程序就是通过这个01机器码来控制计算机的,比如0000是读取,0001是保存等。理论上这是世界上最快的语言,运行直接不用翻译。但是缺点也很明显,就是几乎无法维护。运行5毫秒,编程3小时。由于机器码无法维护,人们在此基础上发明了汇编语言。READ代表0000,SAVE代表0001,更容易理解和维护。汇编虽然在机器码上更加形象直观,但本质上仍然是面向机器的语言,仍然存在较高的编程成本。2、面向过程面向过程是一种以事件为中心的编程思想,相对于面向机器的编程是一个巨大的进步。我们不关注机器指令,而是关注具体问题。它将一件事情拆分成若干个执行步骤,然后通过函数实现各个环节,最后串联起来完成软件设计。流线型设计使编码更清晰。相比机器码或者汇编,开发效率有了很大的提升,包括还有很多场景更适合面向过程完成。但软件工程最大的成本在于维护。由于该过程更侧重于解决问题而不是领域设计,因此代码可重用性和可扩展性的缺点逐渐显露出来。随着业务逻辑越来越复杂,软件的复杂度也越来越不可控。3.面向对象面向对象是分类思考和解决问题的,面向对象的核心是抽象思维。通过抽象提取共性,封装收敛逻辑,通过多态实现扩展。面向对象思想的本质是将数据和行为结合起来。数据和行为的载体称为对象,对象负责定义职责边界。面向过程简单快速。在处理简单的业务系统时,面向对象的效果不如面向过程。但在复杂系统的设计中,通用的业务流程、个性化差异、原子功能组件等,更适合面向对象的编程模型。但是面向对象也不是灵丹妙药,甚至有些场景用起来还不如不用。一切的根源是抽象。按照MECE规则对一个事物进行分类,ifelse是软件工程最严格的分类。我们在为分类设计抽象的时候,不一定能把握住最合适的切入点。错误的抽象比没有抽象更复杂。里氏替换原则的创始人芭芭拉·里斯科(BarbaraLiskov)谈到了抽象的力量。3、面向领域的设计1、你真的“面向对象”吗?//把客户接到销售私海publicStringpick(StringsalesId,StringcustomerId){//验证是否是销售角色Operatoroperator=dao.find("db_operator",销售编号);if("SALES".equals(operator.getRole())){return"operatornotsales";}//检查销售库存是否满inthold=dao.find("sales_hold",salesId);Listcustomers=dao.find("db_sales_customer",salesId);if(customers.size()>=hold){return"holdisfull";}//判断客户是否可以被pick到Opportunityopportunityopp=dao.find("db_opportunity",customerId);if(opp.getOwnerId()!=null){return"不能pickother'scustomer";}//pick一个客户opp.setOwnerId(salesId);dao.save(opp);return"success";}这是CRMFieldSales提取客户的业务代码。这就是我们熟悉的Java面向对象语言,但是这是一段面向对象的代码吗?完全面向事件,没有封装和抽象,难以复用,不易扩展。相信在我们的代码库中,这样的代码不在少数。为什么?因为它把成本放到了未来。我们称之为“披上面向对象的外衣,进行面向过程的活动”。在系统设计初期,业务规则并不复杂,逻辑复用和扩展没有得到强烈体现,而面向流程的代码很容易支持这些相对简单的业务场景。但软件工程最大的成本在于维护。当系统足够复杂的时候,一开始最容易写的代码,会成为日后最难维护的债。2.还有一种领域驱动设计的方式。我们也可以这样写,增加一个“商机”模型,通过商机把客户和销售的关系联系起来。商机的归属也分为公海、私海等特定归属场景。商业机会除了必要的数据外,还应该收集一些商业行为,如收集、开放、分发等。通过领域建模,利用面向对象的特性,确定边界,抽象封装,行为收敛,分治业务。当我们的业务说“将商机分配给私海”时,我们的代码是“opportunity.pickTo(privateSea)”。这就是领域驱动带来的变化,面向领域设计,面向对象编程,领域模型的抽象就是对现实世界的描述。但这不是一次性的过程。当你只摸到大象的身体时,你认为它是一扇门。当你摸到大象的耳朵时,你以为它是一块车前草。只有不断抽象和重构,才能更接近业务的真实模型。将模型作为语言的主干,认识到语言的变化就是模型的变化。然后重构代码,重命名类、方法和模块以符合新的模型---EricEvans《Domain-Driven Design Reference》译文:将模型作为语言的主干,意识到语言的改变就是模型的改变,然后重构代码,重命名类、方法和模块以符合新模型。3.软件复杂性这是MartinFlowler在《企业应用程序架构模式》一书中对复杂性的看法。他将软件开发分为数据驱动和领域驱动。很多时候,人们的开发方式往往是拿到需求后看表怎么设计,然后再看代码怎么写。这其实就是面向过程的一种体现。在软件出现的早期,这种方法的复杂度很低,没有复用也没有扩展,一个人吃饱了也不会饿着一家人。但是随着业务的发展和系统的演进,复杂度会急剧增加。一开始,通过领域建模和面向对象的思想进行软件设计,可以很好地控制复杂度的增加。首先思考我们的领域模型的设计,它是我们业务系统的核心,然后逐步扩展它,从接口到缓存再到数据库。但是领域的边界,模型的抽象,成本从一开始就比数据驱动要高。软件架构的目标是最小化构建和维护所需系统所需的人力资源。---RobertC.Martin?译:系统需求如果我们一开始就直接用数据来驱动面向过程的流程代码,我们可以很轻松的解决问题,以后不会面对更复杂的场景和业务,那么这个模型就是最适合这个系统设计的架构。如果我们的系统会随着业务的发展变得越来越复杂,每次发布都会增加下一次发布的成本,那我们就应该考虑投入必要的成本来面对领域驱动设计。4.抽象的好坏抽象一直是软件工程领域中最难的命题,因为它没有规则,没有标准,甚至没有对错之分,只有好坏之分,只有适合与不适合。同样的淘宝商品模型的领域抽象在业界算是一个标杆,但是不适合你的系统。那么我们如何控制“抽象”呢?UML创始人GradyBooch在《ObjectOrientedAnalysisandDesignwithApplications》一书中提到,判断一个抽象的好坏可以通过以下五个指标来衡量:耦合性、内聚性、充分性、充分性、完整性和基础性。1.耦合衡量一个模块与另一个模块之间建立的关联强度称为耦合。一个模块与其他模块高度相关,因此很难独立地理解、更改或修改。TCL语言的发明者JohnOusterhout教授也有同样的看法。我们应该尽量减少模块之间的耦合依赖,以降低复杂性。复杂性是由两件事引起的:依赖性和模糊性。---JohnOusterhout《A Philosophy of Software Design》译文:复杂性是由两件事引起的:依赖性和模糊性。但这并不意味着我们不需要耦合。软件设计正朝着可扩展性和可重用性的方向发展。继承天然是强耦合,但是它为我们提供了软件系统的可重用性。就像摩擦一样,起初我们以为阻碍了我们的进步,但实际上,没有摩擦,我们就无法前进。2.内聚内聚和耦合都是结构设计中的概念。凝聚力衡量单个模块中元素之间的联系程度。高内聚、低耦合是教科书上写的观点,但我们不能总是一味地追求高内聚。内聚分为偶然内聚和功能内聚。金鱼和消防栓,我们也可以把它们抽象在一起,因为它们不会吹口哨,但是显然我们不应该这样做,这是偶然的衔接。最理想的内聚类型是功能内聚,其中类或模式的元素一起工作以提供一些明确定义的行为。比如我聚合消火栓、灭火器、探测器等,它们都属于消防设施,这就是功能聚合。3.SufficiencySufficiency是指一个类或模块需要记录足够多的抽象特征,否则该组件将变得无法使用。比如Set集合类,如果我们只有remove和get而没有add,那么这个类肯定是无用的,因为它没有形成闭环。不过,这种情况比较少见。只要我们实际使用它,完成它的一系列流程操作,一些缺失的内容是比较容易找到和解决的。4.完整性完整性是指一个类或模块需要记录一个抽象的所有有意义的特征。完整性与充分性相对,充分性是模块的最小内涵,完整性是模块的最大外延。我们走完一个流程,就可以清楚地知道自己缺少什么,可以马上把抽象的充分性补上,但是在另外一个场景下,这些特性可能还不够。我们需要考虑模块需要具备哪些特性,或者应该具备哪些特性。应该加什么技能。五、基本原则充分、完整和基本可以说是三个相互支持和制约的原则。Basic指的是抽象底层表示的最有效的基本操作(好像是自己解释的)。例如Set中的add操作就是一个基本操作。在add已经存在的情况下,是否需要add2操作一次性添加两个元素?显然我们不需要它,因为我们可以通过调用两次add来完成它,所以add2不是基础。但是我们再设想另外一种场景,如果我们要判断一个元素是否在Set集合中,是否需要添加contains方法。Set已经有了foreach、get等操作。根据基础理论,我们也可以遍历所有元素,看元素是否包含。但是在基础上有一个关键词叫“有效”。虽然我们可以组合一些基础操作,但是会消耗很多资源或者复杂度,所以也可以作为基础操作的候选。5、软件设计原则的抽象性可以指导我们进行抽象和建模,但还不够具体。在此基础上,出现了一些更实用、更容易实现的设计原则。最著名的是五个面向对象设计原则S.O.L.I.D。1.开闭原则OCP软件实体应该对扩展开放,但对修改关闭——BertrandMeyer《Object Oriented Software Construction》译:软件实体应该对扩展开放,对修改关闭。开闭原则是BertrandMeyer在1988年的《面向对象的软件构造》一书中提到的一个观点,软件实体应该对扩展开放,对修改关闭。我们来看一个关于开闭原理的例子。需要传入的用户列表要按类型进行两次排序。我们的代码可以这样写。publicListsort(Listusers,Enumtype){if(type==AGE){//按年龄排序users=resortListByAge(users);}elseif(type==NAME){//按照第一个letterofthenameSortusers=resortListByName(users);}elseif(type==NAME){//按客户健康分排序users=resortListByHealth(users);}returnusers;}上面的代码是一个明显违反原则的例子打开和关闭。当我们需要添加新的相似类型时,需要修改主流程。由于这些方法都是在私有函数中定义的,即使我们对现有的逻辑进行了调整,我们仍然需要修改这个代码文件。还有一种方法,可以对扩展开放,对修改关闭。JDK的排序其实已经为我们定义了这样一个标准。我们抽象出不同的排序方式,每个逻辑单独实现,单个调整逻辑不影响其他内容,新的排序方式不需要调整现有模块。2.依赖倒置DIPH高层模块不应该依赖于低层模块。两者都应该依赖于抽象。模块,两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。RobertC.Martin是两本经典书籍的作者《Clean Code》《Code Architecture》。1996年,他在C++Report中发表了一篇名为TheDependencyInversionPrinciple的文章。他认为模块之间的依赖应该是有序的,高层不应该依赖低层,低层应该依赖高层,抽象不应该依赖细节,细节应该依赖抽象。如何理解RobertC.Martin的这个观点。当我们看这幅画时,我们可以用手拿着杯子。我们靠杯子吗?有人说我们需要杯子提供的hold服务才能hold住,所以就靠杯子了。但是我们再想一想,我们可以拿着棍子和水壶,而猫狗不能,为什么?因为我们的杯子是根据我们手的形状来设计的,我们定义了一个holdable接口,杯子是根据我们的需求来设计的。所以杯靠我们,不是我们靠杯。依赖倒置原则并不是一个新创立的理论,它在我们生活中的很多地方都有应用。例如,一家公司需要设立一个“法人”。如果公司有问题,监管局会找到公司的法人。不是监管机构靠公司提供的法人找人,而是公司靠监管机构的要求设立法人。这也是依赖倒置的一种表现。3.其他设计原则在此不再一一列举S.O.L.I.D.如果您想了解更多,可以自己查看。除了SOLID,还有一些其他的设计原则也很不错。PLOAPrincipleofLeastAstonishmentIfanecessaryfeaturehasahighastonishmentfactor,itmaybenecessarytoredesignthefeature--MichaelF.Cowlishaw译:如果一个必要的特征具有很高的惊讶系数,则可能需要重新设计该特征。PLOA最小惊奇原则是由斯坦福大学计算机科学教授MichaelF.Cowlishaw提出的。不管你的代码有多“好”,如果大多数人都对它感到惊讶,也许我们应该重新设计它。JDK中有违反PLOA原则的情况。让我们看看下面的代码。/***设置一个格式化程序。这个格式化程序将用于*格式化日志记录用于这个处理程序。*

*一些Handlers不能使用Formatter,在*这种情况下Formatter会被记住,但不会被使用。*

*@paramnewFormatterFormattertouse(maynotbenull)*@exceptionSecurityExceptionifasecuritymanagerexistsandif*thecallerdoesnothaveLoggingPermission("control")。*/publicsynchronizedvoidsetFormatter(FormatternewFormatter)throwsSecurityException{checkPermission();//Checkforanullpointer=newFormatter;会上,我特意把这行注释给盖掉了,以免大家猜到这里写的newFormatter.getClass()代码的作用。如果要检查空指针,可以使用Objects工具类提供的方法。实现是完全一样的,但是代码显示的意思却大相径庭。publicstaticTrequireNonNull(Tobj){if(obj==null)thrownewNullPointerException();returnobj;}KISS简单原理理论,卡普兰不是软件科学家,他是平衡计分卡的创始人,他提出的理论仍然适用于软件业。把事情复杂化很容易,把事情简单化也很复杂。我们需要尝试简化和简化复杂的问题。6.写在最后软件设计最大的目标就是降低复杂度。我不拥有一切,但我使用一切。最后引用JDK集合框架创始人JoshBloch的话。学习编程艺术从学习基本规则开始,然后再知道何时可以打破它们。你不应该盲目地遵守这些规则,而只是偶尔并有充分理由违反它们。与大多数其他学科一样,学习编程艺术包括首先学习规则,然后学习何时打破规则。---JoshBloch《Effective Java》翻译:你不应该盲目地遵守这些规则,你应该偶尔打破它们一次,而且要有充分的理由。要学习编程艺术,必须先学习基本规则,然后才能知道何时打破它们。见第一册、《Object Oriented Analysis and Design with Applications》https://niexiaolong.github.io/Object%20Oriented%20Analysis%20and%20Design%20with%20Applications.pdf2、《Clean Architecture》https://detail.tmall.com/item.htm?id=6543927642493、《A Philosophy of Software Design》https://www.amazon.com/-/zh/dp/173210221X/ref=sr_1_1?qid=1636246895

猜你喜欢