八、为自定义语言构建解析器和解释器

可扩展性和适应性通常是企业应用程序所必需的特性。通常,用户在运行时更改应用程序的行为和业务规则是有用的、实用的,甚至是实际的功能需求。例如,设想一个电子商务应用程序,其中销售代表可以自行配置业务规则;例如,当系统应为购买提供免费送货,或在满足某些特殊条件时应应用一定的折扣(当购买金额超过 150 欧元,且客户过去已购买两次或两次以上,或已成为客户超过一年时,提供免费送货)。

根据经验,这样的规则往往会变得非常复杂(如果客户是男性,年龄超过 35 岁,有两个孩子和一只名叫“胡须先生”的猫,并在晴朗的满月之夜下订单,则会提供折扣),而且可能会频繁变化。因此,作为一名开发人员,您可能真的很乐意为用户提供一种为自己配置此类规则的可能性,而不必在每次这些规则中的一个发生更改时更新、测试和重新部署应用程序。这样的特性称为最终用户开发,通常使用领域特定语言实现。

特定于域的语言是为一个特定的应用程序域定制的语言(与通用语言相比,如 C、Java 或您猜到的 PHP)。在本章中,我们将为一种小型表达式语言构建自己的解析器,该语言可用于在企业应用程序中配置业务规则。

为此,我们需要重述解析器是如何工作的,以及如何使用形式语法描述形式语言。

口译员和编译器如何工作

解释器和编译器读取用编程语言编制的程序。它们要么直接执行它们(解释器),要么首先将它们转换为机器语言或另一种编程语言(编译器)。解释器和编译器通常都有两个组件,分别称为lexer解析器

How interpreters and compilers work

这是编译器或解释器的基本架构

解释器可以省略代码生成,直接运行解析后的程序,而无需专门的编译步骤。

lexer(也称为扫描器标记器)将输入程序分解为其可能的最小部分,即所谓的标记。每个令牌由一个令牌类(例如,数值或变量标识符)和实际令牌内容组成。例如,给定输入字符串2 + (3 * a)的计算器 lexer 可能会生成以下令牌列表(每个令牌都有一个令牌类和值):

  1. 编号(“2”)
  2. 加法运算符(“+”)
  3. 开口支架(“(”)
  4. 编号(“3”)
  5. 乘法运算符(“*”)
  6. 变量标识符(“a”)
  7. 关闭括号(“)”)

在下一步中,解析器获取令牌流,并尝试从该流导出实际的程序结构。为此,解析器需要使用一组描述输入语言的规则(语法)进行编程。在许多情况下,解析器生成一个数据结构,该结构表示结构化树中的输入程序;所谓的语法树。例如,输入字符串2 + (3 * a)生成以下语法树:

How interpreters and compilers work

可从表达式 2+(3*a)生成的抽象语法树(AST)

