考不上三本也能给自己心爱的语言加上Coroutine(三)

考不上三本也能给自己心爱的语言加上Coroutine(三)

你现在所阅读的并不是第一篇文章,你可能想看目录和前言

昨天写了那么多高中程度的东西,今天继续来讲一些对于普通人来说上了大学才会接触到的。回顾一下之前几篇的内容:

(一):函数 --(人肉处理)--> Coroutine

(二):函数 --(人肉处理)--> 流程图 --(机械化处理)--> Coroutine

那么今天文章的内容将是:

(三):函数 --(人肉处理)-->语法树和符号表 --(机械化处理)--> 流程图 --(机械化处理)--> Coroutine

既然文章的标题是《给心爱的语言加上Coroutine》,那么那么我们当然可以假设已经存在了一个完整的编译器,只是缺少Coroutine,因此语法树和符号表都是现成的——这些东西对于绝大多数语言来说,也超不过三本的内容。你们有兴趣的话,我以后再讲,这几天先把Coroutine讲完。

大家也不要觉得符号表很害怕,我们这里用到的东西都很简单,大概就只有:

  • 知道每一个break(或continue等)语句到底break的是哪一个循环
  • 知道代码里访问的每一个变量到底是在哪里定义的

这就够了!符号表的意义就在于,把代码从头到尾读几遍,然后把这些信息都整理出来,以后可以有各种用途。

相比起前面的几篇文章,今天的内容比较复杂,需要读者提前掌握的内容有

  • 指针:这没什么好说的,我们也不玩复杂的东西,你只要会new、delete和->就够了。
  • 链表:也不需要懂什么复杂的东西,你只需要会添加删除节点就可以了。
  • 递归:啊,递归!
  • 深度优先搜索:语法树也是树,深度优先搜索就是,在你能想到的所有“把树里面所有的指针都读一遍”的方法里面,最简单的那个。你不需要知道这个概念,你第一个写得出来的方法,肯定就是深度优先搜索。

所以大家不要害怕,考不上三本也是没有问题的!

听说很多学校都没有好好讲编译原理的内容,那么自然就会有很多人不知道语法树是什么东西。其实这个概念是很简单的。我们回想一下,课本上是怎么表达一个链表的?

一个方框就是一个对象,而一个箭头就是一个指针成员变量(如果用的是C#那就更简单了)。那么语法树当然可以用这种方法表达出来。区别只在于,语法树根据代码内容的不同,每一个节点可能有不定数量的指针成员变量。那么当我们把下面的这段代码使用语法树来保存在内存里的时候:

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = _xs.Current;
        SHIT!
        sum += x;
    }
    return sum;
}

就会变成这样:

这个图也是一样的,每一个框代表一个对象,左上角的箭头指向的是代表一整个函数体的语句,其中每一个蓝色的对象和每一条粗的箭头,就是我们需要遍历的所有语句的结构。这个图的意思是,从最大的{}开始,指向了四条语句,其中第三条语句是while,while又包含一个{},里面又有三条语句。这个递归的结构就是树。你要遍历树里面的所有节点,最容易的当然是递归地做深度优先搜索。

当然这里我省略了表达式的内部结构,也就是绿色的部分,毕竟我们之前定义的SHIT!这个Coroutine不处理这些内容,把他们当成一个整体就好了。

这里我们要面对的主要是以下这几类语句:

  • 变量声明
  • 表达式
  • if
  • while
  • try-catch
  • break / continue / return / throw

然后我们就要想办法机械化地从语法树里面得到流程图,然后按照上一篇文章的方法(从流程图得到Coroutine),那么我们就从语法树得到Coroutine了!那么接下来我们就挨个来讲一下,每一种语句要怎么处理。

我为什么要专门说一下语法树呢?因为接下来介绍的每一种语句如何变成流程图的这个内容,实际上需要你从函数体递归地遍历进去,按顺序把每一个语句都变成流程图,然后拼接在一起。所以必然需要访问语法树。几乎所有编译器在做语法树和符号表用的都是同一个套路,所以可以举一反三(VC++的旧版本除外)。

变量声明:var N = E;

这个很简单,其实就是把N的定义放在了实现IShitCallable<T>的成员变量里,然后把它改成普通的赋值语句:

需要说明的是,^和$分别代表开始和结束,用来跟其他语句生成的流程图连在一起,然后“优化”掉。我自己的做法是,保证只有一个流程图节点指向$,那么你拿到的流程图,^和$都可以指向唯一的、真实存在的节点,在这里就是“N=E;”。但是画图还是这样画容易理解。

表达式:E;

这个就简单了,原样复制,不画。

块语句:{A; B; C;}

块语句需要做的,就是分别生成A、B、C的流程图,然后把他们首尾相接起来:

