赞
踩
对前面的内容的回顾,温故而知新,包括:面向对象、设计原则、规范与重构三个模块的内容。
代码质量的评价具有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁。这些词汇是从不同的维度去评价代码质量的。它们之间有相互作用,并不是独立的,比如,代码的可读性好、可扩展性好就意味着代码的可维护性好。代码质量高低是一个综合各种因素得到的结论。我们并不能通过单一维度去评价一段代码的好坏。
最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多、最重要的三个评价标准。
要写出高质量的代码,我们就需要掌握一些更加细化、更加能落地的编程方法论,这就包含面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等。
现在,主流的编程范式或者编程风格有三种,它们分别是面向过程、面向对象和函数式编程。面向对象这种编程风格又是这其中最主流的。现在比较流行的编程语言大部分是面向对象编程语言。大部分项目也是基于面向对象编程风格开发的。面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础。
封装也叫作信息隐藏和数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方法来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如 Java 中的 private、protected、public 关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
如果说封装讲的是如何隐藏信息,那抽象讲的就是如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口或者抽象类来实现。抽象存在的意义,一方面是修改实现类不需要改变定义;另一方面,它也是处理复杂系统的有效手段,能有效地过滤不需要关注的信息。
继承用来表示 is-a 的关系,分为两种模式:单根继承和多继承。单继承表示一个子类只能继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要用来解决代码复用的问题。
多态是指子类替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可以提高代码的可扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
面向对象相比面向过程编程的优势:
面向对象编程一般使用面向对象编程语言来进行,但是不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也可能是面向过程编程风格的。
面向对象和面向过程两种编程风格不是完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。
不管使用面向过程还是面向对象来写代码,最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,就大可不用避讳在面向对象编程中书写面向过程风格的代码。
面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环节。
需求分析的过程实际上是一个不断迭代优化的过程。我们不要视图一下就给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化。这样一个思考过程能让我们摆脱无从下手的窘境。
面向对象设计和实现要做的事情就是把合适的代码放到合适的类中。至于到底选择哪种划分方法,判定的标准是让代码尽量满足 “高内聚、松耦合”、单一职责、对扩展开放对修改关闭等我们之前讲到的各种设计思想和原则,尽量地做到代码可复用、易读、易扩展、易维护。
面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节,我们将需求转化为具体的类的设计。这个环节的工作可以拆分为下面四个部分。
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫做抽象方法。子类继承父类必须实现抽象类中的所有抽象方法。
接口不能包含属性(Java 可以定义静态常量),只能包含方法,方法不能包含代码实现(Java8 以后可以有默认实现)。类实现接口的时候,必须实现接口中申明的所有方法。
什么实时使用抽象类?什么时候使用接口?
应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
实际上,“基于接口而非实现编程” 这条原则的另一个表述方式是,“基于抽象而非实现编程”。后者的表述方式其实更能体现这条设计原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。
越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
继承是面向对象的四大特性之一,用来表示 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。
继承的主要作用有三个:表示 is-a 关系、支持多态特性、代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。此外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
尽管鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的开发中,我们还是要根据具体的情况,来选择该用继承还是组合。
平时做的 WEB 项目,大部分都是基于贫血模型的 MVC 三层架构,被称为传统开发模式。之所以称之为 “传统”,是相对于新兴的基于充血模型的 DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格。
不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多的时间和精力,来提高代码的复用性和可维护性,所以相比于基于贫血模型的开发模式,基于充血模型的 DDD 开发模式更加有优势。
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,主要区别在 Service 层。在基于充血模型的开发模式下,我们将部分原来在 Service 类中的业务逻辑移动到了一个充血的 domain 领域模型中,让 Service 类的实现依赖这个 domain 类。不过,Service 类并不会完全移除,而是保留一些不适合放在 domain 领域模型类中的功能。比如,负责与 Repository 层打交道、跨领域模型的业务聚合功能、幂等、事务等非功能性的工作。
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和 Repository 层的代码基本上相同。这是因为,Repository 层的 entity
生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Controller 层和 Repository 层继续沿用贫血模型的设计思路是没有问题的。
一个类只负责完成一个职责或者功能。单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时类职责单一,类依赖的和被依赖的类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明类的设计不满足单一职责原则:
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改代码已有代码(修改模块、类、方法、属性等)的方式来完成。
关于定义,我们有两点要注意。
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码时,我们要多花点时间思考下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更时,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码可扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
子类对象(Object of subtype/derived class)能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
里氏替换原则是用来指导继承关系中子类该如何设计的一个原则。理解里氏替换原则,最核心的就是理解 “design by contract,按照协议来设计” 这几个字。父类定义了函数的 “约定”,子类可以改变函数的内部实现逻辑,但不能改变函数的原有 “约定” 包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
理解这个原则,我们还要弄明白,里氏替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里氏替换原则有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里氏替换原则是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
接口隔离原则的描述是:客户端不应该强迫依赖它不需要的接口。其中的 “客户端”,可以理解为接口的调用者。理解 “接口隔离原则” 的重点是理解其中的 “接口” 二字。这里有三种不同的理解。
如果把 “接口” 理解为一组集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
如果把 “接口” 理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
如果把 “接口” 理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
**控制反转:**实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的 “控制” 指的是对执行流程的控制,而 “反转” 指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员 “反转” 给了框架。
依赖注入:依赖注入和控制反转恰恰相反,它是一种具体的编程技巧。我们不通过 new 的方式在类内部创建依赖的对象,而是将依赖类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或 “注入”)给类来使用。
依赖注入框架:通过依赖注入框架提供的扩展点,简单配置一下所需要的类及其类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象生命周期、依赖注入等原本需要程序员来做的事情。
**依赖反转原则:**也叫做依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层每块,它们依赖一个共同抽象。抽象不需要依赖具体实现细节,具体实现细节依赖抽象。
KISS 原则的中文描述是:尽量保持简单。KISS 原则是保持代码可读性和可维护性的重要手段。KISS 原则中的 “简单” 并不是以代码行数来考量的。代码行数越少并不是代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,也并不违背 KISS 原则。此外,同样的代码,在某个业务场景下不满足 KISS 原则,换一个应用场景可能就不满足了。
对于如何写出满足 KISS 原则的代码,总结了下面几条指导原则:
YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不需要它。这条原则也算是万金油了。当用在软件开发中时,它的意思是:不要去设计用不到的功能,不要是编写当前用不到的代码。实际上,这条原则的核心思想是:不要过度设计。
YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是 “如何做” 的问题(尽量保持简单),而 YAGNI 原则说的是 “要不要做” 的问题(当前不需要的就不要做)。
DRY 原则中文描述是:不要重复自己,将它应用在编程中,可以理解为:不要写重复的代码。
有三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但语义功能不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,也算违反 DRY 原则。而执行代码重复的代码,也算违反 DRY 原则。
此外,还j讲到了提高代码复用性的一些手段,包括:减少代码耦合、满足单一职责原则、模块化、业务与非业务逻辑分离、通用代码下沉、继承、多态、封装、抽象、应用模板等设计模式。
复用意识也非常重要。在设计每个模块、类、函数的时候,要像设计一个外部 API 一样去思考它的复用性。
我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得可复用。
相比于代码的复用,DRY 原则适用性更强一些。我们可以不写可复用的代码,但一定不能写重复的代码。
“高内聚、松耦合” 是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚” 用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓 “松耦合” 指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个代码的改动也不会或很少导致依赖类的代码改动。
迪米特法则的描述为:不该有直接依赖关系的类,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望较少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
对于项目来说,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习经典设计思想、原则、模式、编程规范等理论知识的练兵场。
按照重构的规模,可以将重构分为大规模高层次的重构和小规模低层次的重构。
一定要建立持续重构的意识,把重构作为开发必不可少的部分融入到开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
大规模高层次重构难度比较大,需要有组织、有计划地进行,分阶段小步快跑,时刻保持代码处于一个可运行的状态。
小规模低层次重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。
单元测试是代码层面的测试,用于测试 “自己” 编写的代码的逻辑正确性。单元测试顾名思义是测试一个 “单元”,这个 “单元” 一般是类或函数,而不是系统或模块。
单元测试能有效地发现代码中的 bug、代码设计上的问题。写单元测试的过程本身就是代码重构的过程。单元测试是对集成测试的有力补充,能帮助我们快速熟悉代码,是 TDD 可落地执行的折中方案。
写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将其翻译成代码的过程。可以利用一些测试框架来简化测试代码的编写。对于单元测试,我们需要建立以下正确的认知:
粗略的讲,所谓的代码的可测试性,就是针对代码编写单元测试的难易程度。
对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试代码的时候,可以通过 mock 的方法将不可控的依赖变得可控,这也是我们在编写单元测试的过程中最有技术挑战的地方。除了 mock 方式,我们还可以利用二次封装来解决某些代码行为不可控的情况。
典型的、常见的测试不友好的代码有下面这 5 种:
过于复杂的代码往往在可读性、可维护性上都不友好。解耦,保证代码高内聚、松耦合,是控制代码复杂度的有效手段。如果代码松耦合、高内聚,也就意味着,代码结构清晰、分层、模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。
间接的衡量标准有很多,比如:
直接的衡量标准是把模块与模块之间,及其类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些设计思想与原则,比如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则。当然还有一些设计模式,比如观察者模式。
前面讲了很多设计原则,后面还会讲到设计模式,利用好它们都可以有效地改善改代码的质量。但是,这些知识的合理应用非常依赖个人经验,有时候用不好会适得其反。但是编码规范正好相反,大部分都简单明了,在代码的细节方面,能立竿见影地改善质量。此外,前面也讲到,持续低层次小规模重构依赖的基本上都是这些编程规范,也是改善代码可读性的有效手段。
我总结罗列了 20 条编码规范,分为三个大类:命名与注释、代码风格、编程技巧。
代码风格没有对错之分,不同的编程语言风格都不太一样,只要能在团队、项目中统一即可,不过,最好能跟业内推荐的风格、开源项目的代码风格一致。所以,这里就不展开罗列了,你可以对照这自己熟悉的编程语言的代码风格,自己复习一下。
除了细节的知识点外,最后,还有一条非常重要的,那就是,项目、团队,甚至公司,一定要制定统一的编码规范,并且通过 Code Review 督促执行,这对提高代码质量有立杆见影的效果。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。