MAGES - .NET 的终极脚本






4.99/5 (46投票s)
本文介绍了 MAGES——一个非常简单但功能强大的表达式解析器和解释器。
目录
引言
近年来,涌现了一大批新的编程语言。有些试图成为通用系统编程语言,例如 Rust;有些则希望用于分布式计算的低级编程,例如 Go;还有些则追求生产力,例如 Swift;最后还有一些用于科学计算的语言,例如 Julia。了解它们带来了哪些概念以及它们如何争取流行起来,这非常有趣。
这股编程语言浪潮也深受函数式编程趋势的影响。传统的面向对象编程似乎准备好迎来下一次进化。SOLID 原则迫使我们紧随这一趋势。现在似乎是做一些不同事情的时候了。作为 .NET 开发者,我们看到 F# 的极化趋势,以及 C# 6 中引入的功能,尤其是即将推出的 C# 7 中的功能。
那么,本文是关于什么的?我想向大家介绍我设计的一种新语言——它不是什么革命性的语言,也不会成为主流。然而,它可能非常方便,并且可以解决您今天或将来可能面临的一个或另一个问题。本文将介绍 MAGES,一种快速轻量级的解释型脚本语言,它可以与 .NET Framework 无缝互操作。它可以用于大多数代码,无论是 Unity、Xamarin 还是标准框架。
背景
MAGES 是一个递归首字母缩略词,代表“MAGES: Another Generalized Expression Simplifier”(MAGES:另一个通用表达式简化器)。它是 YAMP 的官方继任者,YAMP 是一个类似的项目,非常成功(参见 Sumerics)。虽然 YAMP 主要是一个玩具项目,设计实验性很强,但 MAGES 完全不同。它对整个解析器管道进行了严格的分离:词法分析、语法分析、语义分析和代码生成。它使用一个高级虚拟机以线性方式解释代码。
MAGES 的主要目标是性能、可用性和可扩展性。这主要通过使用标准的 .NET 类型而不是更方便的包装器来实现。我们将看到 .NET 和 MAGES 之间的互操作非常直接且易于管理。这反过来又使得许多场景成为可能,并允许库的用户快速集成它。基本上,用户只需要声明要打开哪些元素即可。
语言本身并不革命性。不要期望太多。它包含一些很棒的功能,应该非常易读,但其主要关注点是提供一种无需太多学习即可使用的东西。想法是,大多数有 C、Java、C# 或 JavaScript 经验的人都可以使用这种语言。语言本身将不是本文的重点,但我们会在此过程中看到它的一些功能。
架构和解析
MAGES 被设计为一个库。它是一个小型的核心,可以方便地获取,例如,从 Nuget。该库本身包含评估、检查或分析 MAGES 代码所需的一切。评估管道可以分解为以下完全可访问的组件:
- 用于源检查的扫描器
- 用于词法分析的标记器
- 用于语法分析的解析器
- 用于详细检查和代码生成的 AST 遍历器
- 具有高级操作的虚拟机
- 包含运算符和函数的运行时系统
抽象语法树(AST)遍历器非常重要。它们用于检索符号信息、生成虚拟机操作、验证完整代码或提供自动完成信息。
标记化和解析
整个解析模型遵循传统的管道字符。IScanner
为 ITokenizer
提供字符。生成的标记流(IToken
)随后被传递给 IParser
。
接口通常非常轻量级。考虑整个解析器:
public interface IParser
{
IExpression ParseExpression(IEnumerator<IToken> tokens);
IStatement ParseStatement(IEnumerator<IToken> tokens);
List<IStatement> ParseStatements(IEnumerator<IToken> tokens);
}
扫描器稍微完整一些:
public interface IScanner : IDisposable
{
Int32 Current { get; }
TextPosition Position { get; }
Boolean MoveNext();
Boolean MoveBack();
}
相比之下,标记器和标记相当简单:
public interface ITokenizer
{
IToken Next(IScanner scanner);
}
public interface IToken : ITextRange
{
TokenType Type { get; }
String Payload { get; }
}
标记本质上主要是由一个类型定义的,但是,某些标记也可以带有有效负载。例如,字符串字面量还附带定义它的字符串。ITextRange
接口仅声明对象需要声明开始和结束位置。这对于标记来说是正确的,因为它们存在于文本的特定部分。文本位置可以从扫描器中检索,扫描器具有 Position
属性。ITokenizer
负责收集和分配正确的文本位置。
在 MAGES 中,有四种标记器接口的实现。这可能看起来是一个奇怪的实现,然而,我们将看到这给我们带来了一些优势。
集成的实现包括:
- 一个用于简单标记的通用版本
- 一个用于解析数字字面量的版本
- 一个用于解析所有类型注释的版本
- 另一个用于解析字符串字面量的版本
可以说,还有一个第五个版本能够标记字符串字面量。省略它在上面的列表中是因为冗余。这个变体与普通字符串标记器非常相似。它只对花括号敏感,花括号随后由简单标记的版本进行扫描。因此,标记器实现是由现有实现的组合。
让我们看看最基本的实现:
sealed class GeneralTokenizer : ITokenizer
{
private readonly ITokenizer _number;
private readonly ITokenizer _string;
private readonly ITokenizer _comment;
private readonly ITokenizer _interpolated;
public GeneralTokenizer(ITokenizer numberTokenizer, ITokenizer stringTokenizer, ITokenizer commentTokenizer)
{
_number = numberTokenizer;
_string = stringTokenizer;
_comment = commentTokenizer;
_interpolated = new InterpolationTokenizer(this);
}
public IToken Next(IScanner scanner)
{
if (scanner.MoveNext())
{
var current = scanner.Current;
if (current.IsSpaceCharacter())
{
return new CharacterToken(TokenType.Space, current, scanner.Position);
}
else if (current.IsNameStart())
{
return ScanName(scanner);
}
else if (current.IsDigit())
{
return _number.Next(scanner);
}
else
{
return ScanSymbol(scanner);
}
}
return new EndToken(scanner.Position);
}
private IToken ScanSymbol(IScanner scanner)
{
var start = scanner.Position;
switch (scanner.Current)
{
case CharacterTable.FullStop:
return _number.Next(scanner);
case CharacterTable.Comma:
return new CharacterToken(TokenType.Comma, CharacterTable.Comma, start);
// ... and others
}
return new CharacterToken(TokenType.Unknown, scanner.Current, start);
}
private static IToken ScanName(IScanner scanner)
{
var position = scanner.Position;
var sb = StringBuilderPool.Pull();
var current = scanner.Current;
var canContinue = true;
do
{
sb.Append(Char.ConvertFromUtf32(current));
if (!scanner.MoveNext())
{
canContinue = false;
break;
}
current = scanner.Current;
}
while (current.IsName());
if (canContinue)
{
scanner.MoveBack();
}
var name = sb.Stringify();
var type = Keywords.IsKeyword(name) ? TokenType.Keyword : TokenType.Identifier;
return new IdentToken(type, name, position, scanner.Position);
}
}
我们可以看到标记过程相当直接。我们使用 IScanner
实例获取一些字符并基于它做出一些决定。然后我们可能会进入某种特殊状态(例如,ScanName
)以使用先前的信息继续扫描。否则,我们只发出一个字符标记。
然后将标记流传递给解析器,解析器负责语法分析。此步骤的结果是一个树(即,不是一维流),称为抽象语法树,简称 AST。AST 是任何编译器最重要的结构。
MAGES 的解析器本质上是一个所谓的 Pratt 解析器。Pratt 解析器是一种改进的递归下降解析器,它将语义与标记而不是语法规则关联起来。这在我们的情况下是理想的,因为我们从标记流开始。Pratt 解析器的优点是可维护性和可读性。缺点是由于大量的递归函数调用导致性能损失。这是 MAGES 中唯一一项不利于性能的决定。使用另一种实现方式带来的性能提升并不比额外的复杂性更优。因此,我们接受(微小的)性能损失以换取(显著的)可读性提升。
让我们看看这样一个 Pratt 解析器如何用于解析表达式。我们省略辅助函数,仅显示到无效表达式的主路径。
sealed class ExpressionParser : IParser
{
private readonly AbstractScopeStack _scopes;
public ExpressionParser()
{
var root = new AbstractScope(null);
_scopes = new AbstractScopeStack(root);
}
public IExpression ParseExpression(IEnumerator<IToken> tokens)
{
var expr = ParseAssignment(tokens.NextNonIgnorable());
if (tokens.Current.Type != TokenType.End)
{
var invalid = ParseInvalid(ErrorCode.InvalidSymbol, tokens);
expr = new BinaryExpression.Multiply(expr, invalid);
}
return expr;
}
private IExpression ParseAssignment(IEnumerator<IToken> tokens)
{
var x = ParseRange(tokens);
var token = tokens.Current;
var mode = token.Type;
if (mode == TokenType.Assignment)
{
var y = ParseAssignment(tokens.NextNonIgnorable());
return new AssignmentExpression(x, y);
}
else if (mode == TokenType.Lambda)
{
var parameters = GetParameters(x);
return ParseFunction(parameters, tokens.NextNonIgnorable());
}
return x;
}
private IExpression ParseRange(IEnumerator<IToken> tokens)
{
var x = ParseConditional(tokens);
if (tokens.Current.Type == TokenType.Colon)
{
var z = ParseConditional(tokens.NextNonIgnorable());
var y = z;
if (tokens.Current.Type == TokenType.Colon)
{
z = ParseConditional(tokens.NextNonIgnorable());
}
else
{
y = new EmptyExpression(z.Start);
}
return new RangeExpression(x, y, z);
}
return x;
}
private IExpression ParseConditional(IEnumerator<IToken> tokens)
{
var x = ParsePipe(tokens);
if (tokens.Current.Type == TokenType.Condition)
{
var y = ParseConditional(tokens.NextNonIgnorable());
var z = default(IExpression);
if (tokens.Current.Type == TokenType.Colon)
{
z = ParseConditional(tokens.NextNonIgnorable());
}
else
{
z = ParseInvalid(ErrorCode.BranchMissing, tokens);
}
return new ConditionalExpression(x, y, z);
}
return x;
}
private IExpression ParsePipe(IEnumerator<IToken> tokens)
{
var x = ParseOr(tokens);
while (tokens.Current.Type == TokenType.Pipe)
{
if (CheckAssigned(tokens))
{
var y = ParseAssignment(tokens.NextNonIgnorable());
var z = new BinaryExpression.Pipe(x, y);
return new AssignmentExpression(x, z);
}
else
{
var y = ParseOr(tokens);
x = new BinaryExpression.Pipe(x, y);
}
}
return x;
}
private IExpression ParseOr(IEnumerator<IToken> tokens)
{
var x = ParseAnd(tokens);
while (tokens.Current.Type == TokenType.Or)
{
var y = ParseAnd(tokens.NextNonIgnorable());
x = new BinaryExpression.Or(x, y);
}
return x;
}
private IExpression ParseAnd(IEnumerator<IToken> tokens)
{
var x = ParseEquality(tokens);
while (tokens.Current.Type == TokenType.And)
{
var y = ParseEquality(tokens.NextNonIgnorable());
x = new BinaryExpression.And(x, y);
}
return x;
}
private IExpression ParseEquality(IEnumerator<IToken> tokens)
{
var x = ParseRelational(tokens);
while (tokens.Current.IsEither(TokenType.Equal, TokenType.NotEqual))
{
var mode = tokens.Current.Type;
var y = ParseRelational(tokens.NextNonIgnorable());
x = ExpressionCreators.Binary[mode].Invoke(x, y);
}
return x;
}
private IExpression ParseRelational(IEnumerator<IToken> tokens)
{
var x = ParseAdditive(tokens);
while (tokens.Current.IsOneOf(TokenType.Greater, TokenType.GreaterEqual, TokenType.LessEqual, TokenType.Less))
{
var mode = tokens.Current.Type;
var y = ParseAdditive(tokens.NextNonIgnorable());
x = ExpressionCreators.Binary[mode].Invoke(x, y);
}
return x;
}
private IExpression ParseAdditive(IEnumerator<IToken> tokens)
{
var x = ParseMultiplicative(tokens);
while (tokens.Current.IsEither(TokenType.Add, TokenType.Subtract))
{
var mode = tokens.Current.Type;
if (CheckAssigned(tokens))
{
var y = ParseAssignment(tokens.NextNonIgnorable());
var z = ExpressionCreators.Binary[mode].Invoke(x, y);
return new AssignmentExpression(x, z);
}
else
{
var y = ParseMultiplicative(tokens);
x = ExpressionCreators.Binary[mode].Invoke(x, y);
}
}
return x;
}
private IExpression ParseMultiplicative(IEnumerator<IToken> tokens)
{
var x = ParsePower(tokens);
while (tokens.Current.IsOneOf(TokenType.Multiply, TokenType.Modulo, TokenType.LeftDivide, TokenType.RightDivide,
TokenType.Number, TokenType.Identifier, TokenType.Keyword, TokenType.OpenList))
{
var current = tokens.Current;
var implicitMultiply = !current.IsOneOf(TokenType.Multiply, TokenType.Modulo, TokenType.LeftDivide, TokenType.RightDivide);
var mode = implicitMultiply ? TokenType.Multiply : current.Type;
if (!implicitMultiply && CheckAssigned(tokens))
{
var y = ParseAssignment(tokens.NextNonIgnorable());
var z = ExpressionCreators.Binary[mode].Invoke(x, y);
return new AssignmentExpression(x, z);
}
else
{
var y = ParsePower(tokens);
x = ExpressionCreators.Binary[mode].Invoke(x, y);
}
}
return x;
}
private IExpression ParsePower(IEnumerator<IToken> tokens)
{
var atom = ParseUnary(tokens);
if (tokens.Current.Type == TokenType.Power)
{
var expressions = new Stack<IExpression>();
if (!CheckAssigned(tokens))
{
expressions.Push(atom);
do
{
var x = ParseUnary(tokens);
expressions.Push(x);
}
while (tokens.Current.Type == TokenType.Power && tokens.NextNonIgnorable() != null);
do
{
var right = expressions.Pop();
var left = expressions.Pop();
var x = new BinaryExpression.Power(left, right);
expressions.Push(x);
}
while (expressions.Count > 1);
atom = expressions.Pop();
}
else
{
var rest = ParseAssignment(tokens.NextNonIgnorable());
var rhs = new BinaryExpression.Power(atom, rest);
return new AssignmentExpression(atom, rhs);
}
}
return atom;
}
private IExpression ParseUnary(IEnumerator<IToken> tokens)
{
var current = tokens.Current;
var creator = default(Func<TextPosition, IExpression, PreUnaryExpression>);
if (ExpressionCreators.PreUnary.TryGetValue(current.Type, out creator))
{
var expr = ParseUnary(tokens.NextNonIgnorable());
return creator.Invoke(current.Start, expr);
}
return ParsePrimary(tokens);
}
private IExpression ParsePrimary(IEnumerator<IToken> tokens)
{
var left = ParseAtomic(tokens);
do
{
var current = tokens.Current;
var mode = current.Type;
var creator = default(Func<IExpression, TextPosition, PostUnaryExpression>);
if (ExpressionCreators.PostUnary.TryGetValue(mode, out creator))
{
tokens.NextNonIgnorable();
left = creator.Invoke(left, current.End);
}
else if (mode == TokenType.Dot)
{
var identifier = ParseIdentifier(tokens.NextNonIgnorable());
left = new MemberExpression(left, identifier);
}
else if (mode == TokenType.OpenGroup)
{
var arguments = ParseArguments(tokens);
left = new CallExpression(left, arguments);
}
else
{
return left;
}
}
while (true);
}
private IExpression ParseAtomic(IEnumerator<IToken> tokens)
{
switch (tokens.Current.Type)
{
case TokenType.OpenList:
return ParseMatrix(tokens);
case TokenType.OpenGroup:
return ParseArguments(tokens);
case TokenType.Keyword:
return ParseKeywordConstant(tokens);
case TokenType.Identifier:
return ParseVariable(tokens);
case TokenType.Number:
return ParseNumber(tokens);
case TokenType.InterpolatedString:
return ParseInterpolated(tokens);
case TokenType.String:
return ParseString(tokens);
case TokenType.SemiColon:
case TokenType.End:
return new EmptyExpression(tokens.Current.Start);
}
return ParseInvalid(ErrorCode.InvalidSymbol, tokens);
}
private static InvalidExpression ParseInvalid(ErrorCode code, IEnumerator<IToken> tokens)
{
var expr = new InvalidExpression(code, tokens.Current);
tokens.NextNonIgnorable();
return expr;
}
/* ... */
}
我们看到解析器总是向下遍历到最高阶的标记。运算符也以相反的顺序获取,最不粘连的先被达到。
树遍历器
访问者模式是遍历 AST 的最强大模式。它允许我们抽象掉类型检查的冗余,并规范预期的行为。
访问者模式包含三个部分:
- 在接口中声明一个方法来接受访问者
- 一个实现访问者接口的遍历器,其中包含所有要访问的类型的访问方法
- 在所有要访问的类型中实现接受接口
访问者接口如下所示。本质上,我们需要为任何可能被访问的类型提供一个重载。
public interface ITreeWalker
{
void Visit(VarStatement statement);
void Visit(BlockStatement statement);
/* ... */
void Visit(EmptyExpression expression);
void Visit(ConstantExpression expression);
/* ... */
}
接受接口要简单得多。本质上,它归结为:
public interface IWalkable
{
void Accept(ITreeWalker visitor);
}
第三部分是在访问者接口中提到的所有类型中实现接受接口,例如 IWalkable
。
这看起来就像以下代码一样简单:
public void Accept(ITreeWalker visitor)
{
visitor.Visit(this);
}
即使特定类型知道其子类型的类型(通常不是这样),也应该让遍历器决定是否(以及何时!)下降。例如,当遍历器看到一个 BlockStatement
时,它可能会这样做:
public void Visit(BlockStatement block)
{
foreach (var statement in block.Statements)
{
statement.Accept(this);
}
}
这样,一旦我们被接受为节点的访问者,我们也可能希望被接受为子节点的访问者。但是,这个请求必须来自遍历器。被访问的节点只知道“允许进入”哪个方法,而不是如何“引导通过”。有人可能会说,访问者模式有助于我们避免不必要的类型转换,这可能导致代码中的错误以及任何想要使用 AST 的人都无法承受的复杂性。
为了避免重复的语句,创建了一个名为 BaseTreeWalker
的抽象基类,它实现了 ITreeWalker
接口并使所有方法都成为虚拟的。它们都带有基本实现,其目标是:遍历 AST。
假设我们想创建一个自定义树遍历器来查找代码中的任何函数调用。相应的类可能很简单:
class FunctionCallTreeWalker : BaseTreeWalker
{
private readonly List<CallExpression> _calls;
public FunctionCallTreeWalker()
{
_calls = new List<CallExpression>();
}
public List<CallExpression> AllCalls
{
get { return _calls; }
}
public override void Visit(CallExpression expression)
{
_calls.Add(expression);
base.Visit(expression);
}
}
我们所要做的就是拦截 CallExpression
的访问。我们仍然使用基本实现来收集参数和/或函数本身的任何函数调用(例如 f()(2, 3)
或 g(2)(3)(5)()
?)。
使用所谓的树遍历器来收集 AST 中存储的信息的想法也用于密切相关的概念,例如验证。每个节点(即树的元素)都知道如何验证自身,但它不知道如何验证整个树。因此,我们使用树遍历器来访问每个节点并触发自我验证。因此,树遍历和验证结合起来执行完整验证,但它们是独立的,并且不知道或不要求彼此。
树遍历器也用于生成虚拟机中使用的操作。
虚拟机
MAGES 带有一个小型高级虚拟机(VM)。目前,使用以下指令集:
ArgOperation
(加载一个参数)ArgsOperation
(设置函数作用域的args
变量)CondOperation
(评估条件a ? b : c
)ConstOperation
(加载常量)DecOperation
(递减变量)DefOperation
(定义局部变量)GetcOperation
(调用 getter 函数)GetpOperation
(加载对象属性)GetsOperation
(加载局部变量)IncOperation
(递增变量)InitMatOperation
(初始化矩阵值)InitObjOperation
(初始化对象值)JumpOperation
(跳转到给定操作索引)NewFuncOperation
(创建新函数)NewMatOperation
(创建新矩阵)NewObjOperation
(创建新对象)PopOperation
(跳过单个操作)PopIfOperation
(如果为 true,则跳过单个操作)RngeOperation
(创建具有显式步长的范围)RngiOperation
(创建具有隐式步长的范围)RetOperation
(从当前块返回)SetcOperation
(调用 setter 函数)SetpOperation
(存储对象属性)SetsOperation
(存储局部变量)
这些指令随后用于覆盖某些功能。例如,调用函数将解析为:
- 放置评估所有参数表达式的操作。(按相反顺序)
- 放置加载要调用的函数的指令。
- 放置
GetcOperation
或SetcOperation
来调用函数。
在 VM 执行期间,最后的操作将执行以下步骤:
- 创建一个新的
object[]
来存储所有参数。 - 从堆栈中弹出值以获取要调用的函数。
- 从堆栈中弹出值以填充
object[]
。 - 使用给定的参数调用函数。
这就是为什么 VM 操作被称为高级操作的原因。它们不解析为最小的命令,而是使用更高级的 .NET 命令/多个中间语言(IL)指令。
查看生成的指令,例如,对于简单的加法会揭示:
SWM> il("2+3")
const 2
const 3
const [Function]
getc 2
我们将常量(2 和 3)以及加法函数放在堆栈上。由于没有特定的加法指令,操作遍历器通过常量操作将使用的函数放在堆栈上。最后,我们使用两个参数的调用指令,即从堆栈中取出三个值。
对于新矩阵,指令主要涉及初始化。
SWM> il("[1,2;3,4;5,6]")
newmat 3 2
const 1
initmat 0 0
const 2
initmat 0 1
const 3
initmat 1 0
const 4
initmat 1 1
const 5
initmat 2 0
const 6
initmat 2 1
当然,通过常量将整个矩阵放在堆栈上会更简单,但这仅在特殊情况下(如上面介绍的)才可能。通常,矩阵并非完全由常量组成。将来可能会引入优化,仅初始化非常量值。
我们在上面的代码中可以看到,3x2 矩阵是用参数 3 和 2 创建的。此外,对于每个值,至少会插入两个指令。一个用于将常量放在堆栈上,另一个用于将值分配给正确的单元格。后者需要两个参数,表示基于 0 的行和列索引。
对于新函数,指令看起来有点复杂。但是,我们将看到其本质仍然很简单。
SWM> il("(x, y, z) => x + y + z^2")
newfunc start
arg 0 x
arg 1 y
arg 2 z
args args
gets x
gets y
const [Function]
getc 2
gets z
const 2
const [Function]
getc 2
const [Function]
getc 2
newfunc end
重要部分是函数由操作组成,因此必须以某种方式包含这些操作。由于我们处理的是线性链,因此以所示方式进行,即 newfunc
操作实际上包含这些操作。此处显示的大多数指令都属于函数的内部部分。
让我们确认从 gets x
开始的整个尾部属于函数体:
SWM> il("x + y + z^2")
gets x
gets y
const [Function]
getc 2
gets z
const 2
const [Function]
getc 2
const [Function]
getc 2
因此,新函数实际需要的唯一指令是参数初始化,这可以简化为仅特殊的 args
参数。这里的重复意味着变量参数应该可以通过名为 args
的变量进行访问。
API 和可扩展性
核心类和功能位于 Mages.Core 命名空间中。一个非常简单的控制台“Hello World!”应用程序可能如下所示:
using Mages.Core;
using System;
static class Program
{
static void Main(String[] args)
{
var engine = new Engine();
var result = engine.Interpret("21 * 2");
Console.WriteLine("The answer to everything is {0}!", result);
}
}
当然,从这一点开始,MAGES 已经接近 REPL(Read-Evaluate-Print-Loop)了。
var engine = new Engine();
while (true)
{
Console.Write("Query? ");
var input = Console.ReadLine();
var result = engine.Interpret(input);
Console.WriteLine("Answer: {0}", result);
}
此时,用户可以自由开始与 MAGES Engine
实例进行交互。话虽如此,现在看看如何在我们的应用程序中与引擎交互是有意义的。
类型系统
MAGES 不自带数据类型。相反,它使用现有的 .NET 数据类型来减少层数并提高性能。作为积极的副作用,性能会提高,GC 压力也更小。交互也感觉更自然。
var engine = new Engine();
engine.Scope["seven"] = 7.0;
var result = engine.Interpret("seven / 4");
Console.WriteLine(typeof(result).FullName); // System.Double
(全局)作用域只是一个 .NET 字典,它允许我们获取和设置全局变量。在前面的示例中,seven 是我们引入的名称。同样,结果也可以存储在此字典中。
var engine = new Engine();
engine.Interpret("IsSevenPrime = isprime(7)");
Console.WriteLine(engine.Scope["IsSevenPrime"]); // true
MAGES 试图将每种 .NET 数据类型缩小到其数据类型之一:
- 数字 (
System.Double
) - 布尔值 (
System.Boolean
) - 字符串 (
System.String
) - 矩阵 (
System.Double[,]
) - 对象 (
System.Collections.Generic.IDictionary<System.String, System.Object>
) - 函数 (
Mages.Core.Function
,本质上是一个映射Object[]
到Object
的Delegate
) - 未定义 (
null
)
大多数类型将被简单地包装在一个实现 IDictionary<String, Object>
的包装器对象中。我们可以轻松做的一件事是在 MAGES 中创建新函数并在 .NET 应用程序中使用它们:
var engine = new Engine();
var euler = engine.Interpret("n => isprime(n^2 + n + 41)");
var testWith1 = (Boolean)euler.Invoke(new Object[] { 1.0 });
var testWith7 = (Boolean)euler.Invoke(new Object[] { 7.0 });
传递给在 MAGES 中定义的函数的对象需要以 MAGES 兼容的类型提供。因此,使用整数调用将不起作用:
var isNull = euler.Invoke(new Object[] { 1 });
为了规避这类问题,有一个更好的选择:使用 Call
扩展方法。这允许我们这样做:
var testWith1 = euler.Call<Boolean>(1);
var testWith7 = euler.Call<Boolean>(7);
还有一个重载,不指定返回类型(导致返回 Object
实例)。上面的调用在类型未匹配时返回默认值。
之所以将缩小机制包含在 Call
而不是通常的 Invoke 中,是为了允许 MAGES 内部直接调用函数,而无需引入额外的缩小开销。
总的来说,下表通过显示基本类型映射来反映 MAGES 类型系统。
MAGES | .NET (直接) | .NET (转换) |
---|---|---|
数字 | 双精度浮点型 | Single |
十进制 | ||
字节型 | ||
UInt16 | ||
UInt32 | ||
UInt64 | ||
Int16 | ||
Int32 | ||
Int64 | ||
布尔值 | 布尔值 | |
字符串 | 字符串 | Char |
矩阵 | Double[,] | Double[] |
列表 | ||
函数 | 函数 | 委托 |
对象 | IDictionary | 对象 |
无 | null |
此外,在 MAGES 中存在两个有趣的功能:is
和 as
。两者都接受类型名称(例如 "Number"
)和值作为参数。然而,前者返回一个布尔值,表示该值是否为给定类型,而后者则返回转换后的值或未定义。
公开 API
在任何作用域的下方(最底部)是用户输入无法操纵的另一层:API 空间。这一层用于保存函数,例如 sin
或 cos
,而不会因用户对作用域的操纵而永远消失。
API 空间可以通过 Engine
实例的 Globals
属性访问。与作用域一样,API 层是实例绑定的,即两个不同的引擎实例在此处可能看起来不同。
var engine = new Engine();
engine.Globals["three"] = 3.0;
乍一看,MAGES 中的交互与作用域的交互非常相似。然而,区别在于用户无法在此处覆盖函数。建议使用作用域来观察用户所做的更改/变量,而使用全局变量来定义要使用的 API。
由于 API 主要由函数组成(而不是常量),因此引入函数的帮助程序是 MAGES 的重要组成部分。
var engine = new Engine();
var function = new Function(args => (Double)args.Length);
engine.SetFunction("argsCount", function);
var result = engine.Interpret("argsCount(1, true, [])"); // 3.0
如果我们直接使用 Function,我们将负责处理使用的类型。我们确信只有 MAGES 兼容的类型进入,但同时我们需要确保只返回 MAGES 兼容的对象。
潜在地,最好只使用任何类型的委托并传递它。例如,以下内容按预期工作。
var engine = new Engine();
var function = new Func<Double, String, Boolean>((n, str) => n == str.Length);
engine.SetFunction("checkLength", function.Method, function.Target);
var result = engine.Interpret("checkLength(2, \"hi\")"); // true
现在,在前面的示例中,所有使用的类型都是 MAGES 兼容的,但是,我们甚至可以使用(某种程度上)任意类型:
var engine = new Engine();
var func = new Func<Int32, String, Char>((n, str) => str[n]);
engine.SetFunction("getCharAt", func.Method, func.Target);
var result = engine.Interpret("getCharAt(2, \"hallo\")"); // "l"
在此示例中,Double(MAGES 兼容类型)会自动转换为整数。结果类型(Char
)也会自动转换为 String
。
与函数类似,也可以公开常规对象。MAGES 在这里提供了标记所谓的常量的功能,这些常量可以被用户影射,但实际上永远不会被用户覆盖。
var engine = new Engine();
var constant = Math.Sqrt(2.0);
engine.SetConstant("sqrt2", constant);
var result = engine.Interpret("sqrt2^2"); // 2.0
上述方式是直接访问 Globals
对象的首选替代方案。Globals
对象的主要问题之前已说明。在这里,没有启用安全网来防止 MAGES 不兼容的对象进入系统。因此,强烈建议使用 SetFunction
和 SetConstant
包装器来提供函数和常量。
如果常量不够好怎么办?如果用户应该能够创建多个实例怎么办?构造函数是正确的答案。在下面的示例中,.NET 的 StringBuilder
类通过构造函数暴露给 MAGES。
var engine = new Engine();
var function = new Func<StringBuilder>(() => new StringBuilder());
engine.SetFunction("createStringBuilder", function);
var result = engine.Interpret("createStringBuilder().append(\"Hello\").append(\" \").appendLine(\"World!\").toString()"); // "Hello World!\n"
通常,这样的构造函数与 MAGES 的功能(如任意对象的自动包装)相结合至关重要。然而,提供这样的构造函数还有更好的方法。
交互
MAGES 可以轻松地通过 SetStatic
扩展方法公开现有的 .NET 类型。让我们从一个简单的例子开始:
var engine = new Engine();
engine.SetStatic<System.Text.StringBuilder>().WithDefaultName();
var result = engine.Interpret("StringBuilder.create().append(\"foo\").appendLine(\"bar\").toString()"); // "foobar\n"
与上面的代码相比,这似乎相当直接和简单。那么这里到底发生了什么?首先,我们用默认名称公开了 .NET 类型 System.Text.StringBuilder
。与前面提到的扩展方法不同,SetStatic
不直接公开结果。相反,我们需要告诉 MAGES 如何公开它。在这种情况下,我们采用标准方式,即通过其原始名称(“StringBuilder”)。还有另外两种方法,稍后将进行讨论。
按照惯例,构造函数通过 create
方法公开。从这一点开始,代码与上面的代码相同。同样,底层的 .NET 类型(StringBuilder
实例)已被公开。一个合法的问题是:为什么名称不同?
MAGES 能够以 API 相干的方式公开 .NET 类型。因此,所有字段/属性/方法/... 名称都由一个中央服务,即 INameSelector
接口的实现进行转换。默认情况下,使用 CamelNameSelector
,但如果需要,我们可以替换它。此名称选择器将所有 .NET 名称从 PascalCase 转换为 camelCase。
那么,让我们公开一些其他东西——如何公开某种数组?
var engine = new Engine();
engine.SetStatic<System.Collections.Generic.List<System.Object>>().WithName("Array");
var result = engine.Interpret("list = Array.create(); list.add(\"foo\"); list.add(2 + 5); list.insert(1, true); list.at(2)"); // 7
这次我们决定公开 List<Object>
类型。但是,默认名称将无法访问;即使它合法。相反,我们决定给它一个自定义名称——“Array”。我们现在可以使用静态 Array 对象来创建(包装的)List<Object>
实例。在这种情况下,我们将实例命名为 list。最后,一切都如我们之前所见。这里只有一个新东西:at
函数本身并不存在于 .NET List<Object>
上。
MAGES 通过一个称为 at
函数的约定公开 .NET 索引器。此约定,如其他约定一样,可以通过提供自定义 INameSelector
实现来更改。
最后,我们可以使用 SetStatic
扩展方法来公开整个函数集合(或其他对象)。假设我们想公开一些函数,例如:
static class MyMath
{
public static Double Cot(Double x)
{
return Math.Cos(x) / Math.Sin(x);
}
public static Double Arccot(Double x)
{
return Math.Atan(1.0 / x);
}
}
我们可以这样做(由于上面的类是静态的,我们不能将其与泛型一起使用,但幸运的是有一个接受 Type
参数的重载):
var engine = new Engine();
engine.SetStatic(typeof(MyMath)).WithName("mm");
var result = engine.Interpret("mm.arccot(mm.cot(pi))"); // approx. 0
然而,这实际上是将所有这些函数放在某种“命名空间”中(由于它是运行时对象,所以它不是严格意义上的命名空间,但是,从代码的角度来看,它可以被视为这样)。如果我们想直接公开所有这些函数,即全局公开怎么办?这里出现第三种选择:
var engine = new Engine();
engine.SetStatic(typeof(MyMath)).Scattered();
var result = engine.Interpret("arccot(cot(1))"); // 1
使用 Scattered
,对象被分解并插入到全局 API 层。
性能
YAMP 的主要问题之一是其性能。即使 YAMP 表现出不错的性能,但在一些关键领域仍然不足,并且几乎没有改进的空间。由于 YAMP 的解析模型基于使用反射,因此它既有限又难以维护。MAGES 现在试图引入一个更简单的模型。我们已经看到架构设计时考虑了最大的关注点分离。耦合已降至必要的最低限度。这应该确保未来实现的特性的可扩展性。
关键方面
尽管 MAGES 使用标准的四个层来评估某些代码,但它的速度相当快——即使对于小型表达式也是如此。这些层是:
- 源处理(字符流)
- 标记化(标记流)
- 语法模型(抽象语法树)
- 验证和代码生成(操作流)
一旦生成了操作,大部分开销就消失了。在 YAMP 中,解释是针对 AST 执行的。这涉及一部分验证。现在在 MAGES 中,所有与验证相关的内容都与运行代码严格分开。代码生成后,MAGES 保证能够运行。
总而言之,我们从一维字符流到一维标记流,再到树表示,最后到一维操作数组。最终的线性化对于评估很重要。它还允许,例如,缓存检查过的代码以反复使用,而无需花费时间在(无用的)连续验证和 AST 生成等方面。
标记器设计时具有单字符的最大前瞻性。这减少了字符流中的随机访问,并且只需要存储两个字符(前一个,当前)。标记化在没有前瞻的情况下工作,即必须在不知道接下来会发生什么的情况下生成 AST。一切都必须可以转换为所需状态。这通常很简单,然而,对于 lambda 表达式,这个条件是有问题的。
(x, y) => x + y
() => 5
(x) => x^2
x => x^2
前三个表达式都由一个参数表达式、一个函数运算符和一个函数体表达式组成。最后一个由一个变量表达式、一个函数运算符和一个函数体表达式组成。因此,函数运算符必须接受左侧的任何内容,但此内容被限制为参数表达式的特殊子集(仅接受变量表达式作为表达式),以及变量表达式。所有其他表达式都被视为无效。这是由解析器部分执行的检查,由生成的 FunctionExpression
部分执行。
通常,我们需要在生成代码之前运行完整的验证。但是,为了稍微节省性能,我们将其一次性完成——并在遇到第一个错误时停止代码生成。在这种错误时,我们会抛出异常。这种最小化验证是一种保护机制,而不是真正的验证。MAGES 还带有一个真正的验证树遍历器,即一个可以记录任何检测到的错误并且不会在第一个问题处停止的遍历器。
性能评估
MAGES 的性能几乎完全与 YAMP 进行比较。这听起来可能不够,但实际上,由于 YAMP 已与其他许多解决方案进行比较,我们可以使用基本的三角不等式来推断其与其他解决方案的性能。
为了运行性能评估,我们选择了非常可靠的 Benchmark.NET 解决方案。它是一个优秀的开源库,可以通过 Nuget 获取。它考虑了所有必需的要素,例如预热运行、连续基准测试和不同的工作负载,以获得最可靠的数字。最后,打印中位数和标准差。中位数可能看起来很奇怪,然而,由于我们可能有一个平坦的分布带有一个尖峰尾部,我们可以争辩说中间值比被一些异常值破坏的平均数更能准确地代表行为。
提供基准测试解决方案就像创建一个执行以下代码片段的控制台应用程序一样简单:
static class Program
{
static void Main(String[] arguments)
{
BenchmarkRunner.Run<TrivialBenchmarks>();
}
}
然后,该类包含标记为基准测试的方法。下一个片段显示了一个使用带有 BenchmarkAttribute
属性(来自 Benchmark.NET)的方法的类。
public class TrivialBenchmarks
{
private static readonly String AddTwoNumbers = "2 + 3";
private static readonly Parser YampParser = new Parser();
private static readonly Engine MagesEngine = new Engine();
[Benchmark]
public Double Yamp_AddTwoNumbers()
{
return YampNumeric(AddTwoNumbers);
}
[Benchmark]
public Double Mages_AddTwoNumbers()
{
return MagesNumeric(AddTwoNumbers);
}
private static Double YampNumeric(String sourceCode)
{
var result = YampParser.Evaluate(sourceCode);
return ((ScalarValue)result).Value;
}
private static Double MagesNumeric(String sourceCode)
{
var result = MagesEngine.Interpret(sourceCode);
return (Double)result;
}
}
现在让我们看看 MAGES 和 YAMP 之间的实际性能差异。
解析器 | 方法 | 中位数 | 标准差 |
---|---|---|---|
Mages | AddMultiplyDivideAndPowerNumbers | 5.1666 us | 0.0879 us |
Yamp | AddMultiplyDivideAndPowerNumbers | 8.4325 us | 0.0220 us |
Mages | AddTwoNumbers | 1.3742 us | 0.0136 us |
Yamp | AddTwoNumbers | 1.8482 us | 0.0122 us |
Mages | CallStandardFunctions | 8.3647 us | 0.0685 us |
Yamp | CallStandardFunctions | 16.3588 us | 0.1172 us |
Mages | MultiplyTwoVariables | 4.0711 us | 0.0325 us |
Yamp | MultiplyTwoVariables | 5.3462 us | 0.0322 us |
Mages | Transpose4x5Matrix | 15.7278 us | 0.2363 us |
Yamp | Transpose4x5Matrix | 41.2478 us | 0.2637 us |
相对差异最小的是包含多个变量访问语句的测试。主要时间花在变量解析上,即两者之间没有太多区别。变量访问在 MAGES 中工作类似。没有预先解析,尽管这会提高性能。这种设计选择背后的原因在于动态自由度。这样,一个人可以使用缓存而无需修复全局范围。
最大的差异可以在转置操作中看到。尽管在 MAGES 中填充矩阵需要大量操作,但处理矩阵的方式比以前的 YAMP 快得多。这种直接方式也反映在方法处理中。这解释了为什么 MAGES 在调用大量标准函数时轻松超越 YAMP。
MAGES 的一项新功能是能够“编译”准备解释的代码。这样,就可以省略从源代码到操作的验证和转换的整个过程。速度提升是巨大的。
以下基准测试考虑了以下形式的简单查询:
sin(pi / 4) * cos(pi * 0.25) + exp(2) * log(3)
使用 Dictionary<String, Func<Object>>
形式的简单缓存机制进行评估。我们将 MAGES 与没有缓存的 MAGES 进行比较,因为我们知道 MAGES 本身已经超越了 YAMP 两个数量级。
已缓存? | 方法 | 中位数 | 标准差 |
---|---|---|---|
是 | CallStandardFunctions | 1.1564 us | 0.0123 us |
否 | CallStandardFunctions | 8.3767 us | 0.0543 us |
缓存可能(取决于表达式)比直接评估查询快几个数量级。这对于重复来自脚本文件的用户输入至关重要。可以在开始时(或在用户输入更改后)评估用户输入,并使用缓存结果以获得更快、更节能的结果。
到目前为止,我们已经看到 MAGES 比 YAMP 快得多。但对于扩展功能,例如自定义函数或矩阵运算,情况如何?下表比较了其中一些操作。
解析器 | 方法 | 中位数 | 标准差 |
---|---|---|---|
Mages | CreateAndUseFunction | 17.5819 us | 0.0444 us |
Yamp | CreateAndUseFunction | 24.5831 us | 0.2574 us |
Mages | MultiplySmallMatrices | 5.1958 us | 0.0929 us |
Yamp | MultiplySmallMatrices | 13.4905 us | 0.1122 us |
Mages | MultiplyMediumMatrices | 265.5289 us | 0.7518 us |
Yamp | MultiplyMediumMatrices | 17,874.4550 us | 130.2489 us |
Mages | MultiplyLargeMatrices | 4,753.0534 us | 25.9203 us |
Yamp | MultiplyLargeMatrices | 298,255.1038 us | 1,298.1635 us |
Mages | ObjectAccess | 8.8594 us | 0.0871 us |
Yamp | ObjectAccess | 18.7118 us | 0.0976 us |
正如我们所见,MAGES 在所有场景中都出色地超越了 YAMP。特别是在矩阵方面,MAGES 带来了巨大的性能提升。在上面的基准测试中,即使是乘法两个“大”矩阵,MAGES 的性能也超过了 YAMP 乘法中等矩阵的性能。即使是小矩阵,使用 MAGES 也能看到不错的提升。
改进的另一个领域是对象。最初,YAMP 不了解对象,但是,在最近的版本之一中添加了对任意对象支持。现在我们看到 MAGES 从一开始就具有对象字面量和扩展支持,在简单的场景中就能轻松超越 YAMP。
与其他数学表达式解析器相比,性能如何?当然,MAGES 不仅仅是一个数学表达式的解析器,然而,对于大多数用户来说,这将是标准的用例。在这里,我们可以获得其他优秀库的列表。除了 YAMP 之外,此列表还包括:
- dotMath
- Jace
- Arpain
- GuiLabs MathParser
- MuParser
- S#
- MxParser
所有这些的任务是评估给定的表达式:
2^3 * (2 + 3 * sin(1) / 0.3 - sqrt(5))
下表显示了它们的性能。
解析器 | 中位数 | 标准差 |
Jace (默认) | 1.1588 | 0.2792 |
MAGES (已缓存) | 1.3827 | 0.0499 |
dotMath | 8.9858 | 0.1715 |
MAGES (默认) | 11.1582 | 0.6506 |
Jace (未缓存) | 12.7334 | 1.7223 |
Arpain | 15.1224 | 1.8141 |
GuiLabs | 16.4246 | 3.0767 |
YAMP | 30.9068 | 0.5521 |
muParser | 68.0219 | 15.413 |
S# | 246.5942 | 32.9897 |
MxParser | 11,543.41 | 216.0238 |
我们可以看到 MAGES 绝对是速度最快的数学表达式求值器之一。虽然 Jace 完全专注于数学查询,但 MAGES 超越了该领域。如果我们将其与具有类似目标的 S# 进行比较,我们会发现 MAGES 在性能上存在巨大差距。
Jace 和 MAGES 之间的区别之一是 Jace 开箱即用地提供了表达式缓存。相反,MAGES 不会向用户隐藏这一层。拥有缓存也伴随着一些责任——否则,很容易造成内存泄漏。MAGES 不想将此责任推卸给用户。相反,MAGES 提供了一切,让用户可以轻松地将其集成到缓存中。
常见问题解答
此时,一些问题可能尚未得到解答。希望以下假设问题和答案列表可能有所帮助。
为什么还要有一个解释器?
为什么不呢?这里的想法是拥有一种轻量级、可扩展且功能强大的东西。它还应该能够在各种平台上运行。
为什么 REPL 与库分离?
这种设计使得将 MAGES 包含在任何应用程序中都非常容易。我认为提供库中的核心功能是一种更灵活的设计,然后该库可以用于任何类型的应用程序(Web、控制台或 GUI)。
为什么某些函数仅在 REPL 中可用?
大多数函数应该以所谓的插件的形式提供(即,可以在 REPL 之外使用),但是,一小部分函数(例如 spawn
)被视为实验性的,并且没有外包给插件。
为什么它比 YAMP 快得多?
主要原因是计算的直接性——省略不必要的抽象和复制。总的来说,所有这些都是通过更好的设计实现的。
未来的计划是什么?
MAGES 从未被视为“大事”。它不重新发明轮子,并试图最小化编写代码所需的认知负荷。潜在地,一个(可选的)优化器将被包含在管道中。这将简化代码创建过程中的常量表达式,从而可能减少运行时指令。
当我遇到 bug 时该怎么办?
在 官方 GitHub 存储库 中报告它。只需确保您提供一个最小的可工作示例来展示 bug。
我该如何提供帮助?
第一步是给 官方 GitHub 存储库 加星标。如果您能投入更多时间,任何代码贡献(可能与单元测试一样简单,但也可能与添加另一个官方插件或语言功能一样复杂)都将不胜感激。
使用代码
假设我们想将 MAGES 集成到一个现有(或新)的应用程序中。我们应该做的第一步是打开 Nuget 包管理器对话框。在那里我们找到 MAGES 并将其添加到当前项目中。以下屏幕截图说明了这一点。
然后,我们可能会做一些简单的事情。我们将 API 相关类公开到 MAGES。如果我们的设计已经相当不错,那就什么都不用做。否则,我们将把要公开的功能带入一个专用类。
现在我们来看一个示例 Windows Forms 应用程序,我们正是这样做的。我们将 Form1.cs 文件更改为如下所示:
public partial class Form1 : Form
{
private readonly ListsApi _api;
private readonly Engine _mages;
public Form1()
{
InitializeComponent();
_api = new ListsApi(listSource, listTarget);
_mages = new Engine();
_mages.SetConstant("api", _api);
}
}
以下屏幕截图显示了示例应用程序。它基本上由两个列表组成,这两个列表通过两个按钮连接。
以前,这两个按钮可能包含处理逻辑。现在,我们将设计更改为针对 API 层进行工作。无论如何,这都是一种最佳实践。因此,事件处理程序现在如下所示:
private void btPush_Click(Object sender, EventArgs e)
{
_api.Push();
}
private void btPull_Click(Object sender, EventArgs e)
{
_api.Pull();
}
API 类看起来就像这里给出的那样简单:
class ListsApi
{
private readonly ListBox _source;
private readonly ListBox _target;
public ListsApi(ListBox source, ListBox target)
{
_source = source;
_target = target;
}
public Boolean CanPush
{
get { return _source.SelectedItem != null; }
}
public Boolean CanPull
{
get { return _target.SelectedItem != null; }
}
public void SelectSource(String name)
{
Select(_source, name);
}
public void SelectTarget(String name)
{
Select(_target, name);
}
public void Push()
{
Transfer(_source, _target);
}
public void Pull()
{
Transfer(_target, _source);
}
private static void Transfer(ListBox source, ListBox target)
{
var item = source.SelectedItem;
if (item != null)
{
source.Items.Remove(item);
target.Items.Add(item);
}
}
private static void Select(ListBox list, String name)
{
foreach (var item in list.Items)
{
if ((String)item == name)
{
list.SelectedItem = item;
break;
}
}
}
}
本质上,这个类只关心将选定的元素从源移动到目标,反之亦然。但是,拥有这种以 API 为中心的做法使我们能够快速将功能暴露给 MAGES。
最后,这还允许我们在主窗体添加的控制台文本框中输入以下片段。如果我们当前有左侧的元素“e”和右侧的元素“d”,两者都会交换。
api.selectSource("e");
api.push();
api.selectTarget("d");
api.pull()
当然,我们也可以在没有额外的 api
命名空间的情况下公开 API。但是,在这种情况下,可以轻松地看到 MAGES 在哪里访问我们公开的 API。最后的冒号是可选的,因此可以在上面显示的源中省略。
结论
MAGES 是一个很好的工具,可以在任何类型的 .NET 应用程序中引入快速脚本。它性能高、可扩展性强,并且与 .NET 无缝协作。通过拥抱 .NET 类型系统,任何 API 公开都将毫不费力地完成。MAGES 语言与我们的自定义代码之间的交互感觉很自然。
历史
- v1.0.0 | 初始发布 | 2016 年 6 月 26 日
- v1.1.0 | 添加目录 | 2016 年 6 月 27 日
- v1.1.1 | 文本修复和改进 | 2016 年 6 月 28 日
- v1.1.2 | 修正了示例项目的链接 | 2016 年 6 月 29 日
- v1.2.0 | 添加常见问题解答 | 2016 年 6 月 30 日
- v1.2.1 | 修正了一些拼写错误 | 2016 年 7 月 2 日
- v1.3.0 | 添加了 Chocolatey 链接和性能图表 | 2016 年 7 月 6 日
- v1.3.1 | 修正了一些拼写错误 | 2016 年 7 月 10 日