代码简洁之道
1. 代码整洁的意义
1.1 糟糕代码的坏处
-
生产力低下
代码不敢该,改一次花费大量的时间来梳理背后逻辑,应付性堆加,项目延期。
项目现场,频繁出问题,出了问题难以排查,耗费人力,资源浪费。
加新人,恶行循环,导致团队瘫痪。
坏的示范作用。
1.2 何谓整洁代码
- 优雅。
- 阅读起来赏心悦目,可读性强,扩展性强,维护性强。
- 抽象简洁,职责单一。
- 。。。
2. 有意义的命名
名副其实
避免误导
-
类名
类名一般采用
名词
或者名词短语
-
方法名
方法名一般采用
动词
或者动词短语
getXXX
setXXX
isXXX
3. 函数 (重点)
重点:
只做一件事(一个函数一个抽象层级)、函数参数 (参数优化策略)、优化if-else 和 switch-case、返回Null 和 判Null、标识变量做参数
-
短小
建议:if、for、while这种代码块不超过3行
-
只做一件事情
函数只做一件事情,可以确保逻辑单一简洁,可阅读性强,只有一个变化因素。
函数只做一件事情,这一件事情可以分为多个步骤,但是
这几个步骤必须是同一个抽象层级
。如果引入其他低级别的含义,代码就比较臃肿混乱
。// 比如:查找一班同学中成绩TopN的同学 func findTopNStudentsInClass(classId string, topN int) []Students { students := findStudents(classId) // 实现排序 for i, stu := range students{ if students[i].Bigger(students[j]) { // swap... } } // 返回TopN return stduents[:topN] }
实例中这个函数就违背了只做一件事(函数的几个操作步骤应该在同一抽象层级)这个原则。导致了代码的整洁性破坏。
另外还引入函数变化的另外的因素:比如换一种排序方式,就要重新测试该函数,而如果把抽象层级排序函数拎出来,单独实现,变化了,只测试该排序函数即可,本函数不受影响。
// 优化改进版 func findTopNStudentsInClass(classId string, topN int) []Students { students := findStudents(classId) // 实现排序 students = sort(students) // 返回TopN return stduents[:topN] } func sort([]stduents) []students{ // 实现排序算法 }
可以看到,改进后,函数的几个操作步骤都在统一抽象层级中。另外引起函数变化的因素也减少了。
总结:
函数只做一件事情的核心是,几个操作步骤都在统一抽象层级中
。同一抽象层级的核心是围绕一个操作主题(比如示例中几个步骤都是对 主题Students进行处理)
-
每个函数一个抽象层级
描述见上面
-
函数参数
总体原则:函数参数不要超过两个或者三个。
无参函数 > 一个参数函数 > 两个参数函数 (阅读使用更加简洁,参数越少,理解成本越低)
-
一元参数
一元参数 要表达的是函数要操作对象的语意。 比如 write(file string), send(msg) 。这样更整洁,好理解
另外一种是操作event
-
避免使用标识符参数 (即:避免传布尔类型参数)
向函数传布尔类型参数令人骇人听闻,表示这个函数不止做一件事情,违背了函数只做一件事的原则 (如为true,这样做;如为false,则那样做)。
应该把函数一分为二,用两个单独的函数来封装。
// 坏代码 func render(isSuite bool) { if isSuite { // suite 测试 }else { // 单个用力测试 } } // 整洁代码 func renderForSuiteTest(){ } func renderForSingleTest() { }
-
二元函数
二元函数比一元函数难懂。如
writeFiled(name)
比writeFiled(outputStream, name)
好懂。第一个看一下就明白,第二个要停下来理解一下。再比如
assertEquals(expect, actual)
这样的二元函数,好多次会搞错参数的顺序。这就是使用二元函数的代价。应尽量利用一些机制将二元函数转换成一元函数。可以有下面几种方式:
- 把
writeFiled
写成outputStream
的 成员方法。然后直接调用outputStream.writeFiled(name)
- 把
outputStream
写成当前类的成员变量,从而无需再传递它。 直接调用xxx.writeFiled(name)
- 还可以分离出类似
FiledWriter
新类,在其构造器中传入outputStream
, 并提供一个write
方法。然后调用fileWriter.write(name)
- 把
-
三元函数
三元函数比二元函数难懂,需要花时间理解参数含义,传递顺序等。
如果函数需要二、三个、三个以上参数,就说明一些参数需要封装为类了。 改写机制参数二元函数说明。
-
变参函数
变参部分可以理解为 函数列表 (
list
或者slice
), 这样变参函数就变成了一元或者二元函数了。format(fmt string, args ...interface{})
pint(args ...interface{})
-
给函数取好名字
取好名字能够较好的理解函数的意图,以及参数的顺序和意图。
对于一元函数,函数和参数应该形成良好的
动/名词对
形式,如write(Name)
-
-
输出返回值
函数输出要尽量明确,避免把参数当作输出,会破坏一些使用惯例。
-
用多态替代
if/else
或者switch/case
if/else
或者switch/case
这类代码结构,是代码腐化的常见示例,违背了开闭原则。通常可以通过
多态
将其优化掉type Play struct{ palyType string price float32 } const ( stragedyPriceRate = 1.25 comedyPriceRate = 1.1 ) func CalcAmount(play Play) float32{ amount := 0.0 switch play.playType { case "stragedy": amount = stragedyPriceRate * price case "comedy": amount = comedyPriceRate * price default: } return amount }
如果要增加新的 戏曲类型,比如
京剧、豫剧...
等等,都要去修改这个函数,违背开闭原则。通过多态 or 依赖倒置原则来优化type Play interface{ Amount() float32 } type Stragedy struct{ price float32 rate float32 } func (s Stragedy) Amount() float32{ return s.price * s.rate } type Comedy struct{ price float32 rate float32 } func (c Comedy) Amount() float32{ return c.price * c.rate } func CalcAmount(play Play) float32{ return return play.Amount() }
通过依赖倒置优化,扩展性大大提高
-
无副作用
符合只做一件事是一个原则。不能在一个语义函数中做其他事情,比如再checkXXX中做一些初始化操作,就带了副作用
func checkMsgValidte(msg *Msg) { if msg.Set == nil { msg.Set.Init() } }
-
分割命令和查询
函数要么做什么事,要么回答什么事情,二者不可兼得。要么修改值,要么返回值,两样都干可能会导致混乱。
// 设置某个属性,成功返回True, 不存在返回False func (o Object)set(attribute string, value string) bool { if o.hasAttribute(attribute) { o.attribute = attribute return true } return false } // 给使用者会带来混淆。 // 到底是name属性之前不为空,还是设置name为tom成功呢? // 接口有歧义,带来一定程度的误解 if (o.set("name", "tom")) { // ... }
要解决这个问题,可以将
set
函数重命名为setAndCheckIfExists
。或者采用更好的解决方案,把命令和查询
分割开来,防止发生混淆:if attributeExist("name") { SetAttribute("name", "tom") }
新的建议:
函数命名,值得花时间去推敲,反复斟酌修改。先写出来看看,不合适再改,直到简洁满意。
-
写代码和写别的东西很像(比如写文章)。先想什么就写什么,然后再打磨它。粗稿也许粗陋无序,反复斟酌推敲,直到满意。
我写函数时,一开始都冗长复杂。有太多缩进和嵌套,有过长的参数列表,名字也是随意取,也有重复代码。不过我会配上一套单元测试,覆盖每一行丑陋代码。打磨、分解函数、修改名称、消除重复,保持测试通过,遵照本章的规则进行优化。并不是一开始就按照这些规则来写函数,我想没人做得到。
-
消除特性依赖 (非强制)
类的方法应该尽量只对类自身的变量和函数感兴趣,不该垂青其他类的变量和函数。
当类的方法通过其他外部类来实现自身,它就依赖了该外部类。那么应该将该方法放到这个外部类中,这样就能直接访问了。
type HourlyEmployee struct{} func(e HourlyEmployee) GetWorkHours() int {} func(e HourlyEmployee) GetHourSalary() int {} type HourlyPayCalculator struct{} func (h HourlyPayCalculator)CalcultateWeeklyPay(e HourlyEmployee) { workHours := e.GetWorkHours() hourSalary := e.GetHourSalary() return workHours * hourSalary }
如上面示例,
HourlyPayCalculator
通过HourlyEmployee
来计算工资,HourlyPayCalculator
对HourlyEmployee
产生依赖。应该将
CalcultateWeeklyPay
放到HourlyEmployee
类中
4. 注释
尽量用代码做注释,因为它是最真实的,注释有可能是老旧或者有误导性的。尝试让代码变得简洁而有表达力,比花时间来写注释更有意义。
-
用代码来表达,而非注释
尝试让代码变得简洁而有表达力,比花时间来写注释更有意义
好注释 (值得写的注释)
5. 格式
-
垂直方向上的空白区分
每行展现一个表达式或者子句。每组代码行展示一个完整的思路。这些思路用空白行分割开来。
再包声明、包导入、变量或常量定义、函数之间,都有空白行隔开,这些机简单的规则很大的影响了代码的阅读体验
-
垂直方向上的靠近
比如都是成员变量,中间最好不要隔开,避免要移动视线才直到这几个是成员变量
-
相关原则
一个函数调用另外一个函数,就应该放到一起。调用者尽可能的放在被调用者上面,负责自上而下的阅读顺序。
6. 错误处理
原则:要弄清错误处理与整洁代码之间的关系。错误处理很重要,但是如果它搞乱了代码的逻辑,就是错误的做法。
正确的做法是,将错误处理隔离开,单独于主要逻辑之外,就能写出整洁的代码。
-
使用异常而非错误码
因为错误检查容易遗忘。遇到错误时,抛出一个异常,调用代码会比较整洁,逻辑不会被错误处理搞乱
-
别传递Null值
返回Null值是容易引发错误的做法。调用者需要不停的检查返回是否为null.
// 示例: func registerItem(item Item) { if item != nil { registery := store.GetItemRegistery if registery != nil { registery.GetItem(item.GetId()) } } }
这段代码糟糕透了,返回Null值,是在给自己增加工作量,也是给调用者添乱。只要忘记检查Null,就会导致程序失控。
可以显示的返回错误,或者返回一个特例对象。// 返回Null的场景 employees := getEmployees() if employees != nil { for _, employee := range employees{ total += employee.getPay() } } // 如果getEmployees() 返回默认特例,可以避免判Null, 逻辑更简洁 employees := getEmployees() for _, employee := range employees{ total += employee.getPay() } func getEmployees()[]employee{ if false { return emptyEmployees } return employees }
-
被传递Null值
方法中返回Null
是糟糕的做法,但是把Null值
传递给其他函数更糟糕。// 示例: func xProject(p1, p2 *Point) double { return (p1.x - p2.x) * 1.5 } // 如果有人传递了Null值会怎么样? 程序会直接得到一个NULL指针异常 xProject(Null, &Point{1}) // 如何修正? 可以创建一个 NullPointerException 异常 // 只是稍微比Panic好一点,但是还要捕获处理这个异常 func xProject(p1, p2 *Point) double { if p1 == nil || p2 == nil{ throw InvalidArgumentException("invalid arguments") } return (p1.x - p2.x) * 1.5 } // 还可以使用断言,但是也会产生运行时错误 func xProject(p1, p2 *Point) double { if p1 == nil || p2 == nil{ panic("invalid arguments") } return (p1.x - p2.x) * 1.5 } // 总结:最好的方式,还是编码的时候,时刻牢记不能传Null。调用者保证。
7. 边界
对于第三方模型或者框架的引用 形成了边界。为了减少边界变化对业务代码的影响,一般要对边界代码进行封装。
一种是类封装,另外一种是Adaptor,将一种接口转换成另外一种接口。
8. 单元测试
单元测试与生产代码一样重要,它不是二等公民。它需要被思考、设计和照料,应该像生产代码一样保持整洁。
没有测试代码,无法保证 对代码的修改 能如改动前一样正常工作。不会影响到其他部分。
如此,会导致故障增加,不敢修改代码,不再清理生产代码,因为修改带来的成本、风险太高。
如此延续下次,生产代码开始腐败。
单元测试可以让代码可扩展、可维护、可复用。 (其中,为了满足代码的可测试性,要求模块外部依赖可以注入,如此更好扩展)
代码优化,单元测试先行。
测试带来的好处:
有了测试,不怕修改引入新问题。测试用例不变,只要修改前后,用例执行的结果一致,就认为修改前后模型的行为一致。
如此,可以放心的优化代码,改进架构设计
TDD: 测试驱动开发。测试代码先行 (比较理想的一种理念,未必值得推崇)
整洁测试的三要素:可读性。如何做到可读:明确、简洁。
-
测试用例格式:构造 -- 操作 -- 检验 (Build -- Operate -- Check)模式。
每个测试都可以清晰地拆分为三个环节
整洁测试的5大规则:
-
快速
测试应该足够快。如果运行缓慢,你就不想 频繁运行。这样就不能够及早发现问题,不能及时清理优化代码,导致代码腐化。
-
独立
每个用例可独立运行。
可重复
-
自足验证
测试应该有布尔值输出。不能通过主观的判断确认用例是否通过,而应该采用客观比对。
-
及时
用例编写要及时,这样既可以测试功能正确,也可以通过测试对代码进行重构(可测试的)
9. 类
- 类应该尽量小。遵循SRP 单一职责原则,只有一个引起变化的因素。避免改动一个,影响另一个。
- 内聚
- OCP 开闭原则
- DIP 依赖倒置原则
10. 简单设计原则
下面关于简单设计的四条原则,对创建良好设计的软件很有帮助
-
运行所有测试
可测试性也是衡量代码松耦合设计的一个指标。
紧耦合的代码难以测试,写的测试越多,越会使用依赖注入、接口和抽象等手段减少耦合,遵循DIP之类规则。如此,设计也就有长足进步。
-
重构
有了测试,通过增量式地重构,就能保持代码整洁。消除了清理代码的恐惧。
重构过程中,可以从 改进命名、优化函数接口参数、优化函数逻辑(一个抽象层级); 优化类设计、优化模块结构设计; 提高内聚、降低耦合。
-
消除重复
消除重复,抽取公共。通过注入函数(差异化各自实现)或者模板方法模式来优化。
尽量减少类、方法