可配置语法分析器开发纪事(一)——构造语法树

就像之前的博客文章所说的,(主要还是)因为GacUI的原因,我决定开发一个更好的可配置轻量级语法分析器来代替之前的落后的版本。在说这个文章之前,我还是想在此向大家推荐一本《编程语言实现模式》,这的确是一本好书,让我相见恨晚。

其实说到开发语法分析器,我从2007年就已经开始在思考类似的问题了。当时C++还处于用的不太熟练的时候,难免会做出一些傻逼的事情,不过总的来说当年的idea还是能用的。从那时候开始,我为了锻炼自己,一直在实现各种不同的语言。所以给自己开发一个可配置语法分析器也是在所难免的事情了。于是就有:
第一版:http://hi.baidu.com/geniusvczh/archive/tag/syngram%E6%97%A5%E5%BF%97
第二版:http://www.cppblog.com/vczh/archive/2009/04/06/79122.html
第三版:http://www.cppblog.com/vczh/archive/2009/12/13/103101.html
还有第三版的教程:http://www.cppblog.com/vczh/archive/2010/04/28/113836.html

上面的所有分析器都致力于在C++里面可以通过直接描述文法和一些语义行为来让系统可以迅速构造出一个针对特定目的的用起来方便的语法分析器,而“第三版”就是到目前为止还在用的一个版本。至于为什么我要做一个新的——也就是第四版——之前的文章已经说了。

而今天,第四版的开发已经开始了有好几天。如果大家关心进度的话,可以去GacUI的Codeplex页面下载代码,然后阅读Common\Source\Parsing下面的源文件。对应的单元测试可以在Common\UnitTest\UnitTest\TestParsing.cpp里找到。

于是今天就说说关于构造语法树的事情。

用C++写过parser的人都知道,构造语法树以及语义分析用的符号表是一件极其繁琐,而且一不小心就容易写出翔的事情。但是根据我写过无穷多棵语法树以及构造过无穷多个符号表以及附带的副作用,翔,啊不,经验,做这个事情还是有一些方法的。

在介绍这个方法之前,首先要说一句,人肉做完下面的所有事情是肯定要疯掉的,所以这一次的可配置语法分析器我已经决定了一定要TMD写出一个生成语法树的C++代码的工具。

一颗语法树,其实就是一大堆互相继承的类。一切成熟的语法树结构所具有的共同特征,不是他的成员怎么安排,而是他一定会附带一个visitor模式的机制。至于什么是visitor模式,大家请自行参考设计模式,我就不多说废话了。这一次的可配置语法分析器是带有一个描述性语法的。也就是说,跟Antlr或者Yacc一样,首先在一个文本文件里面准备好语法树结构和文法规则,然后我的工具会帮你生成一个内存中的语法分析器,以及用C++描述的语法树的声明和实现文件。这个描述性语法就类似下面的这个大家熟悉到不能再熟悉的带函数的四则运算表达式结构:

class Expression
{
}

class NumberExpression : Expression
{
    token value;
}

class BinaryExpression : Expression
{
    enum BinaryOperator
    {
        Add,
        Sub,
        Mul,
        Div,
    }

    Expression firstOperand;
    Expression secondOperand;
    BinaryOperator binaryOperator;
}

class FunctionExpression : Expression
{
    token functionName;
    Expression[] arguments;
}

token NAME = "[a-zA-Z_]/w*";
token NUMBER = "/d+(./d+)";
token ADD = "/+";
token SUB = "-";
token MUL = "/*";
token DIV = "//";
token LEFT = "/(";
token RIGHT = "/)";
token COMMA = ",";

rule NumberExpression Number
        = NUMBER : value;

rule FunctionExpression Call
        = NAME : functionName "(" [ Exp : arguments { "," Exp : arguments } ] ")";

rule Expression Factor
        = !Number | !Call;

rule Expression Term
        = !Factor;
        = Term : firstOperand "*" Factory : secondOperand as BinaryExpression with { binaryOperator = "Mul" };
        = Term : firstOperand "/" Factory : secondOperand as BinaryExpression with { binaryOperator = "Div" };

rule Expression Exp
        = !Term;
        = Exp : firstOperand "+" Term : secondOperand as BinaryExpression with { binaryOperator = "Add" };
        = Exp : firstOperand "-" Term : secondOperand as BinaryExpression with { binaryOperator = "Sub" };

上面的语法树声明借用的C#语法,描述起来特别简单。但是要在C++里面达到可以使用的程度,肯定要有一个自带的visitor模式。所以出来之后的代码大概就类似于下面这个样子:

class Expression;
class NumberExpression;
class BinaryExpression;
class FunctionExpression;

