2016 年 2 月

第 31 卷,第 2 期

C# - C# 中的可自定义脚本

作者 Vassili Kaplan

在本文中,我将介绍如何使用 C#(无需使用任何外部库)构建自定义脚本语言。我在 MSDN 杂志 2015 年 10 月刊 (msdn.com/magazine/mt573716) 中介绍过,脚本语言根据“拆分与合并”算法在 C# 中分析数学表达式。

通过使用自定义函数,我可以扩展“拆分与合并”算法,以便同时分析数学表达式和可自定义脚本语言。可以将“标准”语言控制流语句(if、else、while、continue、break 等)添加为自定义函数,就像添加其他典型的脚本语言功能(操作系统命令、字符串操作、搜索文件等)一样。

我将把我的语言称为“C# 中的可定义脚本”或 CSCS。我为什么想要创建另一种脚本语言呢? 因为这是一种可自定义的简易语言。添加新的函数或添加提取任意多个参数的新控制流语句只需要添加几行代码即可。此外,可以在任意非英语方案中使用函数名称和控制流语句,只需更改一些配置即可,我也将在本文中予以介绍。通过查看 CSCS 语言的实现方式,您将能够创建您自己的自定义脚本语言。

CSCS 的作用域

实现非常基本的脚本语言相当容易,但实现五星级水平的语言则是既残酷又艰难。在本文中,我将限制 CSCS 的作用域,以便您有个大致的了解:

  • CSCS 语言包含 if、else if、else、while、continue 和 break 控制流语句。此外,还支持嵌套语句。您将学习如何快速添加其他控制语句。
  • 没有布尔值。您需要编写“if (a == 1)”,而不是“if (a)”。
  • 不支持逻辑运算符。您需要将嵌套的 if 编写为“if (a == 1) { if (b == 2) { … } }”,而不是“if (a ==1 and b == 2)”。
  • CSCS 不支持函数和方法,但您可以在 C# 中编写函数和方法,并使用“拆分与合并”分析程序注册它们,从而将它们与 CSCS 结合使用。
  • 只支持“//”-style 注释。
  • 支持变量和单维数组(全都在全局级别定义)。变量可以包含数字、字符串或其他变量的元组(作为列表实现)。不支持多维数组。

图 1 显示了用 CSCS 编写的“Hello, World!”程序。由于错误键入“print”,因此这个程序在最后显示错误: “无法分析令牌 [pint]”。 请注意,所有之前的语句均已成功执行;也就是说,CSCS 是解释器。

用 CSCS 编写的“Hello, World!”
图 1:用 CSCS 编写的“Hello, World!”

“拆分与合并”算法修改

我已对“拆分与合并”算法的“拆分”部分进行了两处更改(“合并”部分保持不变)。

第一处更改是,表达式分析结果现在可以是数字、字符串或值元组(每个都可以是字符串或数字),而不是只能为数字。我创建了以下 Parser.Result 类来存储应用“拆分与合并”算法的结果:

public class Result
{
  public Result(double dRes = Double.NaN, 
    string sRes = null, 
    List<Result> tRes = null)
  {
    Value  = dResult;
    String = sResult;
    Tuple  = tResult;
  }
  public double
       Value  { get; set; }
  public string
       String { get; set; }
  public List<Result> Tuple  { get; set; }
}

第二处更改是,现在不仅要等到找到停止分析字符 ) 或 \n 才能执行“拆分”部分,而且还要等到找到所传递的停止分析字符数组中的任意字符才能执行“拆分”部分。此更改非常有必要,例如,在分析 If 语句的第一个自变量时,其中分隔符可以是任意 <、> 或 = 字符。

您可以在随附的源代码下载中查看修改后的“拆分与合并”算法。

解释器

负责解释 CSCS 代码的类称为“解释器”。它是作为单一实例(即,只能有一个类实例的类定义)实现。在它的 Init 方法中,分析程序(见刚才提到的原文)是通过解释器使用的所有函数进行初始化:

