从FizzBuzzWhizz再谈抽象

道可道,非常道——《道德经》

FizzBuzzWhizz是一道经典的编程题,它能够演化出非常多的内容,比如TDD、OO、函数式编程、设计模式等,在“编码kata,玩转FizzBuzz”与“编码kata,再探FizzBuzz”这两篇文章中我就描述过如何运用设计模式、组合、重构等方法实现FizzBuzzWhizz。这些方法的抽象层次实际上停留在利用语言本身的特性上并没有超脱语言本身,这一点我在很长的时间内没有意识到,直到听了“FizzBuzzWhizz的DSL实现”课后方才发现这样的抽象层次尽管是一种提升但却不是颠覆性的,一旦跳出语言本身的“牢笼”,就会看到一个崭新的世界。

回到题目本身,其内容可以转化为下面简化的表达式,如下所示。从表达式进行简单的推演可以看到这里存在几种逻辑:

times 3 -> "Fizz"
times 5 -> "Buzz"
times 7 -> "Whizz"
contains 3 -> "Fizz"
times 3 & times 5 -> "FizzBuzz"
times 5 & times 7 -> "BuzzWhizz"
times 3 & times 7 -> "FizzWhizz"
times 3 & times 5 & times 7 -> "FizzBuzzWhizz"
default -> number
  • 基础结构或称之为原子(Atom),比如times 3 -> "Fizz"、contains 3 -> "Fizz"、default -> number,它存在的意义有两点,一是独立地表达一个计算;二是几个原子组合出更复杂计算;

  • 组合,几个原子按照一定的关系可以形成一个组合,比如times 3 & times 5 -> "FizzBuzz",用以表达复杂计算,并且还能将几个组合按照一定关系组成一个新的组合;

  • 抽象,描述原子之间或组合之间的关系其实就是抽象,它是实现更复杂计算的关键。

按照上述分析重新表达FizzBuzzWhizz的内容,如下所示。

rule0: default -> number
rule1: times 3 -> "Fizz" or times 5 -> "Buzz" or times 7 -> "Whizz"
rule2: (times 3 -> "Fizz" and times 5 -> "Buzz" and times 7 -> "Whizz") or
          (times 3 -> "Fizz" and times 5 -> "Buzz") or 
          (times 5 -> "Buzz" and times 7 -> "Whizz") or
          (times 3 -> "Fizz" and times 7 -> "Whizz")
rule3: contains 3 -> "Fizz"

在这样做时,我们根本无须关注这个过程是如何计算出它的结果的,只需要注意它能计算出平方值的事实,关于平方是如何计算的细节被隐去不提了,可以推迟到后来在再考虑。——《Structure and Interpretation of Computer Programs》

上述表达式实际上比较接近代码或者伪代码,大致可以转换成如下类似的Python代码。在看到这样的代码时,通常我们会很兴奋因为这代表了问题离解决已经近了一大步但仍有一个问题需要解决,即其中的OR和AND这样用来表达原子与组合关系的抽象方法如何实现?

def spec():
    r1_3 = Atom(Times(3), ToFizz())
    r1_5 = Atom(Times(5), ToBuzz())
    r1_7 = Atom(Times(7), ToWhizz())

    r1 = OR(r1_3, r1_5, r1_7)
    r2 = OR(AND(r1_3, r1_5, r1_7), 
            AND(r1_3, r1_5), 
            AND(r1_5, r1_7), 
            AND(r1_3, r1_7))
    r3 = Atom(Contains(3), ToFizz())
    rd = Atom(Default(), ToNumber())

    return OR(r3, r2, r1, rd)

事实上这里的OR和AND直接去实现并不困难与复杂,但假设我们并不着急去实现它们,那么先来分析下OR与AND究竟在描述什么事情。从规则的语义上分析,OR方法是对指定元素执行布尔或运算,而AND方法是对指定元素执行布尔与运算,这种描述是一种形式上的相近。显然我们需要一种计算机容易识别的方法来刻画上述形式的表达并且方法应当是完备与自洽的,运用Python中的boolean就显得自然而然(这对计算机领域的人也很容易理解)。这一点很重要,尽管我们可以创造一种规则去刻画上述形式的表达,比如我就曾经想过直接利用字符串("" ∧ "Fizz" = "",“Fizz” ∨ "Buzz" = "FizzBuzz"),但上下一对比就会发现自己创造的规则从理解上存在一种刻意的转换,并且缺乏证明说明这样的规则是完备的。

在布尔代数上的运算被称为AND(与)、OR(或)和NOT(非)。代数结构要是布尔代数,这些运算的行为就必须和两元素的布尔代数一样(这两个元素是TRUE(真)和FALSE(假))。亦称逻辑代数.布尔(Boole,G.)为研究思维规律(逻辑学)于1847年提出的数学工具.布尔代数是指代数系统B=〈B,+,·,′〉

通过上面的分析,我们基本上可以得到如下的OR实现,看到这样的实现很快就会有人提出异议,上面的OR明显出现了多于2个参数的情况,这个下面的实现对不上啊!

