65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 LeMP 避免繁琐的编码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (22投票s)

2015年5月26日

LGPL3

19分钟阅读

viewsIcon

30388

downloadIcon

238

词法宏处理器使用受 LISP 启发的宏系统转换您的 C# 代码。它是 T4 模板的良好替代品,具有 Visual Studio 集成和 Linux 兼容编辑器。

注意:源代码请参见 GitHub:源代码

引言

LeMP 是我编写的一个工具,它通过运行用户定义的代码将语法树转换为其他语法树来转换源文件。旨在转换文件语法的代码称为“宏”。LeMP 内置了许多宏,在这篇初步文章中我将只介绍其中一些最有用的宏。您也可以编写自己的宏,这将在其他地方讨论。

LeMP in Visual Studio

LeMP 附带 Visual Studio 语法高亮器(可选)、Visual Studio 自定义工具、命令行工具和可在 Windows 和 Linux 上运行的独立编辑器。所有这些都是开源的。

本文将介绍您可以使用 LeMP 完成的一些事情。

on_finally

Java 引入了 try-finally 构造,以确保在发生异常时进行清理。本文不是为初学者设计的,因此您应该已经知道它看起来像这样

{
    var obj1 = new Class1();
    try {
        var obj2 = obj1.MakeAnotherObject();
        try {
            obj2.DoSomethingElse();
            obj1.DoSomethingMore();
        } finally {
            obj2.Dispose();
        }
    } finally {
        obj1.Dispose();
    }
}

Try-finally 使用起来有点笨拙,所以 C# 引入了 using 语句。使用 using 的代码不仅更紧凑,而且编写起来也更容易正确

{
    using (var obj1 = new Class1())
    using (var obj2 = obj1.MakeAnotherObject()) {
        obj2.DoSomethingElse();
        obj1.DoSomethingMore();
    }
}

然而,using 语句只能在您拥有带有 Dispose 方法的对象时使用。有时您需要进行其他清理,例如将全局变量恢复到旧值。在这种情况下,C# 仍然要求您使用 try-finally。

事实证明,如果您在编写清理代码时**先**写,而不是等到最后,那么更容易记住。LeMP 的 on_finally { Cleanup(); } 语句允许您这样做。on_finally 将当前大括号块中的其余语句包装在一个“try”语句中,然后在末尾添加一个 finally { Cleanup(); }

如果使用 on_finally 而不是 try-finally,上面的原始代码将如下所示

{
    var obj1 = new Class1();
    on_finally { obj1.Dispose(); }
    var obj2 = obj1.MakeAnotherObject();
    on_finally { obj2.Dispose(); }
    obj2.DoSomethingElse();
    obj1.DoSomethingMore();
}

此代码被翻译成您上面看到的原始代码。on_finally 可能不如 using 那么好用,但在清理不像调用 Dispose() 那么简单的情况下,on_finally 比直接使用 try-finally 更方便。

代码查找和替换

C 和 C++ 以使用 #define 指令定义的词法宏而闻名。这些“宏”因几个原因而不受欢迎

  1. 对结构一无所知:C/C++ 宏在词法层面工作,基本上是粘贴文本。由于它们不理解底层语言,因此可能会出现这样的错误

     // Input
     #define SQUARE(x)  x * x
     const int one_hundred = SQUARE(5 + 5)
    
     // Output
     const int one_hundred = 5 + 5 * 5 + 5;  // oops, that's 35
    

    相反,LeMP 解析整个源文件,**然后**操作语法树。将树转换回 C# 代码是最后一步,此步骤将执行诸如自动插入括号以防止此类问题的事情。

  2. 远距离幽灵行动:C/C++ 宏具有全局作用域。如果您在函数内部定义一个宏,它会一直存在到函数结束,除非您使用 #undef 明确地将其删除。更糟糕的是,头文件通常定义宏,这有时会意外地干扰其他头文件或源文件的含义。相反,LeMP 宏(如 replace(与 #define 等效的 LeMP 宏))仅影响当前块(大括号之间)。此外,一个文件不能以任何方式影响另一个文件,因此可以并发处理许多文件(嗯,除了 Visual Studio 插件不能)。

  3. 能力有限:使用 C/C++ 宏能完成的事情不多。使用 LeMP,您可以加载用户定义的宏,这些宏可以执行任意转换(尽管这超出了本文的范围)。

  4. 怪异的语言:C/C++ 预处理器与普通 C/C++ 具有不同的语法。相比之下,LeMP 代码看起来只是一种增强的 C#。

