没有 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 月有几天?
    1. 2020 不被 100 整除
    2. 2020 被 4 整除
    3. 所以 2020 年 2 月有 29 天
  • 2000 年 2 月有几天?
    1. 2000 被 100 整除
    2. 2000 也被 400 整除
    3. 所以 2000 年 2 月有 29 天
  • 1900 年 2 月有几天?
    1. 1900 被 100 整除
    2. 但 1900 不被 400 整除
    3. 所以 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 语句
将分支表示为数据映射

希望你读完之后能有所收获!