Effective Kotlin中文版
  • ReadMe
  • 前言
  • 第一部分:良好的代码
    • 第一章:安全性
      • 第1条:限制可变性
      • 第2条:最小化变量的作用域
      • 第3条:尽可能消除平台类型
      • 第4条: 不要暴露需要推断的类型
      • 第5条:指明你期望的参数和状态
      • 第 6 条: 优先使用标准错误,而不是自定错误
      • 第7条:当返回结果可能缺失时,优先使 null 或 Failure
      • 第8条:妥善处理空值
      • 第9条: 使用 use 来关闭资源
      • 第10条:编写单元测试
    • 第二章:可读性
      • 第11条:为了可读性设计代码
      • 第12条:操作符的行为应该与其名称一致
      • 第13条:避免返回或操作 Unit?
      • 第14条: 在变量不清晰时指定其类型
      • 第15条:考虑显式引用接收者
      • 第16:属性应该代表状态,而非行为
      • 第17条:考虑使用具名参数
      • 第18条:遵守编程惯例
  • 第二部分:良好的设计
    • 第三章:可重用性
      • 第19条:不要重复知识
      • 第20条:不要重复实现常用算法
      • 第21条 使用属性代理来提取公共的属性模式
      • 第22条:当实现公共算法时使用泛型
      • 第23条:避免隐藏类型参数
      • 第24条:在使用泛型时考虑型变
      • 第25条:在不同的平台上提取公共模块进行重用
    • 第四章:抽象设计
      • 第26条:每个方法都应该基于单一的抽象级别而编写
      • 第27条:使用抽象来保护代码不受更改
      • 第28条:指定 Api 的稳定性
      • 第29条:考虑包装扩展 API
      • 第30条:最小化元素的可见性
      • 第31条:用文档定义合约
      • 第32条:遵守抽象合约
    • 第五章:对象的创建
      • 第33条:考虑使用工厂方法代替构造函数
      • 第34条:考虑带命名默认参数的主构造函数
      • 第35条:考虑为复杂的对象创建定义 DSL
    • 第六章:类的设计
      • 第36条:组合优于继承
      • 第37条:使用数据修饰符来表示一组数据
      • 第38条:使用函数类型而不是接口来传递操作和行为
      • 第39条:类层次结构优于标签类
      • 第40条:遵守 equals 的合约
      • 第41条:遵守 hashCode 的合约
      • 第42条:遵守 compareTo 的合约
      • 第43条: 考虑将 API 的非必要部分提取到扩展函数中
      • 第44条:避免在成员中定义扩展
  • 第三部分:性能
    • 第七章:让开发成本更低
      • 第45条:避免不必要的对象创建
      • 第46条:给高阶函数使用 inline 修饰符
      • 第47条:考虑使用内联类
      • 第48条:消除过时的对象引用
    • 第八章:高效的集合处理
      • 第49条:在具有多个处理步骤的大型集合上,优先使用 Sequence
      • 第50条:限制操作步骤的数量
      • 第51条:性能关键处考虑使用原语的数组
      • 第52条:考虑使用可变集合
Powered by GitBook
On this page
  • 知识(Knowledge)
  • 一切都可以改变
  • 什么时候可以允许代码重复?
  • 单一职责原则
  • 总结
  1. 第二部分:良好的设计
  2. 第三章:可重用性

第19条:不要重复知识

Basis、 Not Kotlin-specific

我学到的第一个关于编程的重要规则就是:

如果你在你的项目中使用了复制粘贴,那你很有可能做错了什么。

这是一个很简单的启发,但也是非常明智的。直到今天每当我会想到这句话,我都会惊讶其简单明了的表达了“不要重复知识”原则背后的核心思想。它也经常被称为 DRY 原则,源于 《程序员修炼之道》一书中描述的 “Do not Repeat Yourself”,一些开发人员可能熟悉的是与其相反的 WET(We Enjoy Typing, Waste Everyone’s Time or Write Everything Twice),其讽刺地告诉了我们同样的道理。 DRY 也和 Single Source of Truth(SSOT)实践相关联,正如你所看到的,这个规则非常之流行,它有很多个名字。 然而,它经常被误用或滥用,为了清楚的理解这一规则及其背后的原因,我们需要引入一些新的理论。

知识(Knowledge)

让我们将编程中的_知识_广义地的定义为任何有意义的信息,它可以由代码或数据来表示,也可以由数据的缺失来表示,这意味着我们希望使用默认行为。例如,当我们继承,但是不重写方法,这就像是说,我们希望这个方法的行为与父类中的一样。

通过这种方式来定义知识,我们项目中所有内容都是某种知识。当然,有许多同种类的知识:算法应该如何工作;UI应该是什么样子的;我们希望获得什么样的结果,等等。也有许多方法来表达它:例如,通过使用代码、配置或者模板。最后,我们程序的每一个部分都可以成为被一些工具、虚拟机或直接被其它程序所理解的信息。

