Skip to content

代码整洁之道,笔记

《代码整洁之道》简介

软件质量,不但依赖于架构及项目管理,而且跟代码质量息息相关。代码质量与其整洁度成正比。如同干净的地板能减少事故发生,归置到位的工具能提升生产力一般。干净的代码,既在质量上较为可靠,也为后期维护、升级奠定了良好的基础。

一、谨慎命名

在意你的代码,就像给自己的孩子起名一样给每个变量、每个函数起名。

1.1.名副其实

给函数和变量取个好名字是优秀程序员的基本功,取的名字要名副其实,见文知意。

1.2.避免误导

1.如果数据本身不是个List,否则别用 personList 来指代一组数据。List一词对程序员有特殊意义。

1.3.去掉冗余

1.废话都是冗余,Variable一词永远不应当出现在变量名中,Table一词永远不应当出现在表名中。 nameString 会比 name 好吗? ProductInfo 、 ProductData 和 Product 有什么区别?更糟糕的是,如果代码中同时存在 Article 和 ArticleInfo 类,程序员怎么知道该调用哪个类呢? InfoData 就像 aanthe 一样,属于意义含混的废话。

如果缺少明确的约定, moneyAmount 就与 money 没区别, customerInfo 与 customer 没区别, accountData 和 account 没区别, theMessage 也与 message 没区别,要区分名称,就要以读者能鉴别不同之处的方式来区分。   还有一种使用前缀的情况:设想你有名为 firstName、lastName、street、houseNumber、city、state 和 zipcode 的变量。当它们搁在一块儿时,我们可以很明确的知道这是一个地址。不过,假使是在某个方法中看见孤零零一个 state 变量呢?你还会觉得他是某个地址的一部分吗?这时你可能会添加前缀 addrFirstName、addrLastName、addrState等,以此提供语境。至少,读者会明白这是某个更大的结构的一部分。但是,更好的方案是创建名为 Address 的类,将这些变量放在同一个类中。这样,即使是编辑器也会知道这些变量隶属于某个更大的概念了。

1.4.使用读得出来的名称

在阅读程序时,我往往会再脑海里默念程序执行步骤,如果变量命名如下:

java
var genymdhms // 生成日期,年、月、日、时、分、秒

阅读时我就会在脑海里默念这些奇怪组合的字母,一边默念一边思考含义。当与其他开发者交流时,他一定会问你,你说的这个gen-Y-M-D…是什么变量?修改为:

java
var generationTimestamp

这样,就让思维和交流都更加顺畅。

1.5.严谨,不要俏皮

假设你有一个全局的帮助类,GlobalHelper是一个好名字。而你觉得它像个超人一样非常有用,抖机灵把它命名为DiJia(全能的迪迦奥特曼!Biu Biu Biu~),别这样做,因为这会对阅读者造成误解,请让它保持GlobalHelper——宁可明确,毋为好玩。

1.6.其他规则

  • 类名应该是名词或名词短语。如 Customer、WikiPage、Account,避免使用Manager、Processor、Data、Info这样的类名。
  • 类名不应当是动词。
  • 每个概念对应一个词。如果一堆代码中既有Controller,又有Manager,还有Driver,Presenter,就会令人困惑:他们之间有什么区别?为什么不全用Controller?如果同一概念可被多个词语描述,请确定其中一个名字,并在你的代码中一以贯之。
  • 使用专业术语。只有程序员才会读你的代码,使用技术领域的名称。

二、函数和类

2.1.保持函数短小精悍,一个函数只做一件事

过长的函数不易理解,如果某天这个函数需要修改的话,一个长长的函数让人望而生畏。我们也许只需要修改其中的两行代码,而梳理出这个长函数中的两行代码大大增加了理解成本。并且,小函数也能更好地复用。

1.函数应该清晰明了的实现它的名称声明的事。如果一个函数做了多件事,一个明显的标志是无法为它起一个精准的名字。这时你就应该重构了,将函数提炼成只做一件事的小函数。

