POLAR - 在 .NET 中创建虚拟机






4.97/5 (114投票s)
一个教程,解释如何创建虚拟机以及此类虚拟机的编译器。
POLAR - Paulo's Object-oriented Language with Assembly-like Runtime (面向对象的类汇编语言运行时)
在开始之前,我必须说这是一个高级主题。我试图使其简单化,并纠正在我看来,在解释编译过程时常见的错误。
但无论如何,虚拟机都有汇编器(类汇编器)指令。所以,既然是汇编器,这是一个很难的话题。了解任何其他汇编编程语言将非常有帮助理解本文。
因此,如果您对汇编器感到舒适,或者想看看我的解释是否好,请继续阅读。
在本文中,我将尝试逐步解释如何构建一个虚拟机和一个类似 C# 的编译器来编译这种虚拟机。
名称 (POLAR) 被选中是因为它易于记忆,因为我喜欢寒冷的天气,并且因为我设法为其赋予了意义。
如果我的网站没有宕机,应用程序应该会在您点击下面的链接时加载。
注意:文章末尾有“如何在您自己的项目中使用 POLAR”。因此,如果您对它的工作原理不感兴趣,但想为您的项目添加脚本支持,请看一下。
背景
我喜欢重塑事物。许多人说这是无用的,但它有助于我理解事物是如何工作的,并可能理解它们为什么是这样的。这也有助于我解决找不到内置解决方案的问题,或者将我的一些理论付诸实践。我经常这样做,以至于我的许多朋友说我应该创建一个新的语言或新的操作系统。
嗯……我并不指望创造一种非常流行的语言,但为什么不试试呢?
在编写任何代码之前先知道结果
我曾帮助过一些朋友,他们需要创建“编译器”以供教育目的。但我一直不喜欢常规的做法是事情的流程。
一般来说,人们从文本开始,类似这样
public static long Test(long x, long y)
return (x+x)/y;
然后他们必须将这种文本转换为“标记”,由类型和可能的內容组成,类似这样
类型 | Content |
public | |
static | |
long | |
名称 | 测试 |
( | |
long | |
名称 | x |
, | |
long | |
名称 | 是 |
) | |
return | |
( | |
名称 | x |
+ | |
名称 | x |
) | |
/ | |
名称 | 是 |
; |
最后,他们解释或进一步编译这种代码。
这种方法有什么问题?
这些步骤是必要的,但我的问题在于它们的顺序。
首先,许多人不理解为什么将文本转换为标记很重要。乍一看,标记比文本表示形式更难理解(至少对大多数人来说)。
其次,我们没有定义“目标”。我们将直接解释标记吗?还是我们将用这些标记创建一些其他东西,我们称之为“编译后的代码”?
第三,也是最糟糕的:在将文本转换为标记后,我们无法测试任何内容,因为这些标记是中间步骤。直到我们创建某种解释器,否则标记看起来像是浪费时间。
所以,我的方法不同
- 我们从结果开始。“可运行的代码”。对我来说,这意味着从虚拟机指令集和架构开始。
- 然后我们测试一些指令,并进一步开发指令和虚拟机。
- 最后,我们创建文本解析器,它将编译成这种代码,以便其他程序员无需了解汇编器即可使用我们的虚拟机。
最终结果
如果我的网站工作正常,您可能已经看到了最终结果。但让我们来解释一下
我的个人方法是先创建虚拟机。我确实是这么做的。但在文章中,我决定更进一步。我将向您展示一种具有以下特性的语言
- 编译并(伪)解释执行。它永远不会转换为真实机器代码,但它不解释字节。它只是调用“虚拟方法”。
- 甚至可以在受限环境(如 Silverlight)下用作脚本语言。
- 默认是 64 位(事实上,目前唯一支持的数字值是
long
)。 - 可以访问 .NET 类(它在 .NET 下运行,但要访问 .NET 类,必须显式注册它们)。
- 可以创建新类(但目前不支持继承)。
- 支持
var item = new Class();
(如 C#)或Class item = new();
(据我所知,其他任何语言都不支持- 嗯,这是 2011 年写的,当时 C# 不支持,很多人说支持它很愚蠢……现在 C# 支持了,而且有人告诉我 Java 也支持……事物在变化); - 支持异常处理,并随时可以通过
actualexception
关键字访问实际异常(因此可以默认使用内部异常,并且一个没有参数的方法可以知道它是否是由异常调用的); - 在汇编器(类汇编器)级别支持
yield return
。这是通过创建“保存点”来实现的,任何方法(即使是void
方法)都可以随时返回到该点。调用堆栈直到返回被保存,因此方法可以稍后继续执行。这允许非常灵活的协作式多线程,并在动画中大有帮助。我已经在我的文章 Yield Return Could Be Better 中讨论过这一点,并且yield return
可以由finally
子句调用。 - 允许将参数标记为
required
,因此如果它们为null
,将抛出一个带有正确参数名的ArgumentNullException
。 - 在汇编器级别支持默认参数。好的,我现在不允许多库,但真正的目的是如此。当程序集 A 使用程序集 B 的默认值时,在 .NET 或 C++ 中,它被编译为使用这些默认值的显式调用。如果将来程序集 B 更改了默认值,程序集 A 仍将使用旧的默认值,直到它被重新编译。当默认值在程序集级别时,这种情况不会发生。
- 它不是基于堆栈的。它有一个堆栈(因此方法知道如何返回到其调用者),但任何需要参数或创建新参数的操作都基于局部变量。也许这太技术性了,但这避免了不必要的加载和存储调用,并简化了对“汇编器”代码的理解,因为我们看到的是对局部变量的操作(最常见的情况),而不是计算堆栈上的值。
- 好吧……还有很多东西缺少……但我将展示像这样的简单东西……
What's your name?
<<You write your name here>>
Your name is: <<The name you wrote>>
……是如何由这样的指令制成的
.localcount 2
PutValueInLocal "What's your name?", 0
PrepareDotNetCall Script:WriteLine
SetParameter 0, 0 // sets the parameter 0 with the value at local 0.
CallMethodWithoutReturn
PrepareDotNetCall Script:ReadLine
CallMethodWithReturn 1 // put the result at local 1.
PrepareDotNetCall Script:Write
PutValueInLocal "Your name is: ", 0
SetParameter 0, 0
CallMethodWithoutReturn
PrepareDotNetCall Script:WriteLine
SetParameter 0, 1 // set the parameter 0 to the local 1 (the name)
CallMethodWithoutReturn
ReturnNoResult
这是从这样的代码开始的
public static void Main()
{
Script.WriteLine("What's your name?");
string name = Script.ReadLine();
Script.Write("Your name is: ");
Script.WriteLine(name);
}
并且,在使该简单示例工作后,添加新功能并进行测试,就可以创建像这样的游戏
虚拟机 - 我是如何开始的(可能不是最好的方式)
当我第一次想到虚拟机时,我考虑了很多使用字节表示的指令,这样我就可以使用 switch
语句或拥有一个“操作”列表并使用字节值作为要运行的指令的索引来轻松解释它。
我当然更喜欢面向对象的方法,所以操作列表(或某个接口的对象)就可以了。
所以,非常简略地说,我可能会有这样的东西
字节序列:0 0 1
第一个字节意味着:放置值。该指令应知道如何读取接下来的两个字节。
第二个字节表示:在寄存器 0。
第三个字节表示值。1。所以,将值 1 放到寄存器 0。
但即使在编写第一行代码之前,我已经发现了新的问题。
- 我将读取一个字节来获取指令。难道不能直接知道指令是什么吗?
- 并非所有字节都是指令。第一个字节是指令,而其他两个字节不是。根据指令参数的数量,甚至根据值,该数量都会发生变化。这使得跳转到代码的某个部分并确定字节是否代表指令变得困难。例如,它可能是一个参数或一个整数值。
我如何解决这个问题?
简单 - 二进制表示不是可运行的表示。
在内存中,指令将是具有所有必要参数的完整对象。当然,在编写“可执行文件”时,我们可以使用二进制表示,但在内存中,一个方法由一个指令列表组成,其中包含它需要的所有内容(要调用的方法的信息、要设置的值,或更常见的是要从局部变量读取和写入的值)。
因此,指令是实现“Run
”方法的任何类。
进行第一次测试
最后(当时是最后……但离最终还远),我决定写一些代码。
我创建了 PolarVirtualMachine
类,该类接收一个 IPolarInstruction
列表,并有 256 个寄存器(一个包含 256 个位置的长数组),当然,我还创建了 IPolarInstruction
接口,其中有一个 Run
方法,该方法接收虚拟机的引用,以便它可以访问寄存器。
虚拟机的主体是这样的
public sealed class PolarVirtualMachine
{
public int InstructionIndex { get; internal set; }
internal long[] _registers = new long[256];
public void Run(List<IPolarInstruction> instructions)
{
int count = instructions.Count;
while(InstructionIndex < count)
{
IPolarInstruction instruction = instructions[InstructionIndex];
InstructionIndex++;
instruction.Run(this);
}
}
}
我需要一些指令来测试,所以我创建了 PutValueInLocal
和 Add
指令。我需要查看 _registers
来看看它是否有效,但这对于第一次测试来说已经足够了。
接下来,我创建了 GotoDirect
和条件 Goto
,已经准备好支持 if
、while
和 for
。
想知道 Add 或 GotoDirect 是什么样的吗?
它们在这里
public sealed class Add:
IPolarInstruction
{
public byte Register1 { get; set; }
public byte Register2 { get; set; }
public byte ResultRegister { get; set; }
public void Run(PolarVirtualMachine virtualMachine)
{
long[] registers = virtualMachine._registers;
registers[resultRegister] = registers[Register1] + registers[Register2];
}
}
public sealed class GotoDirect:
IPolarInstruction
{
public int InstructionIndex { get; set; }
public void Run(PolarVirtualMachine virtualMachine)
{
virtualMachine.InstructionIndex = InstructionIndex;
}
}
这有什么用
也许你已经在想这没用了。我写了很多代码只是为了相加。
但是,嘿,任何解释型语言都是这样的。需要很多指令来解释一条虚拟机指令然后执行它。我们目前的优势在于,我们已经可以看到一些结果。我们还没有编写文本解析器,并且我们已经可以看到指令正在工作(嗯……多亏了调试器……但是……)我们正朝着正确的方向前进。
还有什么?
此刻,有很多事情要做。我们需要大量的指令才能使虚拟机可用。
我们需要某种方式来呈现事物。我可以将一个“虚拟地址”指定为屏幕,或者我可以创建指令来与 .NET 已存在的类进行交互(嗯,从示例中,您可以想象我决定做什么)。
那时,我非常想看到一些结果,以至于我甚至忘了考虑“虚拟机架构”。
您看到我使用了 256 个寄存器吗?嗯,.NET IL 和 Java 没有寄存器。它们完全基于堆栈。对于像加法这样的简单任务,它们将值压入堆栈并调用 Add
。然后,值被弹出堆栈,结果被压入堆栈。
实际的虚拟机没有任何类型的堆栈。它根本无法调用另一个方法,即使我添加了这样的指令,我该如何保存当前方法的局部变量?
选择架构
所以,即使我还有很多指令需要创建,我还是决定是时候选择一个架构了。
看到 .NET 和 Java 是基于堆栈的,这让我考虑了这一点……但我真的很喜欢寄存器。它们是我最初对局部变量的设想。
也许这不是最好的决定(应该再说一遍吗?),但我决定使用堆栈帧和局部变量,而不让机器成为基于堆栈的。
基本决定是
- 任何消耗或生成值的操作都作用于局部变量。
- 虚拟机不再有寄存器——它具有当前堆栈帧的概念。
- 每个堆栈帧都了解其局部变量(今天只有局部变量的数量很重要)、前一个堆栈帧,并且在准备方法调用时,了解下一个堆栈帧。
.NET 已经使用了局部变量的概念,但它需要加载局部变量(将它们压入堆栈)并存储它们(将值从堆栈弹出到局部变量)。此外,在 .NET 中,方法参数不是局部变量(它需要另一条指令来加载它[压入堆栈])。
但是,当我用 C#、C、Pascal 等语言编程时,方法参数是一个局部变量,我甚至可以更改它的值。所以我认为我的决定更类似于人们通常看待变量的方式。无论它们是来自参数还是在方法体内部声明的,它们都只是一个“局部”变量。
并且,通过在调用方法之前准备方法调用,我可以选择填充所有默认值。因此,默认值将在“编译”级别支持。这与 .NET 不同,.NET 在编译时支持默认值,但在运行时必须由调用者显式设置。
所以,新的虚拟机、Add
和 GotoDirect
类看起来是这样的
public sealed class PolarVirtualMachine
{
internal _PolarStackFrame _actualFrame;
public void Run(PolarMethod method)
{
if (method == null)
throw new ArgumentNullException("method");
_actualFrame = new _PolarStackFrame();
_actualFrame._method = method;
if (method.LocalCount > 0)
_actualFrame._locals = new object[method.LocalCount];
while(true)
{
var instructions = method.Body;
int instructionIndex = _actualFrame._instructionIndex;
_actualFrame._instructionIndex++;
var instruction = instructions[instructionIndex];
instruction.Run(this);
if (_actualFrame == null)
return;
method = _actualFrame._method;
}
}
}
public sealed class Add:
IPolarlInstruction
{
public int FirstLocalIndex { get; set; }
public int SecondLocalIndex { get; set; }
public int ResultLocalIndex { get; set; }
public void Run(PolarVirtualMachine virtualMachine)
{
var actualFrame = virtualMachine._actualFrame;
var locals = actualFrame._locals;
long value1 = (long)locals[FirstLocalIndex];
long value2 = (long)locals[SecondLocalIndex];
long result = value1 + value2;
locals[ResultLocalIndex] = result;
}
}
public sealed class GotoDirect:
IPolarlInstruction
{
public int InstructionIndex { get; set; }
public void Run(PolarVirtualMachine virtualMachine)
{
virtualMachine._actualFrame._instructionIndex = InstructionIndex;
}
}
我们还有 PrepareCall
、SetParameter
和 CallWithoutResult
。我不会展示所有这些,因为查看源代码可能就足够了。最重要的是知道 PrepareCall
创建下一个堆栈帧,但并不使其成为活动帧。SetParameter
将一个值从当前堆栈帧复制到下一个堆栈帧,而 CallMethod
(无论是否有结果)将使下一个帧成为活动帧。
测试它
为了测试它,我创建了两个指令列表作为方法,其中一个调用了另一个。当时,PrepareCall
接收一个 List<PolarInstruction>
作为方法。后来,我将其更改为实际的方法。我真的相信,如果你想看它,那么从下载部分加载相应的示例,因为它在这里的文章中会占用太多空间。
还有什么 - 第二部分
现在我们可以调用其他方法了。默认值已加载,我们仍然可以设置非默认值,然后调用该方法。
但是我们的语言中没有类的概念。事实上,我们的方法是简单的函数。我确信我需要添加对类、方法、字段、虚拟方法等 things 的支持。
我们也没有异常处理;我们没有堆栈操作(嗯,除了真正的汇编器,我认为没有语言有它,但我写了一篇关于它的文章,并且想证明这个概念)。
事实上,没有单一的方式可以遵循。我可以完成所有这些,而不进行编译,或者我可以创建编译器,即使虚拟机尚未完成。
猜猜我要做什么。
异常处理和 StackSaver
我曾想过编写编译器……但是……我喜欢证明事情。我发表了文章 Yield Return Could Be Better,其中我说 yield return
如果有堆栈操作,实现起来可能会更简单,功能也更强大。
由于堆栈操作类似于异常处理,但更全面,所以我决定先从异常处理开始。
异常处理
异常处理,嗯,是自然控制流程中的一个异常。当抛出异常时,机器会“返回”,直到找到下一个 catch
或 finally
块。它不关心它只是跳转到适当的指令,还是需要丢弃许多堆栈帧。
在我的实现中,“try
”是 PushCatchFinally
。为了使其工作,我需要向堆栈帧添加“CatchFinallyHandler
”的概念。考虑到一个方法可能有一个 try
嵌套在另一个 try
中,try
被“压入”或“堆叠”(但它使用对旧 try
的引用,而不是真正的 Stack
对象)。
完成 try
块后,应该调用 PopCatchFinally
,并且由于存在“finally
”块,它会被弹出堆栈并成为活动指令。PushCatchFinally
已经告诉了在发生错误、PopCatchFinally
或 return
时要调用的指令。
事实上,只有一个 finally
块。但在这个 finally
块内部,可以检查异常,并在最后,可以杀死或重新抛出异常。因此,可以使用它来模拟 catch
和 finally
。
StackFrame
还有一个“actual exception”在那里。任何时候,都可以检查 actualexception
,即使在不将异常作为参数接收的方法中。
处理异常的指令是
PushCatchFinally
- 告诉指令在发生异常或返回时调用Rethrow
- 如果没有异常则什么也不做,如果这是catch
或finally
块,则重新抛出当前异常……该指令默认添加到finally
块的末尾,以便它们进行清理并让异常继续到其他catch/finally
块。PutActualExceptionInLocal
- 将实际异常(如果不存在则为null
)放入指定的局部变量。FinishCatchFinally
- 杀死当前异常并继续执行下一条指令。
堆栈操作
我对堆栈操作的想法是“调用一个创建保存点的函数”。因此,在任何时候,被调用的函数都可以返回到其调用者,而不管它需要回溯多少堆栈级别。
这类似于异常。但是当发生异常时,我们丢失了所有返回的堆栈帧。使用 stacksaver
,我们保存了这些堆栈帧,以便以后可以在该函数中继续执行。
为了实现这一点,有一个 StackSaver
类,它知道方法从哪里开始(给定方法的第一个堆栈帧)、它现在在哪里(因为我们可以从内部方法的中间返回)然后 return
应该被更改以检查它是否在 stacksaver 内部(以考虑它已结束)。为了更改 return
,我还更改了 stackframe 以知道它是否是 stacksaver 的第一个堆栈帧。
即使解释起来可能很难,它也差不多是这样
想象一下方法 A 调用方法 B。方法 B 然后为方法 C 创建一个 stacksaver。
然后它调用方法 C,C 调用方法 D,D 继而 yield return
。
当 yield
返回时,我们返回到方法 B,跳过方法 D 和 C 的其余部分,但这种返回保存了 D 的堆栈帧,并告诉还有更多内容需要执行。方法 B 然后可以正常运行,当它再次调用 stacksaver 时,方法 D 将继续。如果方法 D 返回(不是 yield 返回),它将返回到 C。如果 C 返回,它将标记 stacksaver 为完成,并返回调用,值为 false
,表示它已完成。
如果方法 B 丢失了 StackSaver 的引用会怎样?
yield
返回的一个可能问题是我们提前返回了一个方法,而忽略了其中的 finally
块。这可能导致资源泄漏。
嗯……在我目前的实现中,B 可以将 stacksaver 返回给 A(原始方法),所以我不需要在 B 的末尾检查“丢失的”stacksaver。但是当完成应用程序的第一个方法(Main
方法)时,我会检查这些 stacksaver。
我的解决方案是简单地运行它们直到它们完成。也许更好的方法是抛出类似 ThreadAbortException
的异常,以便它们执行 finally
块而不是执行所有内容,毕竟它们被丢弃了。好吧……我没那么做……但也许如果我收到足够的请求,我会改变这种方法。
编译
好吧……在编译之前,我个人添加了许多其他指令并直接进行了测试。但是……我认为要一一展示的指令太多了,所以我将继续进行编译过程,因为它才是使虚拟机真正可用的。
编译过程是将文本变成指令的过程。请注意,将这些指令保存到磁盘不是编译的一部分。
通常人们编译成二进制表示,特别是生成本机代码时,那是完全正确的。
但我没有生成本机代码,因为内存中的“指令列表”是 Polar 虚拟机理解的最“自然”的表示。
将这些指令转换为字节或反之是一个序列化过程。当然,我本可以使我的编译器直接编译成字节……但我甚至还没有决定“可执行文件”会是什么样子。所以,我更愿意编译成指令,然后在另一个时间,我决定二进制文件的外观,如何从指令转换为字节,以及如何从字节转换为指令。
编译分为两个主要阶段
- 标记识别
- 验证和指令生成
为什么需要标记识别?
为了简化事情。当我们看到“long someVariable = 10;
”时,我们将整个“long
”视为一个实体,然后“someVariable
”视为另一个实体,=
视为另一个实体,10
视为另一个实体,而“;
”(分号)视为最后一个实体。
好吧,这就是标记识别试图为计算机做的事情。我们正在处理的单词或值的大小(无论是 1、100 还是更多字符)无关紧要,它会将其变成一个单一的Token。
对于编译,当我们期望一个名称时,我们不需要处理任意数量的字符。我们将简单地读取下一个标记,如果它不是一个名称,我们就生成一个错误,如果它是一个名称,那么它就是好的。这只有可能,因为标记是由另一个通常早于此的“步骤”发现的。我说通常是因为它们可以一起工作,例如在生成枚举器时,但这又会使事情复杂化,而我希望它保持简单。
那么,标记器应该识别什么?
关键字、算术运算符、“块”结构等等。
我决定让我的编程语言类似于 C#,所以我已经知道关键字、符号结构等等。出于个人原因(我稍后会解释),我决定将以小写字母和大写字母开头的名称分为两种不同类型的标记。
识别标记就像这样
读取一个字符。
如果它是空格或制表符,请继续读取。
当它是换行符时,增加行号。
一旦我们看到不同的东西,我们就试着进一步分析它。
- 0-9 是一个数字。所以调用另一个方法(以避免包含嵌套
switch
的巨大方法)来进一步处理它。 - A-Z 是一个大写名称。所以调用另一个方法来读取名称。
- ( - 左括号,) 右括号,以及许多其他符号被直接视为一个标记。
- 对于其他符号,例如 +,我也必须尝试读取下一个符号来识别它是普通的 +,还是 ++,还是 +=。这些都具有特殊的标记类型,并且位于不同的方法中。
- / 具有相同的可能性是一个算术运算符,但它也可以是单行注释 //(所以我们必须读取到行尾)或多行注释 /*(所以我们读取直到找到 */,然后我们根本不将其添加到标记列表中(毕竟它是注释))。
- a-z 是一个小写名称。所以调用另一个方法来读取名称。在许多解析器中,一旦找到“
i
”,它就会调用一个特殊路径来尝试识别它是“if
”还是“int
”(关键字),还是以“i
”开头的另一个单词。我通过 .NETswitch
简化了这一点。读取完整的名称后,我对该名称使用switch
语句来识别关键字。如果它是关键字,则返回具有适当标记类型的标记(是的,每个关键字都有一个标记类型)。如果它不是关键字,那么它是一个小写名称,所以只需返回一个类型为LowerName
、值为读取到的名称的标记。
即使标记到标记的过程并不难,但我认为这项工作非常、非常无聊。
一些代码
你可能想看一些代码。嗯,我不会在这里展示全部代码,但为了让你有个概念,这是其中一小部分。
// This is the main parser method
private void _CreateTokens()
{
_lineNumber = 1;
for(_position=0; _position<_length; _position++)
{
char c = _unit[_position];
switch(c)
{
case '\n':
_lineNumber++;
continue;
case '\r':
case ' ':
case '\t':
continue;
case '{': _AddToken(_TokenType.OpenBlock, "{"); continue;
case '}': _AddToken(_TokenType.CloseBlock, "}"); continue;
case '(': _AddToken(_TokenType.OpenParenthesis, "("); continue;
case ')': _AddToken(_TokenType.CloseParenthesis, ")"); continue;
case '[': _AddToken(_TokenType.OpenIndexer, "["); continue;
case ']': _AddToken(_TokenType.CloseIndexer, "]"); continue;
case '.': _AddToken(_TokenType.Dot, "."); continue;
case ',': _AddToken(_TokenType.Comma, ","); continue;
case ';': _AddToken(_TokenType.SemiColon, ";"); continue;
case ':': _AddToken(_TokenType.Colon, ":"); continue;
case '~': _AddToken(_TokenType.BitwiseNot, "~"); continue;
case '+': _Add(); continue;
case '-': _Subtract(); continue;
case '*': _Multiply(); continue;
case '/': _Divide(); continue;
case '^': _Xor(); continue;
case '>': _Greater(); continue;
case '<': _Less(); continue;
case '&': _And(); continue;
case '|': _Or(); continue;
case '%': _Mod(); continue;
case '=': _Equals(); continue;
case '!': _Not(); continue;
case '\"': _String(); continue;
case '\'': _Char(); continue;
case '0': _FormattedNumber(); continue;
}
if (c >= 'a' && c <= 'z')
{
_Name(_TokenType.LowerName);
continue;
}
if (c >= 'A' && c <= 'Z')
{
_Name(_TokenType.UpperName);
continue;
}
if (c >= '1' && c <= '9')
{
_Number();
continue;
}
throw new PolarCompilerException
("Parser exception. Invalid character: " + c + ", at line: " + _lineNumber);
}
}
// As you can see, many tokens (like the comma, semicolon etc)
// are added to the list of tokens directly, while others call methods.
// The _Add method look like this (and many methods are extremely similar to it):
private void _Add()
{
int nextPosition = _position + 1;
if (nextPosition == _unit.Length)
{
_AddToken(_TokenType.Add, "+");
return;
}
char c = _unit[nextPosition];
switch(c)
{
case '+':
_position++;
_AddToken(_TokenType.Increment, "++");
return;
case '=':
_position++;
_AddToken(_TokenType.Add_Here, "+=");
return;
}
_AddToken(_TokenType.Add, "+");
}
标记已消失 - 让我们创建指令
幸运的是,我们已经测试了我们的指令。因为现在我们只能看到标记列表,但无法显示任何内容,除非进一步处理以创建指令。
所以,我们应该开始创建指令,但我们又开始遇到问题了。一个正常的单元以 using
子句开始,然后是命名空间声明、类型声明,最后是成员,包括方法。
如果我解析所有这些,我将需要进一步实现解析器。如果我不解析,那么稍后,我将需要做。嗯,我做了一点点,但我认为最好从编译一个方法开始。方法体解析是在 _ParseSingleOrMultipleInstructions
方法中完成的。
所以,让我们尝试编译这个
Console.WriteLine("Test");
我确信 Console
是一个类。但是,如果它是一个属性呢?
或者一个字段?或者一个命名空间名?
事实上,我们在这里有两个问题。
- 我们还没有 OO 支持。我们没有创建类或访问 .NET 类的指令。
- 我们使用标记来简化我们的读取。一般来说,第一个字符告诉我们标记是什么,但在某些情况下(如 + 和 +=),我们必须读取下一个字符才知道该做什么。即使有了独立的标记,我们仍然面临类似的问题。在 C# 中,如果我有一个名为
Console
的类和一个名为Console
的属性,我将只在读取下一个标记后才能发现我实际上在访问什么,以识别它是访问静态成员还是实例成员。
嗯。为了保持简单,我想避免任何歧义。我想读取一个标记并立即知道:
- 它正在设置一个局部变量
- 它正在引用一个类名,然后设置一个属性
- 它正在引用一个完整的类名(命名空间 + 类型名)来调用一个方法
- 或者许多其他可能性。
事实上,为了做到这一点,我最终会创建一个非常不同的语言。我可能会有一种语言,比如这样:
callstaticmethod Console.ReadLine();
setlocal x = 10;
或者任何与此相似的东西,必须在每行开头使用一个关键字,告诉编译器该行应该做什么。但我不希望完全改变语言。
所以,即使我不能从第一个标记就发现所有内容,我也想减少歧义,让事情变得更容易和更有条理。所以我决定使用一些好的实践作为规则:
- 使用
this.
是访问当前类的成员的强制要求。 - 小写名称表示对局部变量的访问。根据第一条规则,要访问字段,我们必须直接写
this.field
而不是field
。 - 大写名称表示类名。所以
Console
不能是属性。 - 要通过完整名称访问类型,我们从
namespace
开始。所以,System.Console
将尝试使用一个名为System
的类。要正确访问System.Console
,我们必须使用namespace.System:Console
。 - 好的,我已经提出了另一个规则。访问命名空间时,点(.)表示内部命名空间,而冒号(:)表示类名。
- 当已经访问局部变量时,例如
this.
,下一个项目可以是区分大小写的名称,用于标识属性或方法,或者是一个小写名称,用于标识字段。好的,方法和属性可以有冲突的名称,但字段永远不会有冲突的名称。 - 这无助于解析器,但我决定字段只能是
internal
或private
。所以,必须创建属性才能使字段对外部可见。 - 要访问当前类的
static
成员,我们必须从static.
开始。因此,我们必须始终使用“this.
”或“static.
”来访问当前类的成员,而无需重新输入类名。 - 类型转换由
cast
关键字前缀,然后是表达式,后跟to
或as
,最后是类型。类型转换是我添加到编译器的最后几件事之一,我不想完全改变表达式的解析。对我来说,开括号仅用于确定求值顺序,它不是可能的类型转换。此外,我真的认为 C 风格的类型转换很难看。C++ 的dynamic_cast
对我来说更干净,我想要一个简单的语法。cast to
如果类型转换无效则抛出InvalidCastException
,而cast as
如果类型转换无效则返回null
。 - 还有其他规则……其中一些是由于我的解析器工作方式而出现的。如果您使用该语言,您可能会发现它们。
考虑到所有这些想法(并编写了一些代码,然后重新审视它并更改一些不完全正确的地方),_ParseSingleOrMultipleInstructions
是这样的:
- 如果标记是
{
(我称之为OpenBlock
),那么我们应该调用_ParseMultipleInstructions
。该方法将检查}
,如果不是这种标记,则会调用内部的_ParseSingleOrMultipleInstructions
。 - 如果它找到任何内置类型关键字(如
object
、string
、long
等),那么它会调用解析器来声明变量,传递新局部变量的正确类型。 - 如果它直接找到
UpperName
(this.Name
以“this
”开头,它会调用另一个路径),那么它将其视为类型名称。所以,它调用一个方法来尝试查找该类型,并可能声明一个变量、调用一个方法或设置一个属性。这就是为什么它是另一个处理它的方法。 - 如果它是
return
,那么它调用一个方法来解析return
。对于void
方法,return
后面应该跟着一个分号(并将ReturnNoResult
指令添加到方法中),但对于非void
方法,return
后面必须跟着一个表达式。 - 您可能会想象我必须检查该行是否以
this
关键字或 lowername 开头,它是对局部变量的调用。事实上,没有检查只读变量,并且this
关键字就像一个普通变量。您可以为它分配另一个值(即使这通常只会导致问题)。
我考虑的另一个非常麻烦、庞大且难以理解的方法是表达式的解析。下面应该发生什么?
return 1 + 2 * 3;
- 在基于堆栈的机器中,
1
将加载常量1
。由于这里每个操作都作用于一个局部变量,我必须创建一个局部变量(即使是临时的)来存放值 1。 - 当读取 + 号时,它会进行子解析(递归调用
_ParseExpression
)。它不会直接处理 +。 - 但是,在解析乘法时,这种乘法是直接在下一个值上完成的。这就是使
2 * 3
在1
之前求值的原因。 - 而且可能在这里应该发生很多优化。毕竟,当我使用“
1
”时,我分配了一个临时局部变量。但是如果我使用“a
”,它已经是一个局部变量,我应该直接使用它,还是应该分配一个新变量并复制值?当只读取时,我确信我应该直接使用它……但我确信我正在创建过多的局部变量,而且我可能会在不应该改变局部变量的值时改变它(这将是一个错误)。
关于实现“if”的简要说明
好吧,解释每个可能的关键字对于一篇文章来说太长了。但是,不解释任何一个又太少了。所以,我将试着解释 if
是如何工作的。
if
工作方式的两个要点。首先,我们需要确切地知道它做了什么,所以它在“汇编器”指令中会是什么样子。
然后,我们需要知道如何解析标记。
所以,让我们看一个简单的例子。
// value is a boolean variable with true or false.
if (value)
{
// Do something.
}
// End instructions
在汇编器指令中,假设 value 是索引为 1
的局部变量。它看起来像
GotoIfFalse 1, endInstructionIndex // checks the value at local 1,
// then jump if it is false.
// Do something instruction(s) here
// End instructions here.
重要注意事项
GotoIfFalse
必须知道要跳转到哪个指令索引。这取决于“Do something”中的汇编器指令数量。为了做到这一点,一旦我们识别出 if
,我们就添加 GotoIfFalse
指令,但我们不填写 InstructionIndex
属性。在解析完 if
内容后,我们设置索引。
注意到我使用了 GotoIfFalse
指令吗?当条件是 if(true)
时,我们实际上会根据条件 false
进行 goto
。像“if (!value)
”这样的指令可以优化为 GotoIfTrue
,但我目前不进行优化。我获取值,取反,然后调用 GotoIfFalse
。
完成这个简单if的方法可能是这样的
void _ParseIf()
{
// Note that another method already read the "if" token and redirected to this method.
// So, after the if, we expect an open-parenthesis, like this:
var token = NextToken();
if (token.Type != TokenType.OpenParenthesis)
throw new CompilerException("( expected.");
// Here an expression should be parsed. Again, it is possible to optimize.
// If it is if(true) we can ignore
// the if and if(false) we can skip the entire if block.
// But, I am not optimizing and I am considering
// that a variable name will be read (not a boolean expression, to simplify things)
token = NextToken();
if (token.Type != TokenType.LowerName)
throw new CompilerException("A variable name is expected.");
// Here I am getting the local variable definition by its name.
// If the local does not exist, such
// method throws an exception.
var localDefinition = GetLocal((string)token.value);
// Here I will create the GotoIfFalse instruction, that will read the
// local variable (by index).
// But at this moment I don't tell where to go to.
var instruction = new GotoIfFalse { LocalIndex = localDefinition.Index };
_instructions.Add(instruction);
// Then I parse one or more instructions, which can have inner ifs, loops and so on.
_ParseSingleOrMultipleInstructions();
// And after parsing all instructions, I discover where to jump to.
// We will jump to the next instruction after all that exists in this method.
// So, we use the Count at the instructions list.
instruction.InstructionIndex = _instructions.Count;
}
嗯,它很相似,不是真正的代码。在实际代码中,我在完成 if
后检查 else
。考虑到 else
存在,GotoIfFalse
将跳转到 else
的内容,并在 if
内容的末尾,它将需要添加一个 goto
到 else
的末尾。
我不认为这真的很难,但也不容易。最困难的部分是调试这段代码,如果出了问题,毕竟指令会生成,我们只有在运行时才会注意到错误。
我真的希望对 if
的解释足以让你明白。while
与 if
非常相似,但在块的末尾,它会 goto
到开头,重新评估条件。
分多步编译
我从方法体开始。但是在解析一个单元时,编译器应该从外部声明开始,然后找到内部声明。
我不能简单地识别一个方法并直接开始解析它。我必须先识别所有方法,这样在解析方法 A 时,如果它调用方法 B,我就应该知道它的存在(即使它还没有被解析)。
C、C++ 和 Pascal 通过前向声明来解决这个问题。如果方法 A 在 B 之前出现,并且没有任何东西告诉编译器它应该存在,那么这是一个错误。
但是前向声明很难看。它们增加了程序员应该编写的代码量,增加了验证次数,因为前向声明可能与实际实现具有不同的参数。所以,为了使其工作,一旦我识别出我正在处理一个方法,我就“存储”它们所有的标记而不进行验证。
识别哪些是标记非常简单:如果在找到开括号({
)之前找到分号(;
),那么块就结束了。如果找到开括号,那么我们就开始计算开括号和闭括号。当计数达到 0
时,块就结束了。此时,不需要知道内部标记是什么,也不需要知道它们是否有效。
事实上,识别块的开始/结束的方法不仅仅局限于方法,它识别任何块,无论是类型声明、内部命名空间等等。
实际的单元解析是这样的
- 识别 Usings、Namespaces 和 Types。Usings 只是存储,Types 只有它们的头部被识别并存储,Namespaces 被进一步处理,识别内部的 usings、namespaces 和 types。
- 验证 usings。我们已经知道所有存在的命名空间,因此可以验证无效的 usings。
- 识别所有存在的成员,验证它们的类型(我们已经存储了所有存在的类型)并存储它们的身体(用于方法或属性)。
- 现在我们终于开始处理方法体了。现在,如果我们看到对字段、方法或属性的引用,我们就知道该成员是否存在,即使它稍后才声明。
- 即使在方法上,我们也有很多步骤,就像开始一个
if
时,我们已经准备好“goto
”到该if
的末尾,而不知道需要跳过多少指令,正如已经展示过的。在处理完if
体后,goto
会被更改。 - 在最后一步,我们修正方法。在编译时,我们正在编译一个指令列表,该列表可能引用一个尚不存在的方法。所以,执行这些成员引用的指令被创建,带有
null
成员,并且在完成所有类型后,这些指令将接收它们的真实目标。
DEBUG
为了使代码可调试,编译器必须生成一些额外的信息。这些额外信息可能是一个将指令绑定到行号和列号的单独列表,或者指令本身可能被修改以包含这些信息。
我不想更改 IPolarInstruction
接口,因为它会迫使我更改所有已创建的指令,并为不可调试的代码添加不必要的信息。我也不想创建一个字典来将指令绑定到行号。我的解决方案是创建 _DebugableInstruction
。这种指令保存了调试信息和一个指向要运行的真实指令的引用。这样,在编译可调试代码时,我们就有了这些额外的信息,而指令将调用 Debugging
事件;在编译不可调试代码时,我们则完全避免它。
然后我将 _MethodParser
中的所有 _instructions.Add
行更改为调用 _AddInstruction
,该方法检查它是在生成可调试代码(创建 _DebugableInstruction
)还是不生成。
嗯,有了这些,我们已经可以创建断点,但这并不完全有用,因为我们无法检查运行应用程序的调用堆栈或查看其局部变量。
所以,我使当前堆栈帧和局部变量可见。我们仍然没有变量名、调用堆栈导航或成员评估,但这只是一个开始。
结论
也许我太早下结论了。但我真的相信重点是解释。要查看代码,最好下载并分析它。语言中仍然缺少很多东西,解析可能还有更好的方法(我不太习惯创建解析器),但我确实添加了一些有趣的东西。
例如,break
允许传递一个计数(如 break(2)
),这样您就可以一次性跳出多个 for
/while
等。在解析时,我堆叠/弹出操作列表,这些列表会添加正确的指令以用于 break
和 continue。所以,我没有为 for
(允许 break
)和普通方法体使用不同的解析器。当找到 break
时,相同的方法(ParseSingleOrMultipleInstructions
)会调用 _ParseBreak
。如果没有堆叠 break
,它将抛出 PolarCompilerException
。
方法参数可以以 required
关键字开头。这样,如果参数为 null
,将抛出带有正确参数名的 ArgumentNullException
。我已经请求微软添加此类资源,但他们告诉我这是不可能的,因为他们将其与可空类型进行了比较,并告诉我一个值较少的新类型将不知道如何自动初始化自身……嗯,我确信他们误解了我的请求,因为我正在像这里一样请求一个参数修饰符。
也许下次,我会更详细地解释这些项目是如何工作的。
如何在您自己的项目中使用 POLAR
POLAR 已经作为库提供。Polarlight 示例只是使用了该库(Pfz.Polar
,但 Pfz
也必需),并将一些类提供给 PolarCompiler
。如果您查看代码,您会看到这个:
var compiler = new PolarCompiler(unit, isDebugable);
compiler.AddDotNetType(typeof(Exception));
compiler.AddDotNetType(typeof(NullReferenceException));
compiler.AddDotNetType(typeof(Script));
compiler.AddDotNetType(typeof(Player));
compiler.AddDotNetType(typeof(Shot));
compiler.AddDotNetType(typeof(Asteroid));
compiler.AddDotNetType(typeof(Message));
compiler.AddDotNetType(typeof(AnimationList));
compiler.AddDotNetType(typeof(AsteroidList));
compiler.AddDotNetType(typeof(GameComponent));
compiler.Compile();
var method = compiler.GetCompiledMethod("Polarlight", "Program", "Main");
_virtualMachine.Run(method);
事实上,PolarCompiler
和 PolarVirtualMachine
类是您所需要的一切。您使用源代码创建 PolarCompiler
,并告知它是否可调试,添加它将可以访问的 .NET 类,然后进行编译。
编译完成后,您可以使用 GetCompiledMethod
获取编译后的方法,并且通过已创建的 PolarVirtualMachine
,您可以运行该方法一次或多次。该方法必须是 static
,返回 void
,并且不应接收任何参数。
Pfz
和 Pfz.Polar
库已经有了普通版本和 Silverlight 版本。我没那么做,但我相信它们可以通过创建合适的项目并添加所有源文件来编译为 Windows Phone 7。
图片来源
我在互联网上获取了 POLAR 中使用的图片。
北极熊的图片来自:http://gieseke.edublogs.org/files/2011/04/cute-22291jo.jpg。
而北极光来自:http://vector.tutsplus.com/tutorials/illustration/make-an-aurora-borealis-design-in-illustrator/。
历史
- 2011 年 11 月 28 日:使编译后的代码可调试并添加了背景图片。
- 2011 年 11 月 11 日:添加了对数字键盘的支持并解释了
cast
的工作原理。 - 2011 年 11 月 7 日:初始版本。