1. 为什么我们需要OOP
这个问题应该是始终贯穿整个OO课程的核心问题之一,在上完每一堂课,写完每一次作业或是写下每一个类和对象之后我们都应该考虑一下这个问题,OOP带给了我们什么好处?其实在每学习一个新的概念的时候,我们都应该去思考它为什么会出现,它给我们带来了哪些好处,比如我们为什么要有汇编语言,很明显这是因为人类需要和机器去交互,我们需要告诉机器去执行我们想要的指令,从而完成计算;那我们有了汇编之后为什么还要学习C语言,简单来说,C语言相比于汇编而言的好处之一是我们可以写更少的代码做更多的事情,比如在执行加法运算的过程中,我们不用再考虑操作数是在寄存器中还是在内存中,而可以直接写下int i=j+k;这样简洁而优雅的代码,再比如调用函数的过程中,我们就不用再考虑保存PC值,保存上下文,恢复上下文这样繁复的操作,其实简而言之,这个概念可以被称作是抽象化,抽象化意味着我们可以忽略一些底层实现上的细节,写一些更高层次的代码,做一个civilized programmer,将那些底层繁琐的操作丢给愚蠢的机器,这其实是编程的一项奥义所在,抽象可以帮助我们写出更优雅的代码,在保持优雅的同时可以提高程序的可靠性、安全性和正确性;接下来考虑一下有了C语言为什么还需要Java这样的OOP编程范式,概括起来也可以说是为了实现抽象化,并且这里的抽象层次显然比C语言所实现的抽象更加高级,众所周知OOP的封装机制其实实现的就是数据上的抽象,那么问题来了,请问继承和多态分别对应什么抽象呢?请读者自行思考,所以说,一个优雅的OOP程序员应该充分利用这些抽象性,这样的代码不仅优雅也更OO,所以回答最上面的问题,为什么OOP?笔者认为,为了优雅;其实在C语言之上,与OOP并列的抽象层次上,还有一个叫做函数式编程的概念,Functional Programming(FP),顾名思义,FP实现的是函数抽象,写起代码来同样非常优雅,在Java8中已经越来越多的支持了函数式语言的特性,比如Functional Interface和Lambda Expression这些特性,对于感兴趣的读者,笔者推荐你们了解一下Haskell这门语言(PL深坑),在此之上,再安利一下Scala这样的FP与OOP的集大成者,带给你前所未有的编程体验;那么问题来了,在OOP和FP之上有没有更高级的抽象?当然可以有,这里推荐你们了解一下Prolog,short for Logic Programming,你们可以训练神经网络的茶余饭后了解一下什么是所谓的“传统”人工智能。说道人工智能,其实Java的多态机制就是一种人工智能,我只需要去调用接口里的实现方法,运行时系统就能自动帮我找到该方法在某个类里的具体实现,这难道不智能?
2. Expression Problem: Finally Tagless vs Visitor Pattern
我相信你们大多数只知道这个标题里的Visitor Pattern是什么,一部分出色的同学知道Expression Problem是什么,然后只有凤毛菱角的FP选手知道Finally Tagless。不过没有关系,读完接下来的内容你至少可以知道Expression Problem,FP与OOP的对应关系,Visitor Pattern的使用方法,这就不亏了,如果你感兴趣了解了Finally Tagless是什么的话,那么恭喜你,简直血赚了一波。
其实Expression Problem,顾名思义,就是与表达式相关的一些问题,比如以本次表达式求导为例,如果我们只考虑常数和加法的情况,可以写出如下,代码风格0分,但是非常OO的代码:
1 interface Exp { Exp diff(); } 2 3 class Lit implements Exp { 4 int x; 5 public Lit(int x) { this.x = x; } 6 public Exp diff() { return new Lit(0); } 7 } 8 9 class Add implements Exp { 10 Exp l, r; 11 public Add(Exp l, Exp r) { this.l = l; this.r = r; } 12 public Exp diff() { return new Add(l.diff(), r.diff()); } 13 }
接下来,指导书update了一波,我们需要将求导功能拓展到乘法运算上,我们可以用非常OO,非常优雅的方式来解决这个问题,只要添加一个新的类就解决了:
1 class Mul implements Exp { 2 Exp l, r; 3 public Mul(Exp l, Exp r) { this.l = l; this.r = r; } 4 public Exp diff() { return new ... } 5 }
写到这里,我们可以充分体验到OOP在可拓展性方面的优势,在需要一个新的类的时候,可以不影响其他的类,直接进行添加,非常OO,非常优雅。不过接下来问题来了,在完成求导运算之后,万恶的指导书又要求我们将求得的结果进行输出,这下就非常尴尬了,我们需要在Exp接口和该接口的所有实例中都添加一个叫做toString()的方法,显然,这很不优雅,所以我就不贴代码了。毕竟我们OOP是面向对象编程,更加注重对对象的拓展功能,因此我们可以轻松地添加一个新的乘法类,但在添加方法的过程中就遇到了一些阻碍,那么是不是有一种面向方法编程模式可以支持优雅简洁的方法拓展呢?没错,这就是函数式编程,同样地首先考虑对常数和加法的求导运算,以Haskell为例,我可以写出下面这段代码(我为什么要贴截图,因为博客园提供高亮的函数式语言我不会,我会的函数式语言博客园不高亮orz):
你没有看错,FP风格的代码写出来就是简洁,接下来我用一些通俗的语言带你3分钟入门函数式编程。首先data关键字帮我们定义了一个表达式数据结构,这个数据结构的意思是:表达式Exp要么是由一个Int类型的常数组成,要么是由两个Exp类型的加法组成,| 这个符号就是“或”的意思,这看起来应该让你想起来C语言里的union数据类型,Const和Add分别是两个标签,于是Exp这个结构也可以称作是tagged union,FP术语里叫做Algebraic Data Type(ADT),代数数据类型,感兴趣的同学可以去了解一下这个数据类型和代数有什么关系,非常有趣的一个话题了。回到正题上,如果你实在理解不了这个Exp是怎么个工作原理,你可以用OO的方式去理解,Exp是一个所谓的“接口”,Const和Add分别对应这个“接口”的两个所谓的“实例”,下面的diff定义了求导函数,Exp -> Exp意味着它的类型是接收一个表达式返回一个表达式,接下来一行说的是,如果接收的参数是一个常数的话,那么求导的结果是一个常数0,最后一行说的是如果接收的参数是一个加法的话,那么求导结果就是两边分别求导再求和,diff函数对接收参数Exp这个数据类型的两种不同可能分别定义两种不同行为,OO的理解方式就是在不同的类里实现了不同的方法,FP术语里叫做Pattern Matching,你可以注意到,在FP中我们不需要什么getter和setter函数,而是通过用自由变量去匹配的方式获得数据类型中的参数,因而得名,模式匹配。接下来我们利用FP面向函数的添加toString方法来将表达式转化成字符串:
可以看到,在FP中添加一个新的函数(Java术语中的方法),就像OOP中添加一个新的类一样,非常优雅简便,因此我们可以得到结论:在类拓展性方面,OOP更加简便;在方法拓展性方面,FP更胜一筹。那么有没有什么办法可以既支持类的灵活拓展,有支持优雅的方法拓展呢?这就是Expression Problem,表达式问题,这看起来是一个鱼和熊掌不可兼得的事情,但是小孩子才做选择,我全都要问题总是要被解决的。在Java等OOP语言中,这个问题的解法之一就是Visitor Pattern设计模式。
相信优秀的你们都会Visitor Pattern,我就直接贴代码了:
1 interface Exp { <A> A accept(Visitor<A> vis); } 2 3 interface Visitor<A> { 4 A lit(int a); 5 A add(Exp l, Exp r); 6 } 7 8 class Diff implements Visitor<Exp> { 9 public Exp lit(int a) { return new Lit(0); } 10 public Exp add(Exp l, Exp r) { return new Add(l.accept(this), r.accept(this)); } 11 } 12 13 class Lit implements Exp { 14 int x; 15 public Lit(int x) { this.x = x; } 16 public <A> A accept(Visitor<A> v) { return v.lit(x); } 17 } 18 19 class Add implements Exp { 20 Exp l, r; 21 public Add(Exp l, Exp r) { this.l = l; this.r = r; } 22 public <A> A accept(Visitor<A> v) { return v.add(l, r); } 23 }
在加入Visitor Pattern之后,这个Java代码看起来就有一点FP了,它实现了方法与类的分离,降低了耦合度,这样的话,如果我们想要添加一个新的操作,只需要添加一个新的类同样实现Visitor里的方法即可:
1 class ToString implements Visitor<String> { 2 public String lit(int a) { return Integer.toString(a); } 3 public String add(Exp l, Exp r) { return "(" + l.accept(this) + "+" + r.accept(this) + ")"; } 4 }
但是我们的代码FP了之后,我们发现它不OOP了,因为要想添加一个新的类型,比如乘法的话,我们就需要改动所有的Visitor,说好的我全都要呢?
要是肯定可以要的,但是我没时间写了,这周campus tour消耗太大。。。有空会接着写用functional interface实现visitor pattern下的expression problem的双向拓展,以及用functional interface 写一个函数式风格的parser combinator。非常有趣的内容,这周先鸽了,接下来是常规内容。
3. 作业总结
3.1 代码分析
第一次作业
第一次作业主要是写了一个解析器,占了总代码量的一般,剩下的部分就是用hashmap组织了一下多项式,主要是熟悉了一下java。
第二次作业
第二次作业感觉用三元组无脑写就可以了。
第三次作业
第三次作业改用了表达式树的结构,不好优化,但求导的业务逻辑非常简单,基本上也是无脑写就可以了。不过这里用表达式树做优化的话感觉应该有点意思,但我没时间做了orz。而且感觉没写多少代码这个行数看起来就爆炸了gg
3.2 bug分析
(1) 代码覆盖度。从测试的角度来分析bug的话,有白盒测试和黑盒测试两种,既然我们可以看到代码的话就可以先白盒测一测,用IDE的代码覆盖度工具,让测试样例覆盖度全部代码,这应该是测试的baseline了,而且这样基本上可以排除最基本的业务逻辑上的错误了。
(2) 功能覆盖度。根据指导书,把所需要实现的功能都转化成对应的测试样例,同时还需要考虑各种功能的组合,感觉这里面学问非常深,而且非常复杂,因为对每个模块进行单独的测试非常简单,但当多个模块组合在一起时,由于组合的可能性瞬间爆炸,就很难覆盖全面,所以很难进行完全覆盖的测试,只能是通过分析易错点来有针对性的写样例,还有就是和同学互相讨论,毕竟大四狗能毕业就行,大家也没啥掖着藏着的了hhhh
(3)immutable variable更安全。因此,如果代码中使用了可变变量这样的东西,就需要额外注意传来传去的指针或者是引用。
(4) 其实这一点可以删掉orz ,我只是在这里简单安利一下,函数式程序更加安全可靠,易于分析。形式化验证和exhaustive test 是可以确保代码没有bug的,后者在无穷数据类型无法实现,前者在我们的课程中也很难实现,但了解了其主要思想可以有助于分析程序。
3.3 总结与感想
(1)在java里实现了parser combinator还是非常有意思的,虽然大部分代码借鉴了GitHub上别人的程序,但在第一次作业实现了解析器之后后面几次作业的解析问题就基本都解决了,而且第一次的强侧就基本上把我最底层的解析器组合元件都测到了,接下来只要把指导书理解对了,然后语法不要写错,就不会有bug(蜜汁自信)
(2)为了OO而OO。观察者模式或者是工厂模式等设计模式一直都是面向对象范式中非常重要的概念,并且在实践中也得到了广泛应用,但在初学的很多时候,对这些模式的理解还不够深刻,就大量滥用的话也会有不好的影响,比如我就觉得我强行OO的时候干了一些非常愚蠢的事情,而且代码非常冗余,然后清高地自以为是优雅hhh。还有大家所诟病的强制类型转换,我学到现在还是觉得java的检查机制可以让这些转换实现得蛮安全的,这样可以省一些代码的工作量和调用的开销。
(3)优化。优化是什么?能吃吗?这三次作业依次从全面优化,到半优化,到最后一次放弃优化,毕竟自己没有天赋就不要学别人去搞优化了。