2.尽量不要在函数签名中传递状态值。状态值是函数做了多件事的明显标志。将其拆分为不同状态的函数,阅读代码和调用时都会更方便。例如:

java
fun setLoading(status: Boolean) {
    if (status) {
        loading.VISIBILITY = View.VISIBLE
    } else {
        loading.VISIBILITY = View.GONE
    }
}

修改为:

java
fun showLoading() {
    loading.VISIBILITY = View.VISIBLE
}

fun hideLoading() {
    loading.VISIBILITY = View.GONE
}

2.2.保持类短小精悍,单一权责原则

同样,类也应该保持短小、并符合单一权责原则。高内聚,低耦合。隔离让系统对每个元素的理解变得容易。

单一权责原则:在面向对象编程领域中,单一权责原则(Single responsibility principle)规定每个类都应该有一个单一的功能,并且该功能应该由这个类完全封装起来。一个类或者模块应该有且只有一个改变的原因。

2.3.条件表达式使用if-else还是卫语句

条件表达式通常有两种表现形式。第一种形式是:所有分支都属于正常行为。第二种形式是:条件表达式提供的答案中只有一种是正常行为,其他都是不常见的情况。

如果两条分支都是正常行为,就应该使用if-else语句。如果其中一个条件是极少发生的,就应该单独检查该条件,并在其发生时,立即从函数中返回。这样的单独检查常常被称作“卫语句”(guard clauses)。例如:

java
fun getPayAmount() {
    var result: Int
    if (isSeparated()) result = separatedAmount()
    else {
        if (isRetired()) result = retiredAmount()
        else result = normalPayAmount()
    }
    return result
}

这是一份获取工资的代码,如果员工已经离职了,获取离职工资;如果员工已经退休了,获取退休工资,否则获取正常工资。

离职和退休都属于特殊情况,修改为:

java
fun getPayAmount() {
    if (isSeparated()) return separatedAmount()
    if (isRetired()) return retiredAmount()
    return normalPayAmount()
}

另外,在条件表达式中,应避免否定性条件,否定式要比肯定式难明白一些。所以,尽可能将条件表示为肯定形式。

2.4.每个函数一个抽象层级

同一个函数中的代码应该属于同一层级。创建分离较高层级一般性概念与较低层级细节概念的抽象模型,这很重要。例如:只与细节有关的常量、变量或工具函数不应该在基类中出现。基类应该对这些东西一无所知。

良好的软件设计要求分离位于不同层级的概念,将它们放到不同容器中。有时,这些容器是基类或派生类,有时是源文件、模块或组件。无论哪种情况,分离都要完整。较低层级概念和较高层级概念不应混杂在一起。

java
fun printOwing() {
    printBanner()
    // 打印详情
    System.out.println("name: $name")
    System.out.println("amount: ${getOutstanding()}")
}

修改为:

java
fun printOwing() {
    printBanner()
    printDetails()
}

fun printDetails() {
    System.out.println("name: $name")
    System.out.println("amount: ${getOutstanding()}")
}

2.5.将修改和查询分离

查询函数中不要做修改操作。这样调用者就可以多次调用查询函数而且没有任何副作用。将有副作用的修改函数单独提出。否则若在查询数据时修改了数据,调用者会产生迷惑。这也容易成为bug之源。(保证get请求的幂等性)

副作用是一种谎言。函数承诺只做一件事,但却偷偷做了其他事。这样的谎言是破坏性的,有时会导致古怪的时序性耦合及顺序依赖。

2.6.使用多态替换switch

switch语句天生就要做多件事。随着switch条件的增多,它做的事情也会增多。如果出现了,考虑使用多态替换之。

2.7.减少函数参数

1.最理想的函数参数数量是0,其次是1,再次是2,应尽量避免3。有足够特殊的理由才能使用三个以上参数——所以无论如何也不要这么做。