那么我们来谈谈 replace,它是 LeMP 中 #define 的等效宏。

替换

replace() {...} 是一个宏,它查找与给定模式匹配的内容,并将模式的所有实例替换为其他模式。例如,

// Input
replace (MB => MessageBox.Show, 
         FMT($fmt, $arg) => string.Format($fmt, $arg))
{
    MB(FMT("Hi, I'm {0}...", name));
    MB(FMT("I am {0} years old!", name.Length));
}

// Output of LeMP
MessageBox.Show(string.Format("Hi, I'm {0}...", name));
MessageBox.Show(string.Format("I am {0} years old!", name.Length));

大括号是可选的。如果存在大括号,则替换只在大括号内部发生;如果以分号而不是大括号结尾,则替换发生在同一块中的所有其余语句上。

如您所见,$fmt$arg 等占位符用于“捕获”表达式,然后将其复制到输出中。在上面的示例中,$arg 在对 FMT 的第一次调用中捕获 name,在第二次调用中,它捕获 name.Length。带有 $ 标记的占位符可以捕获任意大小的语法树,从单个整数到整个类定义。

此示例要求 FMT 恰好接受两个名为 $fmt$arg 的参数,但我们也可以通过添加 .. 运算符来捕获**任意数量**的参数或语句,如下所示

FMT($fmt, $(..args)) => string.Format($fmt, $args) // 1 or more arguments
FMT($(..args)) => string.Format($args)             // 0 or more arguments

replace 是比 C 的 #define 指令更复杂的工具。考虑这个例子

replace ({ 
    foreach ($type $item in $obj.Where($w => $wpred))
        $body;
} => {
    foreach ($type $w in $obj) {
        if ($wpred) {
            var $item = $w;
            $body;
        }
    }
})

var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Console.WriteLine("I wanna tell you about my digits!")
foreach (var even in numbers.Where(n => n % 2 == 0))
    Console.WriteLine("{0} is even!", even);
foreach (var odd  in numbers.Where(n => n % 2 == 1))
    Console.WriteLine("{0} is odd!", odd);

在这里,replace 搜索特定形式的 foreach 循环,并将其替换为更优化的形式

var numbers = new[] { 
    1, 2, 3, 4, 5, 6, 7, 8, 9
};
Console.WriteLine("I wanna tell you about my digits!")
foreach (var n in numbers) {
    if (n % 2 == 0) {
        var even = n;
        Console.WriteLine("{0} is even!", even);
    }
}
foreach (var n in numbers) {
    if (n % 2 == 1) {
        var odd = n;
        Console.WriteLine("{0} is odd!", odd);
    }
}

替换:方法风格

replace 还有另一种语法,看起来像是您正在定义一个方法(或运算符)。这是一个简单的例子

replace MakeSquare($T) { 
    $T Square($T x) { return x*x; }
}
MakeSquare(int);
MakeSquare(double);
MakeSquare(float);
// Output of LeMP
int Square(int x) {
  return x * x;
}
double Square(double x) {
  return x * x;
}
float Square(float x) {
  return x * x;
}

replace 是构造一系列非常相似的方法的好方法,如本例所示。首先我定义 MakeSquare,一个接受单个参数的宏。从技术上讲,$T 可以捕获任何语法树,但要使此示例正常工作,它必须是一个类型名称。MakeSquare 使用该参数生成一个名为 Square 的方法。