请注意,有些程序将通过词法分析,但在接下来的步骤中,解析器会将它们识别为语法错误。例如,名为2 + ( 1的输入字符串将通过 lexer(并生成一个令牌列表,如{Number(2), Addition Operator, Opening bracket, Number(1)}),但它显然在语法上是错误的,因为如果没有匹配的结束括号,开始括号就没有任何意义(假设解析器使用公认的数学表达式语法;在其他语法中,2+(1实际上可能是语法上有效的表达式)

语言和语法

为了让解析器能够理解一个程序,它需要对该语言的正式描述——语法。在本章中,我们将使用所谓的解析表达式语法PEG)。PEG(相对)易于定义,并且有一些库可以自动为给定语法生成解析器。

语法由终端符号非终端符号组成。非终端符号是可能由若干其他符号组成的符号,遵循某些规则(产生式规则。例如,语法可以包含一个数字作为非终端符号。每个数字都可以定义为任意长度的数字序列。因此,数字可以是 0 到 9 之间的任何字符(每个实际数字都是终端符号)。

让我们试着正式地描述数字的结构(然后建立在这个数学表达式的基础上)。让我们从描述一个数字的样子开始。每个数字由一个或多个数字组成,因此让我们从描述数字和数字开始:

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
Number: Digit+ 

在本例中,数字是我们的第一个非终端符号。我们语法的第一条规则规定,0 到 9 的任何字符都是数字。在此示例中,字符“0”到“9”是端子符号,是最小的可能构建块。

提示

实际上,许多解析器生成器允许您使用正则表达式匹配终端符号。在前面的示例中,您不必枚举所有可能的数字,只需声明:Digit: /[0-9]/

我们语法的第二条规则规定Number(我们的第二个非终结符号)由一个或多个Digit符号组成+表示重复一次或多次。使用相同的方法,我们还可以扩展语法以支持十进制数:

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
Integer: Digit+Decimal: Digit* '.' Digit+Number: Decimal | Integer

在这里,我们引入了两个新的非终端符号:IntegerDecimalInteger只是一个数字序列,而Decimal可以以任意数量的数字开始(或者根本没有数字,这意味着像.12这样的值也是一个有效数字),然后是一个点,然后是一个或多个数字。与上面已经使用的+操作符(“重复一次或多次”)不同,*操作符的意思是“无或一次或多次”。Number的产生式规则现在规定一个数字可以是十进制数或整数。

提示

秩序在这里很重要;给定一个输入字符串3.14,整数规则将匹配该输入字符串的3,而十进制规则将匹配整个字符串。因此,在这种情况下,首先尝试将数字解析为十进制更安全,如果解析失败,则将数字解析为整数。

现在,这个语法只描述正数。但是,它可以很容易地修改为也支持负数:

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
Integer: '-'? Digit+ 
Decimal: '-'? Digit* '.' Digit+ 
Number: Decimal | Integer 

本例中使用的?字符表示符号是可选的。这意味着整数和十进制数字现在都可以选择以-字符开头。

我们现在可以继续为我们的语法定义更多的规则。例如,我们可以添加一个描述乘法的新规则:

Product: Number ('*' Number)* 

由于除法与乘法基本上是相同的运算(并且具有相同的运算符优先级),我们可以使用相同的规则处理这两种情况:

Product: Number (('*'|'/') Number)* 

一旦在语法中添加了一个和的规则,就要考虑操作的顺序(先乘法,再加法)。让我们定义一个名为Sum的新规则(同样,用一条规则涵盖加法和减法):

Sum: Product (('+'|'-') Product)* 

乍一看,这似乎违反直觉。毕竟,一个总和实际上并不需要由两个乘积组成。然而,由于我们的Product规则使用*作为量词,它还将匹配单个数字,从而允许将5 + 4等表达式解析为Product + Product

为了使语法变得完整,我们仍然需要解析嵌套语句的能力。事实上,我们的语法能够解析像2 * 32 + 3这样的语句。即使是2 + 3 * 4也将被正确解析为2 + (3 * 4)(而不是(2 + 3) * 4。然而,像(2 + 3) * 4这样的语句不符合我们语法的任何规则。毕竟,Product规则规定产品是由*字符连接的任意数量的Number字符;由于括号内的总和与Number规则不匹配,Product规则也不匹配。为了解决这个问题,我们将引入两个新规则:

Expr: Sum 
Value: Number | '(' Expr ')' 

使用新的Value规则,我们可以调整Product规则以匹配常规数字或括号内的例外情况:

Product: Value ('*' Value)* 

在这里,您将找到描述数学表达式所需的完整语法。它还不支持任何类型的变量或逻辑语句,但它将是我们自己的解析器的合理起点,我们将在本章的其余部分构建:

Expr: Sum 
Sum: Product (('+' | '-') Product)* 
Product: Value (('*' | '/') Value)* 
Value: Number | '(' Expr ')' 
Number: (Decimal | Integer) 
Decimal: '-'? Digit* '.' Digit+ 
Integer: '-'? Digit+ 
Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 

你的第一个 PEG 解析器

从头开始构建标记器和解析器是一项非常繁琐的任务。幸运的是,存在许多库,您可以使用它们从某种形式的语法定义自动生成解析器。

在 PHP 中,您可以使用hafriedlander/php-peg库为任何形式语言的解析器生成 PHP 代码,这些形式语言可以由解析表达式语法描述。为此,创建一个新的项目目录,并创建一个包含以下内容的新composer.json文件:

{ 
  "name": "packt-php7/chp8-calculator", 
  "authors": [{ 
    "name": "Martin Helmich", 
    "email": "php7-book@martin-helmich.de" 
  }], 

  "require": { 
    "hafriedlander/php-peg": "dev-master" 
  }, 
  "autoload": { 
    "psr-4": { 
      "Packt\\Chp8\\DSL": "src/" 
    }, 
    "files": [ 
      "vendor/hafriedlander/php-peg/autoloader.php" 
    ] 
  } 
} 

请注意,hafriedlander/php-peg库不使用 PSR-0 或 PSR-4 自动加载器,而是提供自己的类加载器。因此,您不能使用 composer 的内置 PSR-0/4 类加载器,需要手动包含包的自动加载器。

与前几章类似,我们将使用Packt\Chp8\DSL作为基于src/目录的 PSR-4 类加载器的基本名称空间。这意味着名为Packt\Chp8\DSL\Foo\Bar的 PHP 类应该位于src/Foo/Bar.php文件中。

在使用 PHP PEG 时,您可以将解析器作为一个普通的 PHP 类编写,该类将语法包含在一种特殊的注释中。该类用作实际解析器生成器的输入文件,该生成器随后生成实际的解析器源代码。解析器输入文件的文件类型通常为.peg.inc。解析器类必须扩展hafriedlander\Peg\Parser\Basic类。

我们的解析器将具有Packt\Chp8\DSL\Parser\Parser类名。它将存储在src/Parser/Parser.peg.inc文件中:

namespace Packt\Chp8\DSL\Parser; 

use hafriedlander\Peg\Parser\Basic; 

class Parser extends Basic 
{ 
    /*!* ExpressionLanguage 

    <Insert grammar here> 

    */ 
} 

注意类中以/*!*字符开头的注释。这个特殊的注释块将由解析器生成器拾取,并且需要包含从中生成解析器的语法。

然后,您可以使用 PHP-PEG CLI 脚本构建实际的解析器(该解析器将存储在文件src/Parser/Parser.php中,composer 类加载器可以在该文件中拾取该解析器):

$ php -d pcre.jit=0 vendor/hafriedlander/php-peg/cli.php 
    src/Parser/Parser.peg.inc > src/Parser/Parser.php

提示

-d pcre.jit=0标志是修复 PEG 包中与 PHP7 相关的错误所必需的。禁用pcre.jit标志可能会影响程序的性能;但是,只有在生成解析器时才能禁用此标志。生成的解析器将不受pcre.jit标志的影响。

当前,解析器生成将失败并出现错误,因为解析器类尚未包含有效的语法。这很容易改变;在解析器输入文件中的特殊注释(以/*!*开头)中添加以下行:

/*!* ExpressionLanguage 

Digit: /[0-9]/ 
Integer: '-'? Digit+ 
Decimal: '-'? Digit* '.' Digit+ 
Number: Decimal | Integer 

*/ 

您会注意到,这正是我们在上一节中使用的匹配数字的示例语法。这意味着在重新构建解析器之后,您将拥有一个解析器,该解析器知道数字的外观并能够识别它们。诚然,这是不够的。但我们可以在此基础上再接再厉。

如前所示,通过运行cli.php脚本重建解析器,并在项目目录中创建名为test.php的测试脚本继续:

require_once 'vendor/autoload.php'; 

use \Packt\Chp8\DSL\Parser\Parser; 

$result1 = new (Parser('-143.25'))->match_Number(); 
$result2 = new (Parser('I am not a number'))->match_Number(); 

var_dump($result1); 
var_dump($result2); 

记住,Packt\Chp8\DSL\Parser\Parser类是根据您的Parser.peg.inc输入文件自动生成的。该类继承了hafriedlander\Peg\Parser\Basic类,该类还提供了构造函数。构造函数接受解析器应该解析的表达式。

对于语法中定义的每个非终端符号,解析器将包含一个名为match_[symbol name]()(例如,match_Number)的函数,该函数将根据给定规则匹配输入字符串。

在我们的示例中,$result1是针对有效数字的匹配结果(或者,通常是由解析器语法匹配的输入字符串),而$result2的输入字符串显然不是数字,不应该由语法匹配。让我们看看这个测试脚本的输出:

array(3) { 
  '_matchrule' => 
  string(6) "Number" 
  'name' => 
  string(6) "Number" 
  'text' => 
  string(7) "-143.25" 
} 
bool(false) 

如您所见,解析第一个输入字符串将返回一个数组,该数组包含匹配规则和该规则匹配的字符串。如果规则不匹配(例如在$result2中),match_*函数将始终返回false

让我们继续添加上一节中已经看到的其余规则。这将使我们的解析器不仅可以解析数字,还可以解析整个数学表达式:

/*!* ExpressionLanguage 

Digit: /[0-9]/ 
Integer: '-'? Digit+ 
Decimal: '-'? Digit* '.' Digit+ 
Number: Decimal | Integer 
Value: Number | '(' > Expr > ')' 
Product: Value (> ('*'|'/') > Value)* 
Sum: Product (> ('+'|'-') > Product)* 
Expr: Sum 

*/ 

请特别注意此代码示例中的>字符。这些是解析器生成器提供的一个特殊符号,它匹配任何长度的空白序列。在某些语法中,空格可能很重要,但在解析数学表达式时,您通常不关心是否有人输入了2+32 + 3

重建解析器并调整测试脚本以测试这些新规则:

var_dump((new Parser('-143.25'))->match_Expr()); 
var_dump((new Parser('12 + 3'))->match_Expr()); 
var_dump((new Parser('1 + 2 * 3'))->match_Expr()); 
var_dump((new Parser('(1 + 2) * 3'))->match_Expr()); 
var_dump((new Parser('(1 + 2)) * 3'))->match_Expr()); 

特别注意最后一行。显然,(1 + 2)) * 3表达式在语法上是错误的,因为它包含的结束括号比开始括号多。但是,此输入语句的match_Expr函数输出如下:

array(3) { 
  '_matchrule' => 
  string(4) "Expr" 
  'name' => 
  string(4) "Expr" 
  'text' => 
  string(7) "(1 + 2)" 
} 

如您所见,输入字符串仍然符合Expr规则,只是不符合整个字符串。字符串的第一部分(1 + 2)在语法上是正确的,并且符合Expr规则。在使用 PEG 解析器时,必须记住这一点。如果规则与整个输入字符串不匹配,解析器仍将尽可能多地匹配输入。作为这个解析器的用户,您可以决定部分匹配是否是一件好事(在我们的例子中,这可能会触发一个错误,因为部分匹配的表达式将导致非常奇怪的结果,对于用户来说无疑是非常令人惊讶的)。

求值表达式

到目前为止,我们只使用定制的 PEG 解析器检查输入字符串是否符合给定的语法(也就是说,我们可以告诉输入字符串是否包含有效的数学表达式)。下一个逻辑步骤是实际计算这些表达式(例如,确定'(1 + 2) * 3'计算为'9'

正如您已经看到的,每个match_*函数返回一个数组,其中包含有关匹配字符串的附加信息。在解析器中,您可以注册在匹配给定符号时将调用的自定义函数。让我们从简单的事情开始,尝试将语法匹配的数字转换为实际的 PHP 整数或浮点值。为此,首先修改语法中的IntegerDecimal规则,如下所示:

Integer: value:('-'? Digit+) 
 function value(array &$result, array $sub) { 
 $result['value'] = (int) $sub['text']; 
 } 

Double: value:('-'? Digit* '.' Digit+)
 function value(array &$result, array $sub) { 
 $result['value'] = (float) $sub['text']; 
 }

让我们看看这里发生了什么。在每个规则中,您可以指定规则内子模式的名称。例如,Integer规则中的模式Digit+获得了名为value的名称。一旦解析器找到与此模式匹配的字符串,它将使用Integer规则下提供的相同名称调用函数。将使用两个参数调用该函数:&$result参数将是实际match_Number函数稍后返回的数组。如您所见,参数作为引用传递,您可以在 value 函数中对其进行修改。$sub参数包含子模式的结果数组(在任何情况下,它都包含一个属性text,您可以从中访问匹配子模式的实际文本内容)。

在本例中,我们只需使用 PHP 的内置函数将文本中的数字转换为实际的intfloat变量。然而,这仅仅是因为我们的自定义语法和 PHP 同时以相同的方式表示数字,允许我们使用 PHP 解释器将这些值转换为实际的数值。

如果在某个规则中使用非终端符号,则无需显式指定子模式名称;您可以简单地将符号名用作函数名。这可以在Number规则中完成:

Number: Decimal | Integer 
 function Decimal(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function Integer(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 }

同样,$sub参数包含匹配子模式的结果数组。在本例中,这意味着您之前修改过的match_Decimalmatch_Integer函数返回的结果数组。

使用ProductSum规则,这将变得更加复杂。首先在Product规则的各个部分添加标签:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 

通过向规则中添加相应的规则函数继续:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 
 function left(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function right(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function operator(array &$result, array $sub) { 
 $result['operator'] = $sub['text']; 
 } 
 function operand(array &$result, array $sub) { 
 if ($sub['operator'] == '*') { 
 $result['value'] *= $sub['value']; 
 } else { 
 $result['value'] /= $sub['value']; 
 } 
 }

Sum规则可以分别修改:

Sum: left:Product (operand:(> operator:('+'|'-') > right:Product))* 
 function left(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function right(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function operator(array &$result, array $sub) { 
 $result['operator'] = $sub['text']; 
 } 
 function operand(array &$result, array $sub) { 
 if ($sub['operator'] == '+') { 
 $result['value'] += $sub['value']; 
 } else { 
 $result['value'] -= $sub['value']; 
 } 
 }

最后,您还需要修改ValueExpr规则:

Value: Number | '(' > Expr > ')' 
 function Number(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
 function Expr(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 } 
Expr: Sum 
 function Sum(array &$result, array $sub) { 
 $result['value'] = $sub['value']; 
 }

在解析器中使用这些新函数,它现在可以动态计算解析表达式(请注意,这里我们没有遵循传统的编译器体系结构,因为解析和执行不被视为单独的步骤,而是在同一过程中完成)。使用cli.php脚本重新构建解析器类,并调整测试脚本以测试某些表达式:

var_dump((new Parser('-143.25'))->match_Expr()['value']); 
var_dump((new Parser('12 + 3'))->match_Expr()['value']); 
var_dump((new Parser('1 + 2 * 3'))->match_Expr()['value']); 
var_dump((new Parser('(1 + 2) * 3'))->match_Expr()['value']); 

运行测试脚本将提供以下输出:

double(-143.25) 
int(15) 
int(7) 
int(9) 

构建抽象语法树

目前,我们的解析器解释输入代码并在同一过程中对其求值。大多数编者和口译员;但是,在实际运行程序之前创建一个中间数据结构:抽象语法树AST)。使用 AST 提供了一些有趣的可能性;例如,它为您的程序提供结构化表示,然后您可以对其进行分析。此外,您还可以使用 AST 并将其转换回基于文本的程序(可能是另一种语言)。

AST 是表示程序结构的树。构建基于 AST 的解析器的第一步是设计树的对象模型:需要哪些类以及它们以何种方式与其他类关联。下图显示了可用于描述数学表达式的对象模型初稿:

Building an Abstract Syntax Tree

抽象语法树的(初步)对象模型

在这个模型中,几乎所有类都实现了Expression接口。该接口规定了evaluate()方法,该方法可由该接口的实现提供,以实际执行由相应树节点建模的操作。让我们从实现Packt\Chp8\DSL\AST\Expression接口开始:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
    public function evaluate() 
} 

下一步是Number类及其两个子类:IntegerDecimal。因为我们将要使用 PHP7 的类型暗示特性,IntegerDecimal类都只处理intfloat变量;我们不能充分利用继承,迫使我们将Number类留空:

namespace Packt\Chp8\DSL\AST; 

abstract class Number implements Expression 
{} 

Integer类可以用 PHP 整数值初始化。由于该类对文本整数值进行建模;evaluate()方法在这个类中唯一需要做的就是再次返回这个值:

namespace Packt\Chp8\DSL\AST; 

class Integer extends Number 
{ 
    private $value; 

    public function __construct(int $value) 
    { 
        $this->value = $value; 
    } 

    public function evaluate(): int 
    { 
        return $this->value; 
    } 
} 

Decimal类也可以用同样的方法实现;在这种情况下,只需使用float而不是int作为类型提示:

namespace Packt\Chp8\DSL\AST; 

class Decimal extends Number 
{ 
    private $value; 

    public function __construct(float $value) 
    { 
        $this->value = $value; 
    } 

    public function evaluate(): float 
    { 
        return $this->value; 
    } 
} 

对于类AdditionSubtractionMultiplicationDivision,我们将使用一个公共基类Packt\Chp8\DSL\AST\BinaryOperation。该类将包含构造函数,您不必反复实现该构造函数:

namespace Packt\Chp8\DSL\AST; 

abstract class BinaryOperation implements Expression 
{ 
    protected $left; 
    protected $right; 

    public function __construct(Expression $left, Expression $right) 
    { 
        $this->left  = $left; 
        $this->right = $right; 
    } 
} 

实现对操作进行建模的实际类变得很容易。让我们考虑一个例子:

namespace Packt\Chp8\DSL\AST; 

class Addition extends BinaryOperation 
{ 
    public function evaluate() 
    { 
 return $this->left->evaluate() + $this->right->evaluate(); 
    } 
} 

其余名为SubtractionMultiplicationDivision的类可以用类似于Addition类的方式实现。为了简洁起见,这些类的实际实现留给您作为练习。

现在剩下的就是在解析器中实际构建 AST。这是相对容易的,因为我们现在可以简单地修改已经存在的钩子函数,这些钩子函数在匹配单个规则时由解析器调用。

让我们从解析数字的规则开始:

Integer: value:('-'? Digit+) 
    function value(array &$result, array $sub) { 
 $result['node'] = new Integer((int) $sub['text']); 
    } 

Decimal: value:('-'? Digit* '.' Digit+) 
    function value(array &$result, array $sub) { 
 $result['node']  = new Decimal((float) $sub['text']); 
    } 

Number: Decimal | Integer 
    function Decimal(&$result, $sub) { 
 $result['node']  = $sub['node']; 
    } 
    function Integer(&$result, $sub) { 
 $result['node']  = $sub['node']; 
    } 

IntegerDecimal规则匹配时,我们创建IntegerDecimal类的新 AST 节点,并将其保存在返回数组的 node 属性中。当Number规则匹配时,我们只需接管存储在匹配符号中的已创建节点。

我们可以用类似的方式调整Product规则:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 
    function left(array &$result, array $sub) { 
 $result['node']  = $sub['node']; 
    } 
    function right(array &$result, array $sub) { 
 $result['node']  = $sub['node']; 
    } 
    function operator(array &$result, array $sub) { 
 $result['operator'] = $sub['text']; 
    } 
    function operand(array &$result, array $sub) { 
        if ($sub['operator'] == '*') { 
 $result['node'] = new Multiplication($result['node'], $sub['node']); 
        } else { 
 $result['node'] = new Division($result['node'], $sub['node']); 
        } 
    } 

由于我们的 AST 模型将乘法等操作严格视为二进制操作,解析器将把输入表达式(如1 * 2 * 3 * 4等)解构为一个二进制乘法链(类似于1 * (2 * (3 * 4)),如下图所示):

Building an Abstract Syntax Tree

表达式 123*4 作为语法树

继续以相同的方式调整您的Sum规则:

Sum: left:Product (operand:(> operator:('+'|'-') > right:Product))* 
    function left(&$result, $sub) { 
 $result['node']  = $sub['node']; 
    } 
    function right(&$result, $sub) { 
 $result['node']  = $sub['node']; 
    } 
    function operator(&$result, $sub) { $result['operator'] = $sub['text']; } 
    function operand(&$result, $sub) { 
        if ($sub['operator'] == '+') { 
 $result['node'] = new Addition($result['node'], $sub['node']); 
        } else { 
 $result['node'] = new Subtraction($result['node'], $sub['node']); 
        } 
    } 

现在只剩下读取Value中创建的 AST 节点,Expr规则如下:

Value: Number | '(' > Expr > ')' 
    function Number(array &$result, array $sub) { 
 $result['node'] = $sub['node']; 
    } 

Expr: Sum 
    function Sum(array &$result, array $sub) { 
 $result['node'] = $sub['node']; 
    } 

在测试脚本中,您现在可以通过从match_Expr()函数的返回值中提取node属性来测试 AST 是否正确构建。然后,您可以通过在 AST 的根节点上调用evaluate()方法来获得表达式的结果:

$astRoot = (new Parser('1 + 2 * 3'))->match_Expr()['node']; 
var_dump($astRoot, $astRoot->evaluate()); 

$astRoot = (new Parser('(1 + 2) * 3'))->match_Expr()['node']; 
var_dump($astRoot, $astRoot->evaluate()); 

请注意,此测试脚本中的两个表达式应生成两个不同的语法树(均如下图所示),并分别计算为 7 和 9。

Building an Abstract Syntax Tree

解析 1+2'和(1+2)'表达式产生的两个语法树

打造更好的界面

现在,我们构建的解析器并不容易使用。为了正确使用解析器,用户(在本文中,将“user”理解为“使用您的解析器的另一个开发人员”)必须调用match_Expr()方法(这只是解析器提供的许多公共match_*函数中的一个,这些函数实际上不应该由外部用户调用),提取node属性,然后在此属性中包含的根节点上调用求值函数。此外,解析器还匹配部分字符串(还记得我们的解析器识别为部分正确的示例(1 + 2)) * 3),这可能会让一些用户感到惊讶。

这个理由足以通过封装这些怪癖的新类扩展我们的项目,并为我们的解析器提供更干净的接口。让我们创建一个新类,Packt\Chp8\DSL\ExpressionBuilder

namespace Packt\Chp8\DSL\ExpressionBuilder; 

use Packt\Chp8\DSL\AST\Expression; 
use Packt\Chp8\DSL\Exception\ParsingException; 
use Packt\Chp8\DSL\Parser\Parser; 

class ExpressionBuilder 
{ 
    public function parseExpression(string $expr): Expression 
    { 
        $parser = new Parser($expr); 
        $result = $parser->match_Expr(); 

        if ($result === false || $result['text'] !== $expr) { 
            throw new ParsingException(); 
        } 

        return $result['node']; 
    } 
} 

在本例中,我们通过断言解析器返回的匹配字符串实际上等于输入字符串(而不仅仅是子字符串),来检查是否可以解析整个字符串。如果是这种情况(或者表达式根本无法解析,结果为 false),则抛出一个Packt\Chp8\DSL\Exception\ParsingException实例。此异常类尚未定义;目前,它只需继承基本异常类,不需要包含任何自定义逻辑:

namespace Packt\Chp8\DSL\Exception; 

class ParsingException extends \Exception 
{} 

新的ExpressionBuilder类现在为您提供了一种更简洁的解析和计算表达式的方法。例如,您现在可以在test.php脚本中使用以下构造:

$builder = new \Packt\Chp8\DSL\ExpressionBuilder; 

var_dump($builder->parseExpression('12 + 3')->evaluate()); 

评估变量

到目前为止,我们的解析器可以对静态表达式进行求值,从简单表达式(如3)(其求值结果令人惊讶)到任意复杂表达式(如(5 + 3.14) * (14 + (29 - 2 * 3.918)))(顺便说一下,其求值结果为 286.23496)。然而,所有这些表达都是静态的;他们的评估结果总是一样的。

为了使其更具动态性,我们现在将扩展语法以允许变量。带有变量的表达式的一个示例是3 + a,然后可以使用a的不同值对其进行多次计算。

这次,让我们从修改语法树的对象模型开始。首先,我们需要一个新的节点类型Packt\Chp8\DSL\AST\Variable,例如允许3 + a表达式生成以下语法树:

Evaluating variables

从表达式 3+a 生成的语法树

还有第二个问题:与使用节点的Number节点或算术运算相反,我们不能简单地计算变量节点的数值(毕竟,它可以有任何值——这就是变量的点)。因此,在计算表达式时,我们还需要传递有关哪些变量存在以及它们具有哪些值的信息。为此,我们只需通过一个附加参数扩展Packt\Chp8\DSL\AST\Expression接口中定义的evaluate()函数:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
 public function evaluate(array $variables = []); 
} 

更改接口定义需要更改实现此接口的所有类。在Number子类(IntegerDecimal中),您可以添加新参数并忽略它。静态数字的值根本不依赖于任何变量的值。下面的代码示例显示了在Packt\Chp8\DSL\AST\Integer类中的这种更改,但它还记得以相同的方式更改Decimal类:

class Integer 
{ 
    // ... 
 public function evaluate(array $variables = []): int 
    { 
        return $this->value; 
    } 
} 

BinaryOperation子类(AdditionSubtractionMultiplicationDivision中,定义变量的值也并不重要。但我们需要将它们传递给这些节点的子节点。下面的示例显示了在Packt\Chp8\DSL\AST\Addition类中的这种更改,但它还记得更改SubtractionMultiplicationDivision类:

class Addition 
{ 
 public function evaluate(array $variables = []) 
    { 
 return $this->left->evaluate($variables) 
 + $this->right->evaluate($variables); 
    } 
} 

最后,我们现在可以声明我们的Packt\Chp8\DSL\AST\Variable类:

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UndefinedVariableException; 

class Variable implements Expression 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        $this->name = $name; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UndefinedVariableException($this->name); 
    } 
} 

在这个类“evaluate()方法中,您可以查找这个变量当前拥有的实际值。如果未定义变量(读取:$variables参数中不存在),我们将引发(尚未实现)Packt\Chp8\DSL\Exception\UndefinedVariableException的实例,让用户知道有问题。

提示

如何在自定义语言中处理未定义的变量完全取决于您自己。除了触发错误,您还可以更改Variable类的evaluate()方法,以便在计算未定义变量时返回默认值,例如 0(或任何其他值)。然而,使用未定义的变量可能是无意的,简单地继续使用默认值可能会让您的用户感到非常惊讶。

UndefinedVariableException类可以简单地扩展常规Exception类:

namespace Packt\Chp8\DSL\Exception; 

class UndefinedVariableException extends \Exception 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        parent::__construct('Undefined variable: ' . $name); 
        $this->name = $name; 
    } 
} 

最后,我们需要调整解析器的语法以实际识别表达式中的变量。为此,我们的语法需要两个额外的符号:

Name: /[a-zA-z]+/ 
Variable: Name 
    function Name(&$result, $sub) { 
        $result['node'] = new Variable($sub['name']); 
    } 

接下来,您需要扩展Value规则。目前,Value可以是Number符号,也可以是用大括号包裹的Expr。现在,您还需要允许变量:

Value: Number | Variable | '(' > Expr > ')' 
    function Number(array &$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 
 function Variable(array &$result, $sub) { 
 $result['node'] = $sub['node']; 
 } 
    function Expr(array &$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 

使用 PHP-PEG 的cli.php脚本重建解析器,并向test.php脚本添加一些调用以测试此新功能:

$expr = $builder->parseExpression('1 + 2 * a'); 
var_dump($expr->evaluate(['a' => 1])); 
var_dump($expr->evaluate(['a' => 14])); 
var_dump($expr->evaluate(['a' => -1])); 

这些应分别评估为 3、29 和-1。您还可以尝试在不传递任何变量的情况下对表达式求值,这将(理所当然地)导致抛出一个UndefinedVariableException

添加逻辑表达式

目前,我们的语言只支持数字表达式。另一个有用的补充是支持布尔表达式,这些表达式的计算结果不是数值,而是truefalse。可能的示例包括如下表达式:3 = 4(总是计算为false)、2 < 4(总是计算为true)或a <= 5(取决于变量a的值)。

比较

与前面一样,让我们从扩展语法树的对象模型开始。我们将从一个表示两个表达式之间相等检查的Equals节点开始。使用此节点,1 + 2 = 4 - 1表达式将生成以下语法树(当然最终应计算为true

Comparisons

解析 1+2=4-1 表达式时应产生的语法树

为此,我们将实现Packt\Chp8\DSL\AST\Equals类。这个类可以继承我们之前实现的BinaryOperation类:

namespace Packt\Chp8\DSL\AST; 

class Equals extends BinaryOperation 
{ 
    public function evaluate(array $variables = []) 
    { 
        return $this->left->evaluate($variables) 
            == $this->right->evaluate($variables); 
    } 
} 

我们在做的同时,也可以同时实现NotEquals节点:

namespace Packt\Chp8\DSL\AST; 

class NotEquals extends BinaryOperation 
{ 
    public function evaluate(array $variables = []) 
    { 
 return $this->left->evaluate($variables) 
 != $this->right->evaluate($variables); 
    } 
} 

在下一步中,我们需要调整解析器的语法。首先,我们需要修改语法来区分数值表达式和布尔表达式。为此,我们将在整个语法中将Expr符号重命名为NumExpr。这会影响Value符号:

Value: Number | Variable | '(' > NumExpr > ')' 
    function Number(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function Variable(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
 function NumExpr(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

当然,您还需要更改Expr规则本身:

NumExpr: Sum 
    function Sum(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

接下来,我们可以定义平等(以及非平等)规则:

ComparisonOperator: '=' | '|=' 
Comparison: left:NumExpr (operand:(> op:ComparisonOperator > right:NumExpr)) 
    function left(&$result, $sub) { 
        $result['leftNode'] = $sub['node']; 
    } 
    function right(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function op(array &$result, array $sub) { 
        $result['op'] = $sub['text']; 
    } 
    function operand(&$result, $sub) { 
        if ($sub['op'] == '=') { 
            $result['node'] = new Equals($result['leftNode'], $sub['node']); 
        } else { 
            $result['node'] = new NotEquals($result['leftNode'], $sub['node']); 
        } 
    } 

请注意,在这种情况下,此规则变得更为复杂,因为它支持多个运算符。然而,这些规则现在相对容易被更多的运算符扩展(当我们检查非相等项时,“大于”或“小于”可能是下一个逻辑步骤)。首先定义的ComparisonOperator符号匹配各种比较运算符和使用此符号匹配实际表达式的Comparison规则。

最后,我们可以添加一个新的BoolExpr符号,也可以重新定义Expr符号:

BoolExpr: Comparison 
    function Comparison(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

Expr: BoolExpr | NumExpr 
    function BoolExpr(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function NumExpr(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

调用match_Expr()函数时,我们的解析器现在将匹配数值表达式和布尔表达式。使用 PHP-PEG 的cli.php脚本重建解析器,并向test.php脚本添加一些新调用:

$expr = $builder->parseExpression('1 = 2'); 
var_dump($expr->evaluate()); 

$expr = $builder->parseExpression('a * 2 = 6'); 
var_dump($expr->evaluate(['a' => 3]); 
var_dump($expr->evaluate(['a' => 4]); 

这些表达式应分别计算为。您以前添加的数值表达式应继续像以前一样工作。

与此类似,您现在可以在语法中添加其他比较运算符,例如>>=<<=。由于这些操作符的实现与=|=操作基本相同,我们将留给您作为练习。

and 和 or 运算符

为了完全支持逻辑表达式,另一个重要特性是能够通过“and”和“or”运算符组合逻辑表达式。在我们开发语言时,我们考虑的是最终用户,因此我们将构建我们的语言,以实际支持andor作为逻辑运算符(与许多通用编程语言中普遍存在的&&||形成对比,这些语言是从 C 语法派生的)。

同样,让我们从实现语法树的各个节点类型开始。我们需要为andor操作建模的节点类型,以便将a = 1b = 2等语句解析为以下语法树:

The "and" and "or" operators

解析 a=1 或 b=2 时生成的语法树

首先实现Packt\Chp8\DSL\AST\LogicalAnd类(我们不能使用作为类名,因为这是 PHP 中的保留字):

namespace Packt\Chp8\DSL\AST; 

class LogicalAnd extends BinaryOperation 
{ 
    public function evaluate(array $variables=[]) 
    { 
        return $this->left->evaluate($variables) 
            && $this->right->evaluate($variables); 
    } 
} 

对于or操作符,也可以用同样的方法实现Packt\Chp8\DSL\AST\LogicalOr类。

在使用andor运算符时,需要考虑运算符优先级。虽然算术运算的运算符优先级定义良好,但逻辑运算符的情况并非如此。例如,语句a and b or c and d可以解释为(((a and b) or c) and d)(相同优先级,从左到右),也可以解释为(a and b) or (c and d)(优先级在and上)或(a and (b or c)) and d(优先级在or上)。然而,大多数编程语言都以最高优先级处理and运算符,因此除非有任何其他约定,否则坚持这一传统是有意义的。

下图显示了在a=1 and b=2 or b=3a=1 and (b=2 or b=3)语句上应用此优先级所产生的语法树:

The "and" and "or" operators

解析 a=1 和 b=2 或 b=3 以及 a=1 和(b=2 或 b=3)所产生的语法树

为此,我们需要一些新的语法规则。首先,我们需要一个表示布尔值的新符号。目前,这样的布尔值可以是比较值,也可以是任何用括号括起来的布尔表达式。

BoolValue: Comparison | '(' > BoolExpr > ')' 
    function Comparison(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function BoolExpr(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 

您还记得我们以前是如何使用ProductSum规则实现运算符优先级的吗?我们可以用同样的方式执行AndOr规则:

And: left:BoolValue (> "and" > right:BoolValue)* 
    function left(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function right(array &$res, array $sub) { 
        $res['node'] = new LogicalAnd($res['node'], $sub['node']); 
    } 

Or: left:And (> "or" > right:And)* 
    function left(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function right(array &$res, array $sub) { 
        $res['node'] = new LogicalOr($res['node'], $sub['node']); 
    } 

在此之后,我们可以扩展BoolExpr规则来匹配Or表达式(因为单个And符号也匹配Or规则,所以单个And符号也将是BoolExpr

BoolExpr: Or | Comparison 
 function Or(array &$result, array $sub) { 
 $result['node'] = $sub['node']; 
 } 
    function Comparison(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

您现在可以向test.php脚本添加一些新的测试用例。使用变量并特别注意如何解析运算符优先级:

$expr = $builder->parseExpression('a=1 or b=2 and c=3'); 
var_dump($expr->evaluate([ 
    'a' => 0, 
    'b' => 2, 
    'c' => 3 
]); 

条件

既然我们的语言支持(任意复杂的)逻辑表达式,我们就可以使用它们来实现另一个重要特性:条件语句。我们的语言目前只支持计算为单个数值或布尔值的表达式;现在,我们将实现三元运算符的一个变体,这在 PHP 中也是众所周知的:

($b > 0) ? 1 : 2; 

由于我们的语言面向最终用户,因此我们将使用更具可读性的语法,这将允许使用诸如when <condition> then <value> else <value>之类的语句。在我们的语法树中,这样的构造将由Packt\Chp8\DSL\AST\Condition类表示:

<?php 
namespace Packt\Chp8\DSL\AST; 

class Condition implements Expression 
{ 
    private $when; 
    private $then; 
    private $else; 

    public function __construct(Expression $when, Expression $then, Expression $else) 
    { 
        $this->when = $when; 
        $this->then = $then; 
        $this->else = $else; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if ($this->when->evaluate($variables)) { 
            return $this->then->evaluate($variables); 
        } 
        return $this->else->evaluate($variables); 
    } 
} 

这意味着,例如,when a > 2 then a * 1.5 else a * 2表达式应解析为以下语法树:

Conditions

理论上,我们的语言还应该支持条件或 then/else 部分中的复杂表达式,允许使用诸如when (a > 2 or b = 2) then (2 * a + 3 * b) else (3 * a - b)之类的语句,甚至可以使用诸如when a=2 then (when b=2 then 1 else 2) else 3之类的嵌套语句:

Conditions

通过向解析器的语法中添加新符号和规则继续:

Condition: "when" > when:BoolExpr > "then" > then:Expr > "else" > else:Expr 
    function when(array &$res, array $sub) { 
        $res['when'] = $sub['node']; 
    } 
    function then(array &$res, $sub) { 
        $res['then'] = $sub['node']; 
    } 
    function else(array &$res, array $sub) { 
        $res['node'] = new Condition($res['when'], $res['then'], $sub['node']); 
    } 

另外,调整BoolExpr规则,使其也符合条件。在这种情况下,顺序很重要:如果在BoolExpr规则中首先放置OrComparison符号,则该规则可能会将 when 解释为变量名,而不是条件表达式。

BoolExpr: Condition | Or | Comparison 
 function Condition(array &$result, array $sub) { 
 $result['node'] = $sub['node']; 
 } 
    function Or(&$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function Comparison(&$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 

同样,使用 PHP-PEG 的cli.PHP脚本重建解析器,并向测试脚本添加一些测试语句以测试新的语法规则:

$expr = $builder->parseExpression('when a=1 then 3.14 else a*2'); 
var_dump($expr->evaluate(['a' => 1]); 
var_dump($expr->evaluate(['a' => 2]); 
var_dump($expr->evaluate(['a' => 3]); 

这些测试用例应分别评估为 3.14、4 和 6。

处理结构化数据

到目前为止,我们的自定义表达式语言只支持非常简单的变量数字和布尔值。然而,在实际应用中,这通常不是那么简单。当使用表达式语言提供可编程的业务规则时,您通常会处理结构化数据。例如,考虑一个电子商务系统,其中后台办公室用户有可能定义在什么条件下应该向用户提供折扣,以及应该购买多少折扣(下面的图显示了这样的特征在应用中实际上看起来如何)的一个假设性例子。

通常,您事先不知道用户将如何使用此功能。仅使用数值变量,在计算表达式时必须传递一整套变量,否则用户可能会使用其中的一个或两个。或者,您可以将整个域对象(例如,一个代表购物车的 PHP 对象和另一个代表客户的 PHP 对象)作为变量传递到表达式中,并为用户提供访问这些对象的属性或调用方法的选项。

这样的特性允许用户在表达式中使用诸如cart.value之类的表达式。在计算此表达式时,可以将其转换为直接属性访问(如果$cart变量确实具有可公开访问的$value属性),或者调用getValue()方法:

Working with structured data

结构化数据如何在企业电子商务应用程序中用作变量的示例

为此,我们需要稍微修改一下 AST 对象模型。我们将引入一种新的节点类型,Packt\Chp8\DSL\AST\PropertyFetch,它对从变量获取的命名属性进行建模。然而,我们需要考虑这些属性获取需要链接,例如,在表达式中,如 OutT1。应将此表达式解析为以下语法树:

Working with structured data

为此,我们将重新定义之前添加的Variable节点类型。将Variable类重命名为NamedVariable并添加一个名为Variable的新接口。该接口可以由NamedVariable类和PropertyFetch类实现。然后,PropertyFetch类可以接受Variable实例作为其左操作符。

首先将Packt\Chp8\DSL\AST\Variable类重命名为Packt\Chp8\DSL\AST\NamedVariable

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UnknownVariableException; 

class NamedVariable implements Variable 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        $this->name = $name; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UnknownVariableException(); 
    } 
} 

然后,添加名为Packt\Chp8\DSL\AST\Variable的新接口。它不需要包含任何代码;我们仅将其用于类型提示:

namespace Packt\Chp8\DSL\AST; 

interface Variable extends Expression 
{ 
} 

继续添加Packt\Chp8\DSL\AST\PropertyFetch新类:

namespace Packt\Chp8\DSL\AST; 

class PropertyFetch implements Variable 
{ 
    private $left; 
    private $property; 

    public function __construct(Variable $left, string $property) 
    { 
        $this->left = $left; 
        $this->property = $property; 
    } 

    public function evaluate(array $variables = []) 
    { 
        $var = $this->left->evaluate($variables); 
        return $var[$this->property] ?? null; 
    } 
} 

最后,修改解析器语法中的Variable规则:

Variable: Name ('.' property:Name)* 
    function Name(array &$result, array $sub) { 
        $result['node'] = new NamedVariable($sub['text']); 
    } 
 function property(&$result, $sub) { 
 $result['node'] = new PropertyFetch($result['node'], $sub['text']); 
 }

使用此规则,Variable符号可以由多个与.字符链接在一起的属性名称组成。然后,规则函数将为第一个属性名构建一个NamedVariable节点,然后将该节点放入PropertyFetch节点链中,以获得后续属性。

像往常一样,重建解析器并向测试脚本添加几行:

$e = $builder->parseExpression('foo.bar * 2'); 
var_dump($e->evaluate(['foo' => ['bar' => 2]])); 

处理对象

让最终用户掌握数据结构的概念绝非易事。虽然对象具有属性(例如,客户有名字和姓氏)的概念通常很容易传达,但您可能不会用数据封装和对象方法之类的东西来打扰最终用户。

因此,向最终用户隐藏复杂的数据访问可能是有用的;如果用户想要访问客户的名字,他们应该能够写入customer.firstname,即使底层对象的实际属性受到保护,您通常需要调用getFirstname()方法来读取此属性。由于 getter 函数通常遵循某些命名模式,因此我们的解析器可以自动将表达式(如customer.firstname)转换为方法调用(如$customer->getFirstname())。

为了实现此功能,我们需要通过几个特殊情况扩展PropertyFetchevaluate方法:

public function evaluate(array $variables = []) 
{ 
    $var = $this->left->evaluate($variables); 
 if (is_object($var)) { 
 $getterMethodName = 'get' . ucfirst($this->property); 
 if (is_callable([$var, $getterMethodName])) { 
 return $var->{$getterMethodName}(); 
 }
 $isMethodName = 'is' . ucfirst($this->property); 
 if (is_callable([$var, $isMethodName])) { 
 return $var->{$isMethodName}(); 
 } 
 return $var->{$this->property} ?? null; 
 } return $var[$this->property] ?? null; 
} 

使用此实现时,诸如customer.firstname之类的表达式将首先检查 customer 对象是否实现了可调用的getFirstname()方法。如果不是这样,解释器将检查isFirstname()方法(在这种情况下没有意义,但可以用作 getter 函数,因为布尔属性通常被命名为isSomething而不是getSomething。如果不存在isFirstname()方法,解释器将查找名为firstname的可访问属性,然后作为最后手段,只返回 null。

添加编译器优化解释器

我们的解析器现在可以正常工作,您可以在任何类型的应用程序中使用它,为最终用户提供非常灵活的定制选项。但是,解析器的工作效率不是很高。一般来说,解析表达式的计算代价很高,在大多数用例中,可以合理地假设您正在处理的实际表达式不会随每个请求而更改(或者至少,计算的频率高于更改的频率)。

因此,我们可以通过向解释器添加缓存层来优化解析器的性能。当然,我们不能缓存表达式的实际计算结果;毕竟,当用不同的变量来解释它们时,它们可能会发生变化。

在本节中,我们将要做的是向解析器添加一个编译器特性。对于每个解析的表达式,我们的解析器生成一个表示该表达式结构的 AST。现在可以使用此语法树将表达式转换为任何其他编程语言,例如 PHP。

考虑表达式 T0。此表达式生成以下语法树:

Optimizing the interpreter by adding a compiler

在我们的 AST 模型中,它对应于Packt\Chp8\DSL\AST\Addition类的一个实例,持有对Packt\Chp8\DSL\AST\Number类和Packt\Chp8\DSL\AST\Product类实例的引用(以此类推)。

我们无法实现编译器功能将这些表达式转换回 PHP 代码(毕竟,PHP 也支持简单的算术运算),这可能如下所示:

use Packt\Chp8\DSL\AST\Expression; 

$cachedExpr = new class implements Expression 
{ 
    public function evaluate(array $variables=[]) 
    { 
        return 2 + (3 * $variables['a']); 
    } 
} 

以这种方式生成的 PHP 代码可以保存在文件中,以便以后查找。如果解析器得到一个已经缓存的表达式,它可以简单地加载保存的 PHP 文件,以便不再实际解析表达式。

要实现此功能,我们需要能够将语法树中的每个节点转换为相应的 PHP 表达式。为此,我们先用一种新方法扩展我们的Packt\Chp8\DSL\AST\Expression接口:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
    public function evaluate(array $variables = []); 

 public function compile(): string; 
} 

这种方法的缺点是,现在需要为实现此接口的每个类实现此方法。让我们从简单的事情开始:Packt\Chp8\DSL\AST\Number类。由于每个Number实现的计算结果总是相同的数字(3 总是计算结果是 3,从不计算结果是 4),我们可以简单地返回数值:

namespace Packt\Chp8\DSL\AST; 

abstract class Number implements Expression 
{ 
 public function compile(): string 
 { 
 return var_export($this->evaluate(), true); 
 } 
} 

至于其余的节点类型,我们需要返回 PHP 中每个表达式类型的实现的方法。例如,对于Packt\Chp8\DSL\AST\Addition类,我们可以添加以下compile()方法:

namespace Packt\Chp8\DSL\AST; 

class Addition extends BinaryOperation 
{ 
    // ... 

 public function compile(): string 
 { 
 return '(' . $this->left->compile() . ') + (' . $this->right->compile() . ')'; 
 } 
} 

对于剩余的算术运算:SubtractionMultiplicationDivision以及EqualsNotEqualsAndOr等逻辑运算,同样进行。

对于Condition类,可以使用 PHP 的三元运算符:

namespace Packt\Chp8\DSL\AST; 

class Condition implements Expression 
{ 
    // ... 

 public function compile(): string 
 { 
 return sprintf('%s ? (%s) : (%s)',
             $this->when->compile(), 
 $this->then->compile(), 
 $this->else->compile() 
 ); 
 } 
} 

NamedVariable等级难以调整;类“evaluate()方法当前在引用不存在的变量时抛出UnknownVariableException。但是,我们的compile()方法需要返回一个 PHP 表达式。查找值和引发异常不能在单个表达式中完成。幸运的是,您可以实例化类并对其调用方法:

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UnknownVariableException; 

class NamedVariable implements Variable 
{ 
    // ... 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UnknownVariableException(); 
    } 

    public function compile(): string 
    { 
        return sprintf('(new %s(%s))->evaluate($variables)', 
            __CLASS__, 
            var_export($this->name, true) 
        ); 
    } 
} 

使用此解决方法,a * 3表达式将编译为以下 PHP 代码:

(new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables) * 3 

这就离开了PropertyFetch课程。您可能还记得,这个类比其他节点类型要复杂一些,因为它在如何从对象查找属性方面实现了许多不同的意外情况。理论上,这种逻辑可以使用三元运算符在单个表达式中实现。这将导致foo.bar表达式被编译为以下怪物:

is_object((new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables)) ? ((is_callable([(new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 'getBar']) ? (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)->getBar() : ((is_callable([(new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 'isBar']) ? (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)->isBar() : (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)['bar'] ?? null)) : (new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables)['bar'] 

为了防止编译后的代码变得过于复杂,可以稍微重构一下PropertyFetch类。您可以在静态方法中提取实际的属性查找方法,该方法可以从evaluate()方法和compiled代码中调用:

<?php 
namespace Packt\Chp8\DSL\AST; 

class PropertyFetch implements Variable 
{ 
    private $left; 
    private $property; 

    public function __construct(Variable $left, string $property) 
    { 
        $this->left = $left; 
        $this->property = $property; 
    } 

    public function evaluate(array $variables = []) 
    { 
        $var = $this->left->evaluate($variables); 
 return static::evaluateStatic($var, $this->property); 
    } 

 public static function evaluateStatic($var, string $property) 
 { 
 if (is_object($var)) { 
 $getterMethodName = 'get' . ucfirst($property); 
 if (is_callable([$var, $getterMethodName])) { 
 return $var->{$getterMethodName}(); 
 } 
 $isMethodName = 'is' . ucfirst($property); 
 if (is_callable([$var, $isMethodName])) { 
 return $var->{$isMethodName}(); 
 } 
 return $var->{$property} ?? null; 
 } 
 return $var[$property] ?? null; 
 } 
 public function compile(): string 
 { 
 return __CLASS__ . '::evaluateStatic(' . $this->left->compile() . ', ' . var_export($this->property, true) . ')'; 
 } 
} 

这样,foo.bar表达式将简单地计算为:

\Packt\Chp8\DSL\AST\PropertyFetch::evaluateStatic( 
    (new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 
    'bar' 
) 

在下一步中,我们可以为前面引入的ExpressionBuilder类添加一个替代类,该类透明地编译表达式,将它们保存在缓存中,并在必要时重用编译后的版本。

我们将此类称为Packt\Chp8\DSL\CompilingExpressionBuilder

<?php 
namespace Packt\Chp8\DSL; 

class CompilingExpressionBuilder 
{ 
    /** @var string */ 
    private $cacheDir; 
    /** 
     * @var ExpressionBuilder 
     */ 
    private $inner; 

    public function __construct(ExpressionBuilder $inner, string $cacheDir) 
    { 
        $this->cacheDir = $cacheDir; 
        $this->inner = $inner; 
    } 
} 

由于我们不想重新实现ExpressionBuilder's解析逻辑,这个类将ExpressionBuilder的一个实例作为依赖项。当解析缓存中尚未存在的新表达式时,将使用此内部表达式生成器实际解析此表达式。

让我们继续向此类添加一个parseExpression方法:

public function parseExpression(string $expr): Expression 
{ 
    $cacheKey = sha1($expr); 
    $cacheFile = $this->cacheDir . '/' . $cacheKey . '.php'; 
    if (file_exists($cacheFile)) { 
        return include($cacheFile); 
    } 

    $expr = $this->inner->parseExpression($expr); 

    if (!is_dir($this->cacheDir)) { 
        mkdir($this->cacheDir, 0755, true); 
    } 

    file_put_contents($cacheFile, '<?php return new class implements '.Expression::class.' { 
        public function evaluate(array $variables=[]) { 
            return ' . $expr->compile() . '; 
        } 

        public function compile(): string { 
            return ' . var_export($expr->compile(), true) . '; 
        } 
    };'); 
    return $expr; 
} 

让我们看看这个方法中发生了什么:首先,实际的输入字符串用于计算哈希值,唯一地标识这个表达式。如果缓存目录中存在具有此名称的文件,则该文件将作为 PHP 文件包含,并且该文件的返回值将作为方法的返回值返回:

$cacheKey = sha1($expr); 
$cacheFile = $this->cacheDir . '/' . $cacheKey; 
if (file_exists($cacheFile)) { 
    return include($cacheFile); 
} 

由于该方法的类型提示指定该方法需要返回Packt\Chp8\DSL\AST\Expression接口的实例,因此生成的缓存文件也需要返回该接口的实例。

如果找不到表达式的编译版本,则内部表达式生成器会像往常一样解析表达式。然后使用compile()方法将该表达式编译为 PHP 表达式。然后使用此 PHP 代码片段编写实际的缓存文件。在这个文件中,我们创建了一个新的匿名类,它实现了 expression 接口,并且在它的evaluate()方法中包含编译后的表达式。

提示

匿名类是 PHP7 中添加的一项功能。此功能允许您创建实现接口或扩展现有类的对象,而无需为此显式定义命名类。在语法上,此功能可按如下方式使用:

$a = new class implements SomeInterface {``    public function test() {``        echo 'Hello';``    }``};``$a->test();

这意味着foo.bar * 3表达式将创建一个缓存文件,其中包含以下 PHP 代码:

<?php 
return new class implements Packt\Chp8\DSL\AST\Expression 
{ 
    public function evaluate(array $variables = []) 
    { 
        return (Packt\Chp8\DSL\AST\PropertyFetch::evaluateStatic( 
            (new Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 
            'bar' 
        )) * (3); 
    } 

    public function compile(): string 
    { 
        return '(Packt\\Chp8\\DSL\\AST\\PropertyFetch::evaluateStatic((new Packt\\Chp8\\DSL\\AST\\NamedVariable('foo'))->evaluate($variables), 'bar'))*(3)'; 
    } 
}; 

有趣的是,PHP 解释器本身的工作方式基本相同。在实际执行 PHP 代码之前,PHP 解释器将代码编译成中间表示或字节码,然后由实际解释器进行解释。为了避免反复解析 PHP 源代码,编译后的字节码被缓存;这就是 PHP 操作码缓存的工作原理。

当我们将编译后的表达式保存为 PHP 代码时,这些表达式也将被编译成 PHP 字节码并缓存在操作码缓存中,以获得更高的性能。例如,前一个缓存表达式的 evaluate 方法的计算结果为以下 PHP 字节码:

Optimizing the interpreter by adding a compiler

PHP 解释器生成的 PHP 字节码

验证性能改进

实现 PHP 编译的动机是提高解析器的性能。作为最后一步,我们现在将尝试验证缓存层是否确实提高了解析器的性能。

为此,您可以使用PHPBench软件包,您可以使用 composer 安装该软件包:

$ composer require phpbench/phpbench

PHPBench 提供了一个独立地对单个代码单元进行基准测试的框架(在这方面类似于 PHPUnit,仅针对基准测试而非测试)。每个基准都是一个 PHP 类,其中包含作为方法的场景。每个场景方法的名称需要以bench开头。

首先在根目录中创建一个包含以下内容的bench.php文件:

require 'vendor/autoload.php'; 

use Packt\Chp8\DSL\ExpressionBuilder; 
use Packt\Chp8\DSL\CompilingExpressionBuilder; 

class ParserBenchmark 
{ 
    public function benchSimpleExpressionWithBasicParser() 
    { 
        $builder = new ExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 
} 

然后,可以使用以下命令运行此基准测试:

vendor/bin/phpbench run bench.php --report default

这将生成一个报告,如以下报告:

Verifying performance improvements

目前,PHPBench 只运行基准函数一次,并测量执行该函数所需的时间。在这种情况下,它大约是 2 毫秒。这不是很精确,因为像这样的微观测量可能会有很大的变化,这取决于同时在计算机上发生的其他事情。出于这个原因,通常最好多次执行基准函数(比如说几百次或几千次),然后计算平均执行时间。使用 PHPBench,您可以通过向基准类的 DOC comment 添加一个@Revs(5000)注释来轻松实现这一点:

/** 
 * @Revs(5000) 
 */ 
class ParserBenchmark 
{ 
    // ... 
} 

此注释将导致 PHPBench 实际运行此基准函数 5000 次,然后计算平均运行时间。

我们还将添加第二个场景,在该场景中,我们将使用具有相同表达式的新CompilingExpressionBuilder

/** 
 * @Revs(5000) 
 */ 
class ParserBenchmark 
{ 
    public function benchSimpleExpressionWithBasicParser() 
    { 
        $builder = new ExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 

    public function benchSimpleExpressionWithCompilingParser() 
    { 
        $builder = new CompilingExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 
} 

再次运行基准测试;这一次,通过 5000 次迭代对解析器和进行基准测试:

Verifying performance improvements

正如您在这里看到的,解析和计算a = 2表达式平均需要我们的常规解析器大约 349 微秒(以及大约 20 兆字节的 RAM)。使用编译解析器只需要大约 33 微秒(即大约 90%的运行时间减少)和 5 MB 的 RAM(或大约 71%)。

现在,a=2可能不是最具代表性的基准,因为实际用例中使用的实际表达式可能会变得更复杂一些。

对于更现实的基准测试,让我们再添加两个场景,这次使用更复杂的表达式:

public function benchComplexExpressionBasicParser() 
{ 
    $builder = new ExpressionBuilder(); 
    $builder 
        ->parseExpression('when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2') 
        ->evaluate(['customer' => ['age' => 1], 'cart' => ['value' => 200]]); 
} 

public function benchComplexExpressionCompilingParser() 
{ 
    $builder = new CompilingExpressionBuilder(new ExpressionBuilder(), 'cache/auto'); 
    $builder 
        ->parseExpression('when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2') 
        ->evaluate(['customer' => ['age' => 1], 'cart' => ['value' => 200]]); 
} 

再次运行基准测试并重新查看结果:

Verifying performance improvements

比以前好多了!使用常规解析器解析when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2表达式大约需要 2.5 毫秒(记得我们在上一次基准测试中提到的微秒),而使用优化的解析器只需要 50 微秒!这是大约 98%的改进。

总结

在本章中,您学习了如何使用 PHP-PEG 库实现自定义表达式语言的解析器、解释器和编译器。您还学习了如何为这些语言定义语法,以及如何使用它们开发特定于领域的语言。这些可用于在大型软件系统中提供最终用户开发功能,允许用户在很大程度上自定义其软件的业务规则。

使用特定于领域的语言动态修改程序可能是一个强大的卖点,尤其是在企业系统中。它们允许用户自己修改程序的行为,而无需等待开发人员更改业务规则并触发冗长的发布过程。通过这种方式,可以快速实施新的业务规则,并允许您的客户快速响应不断变化的需求。