2.参数带有太多概念性,没有参数的函数能够见名知意,理解带参数的函数不仅要理解其名字,还要理解其所有参数。

3.太多的参数会造成测试困难。如果参数多于两个,测试覆盖所有可能值的组合让人望而生畏。

2.8.重构

  • 消除特性依恋: 如果一个类过度的使用另一个类的方法,请将依恋代码移至另一个类
  • 消除重复
  • 用命名常量替代魔术数

更多的重构手法参见《重构改善既有代码的设计》一书。

三、坏注释与好注释

3.1.去掉无意义的注释

如果注释纯属废话,请去掉它,例如:

java
// 如果count > 0,返回1,否则返回-1
if (count > 0) {
    return 1
} else {
    return -1
}

代码已经清晰的表达了意图,这些注释看起来就像是喃喃自语。

再比如:

java
// 当this.closed变为true时,直接返回。如果超时未关闭,抛出一个异常
fun waitForClose(timeoutMillis: Long) {
    if (closed) return
    wait(timeoutMillis)
    if (!closed) throw Exception("发射器关闭异常")
}

当this.closed中途变为true时,函数并不会直接返回。函数仅仅在开始时判断了this.closed是否为true,否则就只是等待遥遥无期的超时。然后在超时等待时间结束后,再判断this.closed状态。

这一细微的误导信息,放在比代码本身更难阅读的注释里面,有可能导致某位程序员快乐地调用这个函数,并期望在 this.closed 变为true时立即返回。那位可怜的程序员将会发现自己陷入调试困境之中,拼命想找出代码执行得如此之慢的原因。

3.2.注释掉的代码

曾经有一段时间,注释掉的代码可能有用。但我们已经拥有良好的源代码控制系统如此之久,这些系统可以为我们记住不要的代码。我们无需再用注释来标记,删掉即可。当你需要时,很容易就能找回。事实证明,注释掉的代码往往用着过时的函数和变量,很少有需要的时候。

3.3.我们是作者——为每个类添加头注释

为每个类添加头注释,用@author字段告诉别人你是谁。写代码的时候,记得自己是作者,要为评判你工作的读者写代码,对自己的代码负责。

3.4.警示

用于警告其他程序员会出现某种后果的注释是有用的,例如:

java
fun getDataFormat(): SimpleDateFormat {
    // SimpleDateFormat不是线程安全的,所以我们需要每次创建新的实例
    val df = SimpleDateFormat("yyyy-MM-dd")
    df.timeZone = TimeZone.getTimeZone("GMT")
    return df
}

你也许会抱怨说,还会有更好的解决方法。我同意你所说的。不过上面的注释绝对有道理存在,它能阻止某位急切的程序员以效率之名使用静态初始器。

3.5.TODO注释

有时,我们需要使用 TODO 注释来表示自己认为应该做,但由于某些原因目前还没做的工作。它可能要提醒删除某个不必要的特性,或者要求他人注意某个问题。它可能是恳请别人取个好名字,或者提示对依赖于某个计划事件的更改。无论 TODO 的目的如何,它都不是在系统中留下糟糕代码的借口。

一定要定期查看 TODO 注释,删除不再需要的。不要让 TODO 变成程序员的谎言。

3.6.让代码自解释

注释并不像辛德勒的名单。它们并不“纯然的好”。若代码足够有表达力,用代码来展示意图往往会更好。注释总是一种失败。当我们无法找到不用注释就能表达自我的方法时,我们写了注释,这并不值得庆贺。

如果你发现自己需要写注释,再想想看是否有办法翻盘——用代码来表达。每次用代码来表达,你都该夸奖一下自己。每次写注释,你都该做个鬼脸,羞愧于自己在表达能力上的失败。

我为什么极力贬低注释?因为注释会撒谎。也不是说总是如此或有意如此,但出现得实在太频繁。注释存在的时间越久,就离其所描述的代码越远,越来越变得全然错误。原因很简单。程序员不能坚持维护注释。