您在执行此操作时可能会遇到一个小问题:解析器不知道存在哪些宏,因此它不知道 MakeSquare 期望类型名称作为其参数(replace 本身也不知道此事实,但那是另一回事)。因此,某些类型不能传递给 MakeSquare。最值得注意的是,可空类型(如 MakeSquare(int?))将导致语法错误。请改用 MakeSquare(Nullable<int>)

在我给出第二个例子之前,我想介绍一个特殊的宏,叫做 concatId

concatId(Con, sole).WriteLine("Huh?");
// Output of LeMP
Console.WriteLine("Huh?");

concatId 将两个标识符组合成一个标识符;在本例中,Console 组合得到 Console。这在 replace 宏内部可能很有用,用于从现有名称派生新名称,这正是我们将在下一个示例中要做的

replace SaveAndRestore($var = $newValue) {
  replace (TMP => concatId(old, $var));
  var TMP = $var;
  $var = $newValue;
  on_finally { $var = TMP; }
}

string _curTask = "<No task running>";

void DoPizza(IEnumerable<Topping> toppings)
{
  SaveAndRestore(_curTask = "Make pizza");
  var d = PrepareDough();
  FlattenDough(d);
  AddToppings(d, toppings);
  Bake(d, TimeSpan.FromMinutes(12));
}
// Output of LeMP
string _curTask = "<No task running>";

void DoPizza(IEnumerable<Topping> toppings)
{
  var old_curTask = _curTask;
  _curTask = "Make pizza";
  try {
    var d = PrepareDough();
    FlattenDough(d);
    AddToppings(d, toppings);
    Bake(d, TimeSpan.FromMinutes(12));
  } finally {
    _curTask = old_curTask;
  }
}

在这里,我使用了两种风格的 replace,它们相互嵌套。其中一个 replace 命令将 TMP 更改为 concatId(old, $var)。稍后的代码中,在 SaveAndRestore(_curTask = "Make pizza") 处,语法变量 $var 变为 _curTask,因此 concatId(old, $var) 在实际替换发生之前变为 concatId(old, _curTask)。因此,实际上,此示例创建了一个名为 old_curTask 的变量来保存 _curTask 的旧值。然后,使用 on_finally 在方法结束时恢复 _curTask 的旧值。

SaveAndRestore 要求其单个参数是某种赋值语句。如果不是,例如,如果您编写

SaveAndRestore(a + b);

您会收到一条警告消息,“1 个宏看到了输入并拒绝处理它”,并且 SaveAndRestore(a + b); 将在输出中保持不变。

方法风格的 replace 也可以匹配运算符。例如

[Passive]
replace operator=(Foo[$index], $value) {
    Foo.SetAt($index, $value);
}
x = Foo[y] = z;
// Output of LeMP
x = Foo.SetAt(y, z);

此示例包含一些有趣的元素。首先,请注意此“运算符”的第一个参数是 Foo[$index]。这意味着除非 = 的左侧匹配 Foo[$index],否则宏不起作用。例如,Bar[index] 将不匹配此模式,但 Foo[x + y] 会匹配。另一个有趣之处是 [Passive] 属性。这会告诉宏处理器,当找到不匹配模式的 = 运算符时,不要打印警告。后面的代码中有两个 = 运算符的用法(外部的一个,x = (Foo[y] = z),和内部的一个,Foo[y] = z)。只有内部的一个匹配并被替换。

从技术上讲,方法风格的 replace 宏与上面描述的原始 replace 宏不仅仅是风格上的不同。第一个 replace **直接**对其后面的代码执行搜索和替换。另一方面,方法风格的 replace 实际上是**通过指定名称创建一个新宏**,这允许它执行的任何替换稍后发生,并与其他宏评估交错。然而,在大多数情况下,这个事实并没有什么区别。

实际用例:INotifyPropertyChanged