class OR(Rule):
    def __init__(self, rule1, rule2):
        self.rule1 = rule1
        self.rule2 = rule2

    def apply(self, number):
        ret = self.rule1.apply(number)
        if ret[0]:
            return ret
        return self.rule2.apply(number)

确实对不上,但既然提到OR描述的是布尔或运算,那么形式上它应当满足布尔代数结合律,即(a+b)+c=a+(b+c),我们不妨用下面的式子表达。

r1_3 OR r1_5 OR r1_7 = (r1_3 OR r1_5) OR r1_7 = r1_3 OR (r1_5 OR r1_7)

OR(r1_3, r1_5, r1_7) -> OR(r1_3, OR(r1_5, r1_7))

上面的表达让我们发现可以用两个参数的OR来表达多个参数的OR,无非就是更多的嵌套而已,只要空间够大,多少个参数都没问题,这不就是递归嘛!(AND也是类似)

def _ORN(rules):
    if len(rules) == 1:
        return rules[0]

    return OR(rules[0], _ORN(rules[1:]))

到这里你可能发现了,至始至终我们并没有太多地去说Python语言特性或是编程技巧而是一直都在用布尔代数这个概念来帮助进行推演和设计,这便是抽象的魔力,屏蔽了或者更确切地说是用一个同构领域的概念横跨在编程语言之上,不论是Java、C++还是Erlang、Python,这种横跨都是适用的与语言无关,而不会产生由语言特性发生变化出现的不适用情况。

r2 = OR(AND(r1_3, r1_5, r1_7), 
            AND(r1_3, r1_5), 
            AND(r1_5, r1_7), 
            AND(r1_3, r1_7))
->((r1_3, r1_5, r1_7), (r1_3, r1_5), (r1_5, r1_7), (r1_3, r1_7))

让我们再次观察spec,特别是r2。r2包含了OR和多个AND看上去比较复杂,让我们把OR和AND先去除掉再来观察(如上),现在看上去有些眼熟了——这不就是从n个元素中任取m个元素能形成多少种组合吗?

组合的定义:从n个不同元素中,任取m(m≤n)个元素并成一组,叫做从n个不同元素中取出m个元素的一个组合;从n个不同元素中取出m(m≤n)个元素的所有组合的个数,叫做从n个不同元素中取出m个元素的组合数。用符号 C(n,m) 表示。

从这一点出发,重新理解一下r2:在一个包含了n条规则(如r1_3、r1_5、r1_7)的集合中分别找出包含m条规则(1

step 0: s = (r1_3, r1_5, r1_7)
step 1: c3_3 = (combinations s 3) c3_2 = (combinations s 2)
step 2: s_and = (AND foreach (c3_3, c3_2))
step 3: r2 = (OR s_and)

按照上面的步骤输出第一个版本的方法如下,其实质是一边组合一边计算AND,这两个过程是交织在一起的。

这样的方法对不对?对!但不够好!

def flatten(rs):
    rs_len = len(rs)
    c_rs = []
    while rs_len > 1:
        for c in itertools.combinations(rs, rs_len):
            c_rs.append(_ANDN(list(c)))
        rs_len = rs_len - 1
    return c_rs

如果我们再次审视上述步骤就能发现组合的计算与AND的计算实际上是分离的,就是通过若干次组合获得组合的全集再对这个全集应用AND,而要获得全集就会遇到step 2中有一个细节,每次通过组合获得的集合其实是二维的,要想把这些集合拼接起来,就需要把那些二维的集合在一维展平,又一个数学概念的抽象,wow!

现在我们有了第二个版本的方法(如下),可以看到flatten实现了多个二维集合在一维展平,然后又让AND应用在这个展平的集合上。

def flatten(rules):
    return [i for rs in rules for i in rs]


def spec():
    r1_3 = Atom(Times(3), ToFizz())
    r1_5 = Atom(Times(5), ToBuzz())
    r1_7 = Atom(Times(7), ToWhizz())

    rs = [r1_3, r1_5, r1_7]
    r_cs = flatten([itertools.combinations(rs, 3), itertools.combinations(rs, 2)])

    r1 = ORN(r1_3, r1_5, r1_7)
    r2 = _ORN([_ANDN(r) for r in r_cs])
    r3 = Atom(Contains(3), ToFizz())
    rd = Atom(Always(), ToStr())

    return ORN(r3, r2, r1, rd)

通过一次次语言层次之上的抽象,我们愈发地接近了问题的本质,并且由于我们从代数领域进行了抽象,使得代码相互组合与扩展都降低了难度消除了耦合。这样我们就收获了两个好处:一是消除了代码重复提升了代码的可扩展性,这是之前运用语言特性(如OO、设计模式)可以达到的;二是获得了跨越编程语言的设计,它通过横跨在语言之上的抽象获得并且是之前依赖运用语言特性无法获得的。这便是从编程语言本身的“牢笼”中跳出来去看到一个崭新的世界。

你可能感兴趣的:(从FizzBuzzWhizz再谈抽象)