public void Init()
{
  ParserFunction.AddFunction(Constants.IF,
        new IfStatement(this));
  ParserFunction.AddFunction(Constants.WHILE,
     new WhileStatement(this));
  ParserFunction.AddFunction(Constants.CONTINUE,
  new ContinueStatement());
  ParserFunction.AddFunction(Constants.BREAK,
     new BreakStatement());
  ParserFunction.AddFunction(Constants.SET,
       new SetVarFunction());
...
}

CSCS 中使用的实际名称在 Constants.cs 文件中进行了定义:

...
public const string IF          = "if";
public const string ELSE        = "else";
public const string ELSE_IF     = "elif";
public const string WHILE       = "while";
public const string CONTINUE    = "continue";
public const string BREAK       = "break";
public const string SET         = "set";

使用分析程序注册的所有函数都必须作为派生自 ParserFunction 类的类进行实现,且必须替代其 Evaluate 方法。

解释器在开始处理脚本时做的第一件事就是,通过删除所有空格(除非空格包含在字符串里面)和注释来简化脚本。因此,空格或新行不能用作运算符分隔符。运算符分隔符和注释字符串也在 Constants.cs 文件中进行了定义:

public const char END_STATEMENT = ';';
public const string COMMENT     = "//";

变量和数组

CSCS 支持数字(类型是 Double)、字符串或元组(变量数组,作为 C# 列表实现)。元组的每个元素可以是字符串或数字,但不能是其他元组。因此,不支持多维数组。若要定义变量,请使用 CSCS 函数“set”。C# 类 SetVarFunction 可实现设置变量值的功能,如图 2 所示。

图 2:实现 Set 变量函数

class SetVarFunction : ParserFunction
{
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    string varName = Utils.GetToken(data, ref from, Constants.NEXT_ARG_ARRAY);
    if (from >= data.Length)
    {
      throw new ArgumentException("Couldn't set variable before end of line");
    }
    Parser.Result varValue = Utils.GetItem(data, ref from);
    // Check if the variable to be set has the form of x(i),
    // meaning that this is an array element.
    int arrayIndex = Utils.ExtractArrayElement(ref varName);
    if (arrayIndex >= 0)
    {
      bool exists = ParserFunction.FunctionExists(varName);
      Parser.Result  currentValue = exists ?
            ParserFunction.GetFunction(varName).GetValue(data, ref from) :
            new Parser.Result();
      List<Parser.Result> tuple = currentValue.Tuple == null ?
                                  new List<Parser.Result>() :
                                  currentValue.Tuple;
      if (tuple.Count > arrayIndex)
      {
        tuple[arrayIndex] = varValue;
      }
      else
      {
        for (int i = tuple.Count; i < arrayIndex; i++)
        {
          tuple.Add(new Parser.Result(Double.NaN, string.Empty));
        }
        tuple.Add(varValue);
      }
      varValue = new Parser.Result(Double.NaN, null, tuple);
    }
    ParserFunction.AddFunction(varName, new GetVarFunction(varName, varValue));
    return new Parser.Result(Double.NaN, varName);
  }
}

下面的几个示例展示了如何在 CSCS 中定义变量:

set(a, "2 + 3");  // a will be equal to the string "2 + 3"
set(b, 2 + 3);    // b will be equal to the number 5
set(c(2), "xyz"); // c will be initialized as a tuple of size 3 with c(0) = c(1) = ""

请注意,无需对数组进行特殊的声明:只需定义带索引的变量,即可初始化尚未初始化的数组,并根据需要向其中添加空元素。在上一个示例中,添加的是元素 c(0) 和 c(1),两个元素均初始化为空字符串。我认为这就免去了大多数脚本语言在先声明数组时必须采取的不必要步骤。

所有 CSCS 变量和数组都是使用 CSCS 函数(如 set 或 append)创建。它们全都使用全局作用域进行定义,稍后可以通过调用变量名称或带索引的变量进行使用。在 C# 中,这是在 GetVarFunction 中实现,如图 3 所示。

图 3:实现 Get 变量函数