有些开发者需要大量实现 INotifyPropertyChanged 接口。实现此接口通常涉及大量样板代码和代码重复,并且在复制、粘贴和修改属性时很容易出错。使用普通的 C#,您可以通过在公共方法中共享公共代码来避免一些代码重复,如下所示

public class DemoCustomer : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    /// Common code shared between all the properties
    protected bool ChangeProperty<T>(ref T field, T newValue, 
        string propertyName, IEqualityComparer<T> comparer = null)
    {
        comparer = comparer ?? EqualityComparer<T>.Default;
        if (field == null ? newValue != null : !field.Equals(newValue))
        {
            field = newValue;
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            return true;
        }
        return false;
    }

    private string _customerName = "";
    public  string CustomerName
    {
        get { return _customerName; }
        set { ChangeProperty(ref _customerName, value, "CustomerName"); }
    }

    private object _additionalData = null;
    public  object AdditionalData
    {
        get { return _additionalData; }
        set { ChangeProperty(ref _additionalData, value, "AdditionalData"); }
    }
    
    private string _companyName = "";
    public  string CompanyName
    {
        get { return _companyName; }
        set { ChangeProperty(ref _companyName, value, "CompanyName"); }
    }

    private string _phoneNumber = "";
    public  string PhoneNumber
    {
        get { return _phoneNumber; }
        set { ChangeProperty(ref _customerName, value, "PhoneNumber"); }
    }
}

这还不错,但您可能需要在多个类中重复 ChangeProperty 方法,并且仍然存在一些代码重复,从而有可能犯错(您注意到上面的代码中的错误了吗?)

以下是如何将公共部分分解为 replace 宏的方法

replace ImplementNotifyPropertyChanged({ $(..properties); })
{
    // ***
    // *** Generated by ImplementNotifyPropertyChanged
    // ***
    public event PropertyChangedEventHandler PropertyChanged;

    protected bool ChangeProperty<T>(ref T field, T newValue, 
        string propertyName, IEqualityComparer<T> comparer = null)
    {
        comparer ??= EqualityComparer<T>.Default;
        if (field == null ? newValue != null : !field.Equals(newValue))
        {
            field = newValue;
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            return true;
        }
        return false;
    }
    
    // The [$(..attrs)] part of this example is puts all attributes into a list called 
    // `attrs`. This is important because in EC#/LeMP, modifiers like `public` are 
    // considered to be attributes. So we need this to preserve `public` in the output.
    replace ({
        [$(..attrs)] $Type $PropName { get; set; }
    } => {
        replace (FieldName => concatId(_, $PropName));
        private $Type FieldName;
        [$(..attrs)] $Type $PropName {
            get { return FieldName; }
            set { ChangeProperty(ref FieldName, value, nameof($PropName)); }
        }
    });

    $properties;
}

三重嵌套的 replace 命令可能看起来有点复杂,但您可以将其保存到一个单独的文件中,例如 ImplementNPC.ecs,然后忘记那些实现细节。然后您可以在任何源文件中像这样使用它

includeFile("ImplementNPC.ecs");

public class DemoCustomer : INotifyPropertyChanged
{
    public DemoCustomer(set string CustomerName) {}

    ImplementNotifyPropertyChanged
    {
        public string CustomerName { get; set; }
        public object AdditionalData { get; set; }
        public string CompanyName { get; set; }
        public string PhoneNumber { get; set; }
    }
}

这里的构造函数使用了 LeMP 的另一个特性:构造函数参数 CustomerName 上的上下文关键字 set 导致该参数被赋值给 CustomerName 属性。

注意:此示例的 [$(..attrs)] 部分需要 LeMP 2.3.0 或更高版本。

unroll & notnull

unroll..in 是一种编译时 foreach 循环。它会生成一段代码的多个副本,每次替换一个或多个标识符。与 replace 不同,unroll 只能匹配 in 左侧的简单标识符。

