设计模式的作用
- 应对面试中的设计模式相关问题;
- 告别写被人吐槽的烂代码;
- 提高复杂代码的设计和开发能力;
- 让读源码、学框架事半功倍;
- 为你的职场发展做铺垫。
高质量代码
- 可维护性:不破坏原有代码设计、不引入新的 bug 的情况下,能够快速地修改或者添加代码。
- 可读性:好的程序员能够编写人能够理解的代码。代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等。
- 可扩展性:代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。
- 灵活性:代码易扩展、易复用或者易用。
- 简洁性(简单):尽量保持代码简单。
- 可复用性:尽量减少重复代码的编写,复用已有的代码。
- 可测试性:可单元测试。
- 编写高质量代码的方法概述:面向对象设计思想、设计原则、设计模式、编码规范、重构技巧。
编写高质量代码的方法概述
- 面向对象、设计原则、设计模式、编程规范、代码重构,这五者都是保持或者提高代码质量的方法论,本质上都是服务于编写高质量代码这一件事的。
- 面向对象
- 面向对象的四大特性:封装、抽象、继承、多态
- 面向对象编程与面向过程编程的区别和联系
- 面向对象分析、面向对象设计、面向对象
- 编程接口和抽象类的区别以及各自的应用场景
- 基于接口而非实现编程的设计思想
- 多用组合少用继承的设计思想
- 面向过程的贫血模型和面向对象的充血模型
- 设计原则
- SOLID 原则 -SRP 单一职责原则
- SOLID 原则 -OCP 开闭原则
- SOLID 原则 -LSP 里式替换原则
- SOLID 原则 -ISP 接口隔离原则
- SOLID 原则 -DIP 依赖倒置原则
- DRY 原则、KISS 原则、YAGNI 原则、LOD 法则
- 设计模式
- 创建型:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。不常用的有:原型模式。
- 结构型:代理模式、桥接模式、装饰者模式、适配器模式。不常用的有:门面模式、组合模式、享元模式。
- 行为型:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。
- 编程规范
- 如何给变量、类、函数命名,如何写代码注释,函数不宜过长、参数不能过多等等。
- 代码重构
- 重构的目的(why)、对象(what)、时机(when)、方法(how);
- 保证重构不出错的技术手段:单元测试和代码的可测试性;
- 两种不同规模的重构:大重构(大规模高层次)和小重构(小规模低层次)。
面向对象的定义
- 定义:面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
- 面向对象分析与设计: 围绕着对象或类来做需求分析和设计的。分析和设计两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法,类与类之间如何交互等等。
- UML:
- 类图,用例图、顺序图、活动图、状态图、组件图,
- 类关系:泛化、实现、关联、聚合、组合、依赖
- 封装
- 封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
- 限制任意方法属性,提高可读性、可维护性。
- 提高类的易用性,调用者就不需要了解太多背后的业务细节。
- 抽象
- 抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。
- 抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。
- 代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。
- 继承
- 继承是用来表示类之间的is-a关系。
- 代码复用
- 子类就可以重用父类中的代码,避免代码重复写多遍。
- 继承反应两个类关系。
- 多态
- 多态:子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。
- 实现:继承加方法重写,利用接口类来实现多态特性。
- duck-typing 实现多态的方式非常灵活。两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义即可。
- 多态特性能提高代码的可扩展性和复用性。
- 多态也是很多设计模式、设计原则、编程技巧的代码实现基础。
面向过程与面向对象
- 面向对象编程以类为组织代码的基本单元,支持继承、多态、封装的特征
- 面向过程编程则是以过程(或方法)作为组织代码的基本单元,数据和方法相分离。
- 面向对象编程更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- 注意面向对象写出面向过程的代码
- 滥用getter、setter方法
- 滥用全局变量和全局方法:细化功能类
- 定义数据和方法分离的类
- 原因:流程化思维方式
- 最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。
接口与抽象类
- 抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。
- 接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
- 抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。
- 接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
- 判断关系选用抽象类或者接口类。
基于接口而非实现编程
- 基于抽象而非实现编程:在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性、扩展性、可维护性。
- 定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中。
- 不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计。
面向对象的分析与设计案例总结
- 针对框架、类库、组件等非业务系统的开发,其中一个比较大的难点就是,需求一般都比较抽象、模糊,需要你自己去挖掘,做合理取舍、权衡、假设,把抽象的问题具象化,最终产生清晰的、可落地的需求定义。
- 需求分析的过程实际上是一个不断迭代优化的过程。
- 划分职责进而识别出有哪些类
- 定义类及其属性和方法
- 定义类与类之间的交互关系:更加贴近编程的角度,对类与类之间的关系做了调整,保留四个关系:泛化、实现、组合、依赖(返回参数,参数,局部变量)。
- 将类组装起来并提供执行入口
设计原则
单一职责原则
- 定义:一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
- 一些侧面的判断指标更具有指导意义和可执行性
- 类中的代码行数、函数或者属性过多;
- 类依赖的其他类过多,或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性。
- 通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。
- 如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开闭原则
- 定义:对扩展开发、修改关闭
- 添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。
- 时刻具备扩展意识、抽象意识、封装意识。
- 在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
里式替换原则
- 继承关系中子类该如何设计的一个原则。理解里式替换原则,最### 核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
- 里式替换原则跟多态的区别:多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
接口隔离原则
- 把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
- 接口隔离原则与单一职责原则区别:单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
控制反转、依赖反转、依赖注入
- 控制反转:一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
- 依赖注入:不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。
- 依赖注入框架:提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
- 依赖反转原则:主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
KISS 原则和YAGNI 原则
- KISS,尽量保持简单:保持代码可读和可维护的重要手段。KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。
- KISS原则
- 不要使用同事可能不懂的技术来实现代码;
- 不要重复造轮子,要善于使用已经有的工具类库;
- 不要过度优化。
- YAGNI 原则:要不要做”的问题(当前不需要的就不要做)。
DRY 原则
- 定义:避免代码重复
- 分类:逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执行重复也算是违反 DRY 原则。
- 代码复用性:
- 减少代码耦合
- 满足单一职责原则
- 模块化
- 业务与非业务逻辑分离
- 通用代码下沉
- 继承、多态、抽象、封装
- 应用模板等设计模式
迪米特法则(LOD)
- 高内聚,松耦合:“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓松耦合指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会或者很少导致依赖类的代码改动。
- 迪米特法则:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
设计原则案例总结
- 通过线框图和用户用例来细化业务流程,挖掘一些比较细节的、不容易想到的功能点。
- 面向对象设计的本质就是把合适的代码放到合适的类中。合理地划分代码可以实现代码的高内聚、低耦合,类与类之间的交互简单清晰,代码整体结构一目了然。类比面向对象设计,系统设计实际上就是将合适的功能放到合适的模块中。合理地划分模块也可以做到模块层面的高内聚、低耦合,架构整洁清晰。在面向对象设计中,类设计好之后,我们需要设计类之间的交互关系。类比到系统设计,系统职责划分好之后,接下来就是设计系统之间的交互了。
- 实现总结
规范与重构
代码重构的方法
- 重构的目的:对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。
- 重构的对象:按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。
- 大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。
- 小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。
- 重构的时机:我们一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
- 重构的方法
- 大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。
- 小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。
单元测试保证重构不出错
- 单元测试定义:单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或函数,而不是模块或者系统。
- 单元测试的作用:
- 写单元测试的过程本身就是代码 Code Review 和重构的过程,能有效地发现代码中的 bug和代码设计上的问题。
- 单元测试还是对集成测试的有力补充,还能帮助我们快速熟悉代码,是 TDD 可落地执行的改进方案。
- 编写单元测试方法
- 写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。
- 正确的认知单元测试
- 编写单元测试尽管繁琐,但并不是太耗时;
- 我们可以稍微放低对单元测试代码质量的要求;
- 覆盖率作为衡量单元测试质量的唯一标准是不合理的;
- 单元测试不要依赖被测代码的具体实现逻辑;
- 单元测试框架无法测试,多半是因为代码的可测试性不好。
- 单元测试难执行原因
- 一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;
- 另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。
可测试性好代码
- 代码的可测试性:针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
- 编写可测试性代码的最有效手段:依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。
- 常见的测试不友好( Anti-Patterns)的代码:
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
如何解耦代码
- 重要性:解耦保证代码松耦合、高内聚,是控制代码复杂度的有效手段。
- 封装和抽象:可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
- 我们通过将其封装成一个抽象的open() 函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。
- 我们在改动函数的底层实现的时候,并不需要改动依赖它的上层代码。
- 中间层:
- 引入中间层能简化模块或类之间的依赖关系。
- 在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。
- 重构小步快跑的做法
- 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
- 第二阶段:新开发的代码依赖中间层提供的新接口。
- 第三阶段:将依赖老接口的代码改为调用新接口。
- 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。
- 模块化
- 将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
- 聚焦到软件开发上面,不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。
- 聚焦到代码层面。合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。将每个模块都当作一个独立的lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。
- 其他设计思想和原则
- “高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际
- 设计原则都以实现代码的“高内聚、松耦合”为目的。
- 单一职责原则
- 基于接口而非实现编程
- 依赖注入
- 多用组合少用继承
- 迪米特法则
20条编程规范
- 命名
- 命名的关键是能准确达意。对于不同作用域的命名,我们可以适当地选择不同的长度。作用域小的变量(比如临时变量),可以适当地选择短一些的命名方式。
- 命名中也可以使用一些耳熟能详的缩写。我们可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名
- 命名要符合项目的统一规范,不要用些反直觉的命名。
- 接口有两种命名方式:一种是在接口中带前缀“I”;另一种是在接口的实现类中带后缀“Impl”。对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,一种是不带前缀。这两种命名方式都可以,关键是要在项目中统一。
- 注释
- 注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。
- 注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
- 类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
- 函数、类多大才合适
- 函数的代码行数不要超过一屏幕的大小,比如 50 行。类的大小限制比较难确定
- 一行代码多长最合适
- 最好不要超过 IDE 显示的宽度。当然,限制也不能太小,太小会导致很多稍微长点的语句被折成两行,也会影响到代码的整洁,不利于阅读。
- 善用空行分割单元块
- 对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。
- 在类内部,成员变量与函数之间、静态成员变量与普通成员变量之间、函数之间,甚至成员变量之间,都可以通过添加空行的方式,让不同模块的代码之间的界限更加明确。
- 四格缩进还是两格缩进
- 比较推荐使用两格缩进,这样可以节省空间,特别是在代码嵌套层次比较深的情况下。除此之外,值得强调的是,不管是用两格缩进还是四格缩进,一定不要用 tab 键缩进
- 大括号是否要另起一行
- 比较推荐将大括号放到跟上一条语句同一行的风格,这样可以节省代码行数。但是,将大括号另起一行,也有它的优势,那就是,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更加一目了然。
- 类中成员的排列顺序
- 在 Google Java 编程规范中,依赖类按照字母序从小到大排列。
- 类中先写成员变量后写函数。成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列。
- 编码技巧
- 将复杂的逻辑提炼拆分成函数和类。
- 通过拆分成多个函数或将参数封装为对象的方式,来处理参数过多的情况。
- 函数中不要使用参数来做代码执行逻辑的控制。
- 函数设计要职责单一。
- 移除过深的嵌套层次,方法包括:去掉多余的 if 或 else 语句,使用 continue、break、return 关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数。
- 用字面常量取代魔法数。
- 用解释性变量来解释复杂表达式,以此提高代码可读性。
- 统一编码规范
- 项目、团队,甚至公司,一定要制定统一的编码规范,并且通过 Code Review 督促执行,这对提高代码质量有立竿见影的效果。