在我们的程序中有两个特别重要的知识:

  1. 逻辑 —— 我们期望程序如何表现,以及它应该是个什么样子

  2. 通用算法 —— 实现预期行为的算法

它们的主要区别是,业务逻辑随着时间推移会发生变化,而通用算法在定义之后通常不会发生变化。它们可能会被优化,或者我们使用另一个算法替换,但算法本身通常是稳定的,由于这种差异,我们将在下一项中集中讨论算法,现在,让我们集中于一点:关于程序逻辑的知识。

一切都可以改变

有一种说法:编程中唯一不变的就是改变。想想 10 年或者 20 年前的项目,时间不长,你能指出一个没有改变、而且比较流行的应用程序或网站吗? Android 于2008年发布, Kotlin 第一个稳定版本于 2016 年发布。不仅技术变化如此之快,语言的变化亦是如此。 想想你以前的项目,现在你很可能使用不同的库、体系结构和设计来完成它们。

变化经常发生在我们意想不到的地方,有一个故事,爱因斯坦在给他的学生监考时,其中一个学生站起来大声抱怨这些问题和去年的一样。爱因斯坦说这确有其事,但是今年的答案和去年的完全不同。即使你认为是恒定不变的东西,但它们也是基于法律或科学的,也可能有一天会改变。 没有什么是绝对安全的。

UI 设计标准和技术的变化如此之快,以至于我们对客户的理解经常改变。这就是为什么我们项目中的知识也会发生改变。例如,这里有一些非常典型的改变原因:

  • 公司了解了更多用户需求或习惯

  • 设计标准发生变化

  • 我们需要调整平台、库或一些工具

如今,大多数项目每隔几个月就会改变需求和部分内部结构,这通常是人们所期望的。许多流行的管理系统都是敏捷的,适合支持需求的不同变化。 Slack 最初是一款名为 Glitch 的游戏,这款游戏并未取得成功,但用户喜欢它里面的社交功能。

世事无常,我们应该为此做好准备。变化最大的敌人就是知识的重复,请思考一下,如果我们需要更改程序中许多都重复的内容,该怎么办?最大的答案是,这种情况下你只需搜索重复这个知识的地方,并在所有地方改变它。搜索可能会令人无聊,而且也很麻烦:如果你忘记更改一些重复的内容该怎么办? 如果其中一些已经被修改了,因为它们已经与其它功能集成在一起了呢? 用同样的方式改变它们很难,这些都是真正的问题。

为了让它不那么抽象,我们可以考虑在我们的项目中许多不同的地方使用一个通用按钮,当我们的 UI 设计师决定这个按钮需要改变时,如果为每一个引用到的地方进行更改,我们可能就会遇到问题:我们需要搜索整个项目,并分别更改每个实例。还需要测试人员介入,帮我们检查有没有遗漏什么。

另一个例子:假设我们在项目中使用了一个数据库,然后有一天我们更改了一个表的名称,如果我们忘记调整依赖于该表的所有 SQL 语句,我们可能会出现一个非常严重的错误。如果我们只是定义了一个表结构,就不会有这个问题。

在这两个例子中,你可以看到知识重复是多么危险,并且问题重重。它会降低项目的可扩展性,使其更加脆弱。好消息是,我们程序员多年来一直致力于开发出帮助我们消除知识冗余的工具和特性,在大多数平台上,我们可以自定义按钮样式,或者自定义视图 / 组件来表示它, 我们可以使用 ORM(例如 Hibernate) 或者 DAO(比如 Exposed)来替代手写 SQL 语句。

所有这些解决方案都代表了不同种类的抽象,它们保护我们免受各式各样的知识冗余。对不同类型抽象的分析在_第27项:使用抽象来保护代码不受更改_。

什么时候可以允许代码重复?

在某些情况下,我们可以看到两处相似的代码,但不该被提取到一起。这是指它们看起来相似,但是代表不同的知识。

从一个例子开始,假设我们在同一个项目中有两个独立的 Android 应用程序,它们构建工具配置都是相似的,因此可能很容易提取它们。

但如果我们真这样做之后呢? 应用程序是独立的,假如我们需要更改配置中的某些内容,我们很可能只需要在其中一个应用程序中改动它。在前面这种不计后果的提取之后,改变其中一个是很难的,并不是轻松的。读取配置也比较困难 —— 配置有它们的样板代码,但是开发人员已经熟悉了,进行抽象意味着需要自己去设计 API, 这是使用 API 的开发人员需要额外学习的。 我们在提取一些概念上不相同的知识时,这个例子就能很好说明了问题是多么的大。

当我们思考两段代码是否代表相似的知识时,要问自己一个最重要的问题:它们更可能一起更改还是会单独更改?从实用的角度来说,这是最重要的问题,因为你只要提取了,就会直接导致一个结果:同时更改两个部分比较容易,但只更改单个部分则比较困难。