class GetVarFunction : ParserFunction
{
  internal GetVarFunction(Parser.Result value)
  {
    m_value = value;
  }
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    // First check if this element is part of an array:
    if (from < data.Length && data[from - 1] == Constants.START_ARG)
    {
      // There is an index given - it may be for an element of the tuple.
      if (m_value.Tuple == null || m_value.Tuple.Count == 0)
      {
        throw new ArgumentException("No tuple exists for the index");
      }
      Parser.Result index = Utils.GetItem(data, ref from, true /* expectInt */);
      if (index.Value < 0 || index.Value >= m_value.Tuple.Count)
      {
        throw new ArgumentException("Incorrect index [" + index.Value +
          "] for tuple of size " + m_value.Tuple.Count);
      }
      return m_value.Tuple[(int)index.Value];
    }
    // This is the case for a simple variable, not an array:
    return m_value;
  }
  private Parser.Result m_value;
}

只有 set 变量函数才必须使用分析程序进行注册:

ParserFunction.AddFunction(Constants.SET, new SetVarFunction());

get 变量函数是在 set 变量函数 C# 代码内进行注册(见图 2 中倒数第二行语句):

ParserFunction.AddFunction(varName, new GetVarFunction(varName, varValue));

下面是一些在 CSCS 中获取变量的示例:

append(a, "+ 5"); // a will be equal to the string "2 + 3 + 5"
set(b, b * 2);    // b will be equal to the number 10 (if it was 5 before)

控制流: If、Else If、Else

If、Else If、Else 控制流语句也是作为分析程序函数进行内部实现。它们是使用分析程序进行注册,就像其他任何函数一样:

ParserFunction.AddFunction(Constants.IF, new IfStatement(this));

只有 IF 关键字才必须使用分析程序进行注册。ELSE_IF 和 ELSE 语句将在 IfStatement 实现内部进行处理:

class IfStatement : ParserFunction
{
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    m_interpreter.CurrentChar = from;
    Parser.Result result = m_interpreter.ProcessIf();
    return result;
  }
  private Interpreter m_interpreter;
}

If 语句的真正实现发生在 Interpreter 类中,如图 4 所示。

图 4:实现 If 语句

internal Parser.Result ProcessIf()
{
  int startIfCondition = m_currentChar;
  Parser.Result result = null;
  Parser.Result arg1 = GetNextIfToken();
  string comparison  = Utils.GetComparison(m_data, ref m_currentChar);
  Parser.Result arg2 = GetNextIfToken();
  bool isTrue = EvalCondition(arg1, comparison, arg2);
  if (isTrue)
  {
    result = ProcessBlock();
    if (result is Continue || result is Break)
    {
      // Got here from the middle of the if-block. Skip it.
      m_currentChar = startIfCondition;
      SkipBlock();
    }
    SkipRestBlocks();
    return result;
  }
  // We are in Else. Skip everything in the If statement.
  SkipBlock();
  int endOfToken = m_currentChar;
  string nextToken = Utils.GetNextToken(m_data, ref endOfToken);
  if (ELSE_IF_LIST.Contains(nextToken))
  {
    m_currentChar = endOfToken + 1;
    result = ProcessIf();
  }
  else if (ELSE_LIST.Contains(nextToken))
  {
    m_currentChar = endOfToken + 1;
    result = ProcessBlock();
  }
  return result != null ? result : new Parser.Result();
}

明确规定,If 条件必须采用“自变量 1, 比较符号, 自变量 2”形式:

Parser.Result arg1 = GetNextIfToken();
string comparison  = Utils.GetComparison(m_data, ref m_currentChar);
Parser.Result arg2 = GetNextIfToken();
bool isTrue = EvalCondition(arg1, comparison, arg2);

可以在其中添加可选的 AND、OR 或 NOT 语句。

EvalCondition 函数仅根据比较符号比较令牌:

internal bool EvalCondition(Parser.Result arg1, string comparison, Parser.Result arg2)
{
  bool compare = arg1.String != null ? CompareStrings(arg1.String, comparison, arg2.String) :
                                       CompareNumbers(arg1.Value, comparison, arg2.Value);
  return compare;
}

下面展示了如何实现数字比较:

