Clean Code中文笔记

2021/11/30

Clean Code中文笔记

命名的讲究

名字本身应该能揭示意图,如表示持续了多少天的整数:

let d: Int

就不如能直接看出意图,且看出单位为天的:

let elapsedTimeInDays: Int

避免会产生歧义的名字,在一些较底层的语言里,同时会有一些系统相关的专有名词,如Unix里的命令ls, grep,以这些名词命名变量可能会让读代码的人产生误会;另外,如果命名里包含了类型,如nameString,那么就应该确保此变量或常量类型为String,其实,这里直接命名为name是最简洁的。

此外,避免同时出现差异不大的名字,比如:

class BasicTicketStorageControlCoordinator {}
class BasicTicketDispatchControlCoordinator {}

最后,由于英文字母的关系,有些字母和数字不容易分辨,如小写的L和数字1,以及大写的字母O和数字0。

同时使用的名字需要有合理的区别,不要为了区别不同的变量而有意加上数字或其他不同的字符,比如表示基本工资和奖金的两个变量,与其命名为a1, a2,就远不如basebonus

为了区分名字而对变量进行符号式的区分,比如BookInfoBookData,这两个类型从名字上看无法让人知道具体有什么差别。

命名应该可以读的通顺,比如表示曾用住址和当前住址:

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类的名字应该是个名词或者名词短语,比如AccountCustomerStudent,以及StudentCoordinator,并且避免动词出现在名字里。

书中提到,类名应该避免ManagerProcessorDataInfo这几个单词,也许是因为这些太广泛不具有具体含义?

方法名应该有动词或动词短语,如:

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()

同一个概念,使用同一个单词,比如fetchretrieveget都可以表示获取、读取,但混在一起用就可能会产生不必要的误会,以为这不同的动词有特定的不同含义:

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就比MailingAddressCustomerAddress更简洁;如果要区分不同性质的“地址”,比如居住地址和网址,可以考虑PostalAddressURI

最后一点总结

选择合适的名字需要有比较强的描述能力以及与其他程序员相通的文化背景,这不是技术或管理问题,许多人在这个难点上做的不好;另外一个问题,为了提高代码质量而改名,程序员可能会怕别人不同意,因为有些时候命名是偏主观的,尤其是很多时候开发员不会精准的记住每一个类、方法等名字,因为现代化的开发环境提供了各种便利,比如自动完成。

函数

一个不好的例子:

func makeTea(teaLeaf, hasWater, hasWood) {
  if hasWater == false {
    getBucket()
    getRope()
    getWater()
  }
  if hasWood == false {
    getWoodChopper()
    if isWoodChopperSharp() {
      sharpenWoodChopper()
    }
    getWood()
  }
  fireWood()
  boilWater()
  makeTea()
}

小之又小

函数要尽可能短小,虽然作者没有调查研究证据表示短小方程更好。这就意味着,ifelsewhile同一行的条件代码应该只有一行,甚至只是调用另一个函数;同时,函数代码的缩进不应该超过一到两级。

只做一件事

一个函数应该只做一件事,把这一件事做好就行了。这么说的问题是有时候难以定义“一件事”,前面准备喝茶的例子里,我们做了好几件事:

  1. 检查是否有水,如果没有,找水桶,去挑水
  2. 检查是否有柴火,如果没有,找斧头,如果斧头不锋利,磨斧头,然后去砍柴
  3. 烧火,煮水,泡茶

这些步骤都是为了最后的泡茶,在LOGO语言里,定义函数的关键字是TO(为了),我们可以把函数描述为以TO开头的自然语言:

(TO)为了泡茶,我们检查是否具备必须的用品,如果缺少,我们去补足,最后把茶泡上。

如果一个函数只做比该函数的声明低一级的步骤,那么该函数就在做一件事。我们写函数的原因是将一个大的概念分解为下一抽象层次的步骤。

另外,以上代码里,准备柴火有两个层级的if,这也是一个线索,说明这个函数做了不只一件事。

每个函数只应该有一个抽象层次

拿上面的代码来讲,为了泡茶,我们需要的直接工作是“准备好泡茶需要的工具”,这个就是一个抽象层次,至于检查有没有水,有没有柴,甚至砍柴到是否锋利,都是一个以上的抽象层次。

降级规则

我们可以按照降级规则(stepdown rules)来把程序的功能分解:

这样分析看来,这个函数的抽象逻辑涉及了多个层级。

Switch

swift代码很难只做一件事,因为这个关键字本身的意义就是做N件事。作为编程语言的特性,我们不可能避免使用这个关键字,但是可以用“多态”把switch隐藏在比较底层的地方。

不好的例子:

public func makeFood(_ type: FoodType) {
  switch (type) {
    case .vegetarian:
      makeVegetarianFood()
    case .asian:
      makeAsianFood()
    case .mexican:
      makeMexicanFood()
  }
}

以上代码有几个问题:

解决方案是把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开关是个非常糟糕的做法,它把方法的签名弄得很乱,明目张胆的告诉读者这个方法做多件事,分别对应开关的truefalse两个值。

两个参数的函数

两个参数的函数比一个参数的难懂,比如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设置为tomset在这里是动词还是形容词?解决方法是把操作和查询分开:

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() {}

