Clean Code中文笔记
命名的讲究
名字本身应该能揭示意图,如表示持续了多少天的整数:
let d: Int
就不如能直接看出意图,且看出单位为天的:
let elapsedTimeInDays: Int
避免会产生歧义的名字,在一些较底层的语言里,同时会有一些系统相关的专有名词,如Unix里的命令ls
, grep
,以这些名词命名变量可能会让读代码的人产生误会;另外,如果命名里包含了类型,如nameString
,那么就应该确保此变量或常量类型为String
,其实,这里直接命名为name
是最简洁的。
此外,避免同时出现差异不大的名字,比如:
class BasicTicketStorageControlCoordinator {}
class BasicTicketDispatchControlCoordinator {}
最后,由于英文字母的关系,有些字母和数字不容易分辨,如小写的L和数字1,以及大写的字母O和数字0。
同时使用的名字需要有合理的区别,不要为了区别不同的变量而有意加上数字或其他不同的字符,比如表示基本工资和奖金的两个变量,与其命名为a1
, a2
,就远不如base
和bonus
。
为了区分名字而对变量进行符号式的区分,比如BookInfo
和BookData
,这两个类型从名字上看无法让人知道具体有什么差别。
命名应该可以读的通顺,比如表示曾用住址和当前住址:
let prevStrNoNamSubSta: Address
let currStrNoNamSubSta: Address
就不如:
let previousAddress: Address
let currentAddress: Address
名字要可以容易的被搜索,对常量来讲,如果几个地方都需要用到一个数字,表示庙里有几个和尚,与其直接在这些地方写3
,就不如定义一个常量来用:
let NUMBER_OF_MONKS = 3
这样一来,搜索NUMBER_OF_MONKS
就能找到所有用到和尚数量的代码。
同样,变量来讲,用单个字母是不推荐的,除非使用的scope非常小,比如一小段循环代码里传统都会用一个字母i
。一个大概的规矩,是命名的长度和代码中使用此名的scope大小成正比,这样就能尽可能保证此命名在其scope里可以很容易被搜索。
强制类型语言中,避免把类型写在名字里。一些非强制类型的语言里,为了增加代码的可读性,变量名往往会包含变量的类型,如表示国籍的字符串:
let nationalityString: String
假如某个版本之后,国籍成了一个包含许多信息的结构,里面有国家代码、名称等信息,这样一来,变量名里的String
就错了,如果要修正,就得修改所有用到nationalityString
的地方:
let nationalityString: Nationality
包含类型的名字,往往也会出现在类型定义本身,比如Swift里的protocol
命名,有些程序员会加上Protocol
作为后缀,书中作者建议是protocol
类型不加Protocol
后缀,而是针对具体实现来加以区分,比如:
protocol TicketFetcher {}
class BasicTicketFetcher: TicketFetcher {}
避免需要用思维定势去理解的名字,这个问题常见于单个字母的命名里,比如用u
来表示一个URL
,用r
来对应一个Resource
对象。
Class类的名字应该是个名词或者名词短语,比如Account
,Customer
,Student
,以及StudentCoordinator
,并且避免动词出现在名字里。
书中提到,类名应该避免Manager
,Processor
,Data
,Info
这几个单词,也许是因为这些太广泛不具有具体含义?
方法名应该有动词或动词短语,如:
func postPayment() {}
func deletePage() {}
func save() {}
也许对Swift并不适用,对于一些语言比如Java,用来访问、修改以及查询的方法要加上前缀,如:
String name = employee.getName()
customer.setName("New Name")
if (paycheck.isPosted()) {}
同样,在一些语言如Java中,如果用到构造方法重载,则建议增加一个静态的工厂方法来描述参数,比如:
Grade grade = Grade.FromScore(80.5)
就好过:
Grade grade = new Grade(80.5)
并且,你可以把静态工厂方法用到的_构造方法_定义为private
,以此来强制使用静态工厂方法。
别耍小聪明,因为程序员来自五湖四海,所以应该用普遍接受的名字而不是某地特定的习语,比如:
array.removeAll()
而不是:
array.nuke()
同一个概念,使用同一个单词,比如fetch
,retrieve
,get
都可以表示获取、读取,但混在一起用就可能会产生不必要的误会,以为这不同的动词有特定的不同含义:
func fetchAccounts() {}
func retrieveTransactions() {}
func getHistory() {}
同样的,不同的类名,出现表示类似意思的不同单词也会让人误会:
class RecordManager {}
class TransactionController {}
class ReservationDriver {}
**注意,不要用同一个单词表示不同的意思,这又走入了另一个极端。**比如用add
来计算两个数的和,同时用add
来命名给列表加入新数据的方法:
func add(a: Int, b: Int) {}
func add(item: Item) {}
第二个add
就不如改成insert
或者append
。
使用解决方案的术语,比如程序员熟悉的Queue
:
class ConcurrencyQueue {}
就好过:
class PowerfulGod {}
同理,使用要解决的问题相关的术语,未来遇到问题,程序员可以用这些术语来跟相关人员交流。
增加有意义的上下文,比如表示地址,零散的变量如state
就可能会产生误会,而把这些信息放在一个类型里,如Address
结构,就不会产生歧义。
不要添加没有意义的背景信息,比如Address
就比MailingAddress
或CustomerAddress
更简洁;如果要区分不同性质的“地址”,比如居住地址和网址,可以考虑PostalAddress
和URI
。
最后一点总结
选择合适的名字需要有比较强的描述能力以及与其他程序员相通的文化背景,这不是技术或管理问题,许多人在这个难点上做的不好;另外一个问题,为了提高代码质量而改名,程序员可能会怕别人不同意,因为有些时候命名是偏主观的,尤其是很多时候开发员不会精准的记住每一个类、方法等名字,因为现代化的开发环境提供了各种便利,比如自动完成。
函数
一个不好的例子:
func makeTea(teaLeaf, hasWater, hasWood) {
if hasWater == false {
getBucket()
getRope()
getWater()
}
if hasWood == false {
getWoodChopper()
if isWoodChopperSharp() {
sharpenWoodChopper()
}
getWood()
}
fireWood()
boilWater()
makeTea()
}
小之又小
函数要尽可能短小,虽然作者没有调查研究证据表示短小方程更好。这就意味着,if
、else
、while
同一行的条件代码应该只有一行,甚至只是调用另一个函数;同时,函数代码的缩进不应该超过一到两级。
只做一件事
一个函数应该只做一件事,把这一件事做好就行了。这么说的问题是有时候难以定义“一件事”,前面准备喝茶的例子里,我们做了好几件事:
- 检查是否有水,如果没有,找水桶,去挑水
- 检查是否有柴火,如果没有,找斧头,如果斧头不锋利,磨斧头,然后去砍柴
- 烧火,煮水,泡茶
这些步骤都是为了最后的泡茶,在LOGO语言里,定义函数的关键字是TO
(为了),我们可以把函数描述为以TO
开头的自然语言:
(TO)为了泡茶,我们检查是否具备必须的用品,如果缺少,我们去补足,最后把茶泡上。
如果一个函数只做比该函数的声明低一级的步骤,那么该函数就在做一件事。我们写函数的原因是将一个大的概念分解为下一抽象层次的步骤。
另外,以上代码里,准备柴火有两个层级的if
,这也是一个线索,说明这个函数做了不只一件事。
每个函数只应该有一个抽象层次
拿上面的代码来讲,为了泡茶,我们需要的直接工作是“准备好泡茶需要的工具”,这个就是一个抽象层次,至于检查有没有水,有没有柴,甚至砍柴到是否锋利,都是一个以上的抽象层次。
降级规则
我们可以按照降级规则(stepdown rules)来把程序的功能分解:
- (TO)为了泡茶,我们检查是否有必要的工具
- (TO)检查是否有必要的工具,必须检查是否有水
- 没有水的话我们要找到水桶去挑水
- (TO)检查是否有必要的工具,必须检查是否有柴
- 没有柴的话我们要找到砍柴刀去砍柴,如果刀不锋利,还要磨刀
- …
这样分析看来,这个函数的抽象逻辑涉及了多个层级。
Switch
swift
代码很难只做一件事,因为这个关键字本身的意义就是做N件事。作为编程语言的特性,我们不可能避免使用这个关键字,但是可以用“多态”把switch
隐藏在比较底层的地方。
不好的例子:
public func makeFood(_ type: FoodType) {
switch (type) {
case .vegetarian:
makeVegetarianFood()
case .asian:
makeAsianFood()
case .mexican:
makeMexicanFood()
}
}
以上代码有几个问题:
- 它太长了,而且每增加一个餐饮类型,都必须增加新代码
- 它同时做好几件事
- 它违反了单一责任原则 (Single Responsibility Principle),因为有好几个原因都会造成该函数需要调整
- 它违反了开闭原则(Open Close Principle),每增加一个新的餐饮类型,这个函数都必须增加相应的代码
- 最糟糕的问题,其他处理食物的函数,如果采用同样的
switch
逻辑,都会有以上所有的问题
解决方案是把switch
代码藏在抽象工厂(Abstract Factory)里:
public protocol Chef {
func makeFood()
}
public class ChefFactory {
public static func make(type: Type) -> Chef {
switch type {
case .asianFood:
return AsianFoodChef()
case .vegetarian:
return VegetarianChef()
case .mexican:
return MexicanChef()
}
}
}
let chef = ChefFactory.make(type: type)
chef.makeFood()
使用描述性的名字
一个长但描述性很强的名字,好过一长段用来描述的注释。使用一种命名惯例,让你的函数名称中出现的词,可以轻松的用来给函数起一个名字并说明它的作用。
不确定的时候,你可以给函数多换几个名字,现在的开发环境IDE可以让你轻松的把一个函数所有出现的地方都改名。
不同函数名里,用来指代同一个对象的名词要一致,比如食客
如果出现在多个函数名里,不应该有的叫食客
有的叫食用者
。
函数的参数
最理想的函数应该没有参数,接下来是一个参数,两个参数,应该尽可能避免有三个参数,三个以上的参数必须要有非常好的理由才行。
参数越多,看程序的时候需要理解的就越多。对于测试来说,多一个参数就会多许多不同的参数组合:没有参数的函数最容易测试,一个参数的函数不难测试,两个参数的函数需要测试的情况就多了不少,两个以上的函数就变得很难测试。
函数的返回值比输入参数更难理解。
常见的单一参数形式
需要单一参数的函数通常有两种情况,一种是你想知道这个参数的信息,比如fileExists("MyFile")
,或者对参数进行一些操作,返回一个结果,比如func open(_ file: String) -> File
。
还有一种不是很常见的情况,是单一参数作为事件(Event)来处理,这类函数往往不需要返回值。比如:func didUpdateText(_ newText: String)
,这类情况要谨慎使用,命名要清楚的表达这个函数是在传递一个事件。
尽量避免不属于以上机种情况的单一参数函数,比如func appendTextTo(_ buffer: NSMutableString)
,用参数本身作为返回值会引起困惑,如果函数对输入参数进行操作,操作的结果应该是返回值,即便是这个例子中输入参数本身是可以改变的NSMutableString
,把参数作为返回值也比即是参数又是操作结果要好:func appendText(_ buffer: NSMutableString) -> NSMutableString
。
开关(flag)参数
作为开关的参数很不好,给一个方法传入一个Bool
开关是个非常糟糕的做法,它把方法的签名弄得很乱,明目张胆的告诉读者这个方法做多件事,分别对应开关的true
和false
两个值。
两个参数的函数
两个参数的函数比一个参数的难懂,比如writeField(name)
比writeField(outputStream, name)
更容易读懂,虽然两个函数定义都很清楚,看到第二个函数也许得停一下来想想,一旦出了错误,第一个参数也许不会被注意到。
有的情况,两个参数刚好合适,比如let point = Point(x: 10, y: 12)
,但别忘了,这两个参数是一个复合值的两个部分。
有时候两个参数意义很明显,但也会有理解的问题,比如assertEquals(expected, actual)
,实际写出来,你不知道哪个参数是expected
,哪个是actual
,这就需要习惯性思维记忆。
三个参数的函数
三个参数的函数明显比两个的更难理解,参数的顺序,读代码导致的停顿,思考,略过,需要的时间都会增加很多。比如assertEquals(message, expected, actual)
,很容易分不清哪个参数是哪个。
参数对象
如果一个函数需要超过两三个参数,那么也许一部分参数应该可以包裹在另外一个单独的对象里。比如:
CGRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)
CGRect(origin: CGPoint, size: CGSize)
参数列表
有的函数接受可变参数,比如:
func print(_ items: Any..., separator: String = " ", terminator: String = "\n")
其实这里面的items: Any...
可以看作是一个参数。
动词和关键字
函数的名字应该可以解释函数的目的以及需要参数的自然顺序,对于单一参数的函数来说,函数和参数应该组成一个自然的动词/名词对,比如,addSubview(_ view)
,transfer(_ payee: Payee)
。
我们可以采取关键字形式,把参数的名字写在函数名里,比如assertEquals
可以写成assertExpectedEqualsActual(expected, actual)
,这样一来,就不会有任何哪个参数是哪个的疑虑。
不要有副作用(Side Effect)
既然一个函数应该只做一件事,那么副作用就是谎言,因为函数同时还做了别的事情。因为不知道它做了什么,有时候会改变传入的参数,有时候会改变所在类实例的状态,有时候甚至会改变全局变量。进一步会造成无法预知的耦合,调用一个函数,它却做了你不知道的事情。
输出参数
人们很自然的会认为参数是函数的“输入”,因此看到下面这个函数调用,会觉得有些困惑:
appendFooter(s)
上面的这行代码,是把s
加入到某个地方,还说说往s
里加什么东西?然后你再查这个函数的签名:
public func appendFooter(report: Report)
才知道原来参数本身也是输出,如果你非要查看函数的签名才能明白它到底是怎么回事,这就不是一个好的函数。在面向对象编程出现以前,也许这种操作不罕见,但在面向对象流行的年代,上面的函数可以被下面的方式代替:
report.appendFooter()
总体来说,应该避免函数的参数同时又是输出,需要对某个对象进行造作,改变它的状态,应该在它本身的类型里操作。
另外,函数要么执行某些操作,要么回答某个问题,两者不应该同时发生。比如下面这个函数给指定的属性设置一个值,并返回一个布尔值代表设置是否成功:
func set(attribute: String, value: String) -> Bool
实际使用的时候看到调用这个函数就有些困惑:
if set("username", "tom") { … }
只看上面这一行,它是查询username
是否设置为tom
还是把username
设置为tom
?set
在这里是动词还是形容词?解决方法是把操作和查询分开:
if attributeExists("username") {
setAttribute("username", "tom")
}
抛出异常好过返回错误代码
返回错误代码的问题是有时候需要好几层的if…else…
:
if operationA(input) == .success {
if operationB(input) == .success {
if operationC(input) == .success {
…
} else {
…
}
} else {
…
}
} else {
…
}
如果是抛出异常就会整洁不少:
try {
operationA(input)
operationB(input)
operationC(input)
} catch let _ {
…
}
如果觉得try…catch…
还是不够整洁,那么可以把会抛出异常throw
的操作集中在一起:
func operate(_ input: Input) {
try {
performOperation(on: input)
} catch let _ {
…
}
}
private func performOperation(on input: Input) throws {
operationA(input)
operationB(input)
operationC(input)
}
之前说过,一个函数应该只做一件事,因此,处理错误的try…catch…
本身就已经是一件事了,它之前和之后都不应该再做别的事。
抛出异常的另一个好处,是有些语言里有专门的Error
类型,只要涉及到这些Error
的代码都得导入这个类型,如果Error
定义的内容改变,其他相关代码往往需要重新编译。
根据以上要求修改后的代码
struct TeaPreparation {
let hasWater: Bool
let hasWood: Bool
let isChopperSharp: Bool
}
extension TeaPreparation {
func isReady() -> Bool {
return hasWater && hasWood
}
}
public class TeaTask {
private let preparation: TeaPreparation
init(preparation: TeaPreparation) {
self.preparation = TeaPreparation
}
public func make() throws Exception {
prepareTools()
makeTea()
}
private func prepareTools() {
if preparation.hasWater == false {
getWater()
}
if preparation.hasWood == false {
getWood()
}
}
private func getWater() {
getBucket()
getRope()
// get water from a well
}
private func getWood() {
if preparation.isChopperSharp == false {
// sharpen the chopper
}
// chop wood
}
private func makeTea() throws {
guard preparation.isReady() else {
throw Exception()
}
// make tea
}
}
注释
注释的正确用途是弥补我们代码表达的不清楚。
注释存在的时间越久,和代码真实的意图越可能有差距,原因很简单,现实中程序员无法维护注释。
不准确的注释比没有注释还糟糕。代码是唯一一个可以告诉你真相的地方。
注释不能提高代码质量,如果一段代码需要注释,你应该把代码清理一下。写可以自我解释的代码,而不是用注释来解释代码。
例如:
if employee.flags.contains(.HOURLY_PAY) && employee.age > 65 {}
就不如:
if employee.isEligibleForFullBenefits() {}
来的清楚。
合适的注释
- 法律声明,版权声明等
- 有用的信息,比如一段正则表达式的作用
- 说明作者意向:
// here we fail the app on purpose because this should never happen
- 澄清SDK不清楚的地方,
// Apple's doc doesn't say but it won't work if we don't do this, reference: StackOverflow post
- 警告:
// Creating DateFormatter is expensive, but due to … we have to create one every time, avoid this if you can
- TODO:表示需要做但还没做的事情,不要依赖TODO来不解决代码质量问题
- 强调作用:
// This looks redundant but is needed because …
- 公共API的注释文档
不合适的注释
- 自言自语,只有写注释的人自己明白:
// Two tickets remaining after this
- 重复的注释,甚至注释比代码还长,而代码反倒更容易读懂。比如有些IDE会把方法名拆开来作为自动生成的注释。
- 不准确、误导的注释
- 强制性注释,有些代码工具可以设置给方法参数强制注释,即便是
func updateName(_ name: String)
这么清楚的方法也得来一个:// @param name the name to be updated to
- 日志性注释,有的程序员喜欢把每次改动详细写在注释里,这是版本控制工具如
git
所要做的 - 没有任何意义而只能造成干扰的注释
let dayOfMonth: Int // day of the month
let name: String // the name
- 可以用方法、函数来取代的注释
if student.subjects.contains({ anotherStudent.subjects.contains($0) }) // check if two students have same subject
- 可改为:
if student.hasSameSubject(with: anotherStudent)
- 用作记号的注释,如
// Start Loading ////////////
- 用来对应缩进的注释,比如内嵌了很多层的
if…else…
,try…catch…
等,每个右括号后面加个注释try {…} // try
- 用来表示作者的注释,比如
// Added by Tom
- 不需要的代码应该删掉,而不应该只被注释
- 避免HTML格式的注释,有些工具可以读取代码里HTML格式的注释并生成网页,这降低了代码的可读性
- 提供的信息和被注释的代码不在一个层面,如
let requestPath: String // path of request, production base URL is www.test.com
- 太多的信息,长篇大论,比如曾经各个时间相关的讨论
- 和代码联系不紧密的注释,比如
data = [Int]() // dynamic game board with width x height
- 方法签名的注释,一个好的方法名是可以自己解释自己的
- 提供给文档生成器的非公开代码的注释,因为不公开,所以给这些方法签名加注释文档意义不大
格式
我们希望读代码的人感受代码的整洁、一致且注重细节,不同的模块有序的存在,而不是看到一团乱。这就需要写代码的人遵循一定的格式,如果是团队作业,整个团队需要事先约定好一个大家都遵循的格式,有些自动化软件可以在一定程度上帮助格式化代码。
代码格式的重要性
代码格式的重要性和“让代码正确工作”同样重要,今天的代码也许明天会改变,但代码的高可读性不应该变化。
源代码文件长度
一篇优秀的新闻稿,往往开头会有一个标题,告诉你发生了什么,第一段也许会给你一个事情的概括,继续往下读,会看到事情越来越多的细节。源代码也应该如此,最开始我们应该看到代码所属的模块,跟着是较高级的概念和算法,之后才是细节。正如报纸上不同的新闻稿,如果所有文章都混在一起,则没有人会想读它。
纵向开放性(Vertical Openness)
按照从左到右,从上到下的顺序,下面的代码很好的表达了从上到下垂直的布局:
import UIKit
public final class CustomViewController: UIViewController {
func viewDidLoad() {
super.viewDidLoad()
configureViews()
}
private func configureViews() {}
}
如果去掉空行和换行,代码会变得混乱:
import UIKit
public final class CustomViewController: UIViewController {
func viewDidLoad() { super.viewDidLoad()
configureViews()}
private func configureViews() {}}
书中把源代码上下布局称为“纵向开放性(Vertical Openness)”。
纵向密度(Vertical Density)
纵向开放性强调概念的分离,纵向密度对应的是紧密关联,紧密相关的代码应该有相应的纵向密度。下面的代码展示了合理的纵向密度:
public final class CardRepository {
private let authority: CardAuthority
private let store: CardStore
public func addCard(_ card: Card) throws CardException {
authority.validate(card) { success
if success {
cardStore.add(card)
} else {
throw CardException(.invalidCard)
}
}
}
}
注意,同样的代码,加上了没必要的注释,对纵向密度带来一些影响:
public final class CardRepository {
// authority of card
private let authority: CardAuthority
// card store
private let store: CardStore
// add a card to card store
public func addCard(_ card: Card) throws CardException {
authority.validate(card) { success
if success {
cardStore.add(card)
} else {
throw CardException(.invalidCard)
}
}
}
}
在实际应用中,不必要的注释影响了纵向密度,读者需要上下多看好多行来理解代码。
纵向距离(Vertical Distance)
你是否曾经需要上下翻几个屏幕来理解某一个函数是如何工作的,甚至需要来回看几个文件来理解一个变量是在哪里定义的?这个就是纵向距离。紧密相关的概念应该纵向靠在一起;虽然,如果代码在不同的文件里,这一点就无法做到,但如果代码紧密相关的话,必须有很好的理由才可以分散在不同的文件里。根本的目的是避免读者为了理解某一段代码而需要来回翻看不同的代码和文件。
- 变量的定义应该尽可能靠近它被用到的地方
- 循环用到的控制变量(e.g.
for card in cards {}
)一般定义在循环开始同一地方,罕见的情况,比如循环用到的变量在循环结束后还要用到,可以定义在循环开始前 - 实例变量应该定义在类的开始,而这些变量的纵向距离不应该有影响,因为在一个设计的很好的类里,实例变量应该被尽可能多的方法用到
- 关于实例变量在哪里定义有不少争论,C++里有剪刀规则(scissors rule),所有实例变量定义在最下面;而Java里通常都在最上面
- 这里的关键在于把实例变量定义在一个大家都知道的显著位置
- 概念上有亲和力的代码应该靠近,这不限于一个函数调用另一个函数,也可以是命名一致且做的事情相似,等等
- 如果可能的话,调用者应该在被调用者上面,这会让代码有个自然的流程
横向格式
每行代码都不应该太长,经典的规矩是80个字,现在有很宽的显示器,年轻人又爱用很小的字体,导致每行可以达到200个字,不要这么做。
在一行代码中,我们用空格来分隔赋值,但用括号来分隔函数和参数时就不需要空格,因为函数和参数的关系比赋值等号左右的关系更紧密:
cardRepo.addCard(card)
let total = cardRepo.calculateTotal()
横向对齐
不同人有不同类型的代码对齐习惯:
final class Workflow {
private let coordinator: Coordinator
private let output: Output
private let log: Log
private let analytics: Analytics
}
final class Workflow {
private let coordinator: Coordinator
private let output: Output
private let log: Log
private let analytics: Analytics
}
作者曾经也爱把代码这样来写,逐渐发现这样写的好处不大,重点不在于每行代码对齐,而在于把相关的定义放在一起,不相关的定义分隔开:
final class Workflow {
private let coordinator: Coordinator
private let output: Output
private let log: Log
private let analytics: Analytics
}
代码缩进
我们使用代码缩进来清晰的表达源代码结构的递进关系。类里面的函数定义比类定义缩进一级,函数的实现又比函数名更进一级。有些人喜欢把短的if
,while
等语句写在一行,作者更倾向于把它分开几行,使得代码更清晰。
有些语言里,没有循环体的循环可以省略空括号,如:
while (inputStream.read(buffer, 0, size) != -1);
作者觉得这样写可能会让人忽略最后的分号而把下一行作为循环体,建议如果这么做,也应该让分号明显可见:
while (inputStream.read(buffer, 0, size) != -1) ;
团队工作
最后,如果是一个团队工作,大家应该认同并遵循一个统一的代码格式。
对象与数据结构
一个类的具体实现,包括变量、方法的私有化,不仅仅是把他们标为私有(private),而是表达了一个抽象的概念,使用者只需要知道这个类提供了什么,而不需要知道具体是怎么实现的。
例如:
public class Storage {
let capacity: Int
let occupied: Int
}
public protocol Storage {
fun getFreeSpacePercent()
}
上面两段代码,如果需要提供的功能是计算存储空间剩余百分比,第二段代码就比第一段好,它隐藏了具体的实现。注意:抽象并不是简单的把class
的内容放在protocol
里,而是需要严肃的思考怎样来最好的表达一个对象应该提供的功能。
对象与数据结构的不同
对象和数据结构应该互补:
- 对象(Object):把具体的数据隐藏在抽象概念后面,只公开方法,由这些方法来操作具体数据。
- 数据结构(Data Structure):数据结构简单的提供所代表的数据,不牵涉任何方法。
下面两段代码,第一段偏向于过程(Procedural),第二段偏向于面向对象(OO)。第一段代码定义了不同的形状,提供一个Geometry
类来计算不同形状的面积:
class Square {
let topLeft: Point
let side: Double
}
class Rectangle {
let topLeft: Point
let height: Double
let width: Double
}
class Circle {
let center: Point
let radius: Double
}
class Geometry {
static let PI = 3.14
func area(of shape: NSObject) -> Double throws {
if let s = shape as? Square {
return s.side * s.side
} else if let r = shape as? Rectangel {
return r.height * r.width
} else if let c = shape as? Circle {
return Geometry.PI * c.radius * c.radius
}
throw .noSuchShape
}
}
第二段代码定义了一个形状协议(protocol
),该协议提供了形状的面积,被不同的具体形状来实现:
protocol Shape {
func area() -> Double
}
class Square: Shape {
let topLeft: Point
let side: Double
func area() -> Double {
side * side
}
}
class Rectangle: Shape {
let topLeft: Point
let height: Double
let width: Double
func area() -> Double {
height * width
}
}
class Circle: Shape {
let center: Point
let radius: Double
func area() -> Double {
Circle.PI * radius * radius
}
static let PI = 3.14
}
加入这时需要计算不同形状的周长,第一段代码里应该在Geometry
里增加这个方法,并根据不同形状来计算;第二段代码则需要在Shape
协议中增加一个计算周长的方法,且实现协议的每个具体形状都得再实现这个新增加的方法。总结来说:
- 面向过程(操作数据结构)的代码很容易增加一个新的功能而不需要改变数据结构;面向对象的代码则更容易增加一个新的实现(比如上面代码增加一个三角形)而不需要改变其他已有的实现。
- 面向过程的代码很难增加新的数据结构,因为所有使用数据结构的代码都需要更新;面向对象的代码则不容易增加新的功能,因为所有的具体实现都得再增加这个功能。
增加新类型(比如上面代码的基础上增加三角形)的时候,面向对象更合适;而增加新功能的时候,面向过程的代码则更合适。成熟的程序员知道“任何东西都是一个对象”只是个神话,有时候针对简单的数据结构,面向过程的操作方式比面向对象要来得容易。
得墨忒耳定律(Law of Demeter)
得墨忒耳定律(Law of Demeter,缩写LoD)亦被称作“最少知识原则(Principle of Least Knowledge)。以下面代码为例:
class RegistrationCoordinator {
let userManager: UserManager
func f(name: String) throws {
guard name.isEmpty == false else { // 3.
throw .invalidUserName
}
let user = User(name)
validate(user) { isValid in
if isValid {
user.activate() // 2.
userManager.add(user) // 4.
}
}
}
// 1.
private func validate(_ user: User, completion: (Bool) -> Void) {...}
}
类C
的方法f
只应该调用以下的方法:
- 属于
C
的方法 - 方法
f
里创建的对象的方法 - 作为参数传给方法
f
的对象的方法 C
的实例变量所拥有的对象的方法
方法f
不应该调用允许调用的方法返回值的方法,如:
final resourcePath = context.getConfiguration().getResourceURL().getAbsolutePath()
火车事故(Train Wrecks)
上面提到的一连串的调用有个名字叫Train Wreck,因为看起来像一个接一个的火车碰到一起,分解开来:
let configuration = context.getConfiguration()
let resourceURL = configuration.getResourceURL()
let resourcePath = resourceURL.getAbsolutePath()
注意:如果configuration
、resourceURL
、absolutePath
这些都只是数据结构,这样就不违反Demeter法则,因为数据结构只提供数据,没有任何“行为”:
let resourcePath = context.configuration.resourceURL.absolutePath
避免数据结构和对象混合
有时候数据结构跟对象会混在一起,既有公开可操作的变量,又有公开的方法,而方法也操作这些变量。往这种混合型类里加方法或者变量都不容易,应该尽可能的避免写出这种东西。
把结构隐藏起来
如果configuration
、resourceURL
、absolutePath
都是具体的对象的话,应该会把各自内部的结构隐藏起来,这样一来,怎么能取得absolutePath
呢?无论是在context
里加一个超长的方法,如:
context.getResourceAbsolutePathInConfiguration()
还是让最后一级返回一个数据结构,如:
context.getResourceURLInConfiguration().absolutePath
都不是太合适,如果换个思路,context
得类应该提供这个功能,而不是我们去一层一层取得需要的数据;加入我们拿到最终的absolutePath
是为了创建一个取得该资源的请求(request):
let request = context.createRequestForResource()
这样就把具体的实现隐藏在context
里,而不需要知道context
内部的具体结构实现。
数据传输对象(Data Transfer Object)
Java语言里的DTO是只提供实例变量而没有方法的类,实例变量各自都有setter和getter。
活性纪录(Active Record)
软件工程中,Active Record(简称AR)模式是软件里的一种架构性模式,主要概念是关系型数据库中的数据在内存中以对象的形式存储。由Martin Fowler在其2003年初版的书籍《Patterns of Enterprise Application Architecture》命名。遵循该模式的对象接口一般包括如Insert, Update, 和 Delete这样的函数,以及对应于底层数据库表字段的相关属性。
错误处理
不要让错误处理影响代码质量。
抛出异常,而非返回错误代码
返回错误代码有时候需要好几层的if…else…
来对错误代码进行处理,让代码显得很乱,而处理异常就可以相对整洁很多。
把try-catch
写在前面
try-catch
定义了一个作用域(scope),try
范围的代码执行的时候可以随时被中断,并转移执行catch
域的代码。一个良好的习惯是把可能抛出异常的代码以try-catch
包裹起来,以便清楚的表达你的代码会造成什么异常。
必须处理的异常(Checked Exception)
有些语言里可以定义必须处理的异常,必须在代码中进行恰当处理,而且编译器会强制开发者对其进行处理,否则编译会不通过。作者认为弊大于利。
异常应该提供上下文信息
异常应该提供足够的信息来方便处理。
按照需求来定义异常
对异常的归类应该按照使用的需求,比如下面这段代码,调用某个第三方的Library来打开一个端口,处理不同的异常,有许多代码是重复的:
try {
port.open()
} catch .deviceResponseException(let e) {
reportPortError(e)
} catch .atm1212UnlockException(let e) {
reportPortError(e)
} catch .gmxError(let e) {
reportPortError(e)
}
为了让代码更简洁,我们用一个Wrapper来包裹这个第三方的处理:
final class LocalPort {
private let innerPort: ACMEPort
public init(innerPort: ACMEPort) {
self.innerPort = innerPort
}
public func open() throws {
try {
innerPort.open()
} catch .deviceResponseException(let e) {
throw .portFailure(e)
} catch .atm1212UnlockException(let e) {
throw .portFailure(e)
} catch .gmxError(let e) {
throw .portFailure(e)
}
}
}
…
let port = LocalPort(ACMEPort(80))
try {
port.open()
} catch .portFailure(let e) {
reportPortError(e)
}
把第三方Library包裹起来可以把对此Library的依赖最小化,比如,如果需要换其他的Library,只需要在Wrapper里进行改动;而且,Unit测试的时候也方便插入自己的Mock。
定义常规流程
良好的错误处理可以让代码显得整洁,但有些时候做的过度却会起到相反的效果:
let total = 0
try {
let alcoholExpense = expenseManager.alcoholExpense(user: user)
total += extraExpense
} catch .noAlcoholExpense {
total += expenseManager.getAverageExpense()
}
简化的一个方法,是采取[Fowler]著作中提到的“特殊情况模式”(Special Case Pattern),建立一个专门处理特殊情况的类:
protocol Expense {
func getTotal() -> Int
}
final class AlcoholExpense: Expense {}
final class AverageExpense: Expense {}
…
let expense = expenseManager.expense(for: user)
expense.getTotal()
不要返回nil
用返回nil
来表示错误的问题在于调用者需要检查是否返回了nil
,一旦忘了检查而实际返回了nil
,程序就可能会出现事先没有预料的问题。
- 如果你想返回
nil
,考虑用抛出异常来代替,或者用前面说的“特殊情况模式”来返回一个特殊情况的对象 - 如果是第三方Library的方法返回
nil
,则考虑按照之前说的把此Library包裹在一个类里,然后抛出异常或返回“特殊情况模式”的对象
不要给方法传递nil
除非nil
是你调用的方法允许且合理的,否则应该避免给方法传递nil
。这个问题在Java里很明显,但是作为强类型的Swift,如果方法的参数不是Optional,则编译时就不允许nil
用作参数。
边界
一个大的项目也许会用到第三方的函数库或者开源代码,有时候也会依赖同一公司不同的组来完成各自的代码,之后再把代码整合到一起。两方代码衔接的部分就是边界。
第三方代码
面向公众的第三方代码往往会很Generic,以便尽可能的适用于不同的情况,而对于代码的用户来说,我们需要代码尽可能的针对我们的需求。
早期的Objective-C里的集合类型,如NSDictionary
,从中取出元素时往往需要类型转换:
Student *student = (Student *)[dict objectForKey: studentID];
之后有了泛型(Generic)后就可以省略类型的转换:
NSDictionary<NSString*, Student*> *students;
Student *student = [students objectForKey: studentID];
然而这还不够,作为基础类的NSDictionary
有许多方法我们可能用不到;另外,一旦NSDictionary
的API发生改变,依赖于它地方都得做出调整。解决的方法是把它包裹在我们自定义的类中:
public class StudentManager {
private var students = [String: Student]()
public func student(with studentID: String) -> Student {
students[studentID]
}
}
探索学习第三方库
当我们使用一个陌生的第三方库时,应该从哪里开始?作者的方法是写一些单元测试,来验证第三方库的功能,作为初试的探索和学习,这样做的好处是不会增加太多额外的时间,因为总是要学和掌握,而且以后第三方库版本升级后还能用来发现新版本有没有改动。
尚未存在的代码
如果工作需要用到某一个尚未完成的库,可以采取提供接口(protocol)和Mock的方法来暂时填补这个空缺,甚至可以在一定程度上加入单元测试来检查预期的结果。这个接口大概就是作者说的边界(Boundary)。
边界要清晰
边界相关的代码要把责任清晰的分开,比如项目主要代码和第三方代码,避免第三方代码的知识混在主要代码中。
单元测试
测试驱动开发(TDD)的三大法则:
- 写代码前先写不能通过的测试
- 不能通过的测试只要足够不通过就可以,不要写多
- 写刚好能让当前测试通过的代码
保持测试整洁
- 不整洁的测试随着代码增加会变得不好维护,最后成为写代码的累赘
- 整洁的测试代码可以保证项目代码的灵活程度,高测试覆盖率让你对系统改动的过程中可以改进系统的架构
- 整洁的测试关键在于可读性
测试常用的创建-操作-检查(Build-Operate-Check)的模式下,一个单元测试包含三部分:
- 创建测试数据
- 操作测试数据
- 检查测试结果
随着项目发展,逐渐的会积累起一些项目领域相关的测试工具代码,比如一个付款系统最常用的是用来测试的帐户:
public func makeTestAccount(name: String, bsb: String, number: String, balance: Decimal, productType: String) -> Account {…}
而不需要每个单元测试都要重写一遍生成测试帐户的代码。
双重标准
虽然也需要简单、整洁,测试代码可以不像用来发布的生产代码那样高标准。
每个测试案例只有:一个断言 vs 一个概念
很久以前,单元测试的规矩是每个测试只应该有一个断言(Assert),新的标准是每个测试只针对一个功能或概念。
F.I.R.S.T
整洁的测试遵循五个规矩:
- Fast(快):测试应该可以很快的被执行。跑的很慢的测试代码会让你不像去运行,导致有的问题不会被第一时间发现,相应的也不会那么容易被修复。
- Independent(独立):测试代码之间不应该互相依赖。一个测试案例不应该给另一个测试案例提供前提条件。
- Repeatable(可重复):测试应该可以在任何环境里重复运行,而不依赖于特定的环境才能进行测试。
- Self-Validating(自我验证):测试应该有布尔值的输出来表示成功或失败,而不需要你去读长长的输出文件来判断测试是否成功,不需要你去比较两个文件来判断测试是否成功。
- Timely(即时性):单元测试应该在写在使其通过的生产代码之前。如果先写代码在写测试,可能你会发现测试不那么好写。
类
类应该尽可能地小,对于函数我们计算有多少行代码,对于类我们计算它有几个职责。
单一职责原则(Single Responsibility Principle)
单一职责原则规定每个类只有一个职责,相应的,这个类需要改动的时候只能有一个原因。
许多开发者担心,大量的小的、单一用途的类会让人更难理解大局。他们担心自己必须不断的从一个类跳转到另一个类,以弄清更大范围的某个工作是如何完成的。
然而,一个有许多小类的系统并不比一个有几个大类的系统有更多的活动部件。你是否希望你的工具被组织到有许多小抽屉的工具箱中,每个抽屉中都有定义明确、标签清晰的组件?还是你想有少数几个大抽屉,把所有东西都扔进去?
每一个有一定规模的系统都会有许多逻辑和复杂性,重点是合理的管理他们,让每个开发者都能找到需要找的东西。
耦合性
类应该只有少量的实例变量,类的每个方法都应该操作一个或多个实例变量。每个方法用到的实例变量越多,这个方法就和它所在的类越耦合。耦合性高意味着一个类里的方法和变量互相依赖,凝聚在一起成为一整个逻辑。
在许多小类里保持耦合性
设想,有一个很长的函数,函数里定义了许多局部变量,你发现此函数有一部分的逻辑可以单独分离出来,成为一个单独的函数,然而,这个逻辑当中用到了许多大函数定义的局部变量,是否必须作为新函数的参数传递给它呢?解决方法是把函数里的局部变量升级成类的实例变量,这样一来大函数和分出去的小函数都可以自由使用。
这样做的问题在于,随着我们把大函数划分成小函数,这个类就可能会有越来越多的,只为某些小函数存在的实例变量,这显然会降低耦合性,但是我们转变一下思维,如果有几个实例变量只被某几个小函数用到,为什么不把这些实例变量和这几个小函数再分离出来成为独立的类呢?
系统初始化
Software systems should separate the startup process, when the application objects are constructed and the dependencies are “wired” together, from the runtime logic that takes over after startup.
软件系统应该将启动过程与启动后的运行逻辑分开,前者是应用对象的构建和依赖关系的 “连接”。
假设我们需要派天兵(HeavenlySoildier
)来降妖,而初始化天兵时需要给他一个降魔法宝,考虑以下代码:
private var heavenlySoildier: HeavenlySoildier?
public func makeHeavenlySoildier() -> HeavenlySoildier {
if let heavenlySoildier = self.heavenlySoildier {
return heavenlySoildier
}
heavenlySoildier = HeavenlySoildier(weapon: 捆仙绳())
return heavenlySoildier
}
这段代码检查是否已经有了天兵,如果没有则新建一个,看似考虑到了延迟初始化,在需要用的时候才创建实例,然而它的问题如下:
- 代码里存在一个硬性编码的
捆仙绳()
,万一妖魔不吃这一套,而需要别的法宝怎么办?难道为了给初始化的天兵不同的法宝,而要把这段代码重复一遍并把捆仙绳()
换成别的? - 因为硬性编码的
捆仙绳()
,导致单元测试也是个问题,怎样才能给这个方法传递一个Mock的法宝实例呢?
要解决这两个问题,可以考慮“依赖注入”(Dependency Injection):
protocol MagicWeapon {…}
private var heavenlySoildier: HeavenlySoildier?
public func makeHeavenlySoildier(with weapon: MagicWeapon) -> HeavenlySoildier {
if let heavenlySoildier = self.heavenlySoildier {
return heavenlySoildier
}
heavenlySoildier = HeavenlySoildier(weapon: weapon)
return heavenlySoildier
}
let 李天王 = HeavenlySoildier(weapon: 宝塔())
如果扩大规模,在一个大型的软件系统里,把子系统、实例等等初始化的过程单独分出来,并采用依赖注入(Dependency Injection)的形式,是一个很有效的维护代码质量的方式。
设计干净的代码
根据Kent在《Extreme Programming》里提出的,一个“简单的设计”应该符合四个规则:
- 可以通过所有测试
- 不包含重复
- 表达程序员的意图
- 类和方法的数量最小化
简单设计规则1: 通过所有测试
一个经过全面测试并且在所有时间都能通过测试的系统是一个可测系统,一个不可测的系统无法验证是否正常工作,因此不能被发布投入使用。
可测系统的要求让我们不得不把类设计的尽可能小而且单一目的,一个遵循SRP(单一职责原则)的类更容易测试;而紧密耦合的类不容易测试,因此,随着测试的增加,代码就越多的遵循有用的原则和理念比如DIP(依赖倒转原则)和依赖注入。
简单设计规则2:代码重构(refactor)
在有测试的基础上,我们递增式的进行代码重构,而足够的测试覆盖率,保证了我们进行代码重构时不会破坏已有的功能。
避免重复
以下代码提供两个方法,一个返回去西天还有多少路程,一个返回是否到了西天:
func distanceToTheWest() -> Int {
// 计算剩下的路程
return distance
}
func hasArrivedInTheWest() -> Bool {
// 计算是否已经在西天地界
return hasArrived
}
避免重复,我们可以重新写以上代码:
func distanceToTheWest() -> Int {
// 计算剩下的路程
return distance
}
func hasArrivedInTheWest() -> Bool {
return distanceToTheWest() == 0
}
简单设计规则3:表现力
软件项目的开销之一是长期维护的成本,因此,代码意图清晰就尤为重要。
- 选择合适的名字,知道类或方法名后,具体了解其代码时不应该感觉诧异
- 让类和方法尽可能小,这样一来,命名和代码都比较容易,也就容易读懂
- 你可以用标准的命名,比如设计模式里的一些经典命名,如
MVVM
的ViewModel
- 写得好的单元测试,可以起到文档的作用,看一个类的单元测试就可以明白这个类是干嘛的
最重要的还是勇于尝试,而非写完代码就放任不管了,也许明年用到这段代码的人还是你。当然,也可能是个会用git-blame
且知道你家地址的极度强迫症的精神病患者。
简单设计规则4:类和方法最小化
有时候,遵循一些设计原则和模式会导致很多零散类,比如SRP用到了极端,这时就需要运用你的智慧来判断,在保持系统遵循设计原则的基础上尽可能减少类和方法的数量。
并发
并发程序把“做什么”和“什么时候做”两个概念脱钩,极大的改善程序的吞吐量和结构。
迷思和误解
- 并发总是可以提高性能?
并发有时候可以提高性能,是在有许多等待的时间用来给不同的线程或处理器来分享。
- 写并发程序时,程序的设计不需要改变?
事实上,并发程序的算法设计可能跟单线程系统完全不同,把“做什么”和“什么时候做”分开,通常会对系统结构有很大的影响。
- (Java)使用容器时不需要了解并发相关的问题?
你最好明白容器正在干嘛,以便防止并发可能引起的问题。
-
并发会产生一些开销,包括性能上,以及需要写一些额外的代码。
-
正确的使用并发是一件复杂的工作,即便是针对简单的问题。
-
并发相关的问题往往不容易重现,而经常被当作是一次性的问题被忽略。
-
并发往往需要从根本上改变设计策略。
防止并发出错的几个原则
单职责原则(SRP)
并发程序的设计复杂度,决定了它本身的状态可以不因为外部因素就改变,因此一个系统的并发功能应该和此系统的其他功能分开,尽管并发程序和主程序混在一起的情况很常见。
- 与并发相关的代码有它自己的开发、变化和调整的生命周期
- 与并发症有关的代码有其自身的挑战,这些挑战与非并发症有关的代码不同,往往比它们更困难
- 误写的基于并发的代码可能失败的方式之多,使得它在没有周围的应用程序代码的额外负担的情况下,已经具有足够的挑战性
建议:将你的并发症相关代码与其他代码分开。
限制数据的使用范围
两个线程同时修改同一个数据,可能会导致无法预知的结果。一个解决方法是用DispatchQueue
的sync
来保护使用共享数据的代码部分,然而Swift的闭包(Closure)机制导致了这样的效率不高,因为需要对闭包里的对象进行捕获(closure capture),而且还存在其他一些问题:
- 如果代码中许多地方用到共享数据,你可能会漏掉保护某处
- 每处使用共享数据的代码都需要特别保护,造成无法避免的代码重复
- 一旦有错误发生,很难找到错误的源头
建议:使用数据封装,严肃的限制任何可以被多线程共享的数据。
使用数据的副本
如果使用共享数据只需要读取而不需要改变(readonly),那么可以使用不可改写的数据,就是Swift里的let
,以及复制值的struct
。
线程应该尽可能的独立
尽可能的让线程独立,而不需要共享数据,使每个线程都好像是唯一一个线程。
了解你的函数库
根据你的编程语言,了解语言提供的函数库中,那些工具是线程安全的(thread-safe),包括集合、序列等等。
了解程序的执行模式
- 生产者-消费者:生产者创建需要共享的数据,放在缓冲或者队列里,而消费者必须等缓冲或队列有可用数据时才能进行操作。这需要生产者和消费者之间进行通信,生产者把数据放在队列里并告知消费者,消费者从队列里拿出数据,并告知生产者。
- 读者-作者:当共享数据主要作为信息源被读者读取,但偶尔也会被作者更新时,如何平衡两者的互动时避免并发出问题的关键。
- 小心同步方法的互相依赖:把共享数据的方法标为同步,或者以一种同步的方式来调用,可以避免异步访问同一对象可引发的问题,然而如果共享数据有好几个同步方法,依然还是会出现问题。建议:尽可能避免共享数据有多个可以调用的方法。
测试线程代码
- 将疑似错误当作可能的线程问题来对待
- 把单线程代码先正确工作
- 让多线程代码可插入、替换
- 让多线程代码可调整
- 用比处理器数量多的线程来测试
- 在不同平台上测试(Java)
- 调试代码,试着强制出错