internal bool CompareNumbers(double num1, string comparison, double num2)
{
  switch (comparison) {
    case "==": return num1 == num2;
    case "<>": return num1 != num2;
    case "<=": return num1 <= num2;
    case ">=": return num1 >= num2;
    case "<" : return num1 <  num2;
    case ">" : return num1 >  num2;
    default: throw new ArgumentException("Unknown comparison: " + comparison);
  }
}

字符串比较是类似的,包含在随附的代码下载中(作为 GetNextIfToken 函数的直接实现)。

如果 if、else if 或 else 条件为 true,则代码块中的所有语句都会得到处理。这是在图 5 中 ProcessBlock 方法内实现。如果条件不为 true,则所有语句都会被跳过。这是在 SkipBlock 方法内实现(见随附的源代码)。

图 5:实现 ProcessBlock 方法

internal Parser.Result ProcessBlock()
{
  int blockStart = m_currentChar;
  Parser.Result result = null;
  while(true)
  {
    int endGroupRead = Utils.GoToNextStatement(m_data, ref m_currentChar);
    if (endGroupRead > 0)
    {
      return result != null ? result : new Parser.Result();
    }
    if (m_currentChar >= m_data.Length)
    {
      throw new ArgumentException("Couldn't process block [" +
                                   m_data.Substring(blockStart) + "]");
    }
    result = Parser.LoadAndCalculate(m_data, ref m_currentChar,
      Constants.END_PARSE_ARRAY);
    if (result is Continue || result is Break)
    {
      return result;
    }
  }
}

请注意“Continue”和“Break”语句在 while 循环中的用法。这些语句也是作为函数实现。下面展示了 Continue 用法:

class Continue : Parser.Result  { }
class ContinueStatement : ParserFunction
{
  protected override Parser.Result
    Evaluate(string data, ref int from)
  {
    return new Continue();
  }
}

Break 语句的实现类似。它们均使用分析程序进行注册,就像其他任何函数一样:

ParserFunction.AddFunction(Constants.CONTINUE,  new ContinueStatement());
ParserFunction.AddFunction(Constants.BREAK,     new BreakStatement());

您可以使用 Break 函数退出嵌套的 If 代码块或 while 循环。

控制流: While 循环

while 循环也是使用分析程序作为函数进行实现和注册:

ParserFunction.AddFunction(Constants.WHILE,     new WhileStatement(this));

只要分析 while 关键字,就会调用 WhileStatement 对象的 Evaluate 方法:

class WhileStatement : ParserFunction
{
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    string parsing = data.Substring(from);
    m_interpreter.CurrentChar = from;
    m_interpreter.ProcessWhile();
    return new Parser.Result();
  }
  private Interpreter m_interpreter;
}

所以,while 循环的真正实现发生在 Interpreter 类中,如图 6 所示。

图 6:实现 While 循环

internal void ProcessWhile()
{
  int startWhileCondition = m_currentChar;
  // A heuristic check against an infinite loop.
  int cycles = 0;
  int START_CHECK_INF_LOOP = CHECK_AFTER_LOOPS / 2;
  Parser.Result argCache1 = null;
  Parser.Result argCache2 = null;
  bool stillValid = true;
  while (stillValid)
  {
    m_currentChar = startWhileCondition;
    Parser.Result arg1 = GetNextIfToken();
    string comparison = Utils.GetComparison(m_data, ref m_currentChar);
    Parser.Result arg2 = GetNextIfToken();
    stillValid = EvalCondition(arg1, comparison, arg2);
    int startSkipOnBreakChar = m_currentChar;
    if (!stillValid)
    {
      break;
    }
    // Check for an infinite loop if same values are compared.
    if (++cycles % START_CHECK_INF_LOOP == 0)
    {
      if (cycles >= MAX_LOOPS || (arg1.IsEqual(argCache1) &&
        arg2.IsEqual(argCache2)))
      {
        throw new ArgumentException("Looks like an infinite loop after " +
          cycles + " cycles.");
      }
      argCache1 = arg1;
      argCache2 = arg2;
    }
    Parser.Result result = ProcessBlock();
    if (result is Break)
    {
      m_currentChar = startSkipOnBreakChar;
      break;
    }
  }
  // The while condition is not true anymore: must skip the whole while
  // block before continuing with next statements.
  SkipBlock();
}

