longshanks

  C++博客 :: 首页 :: 联系 :: 聚合  :: 管理
  14 Posts :: 0 Stories :: 214 Comments :: 0 Trackbacks

常用链接

留言簿(10)

我参与的团队

搜索

  •  

最新评论

阅读排行榜

评论排行榜

当GPL遇上MP

莫华枫

    GPL,也就是General Purpose Language,是我们使用的最多的一类语言。传统上,GPL的语法,或者特性,是固态的。然而,程序员都是聪明人(即便算不上“最聪明”,也算得上 “很聪明”吧:)),往往不愿受到语法的束缚,试图按自己的心意“改造”语言。实际上,即便是早期的语言,也提供了一些工具,供聪明人们玩弄语法。我看的第一本C语言的书里,就有这么一个例子,展示出这种“邪恶”的手段:
      #define procedure void
      #define begin {
      #define end }
    然后:
      procedure fun(int x)
      begin
          ...
      end
    实际上,它的意思是:
      void fun(int x)
      {
          ...
      }
    这可以看作是对初学C语言的Pascal程序员的安慰。这种蹩脚的戏法可以算作元编程的一种,在一种语言里创造了另一个语法。不过,实在有些无聊。然而,在实际开发中,我们或多或少地会需要一些超出语法范围的机制。有时为了完善语言,弥补一些缺憾;有时为了增强一些功能;有时为了获得一些方便。更新潮的,是试图在一种GPL里构建Domain Specific Language,或者说“子语言”,以获得某个特性领域上更直观、更简洁的编程方式。这些对语言的扩展需求的实现,依赖于被称为Meta- Programming(MP)的技术。
    另一方面,随着语言功能和特性的不断增加,越来越多的人开始抱怨语言太复杂。一方面:“难道我们会需要那些一辈子也用不到几回的语言机制,来增加语言的复杂性和学习使用者的负担吗?”。另一方面:“有备无患,一个语言机制要到迫在眉睫的时候才去考虑吗?”。但MP技术则将这对矛盾消弭于无形。一种语言,可以简洁到只需最基本的一些特性。而其他特定的语言功能需求,可以通过MP加以扩展。如果不需要某种特性,那么只要不加载相应的MP代码即可,而无需为那些机制而烦恼。
    MP最诱人的地方,莫过于我们可以通过编写一个代码库便使得语言具备以往没有的特性。
    然而,全面的MP能力往往带来巨大的副作用,以至于我们无法知道到底是好处更多,还是副作用更多。语言的随意扩展往往带来某些危险,比如语法的冲突和不兼容,对基础语言的干扰,关键字的泛滥等等。换句话说,MP是孙悟空,本领高强。但没有紧箍咒,是管不住他的。
    那么,紧箍咒是什么呢?这就是这里打算探讨的主题。本文打算通过观察两种已存在的MP技术,分析它们的特点与缺陷,从中找出解决问题的(可能)途径。

