没有 if,怎么写代码? - GDCR 2019
为什么要减少 if
语句的使用
本次编程静修的第三轮主题是「不用 if
语句,实现同样的功能」。
if
语句是写代码时绕不开的一道坎。如果我们想要让代码的行为变得丰富、变得智能,那就要让代码能够根据不同的条件,执行不同的分支。
if
语句就是给代码增加不同分支的最直接的手段。
但是, if
也是增加代码复杂度的主力军。它在把程序变得「智能」的同时,却也将一个新的分支直接地加到了原有的代码里。原本理解这段代码只需要理解一种情况,增加一个 if
之后就需要理解两种情况,再增加一个 if
之后就需要理解四种情况,以此类推。因此,一味地通过 if
语句来增加新的分支会使代码的复杂度指数级地增加,最后达到一个难以理解、难以维护的状态。
这轮练习,就是要使用不同的方法来为程序添加分支,探索 if
语句之外的更多可能性。
从逻辑映射到数据映射
「不用 if
语句」这条限制乍一看起来十分严苛,让人一时找不到思路。但当我们讨论过 if
语句的本质,或者逻辑分支的本质后,不同的实现方案就能自然而然地涌现出来了。
分支的本质是映射
下面是最简单的一个 if
语句:
def f(input) if input == true "Hello!" else "Bye!" end end
如果我们抛去 if
的外衣,尝试用自然语言来描述这段代码的话,它可以被翻译为:
- 在输入为
true
时,输出"Hello!"
- 在输入不为
true
时,输出"Bye!"
也就是说,这段代码实际上将一个输入集合映射到了一个输出集合上:
f: {0, 1, ..., true, false, ...} -> {"Hello!", "Bye!"}
如果两个分支的行为变得更加复杂,每个分支并不仅仅是简单的返回数据,我们还能采用集合来理解他们吗?
def f(input) if input == true # branch T else # branch F end end
在这种情况下,我们依然可以构造一个集合,集合中每个元素对应了每一个分支下的复杂行为。
f: {0, 1, ..., true, false, ...} -> {branch T, branch F}
这样一来,所有 if
语句都能通过这种方式变换为从一个「集合」到另一个「集合」的映射。因此,我们只需要通过其他方式来实现「集合」的映射,便能「不用 if
语句」实现分支语句的效果。
使用数据(数组、哈希表、对象)实现映射
一提到「集合」的映射,我们很容易就能联想到「数组」这一常用的数据结构。通过定义一个「数组」,我们就定义了一组输入集合为 [0..n]
的映射。
MAP = [ "Hello!", "Bye!", ] def f(input) MAP[input] end
但是这样的映射只能在输入集合为自然数集时使用,如果输入集合包含了自然数之外的类型,该如何解决呢?
这时,我们可以将数组转换为哈希表(Hash
),接受所有可能的输入类型:
MAP = { 0 => "Hello!", 1 => "Bye!", true => "Hello!", false => "Bye!", } def f(input) MAP[input] end
使用哈希表之后,在输入集合、输出集合中都可以使用任意类型的数据,但要如何表示复杂的输出行为呢?这就需要用到 Ruby 等高级语言中的函数式特性:函数即数据。
MAP = { 0 => Proc.new { puts "Hello!" }, 1 => Proc.new { puts "Bye!" }, true => Proc.new { Net::HTTP.get('example.com', '/index.html') }, } def f(input) MAP[input].() end
这样一来,原本需要 if
语句表示的分支逻辑,就被抽象成了一组组映射数据。相比于简单粗暴的 if
语句,使用数据映射来实现分支让我们将所有输入/输出的可能性都一览无余地展现出来。这能帮我们梳理所有可能的输入条件,减少错漏。在需要增加分支时,也只需要在原有的数据结构里修改或者增加条目,减少了错综复杂的 if
语句嵌套。
当然,将分支表示为映射表也有其缺点。
当更多类似的映射表被抽象出来时,管理每个映射表的成本也会增加。为了降低代码维护的成本、理解的成本,我们可以将类似的映射分组,抽象成对象,给对象分类,给映射取名:
映射数据太多,不易维护
SALUT_MAP = { 0 => "Hello", 1 => "Bye", } RECEIVER_MAP = { 0 => "World", 1 => "John", } def f(input) "#{SALUT_MAP[input]}, #{RECEIVER_MAP[input]}!" end
将映射分类,抽象为对象
class Class0 def salut "Hello" end def receiver "World" end def greeting "#{salut}, #{receiver}!" end end class Class1 def salut "Bye" end def receiver "John" end def greeting "#{salut}, #{receiver}!" end end def f(input) Object.const_get("Class#{input}").new.greeting end
将分支抽象为对象/类之后,每个分支被进一步地分隔开来:原本每个分支散落在 if
语句中相邻的分支中;在映射数据中,这些分支变成了不同的数值对;在对象/类中,这些分支被切实地分到了不同的文件中。每次分隔,都让分支之间的互相影响降低。最终,每个分支都能够被独立修改,新的分支能够被独立添加,而不会影响到另外的分支。程序的可读性、可维护性也就提高了。
另外,当输入集合包含的可能性增加时,映射表的大小也会随之增加,如果输入集合是个无限的集合(比如自然数集),似乎就无法再用映射表来处理分支了。这时,对象/类又一次展示出了它的优势,它能够通过函数计算,将输入转换为对应的输出,进而表示无限集合之间的映射关系:
class Class0 def initialize(input) @input = input end def salut "Hello" end def receiver "#{@input * 2}" end def greeting "#{salut}, #{receiver}!" end end
小结
经过上面的讨论,我们知道分支只不过是一个输入集合到一个输出集合的映射;
if
语句将映射作为逻辑实现,不够直观;如果直接将这种映射关系作为数据表示,则能够大大增加代码的可读性和可维护性。
使用基本的数据类型(数组/哈希表),我们能简单明了地表示输入和输出之间的映射关系。如果情况变得复杂,我们可以使用对象/类来对映射关系进行抽象。最终,每个映射分支都被分隔开来,每个分支都能够被独立修改,不会互相影响。
以上就是如何简化一个 if
语句的方法。那么这种方法能不能推广到更高的层面,帮助我们更好地维护一个规模庞大的程序呢?
化繁为简
如果我们将一个复杂的程序作为一个整体看待的话,那么它和一个 if
语句也并没有什么不同:他们都是接收输入,进行转换、处理,最终返回输出结果。如此一来,所有程序都能理解为一个输入集合到一个输出集合的映射。因此,我们可以采用同样的方式,将程序里复杂的逻辑转换为直观的数据(对象/类)。
使用这样的方式化繁为简的例子在我们的生活中随处可见。闰年(Leap Year)的计算就是一个很好的例子。如果没有闰年这个概念(类)的话,那我们在讨论今年二月有多少天时就会遇到很多麻烦:
- 2020 年 2 月有几天?
- 2020 不被 100 整除
- 2020 被 4 整除
- 所以 2020 年 2 月有 29 天
- 2000 年 2 月有几天?
- 2000 被 100 整除
- 2000 也被 400 整除
- 所以 2000 年 2 月有 29 天
- 1900 年 2 月有几天?
- 1900 被 100 整除
- 但 1900 不被 400 整除
- 所以 1900 年 2 月有 28 天
将这段逻辑翻译成代码也很繁琐:
def feb(year) if year % 100 != 0 if year % 4 == 0 29 else 28 end else if year % 400 == 0 29 else 28 end end end
将年份分为闰年和非闰年两类之后,就将闰年的判断依据和闰年的性质分隔开来,可以独立修改:
class Year # ... end class LeapYear < Year def feb 29 end end class NormalYear < Year def feb 28 end end
实际上,日期(年/月/日)本身也是为了降低时间的复杂度而诞生的一个应用。人类无法像计算机一样用一个整数(时间戳)来理解、沟通时间的概念。因此,我们发明了年、月、日来区分不同时间戳在不同维度上的性质。
在复杂的程序里,这样的方式能帮我们设计出更简单的程序。
- 经典的 MVC 模式将算法逻辑抽象到 Model(模型)类里,展示逻辑抽象到 View(视图)类里,请求、控制、协调逻辑抽象到 Controller(控制器)里。
- HTML, CSS, JavaScript 是对网页前端来说不可或缺的三剑客。而他们背后的设计思想也是将前端的结构、样式、逻辑拆分到三个模块之中,避免分支之间互相影响。
由此可见,简化 if
语句的思想也能用于简化复杂的程序。找到使用逻辑表示的分支,抽丝剥茧,分离出他们背后隐藏的映射数据,便能将程序化繁为简。
GDCR 2019 总结
以上便是 2019 年上海全球编程静修日的最后一篇总结。在短短的三篇总结中,我们回顾了静修日当天的三个主题以及它们引发的思考。
- 结对编程
- 如何应对结对编程中的思路分歧?
- 测试驱动开发
- 单元测试和集成测试的区别
- 不用
if
语句 - 将分支表示为数据映射
希望你读完之后能有所收获!