注意左边的^和$才是属于{}自己的,右边的分别属于A;、B;和C;。在这里我们可以发现,让三条语句按顺序执行的方法,就是把各自的^和$首尾相接。最终我们就可以把连在一起的^和$给“内部消化”掉,然后就变成这样:

考虑到A;、B;、C;也可能是一些复杂的东西,画图的时候如果你觉得理解起来比较难,可以不辞辛苦地保留所有^和$,然后再把他们拿掉。等下面的实战例子我就来演示一遍,到底这些图如何用在真正的代码上。

if (C) {T} else {F}

从if语句开始,就要使用分支结构了:

我这里把F和T的^和$都省略掉了。这个图的意思很简单,我们先构造一个流程图的节点,他没有语句,只有跳转的条件。如果C成立了,跳到T。否则跳到F。T和F都连到相同的$上面。

while (C) {B}

既然是循环,那么这些箭头自然就会连成一个圈:

循环有两种风格可供选择,我自己倾向于第一种。其实这个图的意思也很简单,一上来先到达一个没有语句的流程图节点,然后看看C是否满足。如果满足了,跳向B。B执行结束之后,又会看一下是否满足C,如果满足了,重新跳向B。不满足条件直接就跳向$,结束循环。

break / continue / return

return就直接跳到整个函数的$那里去(记得要把返回值写进Result属性)。而break和continue则要让你先找到到底要操作的是哪个循环,然后分别跳转到该循环的$或者^节点。

我为什么强调说今天需要你先学会链表,因为你要解决“break的时候到底break的是哪个循环”的问题。假设说我们展开流程图的函数是这样写的:

Tuple<Node, Node> CreateFlowChart(Statement statement);

这个函数接收一条语句,然后返回^和$。如果这个时候statement是“break;”语句,我要怎么拿到循环的^和$节点呢?所以我们需要做一条链表:

class Scope
{
    public Node Begin; // ^
    public Node End; // ^
    public ScopeType Type; // enum{ Function, Loop, TryCatch, ....}
    public Scope Parent;
}

然后把Scope类型的对象添加到函数的参数里。这样当我们处理到了“break;”的时候,就可以沿着当前的Scope,往Parent一个一个找上去,直到发现第一个Type==Loop的Scope对象位置,然后使用包含在Scope里面的节点信息——譬如说告诉你break和continue要往哪跳。

那么我们在处理while语句的时候,自然就要往链表里面添加一个新的、代表自己的Scope对象了:

Tuple<Node, Node> CreateFlowChart(Statement statement, Scope scope)
{
    switch (scope)
    {
    ....
    case WhileStatement whileStatement:
        {
            var begin = new Node();
            var end = new Node();
            var whileScope = new Scope
            {
                Begin = begin,
                End = end,
                Type = Loop,
                Parent = scope
            };

            var bodyFlowChart = CreateFlowChart(whileStatement.Body, whileScope);
            begin.AddIf(whileStatement.Condition, bodyFlowChart.Item1);
            begin.AddElse(end);

            bodyFlowChart.Item2.AddIf(whileStatement.Condition, bodyFlowChart.Item1);
            bodyFlowChart.Item2.AddElse(end);

            return Tuple.Create(begin, end);
        }
        break;
    }
}

这个函数依次做了下面的几件事情:

  • 创造begin(在这里代表的是^它指向的第一个没有语句的节点,因为的确没有必要创建两个)
  • 创造end(代表$)
  • 往scope联表添加一个保存当前while语句信息的对象
  • 获得循环体的流程图的^和$节点
  • 连接while的^到while的$(条件失败)和函数体的^(条件成功)
  • 连接循环体的$到while的$(条件失败)和函数体的^(条件成功)
  • 返回while语句的^和$

CreateFlowChart调用自己,这就是递归;CreateFlowChart函数按顺序递归处理每一种语句的所有子语句,这就是深度优先搜索;在需要的时候往Scope上面加节点,这就是链表;最后Statement类型和他所有的子类加在一起是一棵树。其实无论你写多么复杂的程度,你会发现大部分只需要超级简单的知识就可以了。

实战

让我们来人肉练习一下怎么把完整的语法树转换成流程图吧!我们还是使用我们熟悉的函数:

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    while (_xs.Next())
    {
        var x = _xs.Current;
        SHIT!
        sum += x;
    }
    return sum;
}

这里我就不人肉展开递归了,我选择保留所有的^和$节点,你们去对照上面的图,就可以发现优化前的流程图的结构跟语法树的结构其实是完全一致的。

大家有没有发现,每次我们从一个语句或者复合语句做出流程图的时候,我们总是保证这个流程图有一个明显的开始节点和结束节点?这其实是为了实现的时候方便。一开始我们先生成粗糙的流程图,让后再优化一下减少明显不必要的跳转(也就是说不明显的不管他就可以了!)。通常来讲,我们可以先去掉多余的^和$节点:

