版权申明:本文为博主窗户(Colin Cai)原创,欢迎转帖。如要转贴,必须注明原文网址 http://www.cnblogs.com/Colin-Cai/p/11601046.html 作者:窗户 QQ/微信:6679072 E-mail:6679072@qq.com
诡异的代码
看看这段代码,很明显,是列举出100以内所有的质数。类似这样的程序我们从学程序开始写过很多。
再仔细看看,这种“语言”似乎有点像我们学过的其他语言,但似乎并没见过,语法有那么一点点古怪?!
哦!看到了,原来是一段Python!
上面代码的执行结果是
2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97
再想想,这段程序本身就是一堆函数组成,全都是Python的函数,而且全都作为run函数的参数,甚至参数的参数这样调用执行。好诡异的代码!如果想想这个如何实现的,怕是有点费脑子吧。
面向过程的积木编程
目前少儿编程很流行,世面上有很多平台。最有名的要数Scratch,其他很多的少儿教育平台大都是模仿Scratch的思路。
上面是生成100以内质数的Scratch程序,在代码堆里打滚的我们,即使从没有见过Scratch,很快也会发现上述这样的积木和我们的C语言、Python等常见的面向过程的程序看起来区别不是很大。于是,我们很快就可以使用Scratch进行编程。
Scrach抽象出了变量、各种算术运算,抽象除了过程式(命令式)编程所用到的顺序、循环、条件分支;甚至于Scratch还可以造积木,这一定程度上模拟了函数的意义,而且积木还可以递归;但Scratch下没有break、continue,这在一定程度下使得程序更难编了一些。另外,Scratch甚至模拟了消息(或者说信号)触发这样的抽象,目的可能是为了使编程更加有趣味性。
理论上,积木编程当然也可以支持break、continue等,甚至可以goto。
最普通的实现
本文不打算花太多篇幅来进行前端设计的描述,尽管是一个很复杂的话题。不过设计思路可以简单的说一下。
首先,我们为每一种积木建模,无论是Python还是JavaScript,我们都可以用class来描述。可以事先实现一个积木的基类,属性里可以有积木的尺寸,图片信息等等,方法可以有包括图片的加载。而具体的每一种积木则可以继承于基类,当然,继承里还可以再加一层,比如我们可以给积木分一个类,比如计算类、运行结构类、数据类、输出类、输入类……积木类里可能包含着其他的积木对象,比如对于运算来说,可能是别的两个积木甚至更多的积木的参与。另外,每个积木也可以有一个resize或者reshape这样的方法,因为形状可能与它包含的其他积木对象是相关的。当然,还得需要move这样的方法。
其次,我们要为当前所有的积木建模,可以抽象出block(用来表示一段积木拼成的程序)和block_list(用来表示所有的block)这样的类。block也有split/joint等方法,用于block的分裂、拼接。
鼠标拖动积木,可以对应于以上各种对象的操作。
block_list里记录了所有的积木,以及积木与积木之间的连接关系,其实这些关系对于语言编译、解释来说,正是第一步parser希望得到的结果。现在这些信息虽然不是通过语言编译得到,而是通过前端操作得到,但是信息都完备了,那么可以直接使用这种连接关系一步一步得到最终的结果。这种方式应该是最正常的一种方式。
使用开发语言
于是,最简单的实现可以是各个block映射成当前所用编程语言,再使用编程语言的eval函数来实现。很多语言都有eval函数,所以这个几乎不是什么问题。
比如假设我们的Scratch是用Python写的,Scratch写的质数程序可以挨个积木来翻译,得到以下Python代码:
var["i"] = 2 while not (var["i"]>10): var["k"] = sqrt(var["i"]) var["j"] = 2 while not ((var["i"]>var["j"]) or ((var["i"]%var["j"])==0)): var["j"] += 1 if (var["i"]>var["k"]): say(str(var["i"])+"是质数", 1) var["i"] += 1
然后再在Python里实现该实现的函数即可,比如这里的say函数。
以上的实现更为简单直接,实际上的确世面上是有商业软件是这么做的。特别是有的在线少儿编程教育,希望学生从积木编程过渡到具体计算机语言(一般还是Python、JavaScript)的学*,会给一个积木编程和具体计算机语言之间的对应,对于教育而言,这应该是一个挺不错的想法。但是这个也有一定的问题,因为这种对应学*一般也会做语言到积木方向的映射,这个本身就要做对语言的parser。并且积木编程和具体语言编程很难做到完全合理的对应关系,特别是Python、JavaScript还支持面对对象、函数式编程,基本这些已经很难用积木的方式来表示了。
再者,也不好教授单步、断点这样的调试手段。而对于上一种的方式,却很容易做到支持这些调试方式。
再造一种语言
像上面一样,还是依次把积木映射成字符串,只是,不用原生的程序语言,而是再造一种语言。
比如,上面的语言,可能被映射为以下这样的形式:
until >(i,100) set(k,sqrt(i)) set(j,2) until or(>(j,k),=(%(i,j),0)) add(j,1) end if >(j,k) echo(concatent(i,"是质数")) end add(i,1) end
上面的形式看起来基本全是函数调用形式,虽然与普通的写法不一样,但是因为没有了中缀表达式,语法解析全是函数,因为函数的表示非常简单,基本上数括号就行,于是很容易区分函数名和各个参数,很明显使用递归很容易实现此处的函数调用,乃至自己写一个栈来解析都很简单。关键字此处只有until、if、end,从而可以很方便的做个解释器。
特别的,既然自己给出语言的解释器,自然可以很容易的添加单步、断点。
当然,真的映射为带中缀表达式的自然也可以,只是解释器难度稍大一点罢了。
Scratch采用的是另外一种相似的方式,它将所有的数据用json来封装,而描述上述代码格式的是用如下一段数组,
[["whenGreenFlag"], ["setVar:to:", "i", "2"], ["doUntil", [">", ["readVariable", "i"], "100"], [["setVar:to:", "k", ["computeFunction:of:", "sqrt", ["readVariable", "i"]]], ["setVar:to:", "j", "2"], ["doUntil", ["|", [">", ["readVariable", "j"], ["readVariable", "k"]], ["=", ["%", ["readVariable", "i"], ["readVariable", "j"]], "0"]], [["changeVar:by:", "j", 1]]], ["doIf", [">", ["readVariable", "j"], ["readVariable", "k"]], [["say:duration:elapsed:from:", ["concatenate:with:", ["readVariable", "i"], "是质数"], 1]]], ["changeVar:by:", "i", 1]]]]
这段json的解析方式可以与上述全函数的方式高度一致,自然也是高度一致的处理手段。
当然,我们也可以类似下面这样使用中缀表达式
until i>100 k=sqrt(i) j=2 until j>k or i%j==0 j+=1 end if j>k echo(i,"是质数") end i+=1 end
中缀表达式涉及到运算符的优先级问题,处理起来相对比较复杂一些。当然,我们也可以使用LEX/YACC来写parser,这样会方便很多,linux下LEX一般使用flex,YACC一般使用bison。不过既然这里的语言基本上只是做中间语言来使用,那么做成这样基本是没有必要。
图计算
细致的读者应该可以想到,既然为图形界面建模的过程中,所有的积木之间的关系已经是一个图结构。而编程语言的编译,目的就是为了把编程语言变换成图结构,既然图结构现在已经有了,生成中间代码看上去不过是多转了一道弯而已,那我们是不是可以利用这个图结构直接计算呢?
实际上,当然,我们是完全可以通过图结构来做计算的。例如如下这样的流程图,我们的数据结构中蕴含着流程图的每一条边(图是有向图,从而边有方向)以及节点意义,程序员显然都有直接计算的直觉。
数据结构示意大致如下:
Node:
A : i<=1
B(Switch) : i<100
C : 打印i
D : i<=i+1
Entrance:
A
Edge:
A->B
B(False)->C
C->D
D->A
B(True)->End
内部DSL式建构
所谓内部DSL,则是以宿主语言为基础构建一种属于自己的特定语言。这就意味着,新的语言实际上依然是宿主语言,只是在宿主语言基础上构造了很多其他的限制东西,比如让其看起来非常类似于完全独立的语言。这是一种完全不同于前面设计的方法的设计。
而我们也都知道,Lisp特别擅长干设计DSL的事情,特别是Scheme,有多种手段可以设计。比如我们可以用宏(Macro),Lisp的宏远比C语言的宏要强大太多。虽说宏是文字替换,但Lisp的宏支持递归,我们完全可以用Lisp的宏来做反复递归替换,于是我们可以用非常灵活的方式替换我们的语法树,甚至语法树可以用宏被替换的“面目全非”。Scheme还有神一样的continuation,大部分DSL可以设计为面向过程的方式,而continuation就可以很方便的为面向过程的流控制来建模。以上两个就已经可以让Scheme为所欲为了,记得以前网上有个QBasic的Scheme实现就是利用的这个。而我们当然也可以再来考虑更一般的Scheme程序设计,利用算子中的闭包传递,我们一样可以设计出好的内部DSL。
我们这里并不打算用Scheme或者别的Lisp来讲解,这里依然用我们常用的宿主语言来,比如Python。Python其实是一种抽象度极高的语言,它比Scheme少的东西在我看来也就宏和continuation,再有就是尾递归没有优化。
我们考虑以下这样的一段程序,
repeat 3 ( repeat 4 ( display("=") ) newline() display("test") newline() ) display("The end") newline()
虽然,也许我在此并没有说明这段程序的意思,身经百战的你应该早已想出本程序的输出可能是
====
test
====
test
====
test
The end
这里repeat n (...)就代表着把括号里面的内容执行n遍,而display函数则是把后面的内容打印出来,newline函数就是换行。这是一个最简单的DSL了,但是在这个原理的基础上我们其实可以做比如画画之类的程序,因为一般意义上的教孩子编程所用的画画不会构造过于繁琐的图形,从一开始就可以思考出整体如何画,于是利用循环就可以了,而不需要用到判断(然而复杂意义上的图画可能不是这样,可能是不断的根据当前所画内容决定之后所添加内容,这可能就得需要判断)。
Scratch中画图程序示例如下:
结果就是画个正六边形如下:
和上述的DSL基本是一致的。
但既然是想在Python上做内部DSL,我们就得想一种方法嵌入到Python中,最自然的就是将之前的程序改写就是如下:
run( repeat(3, repeat(4, display("=") ), newline(), display("test"), newline() ), display("The end"), newline() )
既然没有Scheme的Macro和Continuation,Python以上的方式也就是函数调用方式,只是把格式排了一下,加上缩进,从而比较容易看出语义。
接下去考虑的就是具体如何实现:
我们先从display开始,第一个直觉是display和Python的print是一样的。
于是我们就这样设计了:
def display(s): print(s)
看起来我们平常的单个display都OK了,然而很快我们就意识到如同repeat(2, display("test"))这样的程序无法正常工作,因为display打印之后并没有产生别的影响,repeat自然无从知道display的动作,从而无法把它的动作复制。
从而display需要一点影响才能正常工作,一个方法就是通过变量传递出来,这是一个有副作用的手段,很多时候应该尽量去避免。于是,我们只考虑从display返回值来解决问题。也就是display函数需要返回display和display的参数的信息,newline也需要返回包含newline的信息,repeat也要返回repeat以及它所有参数的信息。
最容易想到的当然是用字符串来表示上述信息。
def display(a): return "display " + a def newline(): return "newline"
然后我们再接着写repeat,
from functools import reduce def repeat(times, *args): s = "repeat " + str(times) + '\n(\n' for arg in args: s += reduce(lambda a,b:a+b,map(lambda x:' '+x+'\n',arg.split('\n'))) s += ")" return s
我们运行一下
print(repeat(2, display("test")))
得到
repeat 2
(
display test
)
突然意识到,这样不对啊,如此继续下去,run函数完全成了一个语言的解释器,这和我们之前讨论的完全一样。那我们就不返回字符串,返回tuple、list之类的数据结构呢?换汤不换药,还是那么回事,没有什么新意。
闭包构建
回避不了返回值要包含函数和函数参数的问题,只是,我们可以采用别的方式来做到,也就是闭包。
所谓闭包,是一种算子,把函数的参数信息封进另外一个函数,最终返回这个函数,以下举一个简单的例子就应该很明白了。
def addn(n): def f(x): return x+n return f
或者用lambda,上述写成
addn = lambda n:lambda x:x+n
使用的时候,比如我想得到一个函数,返回输入数得到加1的结果,那么addn(1)就是我所需要的函数。
addn就是一个闭包。
按照这个思路,我们先想想run函数如何写,run的参数是一系列按先后顺序要发生动作的函数,那么run只是按顺序执行这些函数。
def run(*args): for arg in args: arg()
然后,我们试图编写display和newline,代码可以如下:
def display(s): def f(): print(s, end="") return f def newline(): def f(): print("") return f
我们发现使用run、display、newline可以让运行结果完全随我们的想法。
接下来就是repeat。
比如repeat(3, display("a"), newline()), 实际上应该是返回一个函数其执行结果是循环三次执行display("a")和newline()返回的函数,虽然开始有点拗口,但代码并不太复杂:
def repeat(times, *args): def f(): for i in range(times): for arg in args: arg() return f
于是,到此为止,最简单的只包含repeat、display、newline的DSL就已经完成了。
我们运行设计之初的代码,的确得到了想要的结果。
升级思路
上面的这个DSL实在太简单了,它虽然有循环,但是没有引入变量,自然也就没有引入运算,也不会有条件分支。
那么一切都从变量支持开始,变量意味着面向过程的程序在运行的过程中,除了程序当前运行的位置之外,还有其他的状态。我们之前repeat、display、newline返回的函数都是没有参数的,这样除了创造有副作用的函数,否则无法携带其他状态转变的信息。
于是我们可以考虑用一个字典来代表程序中所有变量的状态,然后让所有的闭包最终都返回带一个以这样的表示变量的字典为参数的函数。
于是这种情况下,run函数应该这样写:
def run(*args): var_list={} for arg in args: var_list = arg(var_list)
上面的run_list就是我们所需要的代表所有变量的字典。
在此基础上,我们可以构造用来设置变量的函数和获得变量的函数:
def set_value(k, v): def f(var_list): var_list[k] = to_value(v, var_list) return var_list return f def get_value(k): return lambda var_list : var_list[k]
重写display和newline,返回函数的参数中当然应该添加var_list,虽然两者不是与变量直接关联,但也似乎只需要保证把var_list直接返回,以确保run以及别的闭包调用的正确即可。
def display(s): def f(var_list): print(s, end="") return var_list return f def newline(): def f(var_list): print("") return var_list return f
然而,我们实际上并未细想,display后面接的如果是常量的话,上述的实现并没有错误,但如果不是常量呢?比如display(get_value("i")),get_value("i")是一个函数,上述的print显然不能把希望打印的值打印。
于是我们按理应该在此判断一下display的参数s,如果是函数,应该先求值一下再打印。
def display(s): def f(var_list): if type(s)==type(lambda x:x): print(s(var_list),end="") else: print(s, end="") return var_list return f
上述判断s的类型是函数,也可以使用
from types import FunctionType
isinstance(a, FunctionType)
上述我们可以测试一段代码
run( set_value("i", 1), repeat(3, display(get_value("i")), newline() ) )
运行结果是
1
1
1
与我们想象的一致,从而基本验证其正确性。
当然,这还只是抛砖引玉一下,并没有涉及到计算、条件分支、条件循环以及continue、break,乃至于goto,甚至于变量作用域、函数等。甚至于,在这个模式上我们甚至还可以加上调试模式,加入单步、断点等调试手段。
对于具体做法此处不再展开,读者可以自行思考。
开头的程序
引入的东西相对于文中多一些,我们现在终于也有了一点眉目。程序代码详细如下面文件中所示,恕不细说。
少儿编程教育的思考
一个致力于少儿编程教育的朋友跟我聊天,说到新接手的一个班,虽然之前都在另一少儿编程平台下学*了*一年,但却连最基本的编程逻辑都不懂。虽然少儿编程教的一般是面向过程的编程,可是班上没有一个小朋友可以理解流程图这个东西。质数判断本应是个很简单的问题(当然,我们先不深入的看待此问题),然而甚至于他在班上把详细的过程描述清楚了,小朋友也会按照过程用纸笔来判断,可是一上程序全班没有一个人可以写出来。这位朋友很生气,觉得连流程图都不纳入教学体系是个很过分的事情,因为流程图是面向过程编程的基本抽象。小朋友最终连这么简单的程序都编不出来只能说明之前的教学简直就是应付,甚至欺骗。
而我倒是跟他大谈或许教学目的该为如何教会小朋友一步步学会自己制作少儿编程工具,当然可能是针对对编程非常感兴趣的孩子。现实是,这样的孩子少,可以这样教的老师也少,从而无法产生合理的商业利益。于是,如何教好更多的孩子才是他所认为的教育之重。
我一向认为老师教学生,不是复制自己,而是教会学生思考的方法。孩子学会思考的方法远比手把手的做个老师的克隆更强,独立做出一个程序,即使再烂,其价值远超过老师全力指导下写的高深程序。
于是,还是应该把目光放回到我们目前教学的工具上,如何让孩子真正的理解程序,倒未必一直要从纯数学出发,也不需要什么高深算法,什么函数式编程,先掌握面向过程的精髓吧,让孩子可以自由的产生想法,再由想法变成代码。时机成熟了,再告诉孩子,某些东西可以有,比如算法,比如各种编程范式。当年日本围棋,超一流棋手的摇篮木谷道场,老师对于学生不按老师的想法行棋都是要惩罚的,然而六大超一流棋手风格各异,并没有按照老师的手段来行棋。换句话说,教育中,传承是根,但挖掘潜力才更为重要。