请注意,while 循环会在特定迭代次数后主动检查是否为无限循环,这是由 CHECK_AFTER_LOOPS 常数在配置设置中进行定义。采取试探性的推断,如果 while 中的完全相同值在多个循环中得到比较,那么这就表明存在无限循环。图 7 展示了 while 循环,我忘记了递增此 while 循环内的循环变量 i

检测 CSCS 中的无限循环
图 7:检测 CSCS 中的无限循环

函数

为了让 CSCS 具有更多实用用途,需要多加点料;也就是说,必须实现更多函数。可直接向 CSCS 添加新函数: 先实现派生自 ParserFunction 类的类(同时替代 Evaluate 方法),然后使用分析程序注册此类。下面展示了如何实现 Print 函数:

class PrintFunction : ParserFunction
{
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    List<string> args = Utils.GetFunctionArgs(data, ref from);
    m_interpreter.AppendOutput(string.Join("", args.ToArray()));
    return new Parser.Result();
  }
  private Interpreter m_interpreter;
}

此函数可打印传递到其中的任意数量的逗号分隔自变量。自变量的真正读取是在 GetFunctionArgs 辅助函数中完成,此函数将所传递的全部自变量作为字符串列表进行返回。您可以在随附的源代码中查看此函数。

第二步(也是最后一步)是使用分析程序在程序初始化部分中注册 Print 函数:

ParserFunction.AddFunction(Constants.PRINT,     new PrintFunction(this));

Constants.PRINT 常数被定义为“print”。

图 8 展示了如何实现用于启动新进程的函数。

图 8:实现运行进程函数