/// Input
void ProcessInfo(string firstName, string lastName, object data, string phoneNumber)
{
    unroll ((VAR) in (firstName, lastName, data, phoneNumber)) {
        if (VAR != null) throw new ArgumentNullException(stringify(VAR));
    }
    implementation here;
}
/// Output
void ProcessInfo(string firstName, string lastName, object data, string phoneNumber)
{
    if (firstName != null) 
        throw new ArgumentNullException("firstName");
    if (lastName != null)
        throw new ArgumentNullException("lastName");
    if (data != null)
        throw new ArgumentNullException("data");
    if (phoneNumber != null)
        throw new ArgumentNullException("phoneNumber");

    implementation here;
}

此示例还使用了 stringify() 宏将每个变量名转换为字符串。

然而,您也可以只使用 notnull 属性来获得类似的效果,尽管异常类型不同

void SetInfo(notnull string firstName, notnull string lastName, notnull object data, notnull string phoneNumber)
{
    implementation here;
}
// Output of LeMP
void SetInfo(string firstName, string lastName, object data, string phoneNumber)
{
    Contract.Assert(firstName != null, "Precondition failed: firstName != null");
    Contract.Assert(lastName != null, "Precondition failed: lastName != null");
    Contract.Assert(data != null, "Precondition failed: data != null");
    Contract.Assert(phoneNumber != null, "Precondition failed: phoneNumber != null");
    implementation here;
}

自动生成字段

我不知道你怎么样,但我写了很多“简单”的类和结构,特别是那种被称为“纯旧数据”或 POD 的类和结构,这意味着像这样的小字段组

public class FullAddress
{
    public readonly string Address;
    public readonly string City;
    public readonly string Province;
    public readonly string Country;
    public readonly string PostalCode;
    internal FullAddress(string address, string city, 
                    string province, string country, 
                    string postalCode, bool log = false)
    {
        Address = address;
        City = city;
        Province = province;
        Country = country;
        PostalCode = postalCode;
        if (Address != null && City == null)
            throw new ArgumentException("Hey, you forgot the city!");
        if (log)
            Trace.WriteLine("OMG a new address was just created!!!");
    }
    ...
}

您不需要编写很多次这样的类,就会开始厌倦一遍又一遍地重复相同的信息:“address”、“city”、“province”、“country”和“postalCode”每个都重复了四次,大小写不同,“string”重复了**十**次,而“FullAddress”重复了两次(如果添加默认构造函数,则为三次)。

使用 LeMP 和增强 C#,您可以用更短的代码获得相同的效果

public class FullAddress {
    internal this(
        public readonly string Address,
        public readonly string City,
        public readonly string Province,
        public readonly string Country,
        public readonly string PostalCode,
        bool log = false) 
    {
        if (Address != null && City == null)
            throw new ArgumentException("Hey, you forgot the city!");
        if (log)
            Trace.WriteLine("OMG a new address was just created!!!");
    }
    ...
}

正如主页上所解释的,此代码生成的输出与上面的原始类几乎相同。

C# 6 曾考虑过一个类似的功能,称为“主构造函数”。它们看起来像这样

struct Pair<T>(T first, T second)
{
    public T First { get; } = first;
    public T Second { get; } = second;
    ...
}

但主构造函数是有限的

  1. 您不能像我在 FullAddress 中那样轻松地验证构造函数参数。
  2. 您不能执行与将构造函数参数分配给字段或属性无关的操作,就像我用 log 所做的那样。
  3. 构造函数被迫为 publicFullAddress 有一个 internal 构造函数)。

相比之下,我向您展示的功能实际上与**构造函数无关**。说实话,当我第一次为这个功能编写单元测试时,我忘记在构造函数上测试它……所以自然而然地,它在构造函数上不起作用。

这个宏,也称为 SetOrCreateMember,将适用于任何方法,并且您可以使用 set 属性来仅仅**更改**字段而不是**创建**字段