代码在变动,在演化。从这里移到那里。彼此分离、重造又合到一起。很不幸,注释并不总是随之变动。注释常常会与其所描述的代码分割开来,孑然飘零,越来越不准确。

当你想写一句注释时,更好的方式是将你想要写注释的代码提取出一个单独的方法,再给它起个好名字。

你愿意看到这个:

java
// 如果员工是优秀员工
if (employee.flags == AWESOME && employee.value > 9) {
    // 颁发优秀员工奖励
    employee.holiday += 5
    employee.money += 2000
}

还是这个:

java
if (employee.isExcellent) {
    employee.getReward()
}

写注释的常见动机之一是糟糕代码的存在。我们编写一个模块,发现它令人困扰、乱七八糟,它烂透了。我们告诉自己:“喔,最好写点注释!”—— 不!最好是把代码整理干净。

java
// 初始化数据和初始化视图
fun init(){}

修改为:

java
fun initData(){}

fun initView(){}

四、良好的格式

4.1.自顶向下的阅读顺序

想想看你阅读新闻时,在顶部,你期望有个头条,告诉你故事的主题,好让你决定是否要读下去。第一段是整个故事的大纲,给出粗线条概述,但隐藏了故事细节。接着读下去,细节渐次增加,直至你了解所有的日期、名字、引语、说法及其他细节。

源文件也要像新闻文章一样,最顶部给出高层次概念和算法,细节向下渐次展开,直至找到源文件中最底层的函数和细节。函数应该紧跟调用处,保证垂直方向上的靠近。当阅读代码时,能够很流畅的读完。否则若滑上滑下的阅读代码,容易导致思维不流畅,影响理解。

4.2.缩进与间隔

现代化的IDE都有格式化代码快捷键,例如Mac的Android Studio格式化代码快捷键是"Command + Alt + L",你也可以在设置中搜索"Reformat Code",自定义格式化代码快捷键。随时格式化,并去掉多余的空行,让代码保持清爽是一个好习惯。

五、数据结构

5.1.得墨忒定律

著名的得墨忒定律(The Law of Demeter)认为:模块或对象应该尽可能减少对其他模块或对象的内部细节的了解,‌即一个模块不应该与其不直接相关的其他模块或对象进行过多的交互。‌。

换言之,只跟朋友谈话,不与朋友的朋友谈话。不要为了节省变量,写过长的链式调用。在关键的地方为其取个有意义的名字,更容易理清结构,调试也更方便。

java
val appId = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA).metaData.get("app_id")

修改为:

java
val applicationInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
val metaData = applicationInfo.metaData
val appId = metaData.get("app_id")

六、错误处理

6.1.抽离 try / catch 代码块

try / catch 代码块丑陋不堪,他们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把 try 和 catch 代码块的主体部分抽离出来,另外形成函数。

错误处理就是一件事。

6.2.使用异常代替错误码

现代化的语言都有异常机制,对于绝对不应该出现的情况,有的程序员会选择返回0或者-1等错误码,保持程序不崩溃。

请不要这样做,将错误码替换为抛出异常,出现错误时立马就可以发现,更容易定位问题。而不是在错误的状态下继续执行,将来造成更加迷惑的错误。

6.3.别返回 null 值、别传递 null 值

要讨论错误处理,就一定要提及那些容易引发错误的做法。第一项就是返回 null 值。我不想去计算曾经见过多少几乎每行代码都在检查 null 值的应用程序。返回 null 值基本上就是在给自己增加工作量,也是在给调用者添乱。只要有一处没有检查 null 值,应用程序就会失控。返回 null 不如抛出 NullPointerException ,或是替换为一个空对象。让调用者不再需要检查 null,代码也就更整洁了。

七、测试代码

7.1.保持测试整洁

测试带来的好处不必多说。有了测试,你就不必担心对代码的修改。修改代码后,只需一键运行测试代码,便可验证本次修改是否破坏了原有逻辑。