然后再把那些能合并的节点合并了(还记得SHIT!只能出现在节点的末尾的要求吗?):

这样就得到上一篇文章我们画出来的流程图了!

作业:总结出一个规律,可以被去掉和合并的节点都有什么特征?

那么接下来就轮到try-catch了。在实现Coroutine的过程中,try-catch简直就是恶魔,C#在刚开始的时候甚至不支持你在try-catch里面用yield return和await,可能是来不及测试完(逃

在讨论具体的流程图之前,我们先要对流程图做一个小修改,就是要加上一个新的虚线箭头,代表这个方框里面的代码如果出现了异常,那么就跳转到另一个流程图的节点里面去:

异常变量的意思就是说,因为根据实际情况我们可能不能在生成的try-catch语句里面,套上“catch语句”里面的内容,所以在这种情况下,先把异常保存到成员变量里面,然后跳走。现在我们可以开始来处理try-catch语句了:

try {T} finally {F}

虽然看起来比较复杂,其实意思很简单。我们先执行T,如果没有出现任何状况,就执行F然后结束。如果出现了状况了,先执行F,执行完了重新把异常抛出来。虽然这样做可能会丢失当时的一些堆栈信息,不过你都弄成Coroutine了,顾不了那么多了(逃

在这里,throw ex;并没有后继的代码了。因为如果这一片东西也被包含在另一个T里面的话,那么throw ex;那里将有一条新的虚线,执行到throw ex;就会跳转过去。如果没有的话,整个函数就这么结束了。但是这个时候你可能需要将Coroutine接口标记上一个结束状态,所以你在while (true) 循环外面还要套一层try-catch,来做这个事情。

try {T} catch {C}

这个没啥好说的。如果语言里面像C++和C#一样支持对异常的类型做分支的话,这里的--(ex)-->C要变成很多条。

try {T} catch {C} finally {F}

这里我觉得需要解释一下。首先我们执行T,如果没有出现问题,那么就执行F然后结束。如果出了异常,我们就执行C,然后执行F,然后结束。如果在C,也就是catch语句里面的时候,又出现了一个新的异常,那我们就先把这个新的异常留下来,执行F,重新抛出去。

注意:代码改成流程图的时候,可能一些不同的变量会撞名,需要酌情重命名。这个时候需要访问符号表。

一旦我们有了finally的部分,break、continue和return也要做相应的改动。break、continue和return都会从当前的块里面跳出去,这一跳可能会跨若干个Scope(上面说的Scope对象),譬如说在一个循环里面嵌套一个try-catch,然后再在里面跳出循环。这个时候你要依次执行所有被你跳过的finally语句,然后再走。这个过程很简单,访问Scope.Parent到循环之前,遇到的所有Type==TryCatch的Scope,你看着办就好了。需要注意的是,不同的finally语句里出现的异常可能会要求你跳转到不同的catch里面去。

实战

大家看到这么复杂的流程图不要怕,我们来实际做一下就明白了。先看看这段代码:

int Fuck(IEnumerable<int> xs)
{
    int sum = 0;
    var _xs = xs.CreateEnumerator();
    try
    {
        while (_xs.Next())
        {
            var x = _xs.Current;
            SHIT!
            sum += x;
            if (sum > 10) break;
        }
    }
    finally
    {
        sum = 10000 + sum;
    }
    return sum;
}

大家可以尝试自己在纸上画一遍,最后得到的结果应该跟下面这个图是差不了多少的。如果要验证的话,可以人肉把机械化生成出来的代码打进Visual Studio的C#工程里面,立刻就知道结果了!在这里需要提醒大家,try-catch里面我很轻松地就从T那里引了一些虚线出来。如果T是一个复杂的流程图,那么在里面所有的节点,都要引那一条虚线出来,直到被新的try-catch语句的虚线覆盖为止。因此最后的结果可能看起来会比较混乱:


你们都做出来了吗?

到这里,如何把SHIT_CALLABLE!函数转成Coroutine的内容就介绍完了。但是这个系列还没完,因为后面还有yield return和await要讲。毕竟光给你这几个关键字,要写出真的程序来,也很别扭。所以下一篇文章开始,会介绍如何把各种各样奇怪的功能,一个一个映射到SHIT_CALLABLE!和SHIT!上面去。这样一来,当你真正实现这些功能的时候,只需要把他们统一改写为Coroutine的形式,再编译成普通的代码就好了,节省了大量的时间。

只要有扎实的编码技巧,哪怕你考不上三本,其实都没有问题,绝大多数的程序都是写得出来的!剩下的就是去背诵架构上的套路。

编辑于 2017-04-01

文章被以下专栏收录