一个有用的方法是:如果业务规则来自不同的地方,我们应该假设它们更有可能被独立的更改,对于这种情况,我们甚至有一个教条来保护我们的代码不被意外抽取,即单一职责原则。

单一职责原则

有一非常重要的规则可以提醒我们是否应该提取通用的代码。这就是 SOLID(面向对象设计) 中的单一职责原则。它规定“就一个类而言,应该仅有一个引起它变化的原因”。 这条规则可以简化为:不能出现两个“角色”需要改变同一个类的同一个地方。所谓“角色”,指的是变化的来源。 它们通常是由来自不同部门的开发人员之手,这些人对彼此的工作和领域知之甚少。即使在一个项目中只有一个开发人员,但是有多个管理人员,他们应该也被视为独立的“角色”。这是变化的两个来源,对彼此的了解很少,当两个“角色”编辑同一段代码时,这种情况尤其危险。

我们来看一个例子,假设我们在一个大学工作,我们一个类 Student,奖学金部和认证部门都会用到这个类。这两个部门的开发人员引入了两种不同的属性:

  • isPassing: 由认证部门创建,用于判断学生是否通过考试。

  • qualiesForScholarship 由奖学金部门创建,用于判断学生是否有足够的学分来获得奖学金。

这两个函数都需要计算学生在上学期获得了多少学分,因此开发人员提取一个函数: calculatePointsFromPassedCourses

class Student {
    // ...
    
    fun isPassing(): Boolean = calculatePointsFromPassedCourses() > 15
    
    fun qualifiesForScholarship(): Boolean = calculatePointsFromPassedCourses() > 30
    
    private fun calculatePointsFromPassedCourses(): Int {
        //...
    }
}

之后,原来的规则改变了,院长决定不太重要的课程不参与奖学金部分的学分计算。一个开发人员被指派过来修改 qualifiesForScholarship 函数,他发现了调用了私有方法 calculatePointsFromPassedCourses ,于是它修改这个方法,过滤掉了不符合的课程的学分。 无意之间,开发人员改变了 isPassing 的逻辑,本应通过考试的学生被告知这个学期不及格了,你可以想象这些学生会有什么反应。

确实,如果我们有单元测试(第10条:编写单元测试),我们可以很容易地防止这种情况,但现在我们假设没有这个东西存在。

开发人员可以检查其它地方有无引用该函数,尽管问题是这个开发人员可能没有意识到这个私有函数会被另一个具有完全不同职责的属性所使用。私有函数很少有多个函数共同使用着。

这个问题很普遍,一个文件或类中,相似的两个职责很容易耦合到一起。一个简单的解决方法就是将这些职责提到单独的类中去。我们可能有单独的类 StudentIsPassingValidator 和 StudentQualifiesForScholarshipValidator,尽管在 Kotlin 中,我们不需要使用这样重的设计(参见第四章:设计抽象)。我们可以只定义奖学金获取资格,并从被授予的课程中计算学分,作为位于不同模块的学生的扩展功能:一个模块被奖学金部所负责,另一个由认证部门所负责。

// 奖学金部门
fun Student.qualifiesForScholarship(): Boolean {
    /*...*/
}

// 认证部门
fun Student.calculatePointsFromPassedCourses(): Boolean {
    /*...*/
}

提取一个函数来计算结果如何? 我们可以这样做,但它不能作为这两个方法的私有助手函数,相反,它可以成为:

  1. 在两个模块都引用的公共模块中的通用公共函数,这种情况下,开发人员不应该在没有修改合约和调整使用的情况下,对公共部分进行修改

  2. 两个独立的 helper 方法,在每个部门模块下

这两种选择都是安全的,单一职责原则教会我们两件事情:

  1. 来自两个不同来源(这里是两个不同部门)的知识很可能会独立变化,我们应该把它当做不同的知识来对待

  2. 我们应该严格分离不同的知识,否则,它会吸引你去重用那些不应该重用的部分

总结

一切都在改变,我们的工作就是做好准备:识别通用的知识并提取它们。如果一堆元素具有相似的部分,并且我们很能需要为所有实例更改它,那么提取它可以节省搜索整个项目和更新许多实例的时间。另一方面,通过分离来自不同来源部分来保护自己不受意外修改的影响。 通常来说这是一个更重要的问题, 我看多许多开发人员非常害怕 Do not Repeat Yourself 的字面含义,以致于他们会倾向于耦合两个看起来类似的代码,这种极端的做法是不健康的,我们需要寻找一种平衡。 有时候,要决定一些东西是否应该提取出来是很困难的,这就是为什么设计好信息系统是一门艺术,它需要时间和大量的练习。

Previous第三章:可重用性Next第20条:不要重复实现常用算法

Last updated 2 years ago