编程设计:代码重构
站内链接:
重构
步骤和原因
- 代码重构的原因
项目在不断演进过程中,代码不停地在堆砌。如果没有人为代码的质量负责,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,其可能原因如下:
- 需求紧急度和成本上的考虑,在开发一些功能时往往不考虑整体而是在原功能堆砌式编程
- 多人团队配合,成员之间的代码开发质量不一,在缺乏有效代码质量监督机制的情况下往往会发生此类问题
- 代码开发在初期需要考虑和后续需要考虑的重点不一样,关注点不一样,这就导致初期开发的时候没有想象的那么远,那么深,当然这也是没有问题的。
此时,就需要不断地重构代码来解决代码中的混乱问题。
- 代码重构的步骤:
- 了解项目并评估项目的重构工作量,在确认之后开始进行重构代码
- 对重构后项目代码的回归测试,这个是非常必要的
- 通过重构分支和代码 review 确保重构代码更加合规
- 项目开关:确保线上代码可以通过某个开关回滚到之前的逻辑,这一步非常重要
代码问题
- 代码重复:实现逻辑相同、执行流程相同,这违背了合成复用原则
- 方法过长:这是最常见的问题,将大量的功能放在一个方法里面,面向过程编程而非面向对象编程,这违背了
单一职责原则
- 过大的类:这个问题同长方法类似,不过其是在更大层级上的解耦问题,类中包含太多功能、太多实例和方法,违背了
单一职责、合成复用、开闭原则、接口隔离原则
。 - 逻辑分散:发散式的变化(某个类经常因为不同的原因在不同的方向上发生变化),散弹式变化(发生某种变化时,需要在多个类中做修改),这大部分是因为没有做过好的设计,违背了
里氏替换原则
。 - 严重的情结依恋:某个类的方法过多的使用其他类的成员,这严重违背了
迪米特法则
,类和类之间要尽可能少交流 - 数据泥团:Data Clump 在代码中频繁出现的、相互关联的数据项组合。这些数据项通常以相同的方式一起出现,但却没有被封装成单独的数据结构
- 基本类型偏执:Primitive Obsession,过度依赖基本数据类型的编程风格。在代码中频繁使用原始的基本数据类型,如整数、字符串、布尔值等,而不使用自定义的对象或数据结构来表示更复杂的概念
- 不合理的继承体系:这个也是开发中经常碰到的问题,继承必须符合
里氏替换原则、依赖倒置原则
,否则会造成代码混乱 - 过多的条件判断、过多的参数列表、临时变量过多、令人迷惑的临时字段
- 使用纯数据类而非使用更小代价的数据结构
- 不恰当的命名
这些不好的编程习惯使得代码变得:
- 难以复用:进而导致测试分支的变多,代码理解难度的增加等
- 难以变化:动一发而牵全身,系统的健壮性大大降低
- 难以理解:命令混乱、结构混乱,代码耦合度太高,如果文档又不够好,那简直是灾难
- 难以测试:代码复用的复杂和变化的问题就会自然而然的导致测试问题的产生
重构就是为了解决这些代码问题,从而使代码在如下几个角度尽可能的提升:
- 可维护性
- 可读性
- 可扩展性
- 足够优雅等
团队规范
- 在初期设计项目的时候就应该做好分层结构,这个是可以在前期就尽可能避免的,这个在常理是不存在过度设计、过度优化的问题的,例如对系统设置如下层次结构
- 配置层:负责项目配置等
- 公共层或通用库:编写一些公共的非业务相关的代码,工具相关代码等
- 应用接口层:API 接口
- 核心业务层:处理业务逻辑
- 数据层:module 管理层
在代码开发时要遵守各层的规范,并注意层级之间的依赖关系
- 命名规范
每一种特定的开发都有自己的一套命名规范,例如 pep8,当然,公司内部可能也有一套自己的命名规则,需要在团队内部保持统一的标准:
- git 的提交规范,如何描述提交信息、提交粒度怎么样更好、提交的时机怎么最好等
- 特定语言的编程规范,python 的 PEP8,JAVA 开发语言规范等
- 类的命令、方法的命令,注释的增加等等
体系结构驱动开发
体系结构驱动是一种软件开发方法论,它将软件系统的设计和开发过程以体系结构为中心。在体系结构驱动方法中,系统的整体架构被视为驱动开发过程的核心,它指导了系统的设计、组织和实现。
体系结构驱动方法强调将系统划分为多个独立的组件或模块,这些组件之间通过定义明确的接口和协议进行通信。通过明确定义的接口,不同的组件可以独立开发、测试和维护,从而实现系统的可扩展性和可维护性。
- 确定系统的功能和需求:明确系统需要实现的功能和需求,并将其转化为体系结构设计的目标。
- 设计系统的整体结构:确定系统的整体架构,包括组件的划分、模块之间的通信方式和数据流等。
- 定义组件和接口:对每个组件进行详细设计,包括组件的功能、接口和依赖关系等。
- 实现和集成:根据定义的体系结构设计,分别实现各个组件,并将它们集成到一个完整的系统中。
- 测试和验证:对系统进行测试和验证,确保各个组件之间的协作和功能的正确性。
- 演化和维护:根据需求的变化和反馈进行系统的演化和维护,保证系统的可持续性和适应性。
体系结构驱动方法提供了一种结构化和系统化的方法来设计和开发软件系统。它可以帮助开发团队更好地组织和管理项目,并在系统设计上提供清晰的指导和规范。
测试驱动开发
测试驱动开发(TDD)要求以测试作为开发过程的中心,要求在编写任何代码之前,首先编写用于产码行为的测试,而编写的代码又要以使测试通过为目标。TDD 要求测试可以完全自动化地运行,并在对代码重构前后必须运行测试。测试驱动开发的基本步骤:
- 编写测试用例:根据需求和功能规范,编写测试用例来定义所需的功能和预期的结果。测试用例通常使用一种单元测试框架编写,例如 JUnit(Java)、pytest(Python)等。
- 运行测试用例:运行编写的测试用例,此时测试用例应该全部失败,因为尚未实现相关的代码。
- 编写最小实现代码:开始实现被测试的功能代码,但只实现最小的功能片段,以满足当前一个或一组测试用例。这个阶段的目标是让测试用例通过,而不是追求完整的功能。
- 运行测试用例:再次运行测试用例,验证最小实现代码是否满足测试要求。如果测试通过,说明最小实现代码的功能是正确的。
- 重构代码:对已实现的代码进行重构,改进代码的结构、可读性和性能,但不改变其功能。
- 重复上述步骤:继续编写新的测试用例,再次运行测试用例,编写最小实现代码,运行测试用例,重构代码,循环迭代,逐步完善功能。
TDD 通过分而治之
的思想,在功能开发的时候首先解决代码可用
问题,在后续在解决代码整洁
问题,此类方式和自上而下的体系架构驱动
开发是完全相反的方向,有点类似敏捷驱动开发,此开发方式在项目工期紧张的时候是非常有用的
编程范式
结构化编程
结构化编程是一种编程范式,旨在通过使用顺序、选择和循环等结构化控制流语句来组织和管理程序的执行流程,从而提高代码的可读性、可维护性和可靠性。结构化编程的核心原则包括:
顺序结构(Sequential Structure):程序按照语句的顺序逐行执行,从上到下依次执行。
选择结构(Selection Structure):使用条件语句(如 if 语句、switch 语句)根据条件的真假选择执行不同的代码分支。
循环结构(Iteration Structure):使用循环语句(如 for 循环、while 循环)多次重复执行相同或类似的代码块,直到满足退出条件。
需要注意的是,结构化编程并不意味着禁止使用跳转语句(如 goto 语句),而是鼓励合理使用,并避免滥用跳转语句导致代码逻辑混乱和难以维护。 结构化编程是一种通用的编程范式,适用于大多数编程语言和项目类型,尤其在软件工程领域得到广泛应用。
面向对象编程
面向对象编程(Object-Oriented Programming,简称 OOP)是一种编程范式,它将数据和对数据的操作封装在对象中,通过对象之间的交互来实现程序的设计和开发。面向对象编程的核心概念包括:
类(Class):类是对象的蓝图或模板,描述了对象的属性和行为。它定义了对象的结构和行为,并可以通过实例化创建多个对象。
对象(Object):对象是类的一个实例,具有唯一的标识、状态和行为。对象可以执行类定义的方法,访问和修改其属性。
封装(Encapsulation):封装是将数据和对数据的操作封装在对象中的机制。通过封装,对象的内部状态和实现细节被隐藏起来,只暴露必要的接口。
继承(Inheritance):继承是一种机制,允许创建一个新的类(称为子类或派生类)继承现有类(称为父类或基类)的属性和方法。继承可以实现代码的重用和扩展。
多态(Polymorphism):多态是指对象根据上下文的不同而表现出不同的行为。通过多态,可以使用父类的引用来引用子类的对象,实现统一的接口调用。
面向对象编程在许多编程语言中得到广泛应用,如 Java、C++、Python 等。它提供了一种强大的工具和思维方式,帮助开发人员构建可靠、可维护和可扩展的软件系统。
函数编程
函数式编程(Functional Programming)是一种编程范式,它将计算视为函数求值的过程,强调函数的无副作用和数据不可变性。
函数式编程的核心概念包括:
纯函数(Pure Function):纯函数是指函数的输出只由输入决定,没有任何副作用。同样的输入始终得到同样的输出,不会修改外部状态或影响其他部分的状态。
不可变性(Immutability):数据不可变性是指数据在创建后不能被修改,任何修改操作都会返回一个新的数据副本。这样可以避免并发修改和意外的状态变化。
函数组合(Function Composition):函数组合是将多个函数按照一定的规则组合在一起,形成新的函数。通过函数组合,可以构建复杂的功能并将代码组织得更加清晰和模块化。
高阶函数(Higher-Order Function):高阶函数是指能够接收函数作为参数或返回函数的函数。它可以将函数作为数据进行操作和传递,实现更加灵活和抽象的编程方式。
声明式编程(Declarative Programming):函数式编程强调以声明的方式描述计算过程,而不是命令式地指导计算的执行。通过声明式编程,可以更加关注问题的本质,而不是具体的实现细节。
重构技巧
偏向面向对象的重构而非基于函数编程的重构
函数重构
- 意图导向编程
方法是代码复用的最小粒度,方法过长不利于复用,可读性低,提炼方法往往是重构工作的第一步。基于意图导向编程思想,把处理某件事的流程和具体做事的实现方式分开,将一个额外难题分解为一系列的功能性步骤,即先编写骨架代码
,从而将一个大函数拆分为小函数
- 使用函数对象来替换函数
将函数放进一个单独对象中,如此一来局部变量就变成了对象内的字段。然后你可以在同一个对象中将这个大型函数分解为多个小型函数
- 引入参数对象
对于多参数函数(特别是 python 开发),随着项目演进会不断地往后面增加默认参数,这是非常不合理的现象,应该在中期考虑到参数比较长的时候就应该及时悬崖勒马
,将参数封装到对象中。
变量
- 不必要变量
尽量减少临时变量的定义数量,这些变量会极大的加深代码的理解难度和后续的代码维护成本,一般而言,一个函数中临时变量过多那就说明函数需要进行拆分,同时设计思想或算法可能存在问题。
- 临时性变量使用解释性变量
使用能够解释用途的变量名称来定义变量
- 卫语句表达式替换嵌套表达式
这也是我最常使用的编程习惯,这样虽然多了好多走不到的条件判断,导致有一定的性能损耗(也不一定,编译器会自动优化代码吧?)。卫语句的基本思想是通过提前检查条件并提前返回,来减少嵌套的条件语句,使代码更加清晰和易于理解。
1 | def calculate_price(quantity, unit_price): |
条件判断
- 使用多态替换条件判断:这个应该也是面向对象的思路,不是函数编程的思路
- 使用异常替代返回错误码
这样虽然后期会使异常通用模块非常庞大,但是实际上这样更加清晰,异常的捕获也非常简单,在边界处进行统一捕获和处理。另外,尽可能避免在 finally 中抛出异常的行为,finally 块中尽可能是一个不影响外层堆栈的代码块:
1 | import traceback |
上面代码块的输出:
1 | ➜ osssite git:(main) ✗ python test.py |
类和多态
- 提炼类
根据单一职责原则
,类应该有明确的责任边界,但随着项目的演进,不可避免的往类中增加一些额外的功能,此时就有必要对类进行重构,那么分解类的原则是怎样的呢?
- 按照功能和业务来分解类这是最根本的原则
- 类中某些数据和方法经常一起出现,或者某几个数据总是同时发生变化,那么这些有关联的对象应该放到同一个类中
- 基类包含多个特性,需要不同的方式来子类化才能体现这些特征,那说明此类已经过于庞杂了
- 组合优先于继承
这是本人编程的缺点,在合成复用原则
中就建议多用组合少用继承,这样确保了对象和对象之间的封闭性,因为继承意味着子类依赖于其父类中特定功能的实现细节,此时就必须遵循里氏替换原则
,那么父类的实现随着发行版本的不同而变化,子类可能会遭到破坏,即使他的代码完全没有改变。那么,组合和继承都情况下使用呢?
- 当子类真正是父类的子类型时,才适合继承,例如子类和父类存在
is-a
关系时。 - 在包的内部使用继承还是比较安全的,因为某一个模块或包都在一个程序员的控制下,但是谁知道未来会不会改变呢?
- 对于专门为了继承而设计并且具有很好的文档说明的类来说,使用继承也是非常安全的
除了上述情况,其他情况尽可能使用组合而非继承。
- 优先使用模板或工具类
尽可能的封装并形成相应的模板或工具类,减少重复代码,专注于业务逻辑,这是非常普遍的一个优化逻辑,必然将所有基础功能包封装并提供给团队内部的其他成员。
- 使用接口而非抽象类
嵌套类
嵌套类(nested class)是指定义在另一个类的内部的类。嵌套类存在的目的只是为了它的外部类提供服务,如果其他的环境也会用到的话,应该成为一个顶层类(top-level class)。 嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和 局部类(local class)。
在开发中尽可能使用静态类而非静态类,非静态成员类的每个实例都隐含地与外部类的实例相关联,可以访问外部类的成员属性和方法,这是非常危险的。
权限
尽可能使代码的可访问性
和可变性
最小化,一个模块应该尽可能的隐藏自己的内部实现细节,通过接口对外暴露功能,这种关注点分类、边界职责
在软件工程领域的处处都随处可见,这样极大的提高了代码的可读性和可维护性。
- 可访问性:尽可能提供最小权限,在以后的发行版本中,可以对它进行修改、替换或者删除,而无须担心会影响现有的客户端程序
- 可变性性:不可变类好处就是简单易用、线程安全、可自由共享而不容易出错
模块重构
架构主要思想:分离关注点、边界职责
需求阐述
一般而言,重构有两个最最重要的需求:
- 用户量的增加、数据量的增加导致现有架构已经无法支撑业务的正常运行,此时需要对整个架构进行重构,可以参考容灾-策略和架构中数据库架构的演进
- 业务和功能的增加、团队成员的增加,进而导致的代码量的增加使得代码维护的成本更高,此时就需要对该项目进行模块层面和代码层面的重构,此时大部分用到了设计模式和上面讲述的重构技巧。
这两个需求从重构的规模可以分为大型重构和小型重构:
- 大型重构:对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。
- 小型重构:对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名和注释、消除超大类或函数、提取重复代码等等。小型重构更多的是使用统一的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。什么时候重构 新功能开发、修 bug 或者代码 review 中出现“代码坏味道”,我们就应该及时进行重构。持续在日常开发中进行小重构,能够降低重构和测试的成本。
大型架构就是下节讲述的架构重构
,小型重构就是上面章节所述的内容。 另外,一旦谈到重构,那必然涉及合成复用原则
,一般而言复用有如下三种方式:
- 程序库:例如前端组件、后端 SDK 等
- 设计模式:通过各种设计模式进行代码解耦,关注点分离,增强代码的可用性。
- 应用框架:对整个应用的架构进行重新梳理和设计,这点可以看下
分层架构
等文章说明。
关于架构层面的优化不在此时代码重构的议题中,代码层面的重构技巧已经在上面章节给出。
架构重构
关于架构的重构,这是一个非常大的功能模块,随着技术的演进也提出了各种不同的方式:
编程技巧
合理的分配函数和字段
频繁调用的两个函数之间应该放到同一个对象中,如果不是同一个业务那就在边界外再进行抽象,例如 A 对象经常调用 B 对象中的函数 func1,那就需要好好思考下这个 func1 是否放错位置了.
- 如果一个对象有太多行为和另一个对象耦合,那么就要考虑帮它搬家
- 只要是合理的分配函数,就可以使系统结构,对象本身的行为更加合理
同理,字段的搬迁逻辑也是类似的
拆解大类和合并小类
- 拆解大类
随着需求越来越多,原来设计的对象承担的职责也会不断的增多(方法,属性等……),如果不加以使用重构的手段来控制对象的边界(职责,功能),那么代码最终就会变得过于复杂,难以阅读和理解,最终演化成技术债,代码腐烂,从而导致项目最终的失败。
当一个类变的过于庞大,并且承担很多不属于它的职责(通过类名来辨识)的时候,必须需要对这个类进行拆解。不知道从哪里听过一句话,大意如下:软件设计就是一个不断拆解,然后不断组合的过程
。
拆解大类,是常见的重构技术手段,其最终目的都是保证每个对象的大小,职责都趋向合理。就像我们工作中如果有一个人太忙,那么就找一个人帮他分担就好了。
- 合并小类
如果一个类没有做太多的事情,就要考虑把它和相似的类合并在一起,结合上面的拆解大类说明,我们可以了解到软件设计就是一个不断的进行重构的过程,重构过程中的度如何把握则需要根据实际的场景,实际的需求,当时的技术架构,技术资源等等来决定。
- 尽可能保证和控制每个类的职责在一个合理的范围之内
- 类过大就使用 拆解大类 的手法
- 类太小就使用 合并小类 的手法
隐藏委托和移除中间人
- 隐藏委托关系
隐藏委托关系是一种软件设计原则,旨在减少对象之间的直接依赖关系,从而实现松耦合和模块化的设计。在隐藏委托关系中,一个对象(委托对象)将部分或全部的工作委托给另一个对象(委托者),并将自身的责任限制在一个特定的领域内。
1 | class Logger: |
通过隐藏委托关系,UserManager
类可以通过委托类来执行日志记录的功能,而不需要暴露 Logger
类的实现细节。
- 移除中间人
在软件设计中,”移除中间人”是指通过直接访问对象来简化代码结构,而不是通过中间人(或称为代理)对象进行间接访问。这可以提高代码的简洁性和可维护性,并减少不必要的复杂性。
1 | # 使用中间人的示例 |
通过移除中间人,代码变得更简洁,Customer
类不再依赖于 ShoppingCart
对象的实例,而是直接与 ShoppingCart
类进行交互。这种设计减少了代码的复杂性,提高了可维护性和灵活性。
扩展和增强工具类
- 扩展工具类
场景: 当系统工具库无法满足你需求的时候,但是你又无法修改它(例如 Date
类),那么你可以封装和扩展它,来让它具备你需要的新特性,注意,这种扩展一般都是对所有使用对象生效的。
扩展工具类是指在现有的工具类基础上进行功能的扩展或定制化。
1 | # 原始的 StringUtils 工具类 |
- 增强工具类
增强工具类是指对现有工具类的功能进行增强或拓展,以满足更广泛的使用需求。
1 | # 原始的 MathUtils 工具类 |