這次要實現的是一個形式最簡單的腳本。這種腳本僅有命令、標號及跳轉構成,看起來就跟匯編一樣,不過好是比較好讀的。雖然這種腳本語言的語法非常簡單,但是最基本的要素還是要有的。
作為一個腳本引擎,為了可以在各種各樣的合適的宿主程序中使用,腳本本身最好不要涉及到具體的領域。當然,如果這個腳本被創建的目的僅僅是為了某個領域的話,那就無所謂了。因此,一個腳本引擎需要一個檢查和運行代碼的機制、運行時環境的維護以及一個功能足夠使用的插件系統。一個完整的腳本引擎至少需要如下部件:
1、代碼數據結構。代碼的數據結構用來存放經過分析的腳本代碼。事實上解釋型的腳本引擎,也就是邊執行邊分析代碼字符串的腳本引擎是比較難做,而且效率也不高的。腳本代碼經過事先分析,可以檢查一出一些在運行之前就能夠檢查的錯誤。而且我們把腳本的代碼重新處理成一個數據結構之後,執行也變得更加容易控制。
2、運行時環境。運行時環境用於存放腳本在運行的過程中產生的數據,譬如堆棧、變量和狀態信息等。對於一個已知的代碼,不同的運行時環境代表不同的腳本執行流程。為了讓腳本可以同時(但不一定是並發)執行,將運行時環境獨立出來也就顯得必要了。
3、語法分析器。語法分析器用於將代碼轉換成等價的代碼數據結構,並在發現代碼出錯的時候輸出合適的錯誤信息。
4、插件。插件是腳本與外部環境交互的途徑之一。有了插件系統,我們可以為腳本引擎添加額外的、跟腳本引擎無關的功能,譬如文件操作、屏幕輸入輸出等。如果必要的話,插件系統可以將腳本引擎與領域信息互相隔離,系統將變得更加容易使用。
5、虛擬機。虛擬機用於執行代碼並返回相應的結果。我們在使用腳本引擎時直接跟虛擬機進行交互,虛擬機則協調上述4個部件的相互協作。
在知道了這些之後,我們就可以開始開發一個基於命令的腳本引擎了。為了更加詳細以及明確地講述開發過程以及原理,在這裡將構造一門簡單的基於命令的語言。一門語言至少還是要有分支和循環的。但是為了簡化,我們將分支和循環分解成判斷與跳轉。語言可以自由添加標號,標號將作為跳轉的目標而出現。這門語言使用如下語法:
<值>:值可以是整數、小數、字符串或名字。
<名>:名可以是變量名或者標號等,使用字母與下劃線開始,後接不定數量的字母、下劃線與數字。
<名>::名字後接冒號代表一個標號。這個標號代表著一個指令的位置,用於指定跳轉目標。
goto <名>:goto用於直接跳轉到一個位置繼續執行。
set <名> <值>:set用於將一個值賦值給一個指定名字的變量。這個變量不存在則創建。
opcode <名> <值> <值>:opcode可以是add、minus、mul、div、idiv或mod。這6個命令將兩個值進行加、減、乘、除、整除及求余,並將結果賦值給一個指定名字的變量。這個變量不存在則創建。
if <值>[ opcode <值>] goto <名>:if用於判斷一個條件並在條件滿足被滿足的時候跳轉到指定的地方。條件可以是一個值,這個值必須是整數,並且在這個值不為0的時候條件被滿足。條件也可以是一個比較,這個時候opcode可以是is、is_not、less_than、greater_than、less_equal或greater_equal,分別在第一個值等於、不等於、小於、大於、小於或等於、大於或等於第二個值的時候滿足條件。
exit:結束執行
<名> <值>*:如果命令名稱不是上面的5種的其中一種的話,那麼這個命令將被傳遞給插件進行執行。這個時候,命令可以有任意的參數。
在這種語法下,我們可以假設宿主程序給了我們write、writeln和read命令用於輸入輸出,並得到一個判斷輸入的數字是否質數的程序:
write "請輸入一個數字:"
read Number
if Number less_then 2 goto FAIL
if Number is 2 goto SUCCESS
set Divisor 2
LOOP_BEGIN:
if Number is Divisor goto SUCCESS
mod Remainder Number Divisor
if Remainder is 0 goto FAIL
add Divisor Divisor 1
goto LOOP_BEGIN
SUCCESS:
writeln Number "是質數。"
exit
FAIL:
writeln Number "不是質數。"
這個程序首先判斷輸入是不是小於等於2,如果不是的話則使用一種簡單的方法來判斷輸入是不是質數。假設輸入的數字為n,那麼在n>2的時候,如果2到n-1中的任何一個數字能夠整除n的話,那麼n就不是質數了。下圖是這個腳本的運行結果:
現在開始實現它。
在真正開始讀腳本之前,我們需要一個在內存中表達命令的方法。命令有兩種,一種是跳轉標號,另一種是普通的命令。於是我們可以大概給出一個數據結構。跳轉標號表用於查詢一個名字所指定的命令的位置,而一個命令就由一個名字和一個參數列表構成。參數列表中的參數不僅有內容,還有類型。主要用於區分字符串和名字:
enum LexerType
{
ltString,
ltName
};
class LexerToken
{
public:
LexerType Type;
wstring Token;
};
class Command
{
public:
wstring Name;
vector<LexerToken> Parameters;
};
至於命令與標號的表示方法則用如下代碼:
vector<Command> FCommands;
map<wstring , size_t> FLabels;
好了,現在讓我們看看一行代碼應該如何分析。由於腳本支持字符串,所以我們不能簡單地使用空格來分割。如果我們遇到了“ writeln Number "是質數。"”,那麼我們期望的結果是這一行代碼被拆分成三個部分,分別是writeln、Number和"是質數。"。於是我們可以寫一個函數,一次取出一個部分。那麼我們只要一直取道換行符或者字符串結束,就能獲得一行的所有部分了。
腳本代碼由整數、小數、字符串、名字以及冒號組成。於是我們可以寫很多類似的代碼,然而格式都是int GetXXX(wchar_t*& Input);。這個函數檢查Input是否由XXX開始,返回值代表XXX用掉了多少個字符,然後把Input參數往後推那麼多個字符返回給你。舉個例子:
wchar_t* Input=L”123vczh”;
int Chars=GetInt(Input);
這個時候Chars=3,而且Input已經往後推了三個字符,指向了”vczh”。
於是經過努力,我們就擁有了一些函數:GetInt、GetReal、GetName、GetString、GetColon、GetSpace和GetLineBreak。我們如何使用呢?首先,我們在每一次獲得一個部分之前,我們都要調用GetSpace以過濾所有空格。然後就按如下順序調用上面的5個函數:
GetColon
GetString
GetName
GetReal
GetInt
事實上只要GetInt在GetReal之下就好了。因為如果123.456被GetInt先吃掉了3個字符之後,剩下的就無法解釋了。
如果全都失敗(函數返回0,代表什麼都沒檢查到)了,那麼我們可以GetLineBreak。如果再次失敗,那麼證明這個輸入的腳本就有問題了。那麼報錯吧。在示例代碼的Lexer.h/Lexer.cpp中有一個非常類似的詞法分析器用於將一行代碼分段。
讓我們把“ writeln Number "是質數。"”分行吧。
首先調用GetSpace,字符串指向了“writeln Number "是質數。””,然後依次調用5個函數一直到GetName成功。GetName返回7,拿到了writeln,字符串指向了“” Number "是質數。””。
然後調用GetSpace,接著仍然到了GetName成功。GetName返回6,字符串指向了“"是質數。””。
接著調用GetSpace,調用到GetString的時候就成功了。GetString返回6(注意我們用的是wchar_t),字符串指向了“”。
後面所有的調用都失敗了。我們意識到字符串已經用完了,於是對這一行代碼的分析就到此為止了。
到了這裡,我們把所有的行都分割成一堆東西了。於是下面可以在采取一個步驟。我們首先辨別出哪一些是標號,哪一些是命令,然後填入上面的代碼中提到的vector<Command>和map<wstring , size_t>中。如果我們遇到了一個標號,那麼就將標號名和命令表當前存在的命令的數量加入標號表,其余的都放進命令表。於是我們在goto的時候,就可以從標號表中查到命令在命令表中的位置,從而成功跳轉了。
對於上面那段檢查是否質數的代碼,最終的分析結果如下:
標號表:
LOOP_BEGIN: 05
SUCCESS: 10
FAIL:12
命令表:
00 write "請輸入一個數字:"
01 read Number
02 if Number less_then 2 goto FAIL
03 if Number is 2 goto SUCCESS
04 set Divisor 2
05 if Number is Divisor goto SUCCESS
06 mod Remainder Number Divisor
07 if Remainder is 0 goto FAIL
08 add Divisor Divisor 1
09 goto LOOP_BEGIN
10 writeln Number "是質數。"
11 exit
12 writeln Number "不是質數。"
命令表裡面有13個項,每一個項都被分成了命令名和參數表兩個部分。執行的時候可以通過命令名來做相應的工作。讓我們來手工執行一下這個代碼。
執行00,執行01,我們輸入“5”。
02條件失敗,03條件失敗,04設置變量Divisor為2。
05條件失敗,06設置Remainder=5%2=1,07條件失敗,08 Divisor變成3,09跳轉到05(LOOP_BEGIN)。
05條件失敗,06設置Remainder=5%3=2,07條件失敗,08 Divisor變成4,09跳轉。
05條件失敗,06設置Remainder=5%4=1,07條件失敗,08 Divisor變成5,09跳轉。
05條件成功,跳轉到10(SUCCESS)。
10輸出“是質數。”,11退出程序。
於是現在剩下了最後一個問題。write、writeln和read原本是不存在於腳本引擎的。但是腳本引擎不具有輸入輸出的方法也是不行的,所以我們需要實現一個插件系統。這個插件系統可以讓我們在腳本引擎的外部添加命令。也就是說,我們構造了一個腳本引擎,然後在外部創建一個插件,包含write、writeln和read,然後連接他們。最後做一些手段讓腳本引擎在執行到外部命令的時候將控制權轉移給插件。
在這裡,我們可以使用責任鏈模式。腳本引擎在遇到一個不認識的命令的時候,就訪問第一個鏈接到腳本引擎的插件。這個時候插件可以返回三種結果:成功、失敗或者棄權。返回成功代表命令被成功執行,腳本引擎繼續往下走。返回失敗代表指令被執行了,但是執行出錯,這個時候腳本引擎返回錯誤信息並停止執行。返回棄權代表這個插件不受理這個命令,腳本引擎將這個命令傳遞給下一個插件。如果所有的插件都棄權的話,那麼腳本引擎將返回“無效命令”並停止執行。
所以插件只需要有一個函數就行了。這個函數返回執行結果(成功、失敗或棄權),參數為當前的命令以及運行時環境(保存變量的地方)。腳本引擎使用一個vector去記錄所有鏈接的插件的指針,這樣的話腳本引擎在遇到不能解釋的命令的時候就可以依次訪問插件了。下面是插件的示例代碼:
class Plugin
{
public:
virtual PluginStatus Execute(const Command& aCommand , Environment& aEnvironment , wstring& ErrorMessage)=0;
};
vector<Plugin*> FPlugins;
命令腳本的東西就講到這裡了。接下來的一些文章將講述如何處理高級語言,並且開發一門新的語言出來。這門語言將只支持bool、int、double、string、數組和函數。
代碼結構如下:
Lexer.h/Lexer.cpp:詞法分析器
ScriptCommand.h/ScriptCommand.cpp:腳本引擎
Main.cpp:主程序
這個程序(SE_02.exe)讀取一個文本文件(SE_02.txt)並執行,可以在debug文件夾下看到編譯結果。