来的清楚。

合适的注释

不合适的注释

格式

我们希望读代码的人感受代码的整洁、一致且注重细节,不同的模块有序的存在,而不是看到一团乱。这就需要写代码的人遵循一定的格式,如果是团队作业,整个团队需要事先约定好一个大家都遵循的格式,有些自动化软件可以在一定程度上帮助格式化代码。

代码格式的重要性

代码格式的重要性和“让代码正确工作”同样重要,今天的代码也许明天会改变,但代码的高可读性不应该变化。

源代码文件长度

一篇优秀的新闻稿,往往开头会有一个标题,告诉你发生了什么,第一段也许会给你一个事情的概括,继续往下读,会看到事情越来越多的细节。源代码也应该如此,最开始我们应该看到代码所属的模块,跟着是较高级的概念和算法,之后才是细节。正如报纸上不同的新闻稿,如果所有文章都混在一起,则没有人会想读它。

纵向开放性(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)

你是否曾经需要上下翻几个屏幕来理解某一个函数是如何工作的,甚至需要来回看几个文件来理解一个变量是在哪里定义的?这个就是纵向距离。紧密相关的概念应该纵向靠在一起;虽然,如果代码在不同的文件里,这一点就无法做到,但如果代码紧密相关的话,必须有很好的理由才可以分散在不同的文件里。根本的目的是避免读者为了理解某一段代码而需要来回翻看不同的代码和文件。

横向格式

每行代码都不应该太长,经典的规矩是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
}

代码缩进

我们使用代码缩进来清晰的表达源代码结构的递进关系。类里面的函数定义比类定义缩进一级,函数的实现又比函数名更进一级。有些人喜欢把短的ifwhile等语句写在一行,作者更倾向于把它分开几行,使得代码更清晰。

有些语言里,没有循环体的循环可以省略空括号,如:

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里,而是需要严肃的思考怎样来最好的表达一个对象应该提供的功能。

对象与数据结构的不同

对象和数据结构应该互补:

下面两段代码,第一段偏向于过程(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只应该调用以下的方法:

  1. 属于C的方法
  2. 方法f里创建的对象的方法
  3. 作为参数传给方法f的对象的方法
  4. C的实例变量所拥有的对象的方法

方法f不应该调用允许调用的方法返回值的方法,如:

final resourcePath = context.getConfiguration().getResourceURL().getAbsolutePath()

火车事故(Train Wrecks)

上面提到的一连串的调用有个名字叫Train Wreck,因为看起来像一个接一个的火车碰到一起,分解开来:

let configuration = context.getConfiguration()
let resourceURL = configuration.getResourceURL()
let resourcePath = resourceURL.getAbsolutePath()

注意:如果configurationresourceURLabsolutePath这些都只是数据结构,这样就不违反Demeter法则,因为数据结构只提供数据,没有任何“行为”:

let resourcePath = context.configuration.resourceURL.absolutePath

避免数据结构和对象混合

有时候数据结构跟对象会混在一起,既有公开可操作的变量,又有公开的方法,而方法也操作这些变量。往这种混合型类里加方法或者变量都不容易,应该尽可能的避免写出这种东西。

把结构隐藏起来

如果configurationresourceURLabsolutePath都是具体的对象的话,应该会把各自内部的结构隐藏起来,这样一来,怎么能取得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

除非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)的三大法则:

  1. 写代码前先写不能通过的测试
  2. 不能通过的测试只要足够不通过就可以,不要写多
  3. 写刚好能让当前测试通过的代码

保持测试整洁

测试常用的创建-操作-检查(Build-Operate-Check)的模式下,一个单元测试包含三部分:

  1. 创建测试数据
  2. 操作测试数据
  3. 检查测试结果

随着项目发展,逐渐的会积累起一些项目领域相关的测试工具代码,比如一个付款系统最常用的是用来测试的帐户:

public func makeTestAccount(name: String, bsb: String, number: String, balance: Decimal, productType: String) -> Account {}

而不需要每个单元测试都要重写一遍生成测试帐户的代码。

双重标准

虽然也需要简单、整洁,测试代码可以不像用来发布的生产代码那样高标准。

每个测试案例只有:一个断言 vs 一个概念

很久以前,单元测试的规矩是每个测试只应该有一个断言(Assert),新的标准是每个测试只针对一个功能或概念。

F.I.R.S.T

整洁的测试遵循五个规矩:

  1. Fast(快):测试应该可以很快的被执行。跑的很慢的测试代码会让你不像去运行,导致有的问题不会被第一时间发现,相应的也不会那么容易被修复。
  2. Independent(独立):测试代码之间不应该互相依赖。一个测试案例不应该给另一个测试案例提供前提条件。
  3. Repeatable(可重复):测试应该可以在任何环境里重复运行,而不依赖于特定的环境才能进行测试。
  4. Self-Validating(自我验证):测试应该有布尔值的输出来表示成功或失败,而不需要你去读长长的输出文件来判断测试是否成功,不需要你去比较两个文件来判断测试是否成功。
  5. Timely(即时性):单元测试应该在写在使其通过的生产代码之前。如果先写代码在写测试,可能你会发现测试不那么好写。