class Expression : public ParsingTreeCustomBase
{
public:
    class IVisitor : public Interface
    {
    public:
        virtual void Visit(NumberExpression* node)=0;
        virtual void Visit(BinaryExpression* node)=0;
        virtual void Visit(FunctionExpression* node)=0;
    };

    virtual void Accept(IVisitor* visitor)=0;
};

class NumberExpression : public Expression
{
public:
    TokenValue value;

    void Accept(IVisitor* visitor){visitor->Visit(this);}
};

class BinaryExpression : public Expression
{
public:
    enum BinaryOperator
    {
        Add, Sub, Mul, Div,
    };
    Ptr<Expression> firstOperator;
    Ptr<Expression> secondOperator;
    BinaryOperator binaryOperator;

    void Accept(IVisitor* visitor){visitor->Visit(this);}
};

class FunctionExpression : public Expression
{
public:
    TokenValue functionName;
    List<Ptr<Expression>> arguments;

    void Accept(IVisitor* visitor){visitor->Visit(this);}
};

为什么要这样做呢?学习过面向对象开发方法的都知道,把一个明显是继承结构的东西写成一堆union/struct和一个enum来判断他们,是不对的。第一个不好的地方就是,如果其中的成员需要构造函数和析构函数,那union就用不了了,struct就一定会造成大量的内存浪费。因为一颗语法树是可以很大的。其次,当语法树的结构(主要是添加删除了新的语法树类型)之后,我们根本不可能保证我们所有的swtich(node->enumType)语句都接受到了正确的更新。

那要如何解决这两个问题呢?答案之一就是使用visitor模式。尽管刚开始写起来的时候可能会有点别扭,但是我们只要把原本是swtich结构的代码做一下Continuation Passing Style变换,就可以写出使用visitor的版本了。在这里我做一个小小的演示,如何把一个“把上面的语法树还原成四则运算式子的函数”给用Expression::IVisitor的框架下实现出来:

class FunctionExpression : public Expression
{
public:
    TokenValue functionName;
    List<Ptr<Expression>> arguments;

    void Accept(IVisitor* visitor){visitor->Visit(this);}
};

class ExpressionPrinter : public Expression::IVisitor
{
public:
    WString result;

    void Visit(NumberExpression* node)
    {
        result+=node->value.stringValue;
    }

    void Visit(BinaryExpression* node)
    {
        result+=L"(";
        node->firstOperand->Accept(this);
        switch(binaryOperator)
        {
        case Add: result+=L" + "; break;
        case Sub: result+=L" - "; break;
        case Mul: result+=L" * "; break;
        case Div: result+=L" / "; break;
        }
        node->secondOperand->Accept(this);
        result+=L")";
    }

    void Visit(FunctionExpression* node)
    {
        result+=node->functionName.stringValue+L"(";
        for(int i=0;i<arguments.Count();i++)
        {
            if(i>0) result+=L", ";
            arguments[i]->Accept(this);
        }
        result+=L")";
    }
};

WString PrintExpression(Ptr<Expression> expression)
{
    ExpressionPrinter printer;
    expression->Accept(&printer);
    return printer.result;
}

其实大家可以看到,使用了visitor模式,代码量其实也没有多大变化,本来是递归的地方还是递归,本来该计算什么还计算什么,唯一不同的就是原本这个“函数”的参数和返回值都跑到了一个visitor类的成员变量里面去了。当然,为了便于使用,一般来说我们会把原本的函数的原型写出来,并且在里面调用visitor模式,就像上面的PrintExpression函数一样。如果我们高兴的话,完全可以在ExpressionPrinter这个visitor类里面使用PrintExpression,无非就是在里面构造新的ExpressionPrinter然后获取结构罢了。一般来说,visitor类都是非常的轻量级的,在现今的CPU性能下面,构造多几个完全不会带来多大影响。

可配置语法分析器既然拥有一个描述性语法,那么我肯定也针对这个描述性语法写了一颗语法树的。这颗语法树的代码在Common\Source\Parsing\ParsingDefinition.h里面,而ParsingLogging.cpp则是跟上面说的一样,用visitor的方法写了一个庞大的把语法树转回描述性语法的函数。这个函数非常有用,不仅可以用来打log,还可以用来保存程序生成的一个语法规则(反正可以parse回来,所以保存成文本是一件特别方便的事情),甚至是生成错误消息的片段等等。

今天就先讲到这里了。现在的可配置语法分析器的开发进度是正在写语义分析的部分。等到语义分析写完了,我会再写一篇纪事来说明开发语义分析程序和构造符号表的一般做法。

posted on 2012-11-21 22:46  陈梓瀚(vczh)  阅读(6258)  评论(6编辑  收藏  举报