class RunFunction : ParserFunction
{
  internal RunFunction(Interpreter interpreter)
  {
    m_interpreter = interpreter;
  }
  protected override Parser.Result Evaluate(string data, ref int from)
  {
    string processName = Utils.GetItem(data, ref from).String;
    if (string.IsNullOrWhiteSpace(processName))
    {
      throw new ArgumentException("Couldn't extract process name");
    }
    List<string> args = Utils.GetFunctionArgs(data, ref from);
    int processId = -1;
    try
    {
      Process pr = Process.Start(processName, string.Join("", args.ToArray()));
      processId = pr.Id;
    }
    catch (System.ComponentModel.Win32Exception exc)
    {
      throw new ArgumentException("Couldn't start [" + processName + "]:
        " + exc.Message);
    }
    m_interpreter.AppendOutput("Process " + processName + " started, id:
      " + processId);
    return new Parser.Result(processId);
  }
  private Interpreter m_interpreter;
}

下面展示了如何在 CSCS 中查找文件、启动和停止进程以及打印一些值:

 

set(b, findfiles("*.cpp", "*.cs"));
set(i, 0);
while(i < size(b)) {
  print("File ", i, ": ", b(i));
  set(id, run("notepad", b(i)));
  kill(id);
  set(i, i+ 1);
}

图 9 列出了可下载源代码中实现的函数及其简短说明。其中大部分函数都是对相应的 C# 函数进行包装。

图 9:CSCS 函数

abs 获取表达式的绝对值
append 在字符串中追加字符串或数字(然后转换成字符串)
cd 更改目录
cd.. 将目录提高一级
dir 显示当前目录的内容
enc 获取环境变量的内容
exp 指数函数
findfiles 查找具有指定模式的文件
findstr 查找所含字符串具有指定模式的文件
indexof 返回子字符串的索引或 -1(如果找不到的话)
kill 停止具有指定进程 ID 号的进程
pi 返回 pi 常数的近似值
pow 向第二个自变量的幂返回第一个自变量
打印 打印指定列表的自变量(数字和列表会转换成字符串)
psinfo 返回指定进程的进程信息
pstime 返回此进程的总处理器时间;适用于衡量时间
pwd 显示当前目录的路径名
run 启动具有指定自变量列表的进程,并返回 进程 ID
setenv 设置环境变量的值
set 设置变量值或数组元素值
sin 返回指定自变量的正弦值
size 返回字符串长度或列表大小
sqrt 返回指定数字的平方根
substr 返回从指定索引开始的字符串的子字符串
tolower 将字符串转换成小写形式
toupper 将字符串转换成大写形式

国际化

请注意,您可以使用分析程序注册与同一函数相对应的多个标签(函数名称)。这样一来,您便可以添加任意数量的其他语言。

添加翻译的步骤是使用同一 C# 对象注册另一个字符串。对应的 C# 代码如下所示:

var languagesSection =
  ConfigurationManager.GetSection("Languages") as NameValueCollection;
string languages = languagesSection["languages"];
foreach(string language in languages.Split(",".ToCharArray());)
{
  var languageSection =
    ConfigurationManager.GetSection(language) as NameValueCollection;
  AddTranslation(languageSection, Constants.IF);
  AddTranslation(languageSection, Constants.WHILE);
...
}

AddTranslation 方法可添加现有函数的同义词:

public void AddTranslation(NameValueCollection languageDictionary, string originalName)
{
  string translation = languageDictionary[originalName];
  ParserFunction originalFunction =
    ParserFunction.GetFunction(originalName);
  ParserFunction.AddFunction(translation, originalFunction);
}

得益于 C# 支持 Unicode,您可以通过这种方式添加大多数语言。请注意,变量名称也可以位于 Unicode 中。

所有翻译都是在配置文件中进行指定。下面展示了西班牙语的配置文件:

<Languages>
  <add key="languages" value="Spanish" />
</Languages>
<Spanish>
    <add key="if"    value ="si" />
    <add key="else"  value ="sino" />
    <add key="elif"  value ="sinosi" />
    <add key="while" value ="mientras" />
    <add key="set"   value ="asignar" />
    <add key="print" value ="imprimir" />
 ...
</Spanish>

下面的示例展示了西班牙语的 CSCS 代码:

asignar(a, 5);
mientras(a > 0) {
  asignar(expr, 2*(10 – a*3));
  si (expr > 0) {
    imprimir(expr, " es mayor que cero");
  }
  sino {
    imprimir(expr, " es cero o menor que cero");
  }
  asignar(a, a - 1);
}

请注意,分析程序现在可以处理英语和西班牙语的控制语句和函数。您可以添加任意数量的语言。

总结

所有 CSCS 元素(控制流语句、变量、数组和函数)都是通过以下方式实现:定义派生自 ParserFunction 基类的 C# 类,并替代其 Evaluate 方法。然后,您便可以使用分析程序注册此类的对象。这种方法具有以下优点:

  • 模块性: 由于每个 CSCS 函数和控制流语句都位于自己的类中,因此您可以轻松地定义新的函数或控制流语句,也可以轻松地修改现有的函数或控制流语句。
  • 灵活性: 可以使用任意语言的 CSCS 关键字和函数名称。只需要修改配置文件。与其他大多数语言不同,在 CSCS 控制流语句中,函数名称和变量名称无需使用 ASCII 字符。

当然,在这一阶段,CSCS 语言的工作远未完成。下面介绍了一些可提高其实用性的方法:

  • 创建多维数组。可以使用与单维数组 (List<Result>) 相同的 C# 数据结构。不过,在获取和设置多维数组的元素时,必须添加更多分析功能。
  • 让元组在一行初始化。
  • 对于 if 和 while 语句,添加逻辑运算符(AND、OR、NOT 等)将会非常有用。
  • 添加在 CSCS 中编写函数和方法的功能。目前,只能使用之前在 C# 中编写和编译的函数。
  • 添加包含来自其他单元的 CSCS 源代码的功能。
  • 添加更多执行与操作系统相关的典型任务的函数。因为大多数此类任务都可以在 C# 中轻松实现,因此大多数函数只需要对相应的 C# 函数进行简单包装即可。
  • 创建 set(a, b) 函数的快捷方式“a = b”。

我希望您已经大致了解 CSCS 语言以及如何创建您自己的自定义脚本语言。


Vassili Kaplan是一位前 Microsoft Lync 开发人员。他对 C# 和 C++ 编程充满了热情。现居住于瑞士的苏黎世,作为一名自由职业者供职于各银行。你可以通过 iLanguage.ch 与他联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: James McCaffrey