如何设计一门语言(八)——异步编程和CPS变换
关于这个话题,其实在(六)里面已经讨论了一半了。学过Haskell的都知道,这个世界上很多东西都可以用monad和comonad来把一些复杂的代码给抽象成简单的、一看就懂的形式。他们的区别,就像用js做一个复杂的带着几层循环的动画,直接写出来和用jquery的“回调”写出来的代码一样。前者能看不能用,后者能用不能看。那有没有什么又能用又能看的呢?我目前只能在Haskell、C#和F#里面看到。至于说为什么,当然是因为他们都支持了monad和comonad。只不过C#作为一门不把“用库来改造语言”作为重要特征的语言,并没打算让你们能跟haskell和F#一样,把东西抽象成monad,然后轻松的写出来。C#只内置了yield return和async await这样的东西。
把“用库来改造语言”作为重要特征的语言其实也不多,大家熟悉的也就只有lisp和C++,不熟悉的有F#。F#除了computation expression以外,还有一个type provider的功能。就是你可以在你的当前的程序里面,写一小段代码,通知编译器在编译你的代码的时候执行以下(有点类似鸡生蛋的问题但其实不是)。这段代码可以生成新的代码(而不是跟lisp一样修改已有的代码),然后给你剩下的那部分程序使用。例子我就不举了,有兴趣的大家看这里:http://msdn.microsoft.com/en-us/library/vstudio/hh361034.aspx。里面有一个例子讲的是如何在F#里面创造一个强类型的正则表达式库,而且并不像boost的spirit或者xpress那样,正则表达式仍然使用字符串来写的。这个正则表达式在编译的时候就可以知道你有没有弄错东西了,不需要等到运行才知道。
Haskell和F#分别尝试了monad/comonad和computation expression,为的就是能用一种不会失控(lisp的macro就属于会失控的那种)方法来让用户自己表达属于自己的可以天然被continuation passing style变换处理的东西。在介绍C#的async await的强大能力之前,先来讲一下Haskell和F#的做法。为什么按照这个程序呢,因为Haskell的monad表达能力最低,其次是F#,最后是C#的那个。当然C#并不打算让你自己写一个支持CPS变换的类型。作为补充,我将在这篇文章的最后,讲一下我最近正在设计的一门语言,是如何把C#的yield return和async await都变成库,而不是编译器的功能的。
下面我将抛弃所有跟学术有关的内容,只会留下跟实际开发有关系的东西。
一、Haskell和Monad
Haskell面临的问题其实比较简单,第一是因为Haskell的程序都不能有隐式状态,第二是因为Haskell没有语句只有表达式。这意味着你所有的控制流都必须用递归或者CPS来做。从这个角度上来讲,Monad也算是CPS的一种应用了。于是我为了给大家解释一下Monad是怎么运作的,决定来炒炒冷饭,说error code的故事。这个故事已经在(七)里面讲了,但是今天用的是Haskell,别有一番异域风情。
大家用C/C++的时候都觉得处理起error code是个很烦人的事情吧。我也不知道为什么那些人放着exception不用,对error code那么喜欢,直到有一天,我听到有一个傻逼在微博上讲:“error code的意思就是我可以不理他”。我终于明白了,这个人是一个真正的傻逼。不过Haskell还是很体恤这些人的,就跟耶稣一样,凡是信他就可以的永生,傻逼也可以。可惜的是,傻逼是学不会Monad的,所以耶稣只是个传说。
由于Haskell没有“引用参数”,所以所有的结果都必须出现在返回值里面。因此,倘若要在Haskell里面做error code,就得返回一个data。data就跟C语言的union一样,区别是data是强类型的,而C的union一不小心就会傻逼了:
data Unsure a = Sure a | Error string
然后给一些必要的实现,首先是Functor:
instance Functor Unsure where fmap f (Sure x) = Sure (f x) fmap f (Error e) = Error e
剩下的就是Monad了:
instance Monad Unsure where return = Sure fail = Error (Sure s) >>= f = f s (Error e) >>= f = Error e
看起来也不多,加起来才八行,就完成了error code的声明了。当然就这么看是看不出Monad的强大威力的,所以我们还需要一个代码。譬如说,给一个数组包含了分数,然后把所有的分数都转换成“牛逼”、“一般”和“傻逼”,重新构造成一个数组。一个真正的Haskell程序员,会把这个程序分解成两半,第一半当然是一个把分数转成数字的东西:
// Tag :: integer -> Unsure string Tag f = if f < 0 then Error "分数必须在0-100之间" else if f<60 then Sure "傻逼" else if f<90 then Sure "一般" else if f<=100 then Sure "牛逼" else Error "分数必须在0-100之间"
后面就是一个循环了:
// TagAll :: [integer] -> Unsure [string] TagAll [] = [] TagAll (x:xs) = do first <- Tag x remains <- TagAll xs return first:remains
TagAll是一个循环,把输入的东西每一个都用Tag过一遍。如果有一次Tag返回失败了,整个TagAll函数都会失败,然后返回错误。如果全部成功了,那么TagAll函数会返回整个处理后的数组。
当然一个循环写成了非尾递归不是一个真正的Haskell程序员会做的事情,真正的Haskell程序员会把事情做成这样(把>>=展开之后你们可能会觉得这个函数不是尾递归,但是因为Haskell是call by need的,所以实际上会成为一个尾递归的函数):
// TagAll :: [integer] -> Unsure [string] TagAll xs = reverse $ TagAll_ xs [] where TagAll [] ys = Sure ys TagAll (x:xs) ys = do y <- Tag x TagAll xs (y:ys)
为什么代码里面一句“检查Tag函数的返回值”的代码都没有呢?这就是Haskell的Monad的表达能力的威力所在了。Monad的使用由do关键字开始,然后这个表达式可以被这么定义:
MonadExp ::= "do" FragmentNotNull FragmentNotNull ::= [Pattern "<-"] Expression EOL FragmentNull FragmentNull ::= FragmentNotNull ::= ε
意思就是说,do后面一定要有“东西”,然后这个“东西”是这么组成的:
1、第一样要是一个a<-e这样的东西。如果你不想给返回值命名,就省略“a<-”这部分
2、然后重复
这表达的是这样的一个意思:
1、先做e,然后把结果保存进a
2、然后做下面的事情
看到了没有,“然后做下面的事情”是一个典型的continuation passing style的表达方法。但是我们可以看到,在例子里面所有的e都是Unsure T类型的,而a相应的必须为T。那到底是谁做了这个转化呢?
聪明的,哦不,正常的读者一眼就能看出来,“<-”就是调用了我们之前在上面实现的一个叫做“>>=”的函数了。我们首先把“e”和“然后要做的事情”这两个参数传进了>>=,然后>>=去解读e,得到a,把a当成“然后要做的事情”的参数调用了一下。如果e解读失败的到了错误,“然后要做的事情”自然就不做了,于是整个函数就返回错误了。
Haskell一下就来尾递归还是略微复杂了点,我们来写一个简单点的例子,写一个函数判断一个人的三科成绩里面,有多少科是牛逼的:
// Count牛逼 :: integer -> integer -> integer –> Unsure integer Count牛逼 chinese math english = do a <- Tag chinese b <- Tag math c <- Tag english return length [x | x <- [a, b, c], x == "牛逼"]
根据上文的描述,我们已经知道,这个函数实际上会被处理成:
// Count牛逼 :: integer -> integer -> integer –> Unsure integer Count牛逼 chinese math english Tag chinese >>= \a-> Tag math >>= \b-> Tag english >>= \c-> return length [x | x <- [a, b, c], x == "牛逼"]
>>=函数的定义是
instance Monad Unsure where return = Sure fail = Error (Sure s) >>= f = f s (Error e) >>= f = Error e
这是一个运行时的pattern matching。一个对参数带pattern matching的函数用Haskell的case of写出来是很难看的,所以Haskell给了这么个语法糖。但这个时候我们要把>>=函数展开在我们的“Count牛逼”函数里面,就得老老实实地用case of了:
// Count牛逼 :: integer -> integer -> integer –> Unsure integer Count牛逼 chinese math english case Tag chinese of { Sure a -> case Tag math of { Sure b -> case Tag english of { Sure c -> Sure $ length [x | x <- [a, b, c], x == "牛逼"] Error e -> Error e } Error e -> Error e } Error e -> Error e }
是不是又回到了我们在C语言里面被迫做的,还有C++不喜欢用exception的人(包含一些觉得error code可以忽略的傻逼)做的,到处检查函数返回值的事情了?我觉得只要是一个正常人,都会选择这种写法的:
// Count牛逼 :: integer -> integer -> integer –> Unsure integer Count牛逼 chinese math english Tag chinese >>= \a-> Tag math >>= \b-> Tag english >>= \c-> return length [x | x <- [a, b, c], x == "牛逼"]
于是我们用Haskell的Monad,活生生的把“每次都检查函数返回值”的代码压缩到了Monad里面,然后就可以把代码写成try-catch那样的东西了。error code跟exception本来就是一样的嘛,只是一个写起来复杂所以培养了很多觉得错误可以忽略的傻逼,而一个只需要稍微训练一下就可以把代码写的很简单罢了。
不过Haskell没有变量,那些傻逼们可能会反驳:C/C++比Haskell复杂多了,你怎么知道exception就一定没问题呢?这个时候,我们就可以看F#的computation expression了。
二、F#和computation expression
F#虽然被设计成了一门函数式语言,但是其骨子里还是跟C#一样带状态的,而且编译成MSIL代码之后,可以直接让F#和C#互相调用。一个真正的Windows程序员,从来不会拘泥于让一个工程只用一个语言来写,而是不同的大模块,用其适合的最好的语言。微软把所有的东西都设计成可以强类型地互操作的,所以在Windows上面从来不存在什么“如果我用A语言写了,B就用不了”的这些事情。这是跟Linux的一个巨大的区别。Linux是没有强类型的互操作的(字符串信仰者们再见),而Windows有。什么,Windows不能用来做Server?那Windows Azure怎么做的,bing怎么做的。什么,只有微软才知道怎么正确使用Windows Server?你们喜欢玩的EVE游戏的服务器是怎么做的呢?
在这里顺便黑一下gcc。钱(区别于财产)对于一个程序员是很重要的。VC++和clang/LLVM都是领着工资写的,gcc不知道是谁投资的(这也就意味着写得好也涨不了工资)。而且我们也都知道,gcc在windows上编译的慢出来的代码还不如VC++,gcc在linux上编译的慢还不如clang,在mac/ios上就不说了,下一个版本的xcode根本没有什么gcc了。理想主义者们醒醒,gcc再见。
为什么F#有循环?答案当然是因为F#有变量了。一个没有变量的语言是写不出循环退出条件的,只能写出递归退出条件。有了循环的话,就会有各种各样的东西,那Monad这个东西就不能很好地给“东西”建模了。于是F#本着友好的精神,既然大家都那么喜欢Monad,那他做出一个computation expression,学起来肯定就很容易了。
于是在F#下面,那个TagAll终于可以读入一个真正的列表,写出一个真正的循环了:
let TagAll xs = unsure { let r = Array.create xs.length "" for i in 0 .. xs.length-1 do let! tag = Tag xs.[i] r.[i]<-tag return r }
注意那个let!,其实就是Haskell里面的<-。只是因为这些东西放在了循环里,那么那个“Monad”表达出来就没有Haskell的Monad那么纯粹了。为了解决这个问题,F#引入了computation expression。所以为了让那个unsure和let!起作用,就得有下面的代码,做一个名字叫做unsure的computation expression:
type UnsureBuilder() = member this.Bind(m, f) = match m with | Sure a -> f a | Error s -> Error s member this.For(xs, body) =unsure { match xs with | [] -> Sure () | x::xs -> let! r = Tag x body r return this.For xs body } .... // 还有很多别的东西
let unsure = new UnsureBuilder()
所以说带有副作用的语言写出来的代码又长,不带副作用的语言写出来的代码又难懂,这之间很难取得一个平衡。
如果输入的分数数组里面有一个不在0到100的范围内,那么for循环里面的“let! tag = Tag xs.[i]”这句话就会引发一个错误,导致TagAll函数失败。这是怎么做到的?
首先,Tag引发的错误是在for循环里面,也就是说,实际运行的时候是调用UnsuerBuilder类型的unsure.For函数来执行这个循环的。For函数内部使用“let! r = Tag x”,这个时候如果失败,那么let!调用的Bind函数就会返回Error s。于是unsure.Combine函数判断第一个语句失败了,那么接下来的语句“body r ; return this.For xs body”也就不执行了,直接返回错误。这个时候For函数的递归终止条件就产生作用了,由一层层的return(F#自带尾递归优化,所以那个For函数最终会被编译成一个循环)往外传递,导致最外层的For循环以Error返回值结束。TagAll里面的unsure,Combine函数看到for循环完蛋了,于是return r也不执行了,返回错误。
这个过程跟Haskell的那个版本做的事情完全是一样的,只是由于F#多了很多语句,所以Monad展开成computation expression之后,表面上看起来就会复杂很多。如果明白Haskell的Monad在干什么事情的话,F#的computation expression也是很容易就学会的。
当然,觉得“error code可以忽略”的傻逼是没有可能的。
三、C#的yield return和async await
如果大家已经明白了Haskell的>>=和F#的Bind(其实也是let!)就是一回事的话,而且也明白了我上面讲的如何把do和<-变成>>=的方法的话,大家应该对CPS在实际应用的样子心里有数了。不过,这种理解的方法实际上是相当有限的。为什么呢?让我们来看C#的两个函数:
IEnumerable<T> Concat(this IEnumerable<T> a, IEnumerable<T> b) { foreach(var x in a) yield return x; foreach(var x in b) yield return x; }
上面那个是关于yield return和IEnumerable<T>的例子,讲的是Linq的Concat函数是怎么实现的。下面还有一个async await和Task<T>的例子:
async Task<T[]> SequencialExecute(this Task<T>[] tasks) { var ts = new T[tasks.Length]; for(int i=0;i<tasks.Length;i++) ts[i]=await tasks[i]; return ts; }
这个函数讲的是,如果你有一堆Task<T>,如何构造出一个内容来自于异步地挨个执行tasks里面的每个Task<T>的Task<T[]>的方法。
大家可能会注意到,C#的yield return和await的“味道”,就跟Haskell的<-和>>=、F#的Bind和let!一样。在处理这种语言级别的事情的时候,千万不要去管代码它实际上在干什么,这其实是次要的。最重要的是形式。什么是形式呢?也就是说,同样一个任务,是如何被不同的方法表达出来的。上面说的“味道”就都在“表达”的这个事情上面了。
这里我就要提一个问题了。
- Haskell有Monad,所以我们可以给自己定义的类型实现一个Monad,从而让我们的类型可以用do和<-来操作。
- F#有computation expression,所以我们可以给自己定义的类型实现一个computation expression,从而让我们的类型可以用let!来操作。
- C#有【什么】,所以我们可以给自己定义的类型实现一个【什么】,从而让我们的类型可以用【什么】来操作?
熟悉C#的人可能很快就说出来了,答案是Linq、Linq Provider和from in了。这篇《Monadic Parser Combinator using C# 3.0》http://blogs.msdn.com/b/lukeh/archive/2007/08/19/monadic-parser-combinators-using-c-3-0.aspx 介绍了一个如何把语法分析器(也就是parser)给写成monad,并且用Linq的from in来表达的方法。
大家可能一下子不明白什么意思。Linq Provider和Monad是这么对应的:
- fmap对应于Select
- >>=对应于SelectMany
- >>= + return也对应与Select(回忆一下Monad这个代数结构的几个定理,就有这么一条)
然后诸如这样的Haskell代码:
// Count牛逼 :: integer -> integer -> integer –> Unsure integer Count牛逼 chinese math english = do a <- Tag chinese b <- Tag math c <- Tag english return length [x | x <- [a, b, c], x == "牛逼"]
就可以表达成:
Unsure<int> Count牛逼(int chinese, int math, int english) { return from a in Tag(chinese) from b in Tag(math) from c in Tag(english) return new int[]{a, b, c}.Where(x=>x=="牛逼").Count(); }
不过Linq的这个表达方法跟yield return和async await一比,就有一种Monad和computation expression的感觉了。Monad只能一味的递归一个一个往下写,而computation expression则还能加上分支循环异常处理什么的。C#的from in也是一样,没办法表达循环异常处理等内容。
于是上面提到的那个问题
C#有【什么】,所以我们可以给自己定义的类型实现一个【什么】,从而让我们的类型可以用【什么】来操作?
其实并没有回答完整。我们可以换一个角度来体味。假设IEnumerable<T>和Task<T>都是我们自己写的,而不是.net framework里面的内容,那么C#究竟要加上一个什么样的(类似于Linq Provider的)功能,从而让我们可以写出接近yield return和async await的效果的代码呢?如果大家对我的那篇《时隔多年我又再一次体验了一把跟大神聊天的感觉》还有点印象的话,其实我当时也对我自己提出了这么个问题。
我那个时候一直觉得,F#的computation expression才是正确的方向,但是我怎么搞都搞不出来,所以我自己就有点动摇了。于是我跑去问了Don Syme,他很斩钉截铁的告诉我说,computation expression是做不到那个事情的,但是需要怎么做他也没想过,让我自己research。后来我就得到了一个结论。
四、Koncept(我正在设计的语言)的yield return和async await(问题)
Koncept主要的特征是concept mapping和interface。这两种东西的关系就像函数和lambda表达式、instance和class一样,是定义和闭包的关系,所以相处起来特别自然。首先我让函数只能输入一个参数,不过这个参数可以是一个tuple,于是f(a, b, c)实际上是f.Invoke(Tuple.Create(a, b, c))的语法糖。然后所有的overloading都用类似C++的偏特化来做,于是C++11的不定模板参数(variadic template argument)在我这里就成为一个“推论”了,根本不是什么需要特殊支持就自然拥有的东西。这也是concept mapping的常用手法。最后一个跟普通语言巨大的变化是我删掉了class,只留下interface。反正你们写lambda表达时也不会给每个闭包命名字(没有C++11的C++除外),那为什么写interface就得给每一个闭包(class)命名字呢?所以我给删去了。剩下的就是我用类似mixin的机制可以把函数和interface什么的给mixin到普通的类型里面去,这样你也可以实现class的东西,就是写起特别来麻烦,于是我在语法上就鼓励你不要暴露class,改为全部暴露function、concept和interface。
不过这些都不是重点,因为除了这些差异以外,其他的还是有浓郁的C#精神在里面的,所以下面在讲Koncept的CPS变换的时候,我还是把它写成C#的样子,Koncept长什么样子以后我再告诉你们,因为Koncept的大部分设计都跟CPS变换是没关系的。
回归正题。之前我考虑了许久,觉得F#的computation expression又特别像是一个正确的解答,但是我怎么样都找不到一个可以把它加入Koncept地方法。这个问题我从NativeX(这里、这里、这里和这里)的时候就一直在想了,中间兜了一个大圈,整个就是试图山寨F#结果失败的过程。为什么F#的computation expression模型不能用呢,归根结底是因为,F#的循环没有break和continue。C#的跳转是自由的,不仅有break和continue,你还可以从循环里面return,甚至goto。因此一个for循环无论如何都表达不成F#的那个函数:M<U> For(IEnumerable<T> container, Func<T, M<U>> body);。break、continue、return和goto没办法表达在类型上。
伟大的先知Eric Meijer告诉我们:“一个函数的类型表达了关于函数的业务的一切”。为什么我们还要写函数体,是因为编译器还没有聪明到看着那个类型就可以帮我们把代码填充完整。所以其实当初看着F#的computation expression的For的定义的时候,是因为我脑筋短路,没有想起Eric Meijer的这句话,导致我浪费了几个月时间。当然我到了后面也渐渐察觉到了这个事情,产生了动摇,自己却无法确定,所以去问了Don Syme。于是,我就得到了关于这个问题的结论的一半:在C#(其实Koncept也是)支持用户可以自由添加的CPS变换(譬如说用户添加IEnumerable<T>的时候添加yield return和yield break,用户添加Task<T>的时候添加await和return)的话,使用CPS变换的那段代码,必须用控制流图(control flow graph)处理完之后生成一个状态机来做,而不能跟Haskell和F#一样拆成一个一个的小lambda表达式。
其实C#的yield return和async await,从一开始就是编译成状态机的。只是C#没有开放那个功能,所以我一直以为这并不是必须的。想来微软里面做语言的那帮牛逼的人还是有牛逼的道理的,一下子就可以找到问题的正确方向,跟搞go的二流语言专家(尽管他也牛逼但是跟语言一点关系也没有)是完全不同的。连Mozilla的Rust的设计都比go强一百倍。
那另一半的问题是什么呢?为了把问题看得更加清楚,我们来看两个长得很像的yield return和async await的例子。为了把本质的问题暴露出来,我决定修改yield return的语法:
- 首先把yield return修改成yield
- 其次吧yield break修改成return
- 然后再给函数打上一个叫做seq的东西,跟async对称,就当他是个关键字
- 给所有CPS operator加上一个感叹号,让他变得更清楚(这里有yield、await和return)。为什么return也要加上感叹号呢?因为如果我们吧seq和aysnc摘掉的话,我们会发现return的类型是不匹配的。所以这不是一个真的return。
然后就可以来描述一个类似Linq的TakeWhile的事情了:
seq IEnumerable<T> TakeWhile(this IEnumerable<T> source, Predicate<T> predicate) { foreach(var x in source) { if(!predicate(x)) return!; yield! x } } async Task<T[]> TakeWhile(this Task<T>[] source, Predicate<T> predicate) { List<T> result=new List<T>(); foreach(var t in source) { var x = await! t; if(!predicate(x)) return! result.ToArray(); result.Add(x); } return! result.ToArray(); }
- seq和async到底是什么?
- seq下面的yield和return的类型分别是什么?
- async下面的await和return的类型分别是什么?
其实这里还有一个谜团。其实seq返回的东西应该是一个IEnumerator<T>,只是因为C#觉得IEnumerable<T>是更好地,所以你两个都可以返回。那么,是什么机制使得,函数可以构造出一个IEnumerable<T>,而整个状态机是在IEnumerator<T>的MoveNext函数里面驱动的呢?而async和Task<T>就没有这种情况了。
首先解答第一个问题。因为yield、return和await都是函数,是函数就得有个namespace,那我们可以拿seq和async做namespace。所以seq和async,设计成两个static class也是没有问题的。
其次,seq的yield和return修改了某个IEnumerator<T>的状态,而async的await和return修改了某个Task<T>的状态。而seq和async的返回值分别是IEnumerable<T>和Task<T>。因此对于一个CPS变换来说,一共需要两个类型,第一个是返回值,第二个是实际运行状态机的类。
第三,CPS变换还需要有一个启动函数。IEnumerator<T>的第一次MoveNext调用了那个启动函数。而Task<T>的Start调用了那个启动函数。启动函数自己维护着所有状态机的内容,而状态机本身是CPS operator们看不见的。为什么呢?因为一个状态机也是一个类,这些状态机类是没有任何公共的contract的,也就是说无法抽象他们。因此CPS operator必须不能知道状态机类。
而且yield、return和await都叫CPS operator,那么他们不管是什么类型,本身肯定看起来像一个CPS的函数。之前已经讲过了,CPS函数就是把普通函数的返回值去掉,转而添加一个lambda表达式,用来代表“拿到返回之后的下一步计算”。
因此总的来说,我们拿到了这四个方程,就可以得出一个解了。解可以有很多,我们选择最简单的部分。
那现在就开始来解答上面两个TakeWhile最终会被编译成什么东西了。
五、Koncept(我正在设计的语言)的yield return和async await(seq答案)
首先来看seq和yield的部分。上面讲到了,yield和return都是在修改某个IEnumerator<T>的状态,但是编译器自己肯定不能知道一个合适的IEnumerator<T>是如何被创建出来的。所以这个类型必须由用户来创建。而为了第一次调用yield的时候就已经有IEnumerator<T>可以用,所以CPS的启动函数就必须看得到那个IEnumerator<T>。但是CPS的启动函数又不可能去创建他,所以,这个IEnumerator<T>对象肯定是一个continuation的参数了。
看,其实写程序都是在做推理的。尽管我们现在还不知道整个CPS要怎么运作,但是随着这些线索,我们就可以先把类型搞出来。搞出了类型之后,就可以来填代码了。
- 对于yield,yield接受了一个T,没有返回值。一个没有返回值的函数的continuation是什么呢?当然就是一个没有参数的函数了。
- return则连输入都没有。
- 而且yield和return都需要看到IEnumerator<T>。所以他们肯定有一个参数包含这个东西。
那么这三个函数的类型就都确定下来了:
public static class seq { public static IEnumerator<T> CreateCps<T>(Action<seq_Enumerator<T>>); public static void yield<T>(seq_Enumerator<T> state, T value, Action continuation); public static void exit<T>(seq_Enumerator<T> state /*没有输入*/ /*exit代表return,函数结束的意思就是不会有一个continuation*/); }
什么是seq_Enumerator<T>呢?当然是我们那个“某个IEnumerator<T>”的真是类型了。
于是看着类型,唯一可能的有意义又简单的实现如下:
public class seq_Enumerable<T> : IEnumerable<T> { public Action<seq_Enumerator<T>> startContinuation; public IEnumerator<T> CreateEnumerator() { return new seq_Enumerator<T> { startContinuation=this.startContinuation) }; } } public class seq_Enumerator<T> : IEnumerator<T> { public T current; bool available; Action<seq_Enumerator<T>> startContinuation; Action continuation; public T Current { get { return this.current; } } public bool MoveNext() { this.available=false; if(this.continuation==null) { this.startContinuation(this); } else { this.continuation(); } return this.available; } } public static class seq { public static IEnumerable<T> CreateCps<T>(Action<seq_Enumerator<T>> startContinuation) { return new seq_Enumerable { startContinuation=startContinuation }; } public static void yield<T>(seq_Enumeartor<T> state, T value, Action continuation) { state.current=value; state.available=true; state.continuation=continuation; } public static void exit<T>(seq_Enumeartor<T> state) { } }
那么那个TakeWhile函数最终会变成:
public class _TakeWhile<T> { seq_Enumerator<T> _controller; Action _output_continuation_0= this.RunStateMachine; int _state; IEnumerable<T> _source; IEnumerator<T> _source_enumerator; Predicate<T> _predicate; T x; public void RunStateMachine() { while(true) { switch(this.state) { case 0: { this._source_enumerator = this._source.CreateEnumerator(); this._state=1; } break; case 1: { if(this._state_enumerator.MoveNext()) { this.x=this._state_enumerator.Current; if(this._predicate(this.x)) { this._state=2; var input=this.x; seq.yield(this._controller. input, this._output_continuation_0); return; } else { seq.exit(this._controller); } } else { state._state=3; } } break; case 2: { this.state=1; } break; case 3: { seq.exit(this._controller); } break; } } } }
但是TakeWhile这个函数是真实存在的,所以他也要被改写:
IEnumerable<T> TakeWhile(this IEnumerable<T> source, Predicate<T> predicate) { return seq.CreateCps(controller=> { var sm = new _Where<T> { _controller=controller, _source=source, _predicate=predicate, }; sm.RunStateMachine(); }); }
最终生成的TakeWhile会调用哪个CreateCps函数,然后把原来的函数体经过CFG的处理之后,得到一个状态机。在状态机内所有调用CPS operator的地方(就是yield!和return!),都把“接下来的事情”当成一个参数,连同那个原本写上去的CPS operator的参数,还有controller(在这里是seq_Enumeartor<T>)一起传递过去。而return是带有特殊的寓意的,所以它调用一次exit之后,就没有“然后——也就是continuation”了。
现在回过头来看seq类型的声明
public static class seq { public static IEnumerator<T> CreateCps<T>(Action<seq_Enumerator<T>>); public static void yield<T>(seq_Enumerator<T> state, T value, Action continuation); public static void exit<T>(seq_Enumerator<T> state /*没有输入*/ /*exit代表return,函数结束的意思就是不会有一个continuation*/); }
其实想一想,CPS的自然属性决定了,基本上就只能这么定义它们的类型。而他们的类型唯一定义了一个最简单有效的函数体。再次感叹一下,写程序就跟在做推理完全是一摸一样的。
六、Koncept(我正在设计的语言)的yield return和async await(async答案)
因为CPS operator都是一样的,所以在这里我给出async类型的声明,然后假设Task<T>的样子长的就跟C#的System.Tasks.Task<T>一摸一样,看看大家能不能得到async下面的几个函数的实现,以及上面那个针对Task<T>的TakeWhile函数最终会被编译成什么:
public static class async { public static Task<T> CreateCps<T>(Action<FuturePromiseTask<T>> startContinuation); { /*请自行填补*/ } public static void await<T>(FuturePromiseTask<T> task, Task<T> source, Action<T> continuation); { /*请自行填补*/ } public static void exit<T>(FuturePromiseTask<T> task, T source); /*在这里async的return是有参数的,所以跟seq的exit不一样*/ { /*请自行填补*/ } } public class FuturePromiseTask<T> : Task<T> { /*请自行填补*/ }