AST宏

    首先,先来看一下宏,这种远古时代遗留下来的技术。以及它的后裔,ast宏。
    关于传统的宏的MP功能,上面的代码已经简单地展示了。但是,这种功能是极其有限的。宏是通过文本替换的形式,把语言中的一些符号、操作符、关键字等等替换成另一种形式。而对于复杂的语法构造的创建无能为力。问题的另一面,宏带来了很多副作用。由于宏的基础是文本替换,所以几乎不受语法和语义的约束。而且,宏的调试困难,通常也不受命名空间的约束。它带来的麻烦,往往多于带来的好处。
    ast宏作为传统宏的后继者,做了改进,使得宏可以在ast(Abstract Syntax Tree)结构上执行语法的匹配。(这里需要感谢TopLanguage上的Olerev兄,他用简洁而又清晰的文字,对我进行了ast宏的初级培训:))。这样,便可以创造新的语法:
      syntax(x, "<->", y, ";")
      {
          std::swap(x, y);
      }
    当遇到代码:
      x <-> y;
    的时候,编译器用std::swap(x,y);加以替换。实际上,这是将一种语法结构映射到另一个语法结构上。而ast宏则是这种映射的执行者。
    但是,ast宏并未消除宏本身的那些缺陷。如果x或者y本身不符合swap的要求(类型相同,并且能复制构造和赋值,或者拥有swap成员函数),那么 ast宏调用的时候无法对此作出检验。宏通常以预编译器处理,ast宏则将其推迟到语法分析之时。但是此时依然无法得到x或y的语义特征,无法直接在调用点给出错误信息。
    同时,ast宏还是无法处理二义性的语法构造。如果一个ast宏所定义的语法构造与主语言,或者其他ast宏的相同,则会引发混乱。但是,如果简单粗暴地将这种“重定义”作为非法处理,那么会大大缩小ast宏(以及MP)的应用范围。实际上,这种语法构造的重定义有其现实意义,可以看作一种语法构造的“重载”,或者函数(操作符)重载的一种扩展。
    解决的方法并不复杂,就是为ast宏加上约束。实际上,类似的情形在C++98的模板上也存在,而C++则试图通过为模板添加concept约束加以解决。这种约束有两个作用:其一,在第一时间对ast宏的使用进行正确性检验,而无需等到代码展开之后;其二,用以区分同一个语法构造的不同版本。
    于是,对于上述例子可以这样施加约束(这些代码只能表达一个意思,还无法看作真正意义上的MP语法):
      syntax(x, "<->", y, ";")
           where x,y is object of concept (has_swap_mem or (CopyConstructable and Assignable))
                && typeof(x)==typeof(y)
      {
          std::swap(x,y);
      }
    如此,除非x,y都是对象,并且符合所指定的concept,否则编译器会当即加以拒绝,而且直截了当。
    不过,如此变化之后,ast宏将不会再是宏了。因为这种约束是语义上的,必须等到语义分析阶段,方能检验。这就超出了宏的领地了。不过既然ast宏可以从预处理阶段推迟到语法分析阶段,那么再推迟一下也没关系。再说,我们关注的是这种功能,带约束的ast宏到底是不是宏,也无关紧要。