/// Input
string _existingField;
public float Example(set string _existingField, 
                     private int _createNewField,
                     float num) { return num*num; }

/// Output
string _existingField;
private int _createNewField;
public float Example(string existingField, int createNewField, float num)
{
    _existingField = existingField;
    _createNewField = createNewField;
    return num * num;
}

安装 LeMP

LeMP Standalone

如果您喜欢这个工具,您会想要运行它,所以请按照安装说明进行操作。如果您想在 Linux 上运行它,LeMP 还具有内置编辑器(例如,运行 mono LeMP.exe --editor

要使用自定义工具,

  1. 创建一个 C# 文件,并选择性地在其中编写一些代码(有时我会在此时编写大量代码,因为 IntelliSense 在下一步会消失)。
  2. 在解决方案资源管理器中,将新文件的扩展名更改为 .ecs
  3. 在解决方案资源管理器中右键单击您的 .ecs 文件并单击“属性”
  4. 在“属性”面板中,将“自定义工具”字段更改为“LeMP”(不区分大小写)。应该会出现一个输出文件,其扩展名为 .out.cs

顺便说一句,如果您想让我写一篇关于如何编写 VS 语法高亮器的文章,我也可以这样做……毕竟我已经写了一篇关于单个文件生成器的文章了……

LLLPG 简介

我还要提一个宏,它非常庞大——字面上,它有自己的 353 KB 程序集。

它叫做 LLLPG,Loyc LL(k) 解析器生成器,它从 LL(k) 语法生成解析器和词法分析器。

增强 C# 简介

增强 C# 是带有许多额外语法的普通 C#。这实际上与 LeMP 无关,除了许多新语法仅仅是为了允许宏使用它而存在这一事实。与其他一些宏系统不同,LeMP 和 EC# **不允许**宏定义新语法。EC# 是一个“固定功能”解析器,而不是可编程解析器。

本文中已经使用了一些这种语法

  • 如您所见,用于捕获语法树的 $.. 运算符。
  • 单词属性:我观察到普通 C# 有一种称为“上下文关键字”的东西,如 yieldpartial,它们通常**不是**关键字,除非在特定上下文中使用。我通过允许我的解析器将**任何**标识符视为上下文关键字来概括这个想法。因此,setset string _existingField 中是上下文关键字,而 rulepublic static rule int ParseInt(string input) 中是上下文关键字。
  • 宏块:有一种新形式的语句 identifier (args) {statements;}。它用于调用宏,尽管也有许多宏不使用此语法。宏块也可以有更简单的形式 identifier {statements;}。属性获取器和设置器(如 get {...}set {...})实际上是使用此规则解析的。
  • 方法作为二进制运算符:给定一个方法如 Add(x, y),您可以用 x `Add` y 代替。它们的意思相同。
  • 表达式上的属性:像 publicstaticoverrideparams 这样的词是“属性关键字”,它们修改其后内容的含义。在普通 C# 中,您只能将这些属性放在字段、方法和类等对象上;但是增强 C# 允许您将属性放在**任何**表达式上,以防宏可能使用该属性。这解释了为什么 Constructor(public readonly int Foo) {} 是一个有效的语句。
  • 令牌树:正如我所提到的,EC# 是一种具有固定语法的固定语言。然而,其中一种语法被称为令牌树,其形式为 @{ tokens 列表 }。令牌树是带有括号、方括号和花括号的令牌集合(例如,@{ ] } 是一个无效的令牌树,因为右方括号没有与左方括号匹配)。文件解析很久之后,令牌树可以被宏(例如 LLLPG)重新解析,以赋予其内容意义。

EC# 包含了许多其他对 C# 语法的调整,它们几乎 100% 向后兼容标准 C#,尽管解析器可能包含错误,我欢迎您报告错误。

您可能会想,“嘿,您不是不得不做大量工作来扩展 C# 解析器以支持所有这些额外的语法吗?”答案是:实际上,不,不是真的;我的意思是,从头开始解析 C# 确实是一项繁重的工作,但实际上,增强 C# 解析器比标准解析器**更不复杂**。上次我查看时,Roslyn 的解析器有 10,525 行代码(442 KB),而 EC# 解析器大约有 2500 行代码(两者都有相似数量的注释)。EC# 使用 LLLPG,生成了大约 5000 行输出代码(137 KB)。

语法更多,它怎么可能更小呢?嗯,LINQ 还没有完成,所以这是一个因素。但在许多方面,EC# 的语法比标准 C# **更规则**;例如,方法的形参本质上只是一个表达式列表,所以这个方法成功解析了

public void Foo<T>(new T[] { "I don't think this belongs here" }) {}

实际上,我已将检查有效输入的一些负担转移到编译器的后期阶段——顺便说一句,这些阶段尚不存在。这种设计有两个优点

  1. 解析器更简单。

  2. 宏可以利用任何允许的奇怪语法。例如,还记得替换宏吗?

     replace ($obj.ToString() => (string)$obj) {...}
    

    表达式 $obj.ToString() => (string)$obj 将 lambda 运算符 => 重用于其从未设计的新目的。为了使其成功解析,lambda 运算符与 += 等其他运算符的处理方式几乎相同;它只是具有不同的优先级并能够识别左侧未赋值的变量声明。通过**不**将 => 视为特例,我同时简化了解析器并为其添加了一种新的运算符重载形式(需要明确的是,这与您习惯的运算符重载完全不同——它仅适用于宏)。

一切皆表达式

增强 C# 建立在一种我称为 Loyc 树 的“通用语法树”概念之上。EC# 解析器不是解析为专门为 C# 设计的语法树,而是解析为这种更通用的形式。如果您想编写自己的宏,您可能需要处理 Loyc 树,尽管通常您可以依靠 quotematchCode 宏(在第二篇文章中描述)来避免了解任何内容。

如果您曾经用 LISP 编程,您会知道没有“语句”和“表达式”的独立概念:**一切**都是表达式。可以说,增强 C# 最有趣的地方在于它也是一种基于表达式的语言。当然,解析器必须明确区分语句和表达式:X * Y; 是一个指针变量声明,而 N = (X * Y); 是一个乘法。语句以分号结尾,而表达式则不以分号结尾。

例如,以下代码依赖于(在语法树中)语句和表达式之间没有区别

/// Input
string nums = string.Concat(
    unroll(N in (1,2,3,4,5,6,7)) {
        stringify(N);
    }, " [the end]"
);

/// Output
string x = string.Concat("1", "2", "3", "4", "5", "6", "7", " [the end]");

unroll 不知道也不关心它位于“表达式上下文”而不是“语句上下文”中。

当解析器解析表达式(例如 1,2,3)时,它们由逗号分隔,但花括号通常会导致切换到语句解析模式;因此 stringify(N) 后跟一个分号。分号不是语法树的一部分,它只是标记每个“语句”的结尾,因为花括号期望包含“语句”。然后当 unroll 宏完成时,它会删除自身以及花括号,只留下表达式列表 "1", "2", "3" 等。因为这些是**打印**在期望**表达式**的位置,所以它们由逗号而不是分号分隔。

另一方面,如果我们简单地写

unroll(N in (1,2,3,4,5,6,7)) { nameof(N); }

输出**确实**由分号分隔

"1";
"2";
"3";
"4";
"5";
"6";
"7";

当然,这个输出不是有效的 C#,但它是一个完全有效的语法树。实际上更像一个列表。随便吧。

欢迎来到比扎罗世界

这种基于表达式的语言概念解释了 EC# 中一些令人费解的事情。例如,如果我给 EC# 以下输入

[#static]
#fn(int, Square, #(#var(int, x)), @`{}`( #return(x*x) ));

它吐出以下输出

static int Square(int x)
{
    return x * x;
}

到底发生了什么?不,#fn 不是某种比扎罗预处理器指令。您看到的是方法语法树的表示。#fn 意思是“定义一个函数”。# 符号对解析器来说**没有**其他特殊之处;除非您编写预处理器指令(如 #if),否则 # 被视为标识符字符,不比下划线更特殊。

#fn 接受四个参数(以及无限数量的属性):返回类型 (int)、方法名称 (Square)、参数列表(#(#var(int, x)) 是一个只包含一项的列表;#var(int, x) 声明了一个名为 x 的变量)和方法体。不常用的 @`{}` 符号是一个名为 "{}" 的标识符,它正在被“调用”,带有一个参数,即 #return 语句。当然,大括号本身不是一个函数,当我说 @`{}` 被“调用”时,我只是指子表达式正在与一个名为“{}”的标识符关联。这些子表达式被称为 @`{}` 的“参数”。

有一个叫做“EC# 节点打印机”的东西,它的任务是打印 C# 代码。当它看到一个像这样的树时

@#fn(#of(@`?`, double), Sqrt, #(#var(double, x)), 
    { return x < 0 ? null : Math.Sqrt(x); }
);

它将其识别为函数声明的完美正常语法树,因此它打印

double? Sqrt(double x)
{
    return x < 0 ? null : Math.Sqrt(x);
}

如您所见,您可以自由地将 #var(double, x) 这样的“前缀表示法”与 Math.Sqrt(x) 这样的普通表示法混合使用。我建议**不要**直接使用 #fn#var 之类的东西,因为方法或变量声明的实际语法树并不是我所说的稳定;我将来可能会重构这些树。

使用“Loyc 树”表示编程语言的好处是它提供了一个在编程语言之间转换代码的起点。理论上,可以定义一种“标准命令式语言”作为中间表示,作为帮助将任何源语言转换为任何目标语言的媒介。

Loyc 树的另一个优点是 LeMP 可以操作任何 Loyc 树,无论它来自哪种编程语言。目前 LeMP 只适用于两种语言,EC# 和我设计的一种名为 Loyc 表达式语法 (LES) 的小语言,但我希望有一天它能支持其他语言,如 Java、ES6、Python,或社区愿意编写解析器和打印器的任何语言。

您可能会觉得反向操作很有趣,看看您的普通 C# 代码是如何被解析成语法树的。只需在 .ecs 文件中编写一些普通的 C# 代码

using System.Collections.Generic;

class MyList<T> : IList<T> {
    int _count;
    public int Count { get { return this._count; } }
}

然后将 Visual Studio 自定义工具更改为“LeMP_les”,以将输出视为 LES 语法树

#import(System.Collections.Generic);
#class(#of(MyList, T), #(#of(IList, T)), {
    #var(#int32, _count);
    [#public] #property(#int32, Count, {
        get({
            #return(#this._count);
        });
    });
});

好了,今天就到这里,比扎罗世界足够了。

结束语

如果您可以向 C# 添加功能,它们会是什么?如果有一种方法可以将该功能视为纯粹的语法转换(“语法糖”),那么很有可能通过 LeMP 来实现它。

要了解更多可用宏,请访问主页。如果您对如何使用 LeMP 完成某项任务感到困惑,也许我可以提供帮助——只需提问!

寻求帮助: 如果编辑解析器听起来很有趣,EC# 仍然无法解析 LINQ。我需要一些帮助来为解析器添加 LINQ 支持!如果您有编写 Visual Studio 扩展的技能,我需要一些帮助将 LeMP 更好地集成到 Visual Studio 中!例如,我希望将我的单文件生成器转换为 vsix 扩展。此外,如果能够在普通的 C# 文件中编写 LeMP 代码,然后选择它,右键单击并选择“展开 LeMP 代码”之类的选项,那将是很好的。

历史

请参阅 LeMP 发布说明

© . All rights reserved.