如何設計一門語言(八)——非同步程式設計和CPS變換

陳梓瀚(vczh)發表於2013-07-27

關於這個話題,其實在(六)裡面已經討論了一半了。學過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!一樣。在處理這種語言級別的事情的時候,千萬不要去管程式碼它實際上在幹什麼,這其實是次要的。最重要的是形式。什麼是形式呢?也就是說,同樣一個任務,是如何被不同的方法表達出來的。上面說的“味道”就都在“表達”的這個事情上面了。

這裡我就要提一個問題了。

  1. Haskell有Monad,所以我們可以給自己定義的型別實現一個Monad,從而讓我們的型別可以用do和<-來操作。
  2. F#有computation expression,所以我們可以給自己定義的型別實現一個computation expression,從而讓我們的型別可以用let!來操作。
  3. 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是這麼對應的:

  1. fmap對應於Select
  2. >>=對應於SelectMany
  3. >>= + 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的語法:

  1. 首先把yield return修改成yield
  2. 其次吧yield break修改成return
  3. 然後再給函式打上一個叫做seq的東西,跟async對稱,就當他是個關鍵字
  4. 給所有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();
}
於是問題就很清楚了。如果我們想讓使用者自己通過類庫的方法來實現這些東西,那麼yield和await肯定是兩個函式,因為這是C#裡面唯一可以用來寫程式碼的東西,就算看起來再奇怪,也不可能是別的。
  1. seq和async到底是什麼?
  2. seq下面的yield和return的型別分別是什麼?
  3. 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要怎麼運作,但是隨著這些線索,我們就可以先把型別搞出來。搞出了型別之後,就可以來填程式碼了。

  1. 對於yield,yield接受了一個T,沒有返回值。一個沒有返回值的函式的continuation是什麼呢?當然就是一個沒有引數的函式了。
  2. return則連輸入都沒有。
  3. 而且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>
{
    /*請自行填補*/
}

相關文章