TMP

    下面,我们回过头,再来看看另一种MP技术——TMP(参考David Abrahams, Aleksey Gurtovoy所著的《C++ Template Metaprogramming》)。对于TMP存在颇多争议,支持者认为它提供了更多的功能和灵活性;反对者认为TMP过于tricky,难于运用和调试。不管怎么样,TMP的出现向我们展示了一种可能性,即在GPL中安全地进行MP编程的可能性。由于TMP所运用的都是C++本身的语言机制,而这些机制都是相容的。所以,TMP所构建的 MP体系不会同GPL和其他子语言的语法机制相冲突。
    实际上,TMP依赖于C++的模板及其特化机制所构建的编译期计算体系,以及操作符的重载和模板化。下面的代码摘自boost::spirit的文档:
      group = '(' >> expr >> ')';

      expr1 = integer | group;

      expr2 = expr1 >> *(('*' >> expr1) | ('/' >> expr1));

   expr = expr2 >> *(('+' >> expr2) | ('-' >> expr2));

    这里表达了一组EBNF(语法着实古怪,这咱待会儿再说):>>代表了标准EBNF的“followed by”,*代表了标准EBNF的*(从右边移到左边),括号还是括号,|依旧表示Union。通过对这些操作符的重载,赋予了它们新的语义(即EBNF的相关语义)。然后配合模板的类型推导、特化等等机制,变戏法般地构造出一个语法解析器,而且是编译时完成的。

    尽管在spirit中,>>、*、|等操作符被挪作他用,但是我们依然可以在这些代码的前后左右插入诸如:cin>> *ptrX;的代码,而不会引发任何问题。这是因为>>等操作符是按照不同的类型重载的,对于不同类型的对象的调用,会调用不同版本的操作符重载,互不干扰,老少无欺。

    但是,TMP存在两个问题。其一,错误处理不足。如果我不小心把第二行代码错写成:expr1 = i | group;,而i是一个int类型的变量,那么编译器往往会给出一些稀奇古怪的错误。无非就是说类型不匹配之类的,但是很晦涩。这方面也是TMP受人诟病的一个主要原因。好在C++0x中的concept可以对模板作出约束,并且在调用点直接给出错误提示。随着这些技术的引入,这方面问题将会得到缓解。

    其二,受到C++语法体系的约束,MP无法自由地按我们习惯的形式定义语法构造。前面说过了,spirit的EBNF语法与标准EBNF有不小的差异,这对于spirit的使用造成了不便。同样,如果试图运用TMP在C++中构造更高级的DSL应用,诸如一种用于记账的帐务处理语言,将会遇到更大的限制。实际上TMP下的DSL也很少有令人满意的。

    所以说,TMP在使用上的安全性来源于操作符复用(或重载)的特性。但是,操作符本身的语法特性是固定的,这使得依赖于操作符(泛化或非泛化)重载的TMP不可能成为真正意义上的MP手段。

    那么,对于TMP而言,我们感兴趣的是它的安全性和相容性。而对其不满的,则是语法构造的灵活性。本着“去其糟粕,取其精华”的宗旨,我们可以对TMP做一番改进,以获得更完整的MP技术。TMP的核心自然是模板(类模板和函数/操作符模板),在concept的帮助下,模板可以获得最好的安全性和相容性。以此为基础,如果我们将模板扩展到语法构造上,那么便可以在保留TMP的安全性和相容性的情况下,获得更大的语法灵活性。也就是说,我们需要增加一种模板——语法模板

      template<typename T>
      syntax synSwap=x "<->" y ";"
         require SameType<decltype(x), T> && SameType<decltype(y), T>
                && (has_swap_mem<T> || (CopyConstructable<T> and Assignable<T>)
      {
          std::swap(x, y);
      }

    这里我杜撰了关键字syntax,并且允许为语法构造命名,便于使用。而语法构造描述,则是等号后面的部分。require沿用自C++0x的concept提案。稍作了些改造,允许concept之间的||。

用户定义的语法

    如果比较带约束的ast宏和语法模板,会发现极其相似。实际上,两者殊途同归,展示了几乎完全相同的东西。ast宏和TMP分别位于同一个问题的两端,当它们的缺陷得到弥补时,便会相互靠拢,最终归结到一种形式上。 

    现在,通过结合两种方案的特色,我们便可以得到一个最终的MP构造,既安全,又灵活:

      syntax synSwap=x "<->" y ";";

      where

          (x,y is object)

          && SameType<x, y>

          && (has_swap_mem<x> || (CopyConstructable<x> and Assignable<x>))

      {

          std::swap(x, y);

      }

    我去掉了template关键字,在约束(where)的作用下,template和类型参数列表都已经没有必要了。同时,也允许直接将对象放入 concept:Assignable<x>,这相当于:Assignable<decltype<x>>。

    “is ...”是特别的约束,用来描述那些超越concept范畴的特性。“...”可以是关键字“object”,表明相应的标识符是对象;也可以是关键字 “type”,表明相应的标识符是类型;或者是关键字“concept”,指定相应的标识符是concept等等。操作符的重载所涉及的参数只会是对象,只需对其类型做出约束,因此concept便足够使用。但语法构造的定义则广大的多,它不仅仅会涉及对象,更可能涉及其它类型的语法要素。实际上,语法构造所面对的参数是“标识符”。那么一个标识符上可能具备的各种特性,都应当作为约束的内容。这里大致归纳出以下几点需要约束的特性:

  1. 分类。对象、类型、concept、函数等等。is ...;
  2. 来源。来自哪个namespace。from ...;
  3. 尺寸。类型大小。sizeof(x)>20;
  4. 从属。指定一个语法构造是否从属于其他语法构造(其他语法构造的一部分)。如果是从属语法构造,那么将不能单独出现在独立的语句中。更进一步,可以强行指定一个语法构造从属于某个语法构造,不允许在其他语法构造中使用。belong_to ...;
  5. 类型及对象间的关系。诸如继承、子对象、子类型、true typedef、别名、成员等等。
  6. ...

    也可以认为,语法构造的约束是concept的自然延伸。concept对类型做出约束,而语法构造的约束的对象,则是标识符。

    为了强化语法构造的创建能力,应当允许在语法构造的定义中使用BNF,或其他类似的语法描述语言。

    在语法构造约束的支援下,便可以化解一些子语言同宿主语言之间的语法冲突。比如,我们创建了一种类似spirit的EBNF子语言,可以按照标准的EBNF形式编写语法,构造一个语法解析器:

      syntax repeat1=p '+'

      where

          p is object of type(Parser)

      {...}

    这样便定义了一个后缀形式的+操作符,但仅仅作用于类型Parser的对象上。对于如下的代码:

      letter + number

    使用主语言的+(加),还是使用EBNF的+(重复,至少一次),取决于letter和number的特征(这里是类型):

      int letter, number;

      letter + number; //使用主语言的+,表示letter加number

      Parser letter, number;

      letter + number; //使用EBNF的+,表示一串letter后面跟一个number

    如此,便使得用户定义的语法不会同主语言的相同语法发生冲突。

    但是,语法构造的约束并不能完全消除所有的语法冲突。其中一种情况便是常量的使用。比如:

      'x' + 'y'

    它在宿主语言中,会被识别为两个字符的相加。但在BNF子语言中,则会是若干个字符'x'之后紧跟一个‘y'。由于没有类型、对象等代码实体的参与,编译器无法分辨使用哪种语言的语法规则来处理。解决的方法是使得常量类型化。这种做法在C++等语言中已经运用多年。通常采用为常量加前后缀:

      'x'b_ + 'y'b_

    以此代表BNF子语言的常量。常量的语法定义可以是:

      syntax bnf_const="'" letter "'" "b_";

      where

          is const of ... //...可以是某种类型

      {...}

    通过is const of约束,将所定义的常量与某个类型绑定。而通过类型,编译器便可以推断出应当使用那种语法对其处理。

    然而,尽管带约束的语法构造定义可以在很大程度上消除语法冲突和歧义,但不可能消除所有的语法矛盾。另一方面,结构相似,但语义完全不同的语法构造混合在一起,即便不引发矛盾,也会对使用者造成难以预计的混乱。因此,在实际环境下,需要通过某种“语法围栏”严格限定某种用户定义语法的作用范围。最直接的形式,就是通过namespace实现。namespace在隔离和协调命名冲突中起到很好的作用,可以进一步将其运用到MP中。由于namespace一经打开,其作用范围不会超出最内层的代码块作用域:

      {

          using namespace XXX;

          ...

      } //XXX的作用范围不会超出这个范围

    运用这个特性,可以很自然地将蕴藏在一个namespace中的用户定义语法构造限制在一个确定的范围内。

    但是,仅此不够。毕竟语法不同于命名,不仅会存在冲突,还会存在(合法的)混淆,而且往往是非常危险的。为此,需要允许在using namespace的时候进一步指定语法的排他性。比如:

      {

          using namespace XXX exclusive;

          ...

      }

    如果namespace中的用户定义语法与外部语法(宿主语言,或外围引用的namespace)重叠(冲突或混淆),外部语法将被屏蔽。更进一步,可以允许不同级别的排他:排斥重叠和完全屏蔽。前者只屏蔽重叠的语法,这种级别通常用于扩展性的用户语法构造(扩展主语言,需要同主语言协同工作。而且语法重叠较少);而后者则将外部语法统统屏蔽,只保留所打开的namespace中的语法(子语言在代码块中是唯一语言),这种级别用于相对独立的功能性子语言(可以独立工作的子语言,通常会与主语言和其他子语言间存在较大的语法重叠,比如上述BNF子语言等等)。

    为提供更多的灵活性,可以在完全屏蔽的情况下,通过特定语句引入某种不会引发冲突的外部语法。比如:

      {

          using namespace XXX exclusive full;

          using host_lang::syntax(...); //引入主语言的某个语法,...代表语法名称

          ...

      }

    通常情况下,子语言不会完全独立运作,需要同宿主语言或其他子语言交互,也就是数据交换。主语言代码可能需要将数据对象传递给子语言,处理完成后再取回。由于编译器依赖于标识符的特性(主要是类型)来选择语法构造。所以,一种子语言必须使用其内部定义的类型和对象。为了能够交换数据,必须能够把主语言的对象包装成子语言的内部类型。通过在子语言的namespace中进行true typedef,可以将主语言类型定义成一个新的(真)类型,在进入子语言作用域的时候,做显式的类型转换。另外,为了方便数据转换,还可以采用两种方法:将主语言类型map到一个子语言类型(相当于给予“双重国籍”),进入子语言的语法范围时,相应的对象在类型上具有两重性,可以被自动识别成所需的类型,但必须在确保不发生语法混淆的情况下使用;另一种稳妥些的方法,可以允许执行一种typedef,介于alias和true typedef之间,它定义了一个新类型,但可以隐式地同原来的类型相互转换。数据交换的问题较复杂,很多问题尚未理清,有待进一步考察。

总结

    作为MP手段,ast宏拥有灵活性,而TMP则具备安全性,将两者各自的优点相结合,使我们可以获得更加灵活和安全的语法构造定义手段。通过在用户定义的语法构造上施加全面的约束,可以很好地规避语法冲突和歧义。

    但是,需要说明的是,这里所考察的仅仅局限在用户定义语法构造的冲突和歧义的消除上。GPL/MP要真正达到实用阶段,还需要面对更多问题。比如,由于存在用户定义的语法构造,语法分析阶段所面对的语法不是固态的,需要随时随地接受新语法,甚至重叠的语法(存在多个候选语法,不同的候选语法又会产生不同的下级语法),这就使语法分析大大复杂;语法模式匹配被推迟到语义分析阶段,此前将无法对某些语法错误作出检验;一个语法构造的语义需要通过宿主语言定义,如何衔接定义代码和周边的环境和状态;如何为用户定义的语法构造设置出错信息;由于某些语法构造的二义性,如何判别语法错误属于哪个语法构造;...。此外还有一些更本质性的问题,诸如语法构造的重载和二义性是否会更容易诱使使用者产生更多的错误等等,牵涉到错综复杂的问题,需要更多的分析和试验。

    另外,作为MP的重要组成部分,编译期计算能力也至关重要。TMP运用了C++模板特化,D语言通过更易于理解的static_if等机制,都试图获得编译期的计算能力,这些机制在完整的MP中需要进一步扩展,而并非仅仅局限在与类型相关的计算上。其他一些与此相关的特性,包括反射(编译期和运行期)、类型traits等,也应作为MP必不可少的特性。

posted on 2008-01-25 15:09 longshanks 阅读(1320) 评论(4)  编辑 收藏 引用

Feedback

# re: 当GPL遇上MP 2008-01-27 15:20 Magic
原来GPL还有这个意思,学习了!  回复  更多评论
  

# re: 当GPL遇上MP 2008-01-27 17:01 WindyWinter
标题党  回复  更多评论
  

# re: 当GPL遇上MP 2008-01-30 09:55 K120
MP 太过分了吧。  回复  更多评论
  

# re: 当GPL遇上MP 2008-03-09 16:49 DK_jims
现在喜欢kiss原则,,,,喜欢笨的  回复  更多评论
  


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理