在 上個月的專欄文章中,如果您掌握以下幾點的話,那麼您會明白,底層代碼的可用性不會成為問題:
如何識別配置腳本
如何選擇允許哪種配置
識別哪種環境要求黑箱可擴展性
衡量可擴展性所帶來的構建復雜性
當提供此擴展性給配置腳本時,您 實際上正在構建一種語言。
您還認識到,考慮到應用程序的黑箱可擴展性,使用 S-expression 是一種快速建立一種配置語言的有效手段。我們將在本文深入研究 S-expression,並提供了一個如何用這些 S-expression 來快速方便地為特定應用程序建立配置語言的示例。
關於 S-expression 的一些知識
讓我們回憶一下,S-expression 是由圓括號分隔的元素列表的語法表示法。S-expression 有三種形式:
空元素列表
非空元素列表
單一原子元素(如一個字)
S-expression 作為配置語言非常有用,因為它們易於解析。一般的 S-expression 解析器將數據讀入程序,然後這個程序再檢查表達式是否遵守更具體的語法約束。用這種方法,我們得到了解析輸入的所有好處 ― 如早期的錯誤輸入檢測和增加的安全性 ― 除去了編寫和維護常規語法解析器時所帶來的精力消耗和開銷。同樣,不同於解析器生成器所構造的語法解析器,跟蹤語法錯誤來源時,錯誤消息的輸出可以很精確且很有幫助。
“S”較 XML 的優勢
正如我在上一篇文章中提到的,使用 S-expression 的許多好處同樣可以通過使用基於 XML 的配置語言而獲得。基於 S-expression 配置語言較 XML 的主要優勢在於它是非常輕量型的而且建立快速。
同樣,在許多情況下,基於 S-expression 的配置腳本比等價的基於 XML 的腳本更易於閱讀和編輯。當我們討論下面一些基於 S-expression 腳本的示例時,請考慮在 XML 符號中它們是什麼樣子。
示例:給編輯器添加宏支持
假定我們希望給文本編輯器添加簡單的宏支持,允許用戶定義基本操作的復雜序列。我們可能甚至想加入對循環或遞歸構造的支持。
這裡是宏的可能情形的示例:
清單 1. 簡單的宏
(define (cutAndPasteAtEnd)
(sequence
(cut HIGHLIGHTED_TEXT)
(move-to END_OF_DOCUMENT)
(paste CLIPBOARD))
即使我們還沒有討論我們配置語言的語法和語義,您也許猜出了這個腳本的意圖:一個對文本進行 剪切、 移動和 粘貼操作的序列,這些操作通常是應用程序的用戶自己做的事情。
寫下您希望您的語言能適合的各種腳本的示例是構建您的語言好的開端。事實上,對於您的解釋器,這些示例有助於形成良好的單元測試組。
以下是更復雜宏的示例:
清單 2. 內容更豐富的宏
(define (find-and-replace target replacement)
(move-to BEGINNING_OF_DOCUMENT)
(while (not AT_END)
(cut NEXT_WORD)
(if (equals CLIPBOARD target)
(paste replacement)
(paste target))
(move-to NEXT_WORD)))
同樣,您也許猜出了這個腳本表示應用程序腳本語言中的 查找並替換的實現。對於不熟悉 Scheme 風格語法的讀者,我要指出以下幾件事情:
所有操作都使用 前綴符號。這種風格使解析更容易,但對於某些操作,如 equals ,通常寫成中綴操作符,這對於初學者來說,看起來會很奇怪。
if語句,如大多數語言中一樣,有三個部分:按順序寫出 條件子句、 滿足條件時執行的部分和 不滿足條件時執行的部分。但是,同樣的,為了使解析更容易,我沒有用 else 關鍵字來標記不滿足條件時要執行的部分。
這個示例說明了向應用程序添加有充分表達性的腳本語言是用來提高可擴展性的一種較佳的方法。它使得能夠添加各種新功能而無須修改甚至查看原始源代碼。
示例:確定腳本編制語言
為實現這一腳本編制語言,首先必須確切地說明這個語言到底是什麼。這意味著明確語法和語義。
我們可以使用 BNF 符號來說明語法,如下:
清單 3. 明確說明腳本編制語言的語法
<script> ::= (<definitions> <expression>)
<definitions> ::= <empty>
| <definition> <definitions>
<definition> ::= (define (<var> <args>) <statement>)
<args> ::= <empty>
| <var> args
<statement> | (cut <name>)
| (paste <name>)
| (move-to <name>)
| (sequence <statements>)
| (if <expression> <statement> <statement>)
| (while <expression> <statement> <statement>)
| (return <expression>)
| (<var> <args>)
<statements> ::= <empty>
| <statement> <statements>
<name> ::= <var>
| <constant>
<expression> ::= <name>
| (not <expression>)
| (or <expression> <expression>)
| (and <expression> <expression>)
| (equals <name> <name>)
| (<var> <args>)
<var> ::= any word consisting of only letters and numbers
(but starting with a letter).
<constant> ::= BEGINNING_OF_DOCUMENT
| END_OF_DOCUMENT
| AT_END
| CLIPBOARD
深入研究
請注意這種語言包含過程定義。這些過程定義允許用戶抽象出經常使用的操作序列,然後在多處應用此抽象,作為值傳遞給不同參數(類似 Java 語言中的方法定義)。我正在定義一個腳本,這個腳本包含後跟一個表達式的這些定義的序列。
在這個簡單語言中遺漏了幾件事:
Let表達式,用來綁定過程中的變量;可以用額外的過程定義來模擬 let 表達式,但將它們作為語言的一部分會更方便。
賦值語句
靜態類型系統
尤其是,添加靜態類型系統將允許解釋器在腳本運行之前捕捉到許多錯誤。
另一方面,靜態類型系統也會使語言更冗長,而且不可避免地,它們不僅拒絕真正有錯誤的程序,還拒絕一些本來運行得很好的程序。出於這些原因,您通常會看到在程序趨於較短時會從腳本語言中省去靜態類型。在該示例中我們將省去它們,但如果想添加的話,當然沒有問題。
精心編寫三階段解析器
一旦我們對這個語言的語法滿意,我們就可以著手寫它的語法解析器。這就是使用 S-expression 的優勢所在。與編寫常規二階段語法解析器(標記化和解析)不同,我們通過增加額外的階段來大大簡化整個過程。
這個額外的階段發生在標記化與解析成語法樹之間。它包括將輸入分割為 S-expression 內部的表達式。這個分割過程基本上等同於圓括號匹配,但這樣做使得解析過程更加簡單。
從流中抽取表示
假設我們已經使用標記程序(比如,象用於較小規模輸入的 java.util.StreamTokenizer 或 StringTokenizer )將數據轉換成標記流,其中每個標記要麼是左圓括號,右圓括號,要麼是詞。
操作這個流最方便的辦法是堆棧。在清單 5,我定義了接口 StackI 它可以用任何我們喜歡使用的標記程序的適配器來實現。用這種方法,我們可以把注意力放在程序結構上而不用擔心任一特定標記程序的細節。然後我們可以編寫一些方法來從這個流中構造 S-expression 表達式。
本質上,這個過程涉及解析流中的第一個 S-expression,然後確定這個 S-expression 之後沒有東西了(因為整個程序只是一個大的 S-expression)。對 S-expression 的解析可以遞歸定義,因為一個復雜 S-expression 的元素自身就是較簡單的 S-expression:
清單 4. 解析原始標記流
import java.util.LinkedList;
import java.util.*;
class SExpParseException extends Exception {
public SExpParseException(String msg) {
super(msg);
}
}
interface StackI {
public Object peek();
public Object pop();
public Object push(Object o);
public boolean empty();
}
abstract class SExp {
public static final String LEFT_PAREN = '(";
public static final String RIGHT_PAREN = ")";
public static SExp parseSExp(StackI tokens) throws SExpParseException {
SExp nextSExp = parseNextSExp(tokens);
if (tokens.empty()) {
// The stack of tokens consisted of a single S-expression
// (with possible subexpressions), as expected.
return nextSExp;
}
else {
throw new SExpParseException("Extraneous material " +
"at end of stream.");
}
}
public static SExp parseNextSExp(StackI tokens) throws SExpParseException {
if (tokens.empty()) {
throw new SExpParseException("Unexpected end of token stream.");
}
else { // tokens.pop() succeeds
Object next = tokens.pop();
if (next.equals(LEFT_PAREN)) {
// The S-expression is a list. Accumulate the subexpressions
// this list contains, and return the result.
SList result = new SEmpty();
while (! tokens.empty()) { // tokens.pop() succeeds
next = tokens.peek();
if (next.equals(RIGHT_PAREN)) {
// We've reached the end of the list. We need only
// pop off the ending right parenthesis before returning.
// Since subexpressions were accumulated in the front
// of the list, we must return the reverse of the list
// to reflect the proper structure of the S-expression.
tokens.pop();
return result.reverse();
}
else {
// Recursively parse the next subexpression and
// add it to result.
result = new SCons(parseNextSExp(tokens), result);
}
}
// If we haven't yet returned, then we've reached the end
// of the token stream without finding the matching right
// paren.
throw new SExpParseException("Unmatched left parenthesis.");
}
else if (next.equals(RIGHT_PAREN)) {
// A right parenthesis was encountered at the beginning of
// the S-expression!
throw new SExpParseException("Unmatched right parenthesis.");
}
else {
// The next S-expression is an atom.
return new Atom(next);
}
}
}
}
abstract class SList extends SExp {
abstract SList reverse();
}
class SEmpty extends SList {
public String toString() {
return '( )";
}
SList reverse() {
return this;
}
}
class SCons extends SList {
public SExp first;
public SList rest;
public SCons(SExp _first, SList _rest) {
this.first = _first;
this.rest = _rest;
}
SList reverse() {
SList result = new SEmpty();
SList elements = this;
while (! (elements instanceof SEmpty)) {
result = new SCons(((SCons)elements).first, result);
elements = ((SCons)elements).rest;
}
return result;
}
}
class Atom extends SExp {
public Object value;
public Atom(Object _value) {
this.value = _value;
}
}
定義用於解析語法樹的類
現在,與解析原始標記流相比,將 S-expression 解析為語法樹就是小事一樁。但為了這麼做,我們將需要為語法中每個語法結構定義一個單獨的類:
清單 5. 為每個語法結構定義單獨的類
import java.util.LinkedList;
abstract class SyntaxTree {
public abstract Object accept(SyntaxTreeVisitor that);
}
class Script extends SyntaxTree {
LinkedList definitions;
Expression body;
public Script(LinkedList _definitions, Expression _body) {
this.definitions = _definitions;
this.body = _body;
}
public Object accept(SyntaxTreeVisitor that) {
return that.forScript(this);
}
}
abstract class Statement extends SyntaxTree {}
class CutStatement extends Statement {
Name name;
public CutStatement(Name _name) {
this.name = _name;
}
public Name getName() {return this.name;}
public Object accept(SyntaxTreeVisitor that) {
return that.forCutStatement(this);
}
}
...
abstract class Expression extends SyntaxTree {}
...
abstract class Name extends SyntaxTree {}
...
abstract class SyntaxTreeVisitor {
public abstract Object forScript(Script that);
public abstract Object forCutStatement(CutStatement that);
...
}
一系列的定義過程。對於語法中每個非終端符號,我們需要一個抽象類,對那個非終端符號的每種格式,我們需要一個具體類。我們還將希望定義這個類層次結構的訪問者(visitor)。
為了給您啟發,我僅提供了一些必要的代碼。擴展它非常簡單。類似這樣的代碼非常適合於自動代碼生成。
遞歸定義解析方法
在定義完所有這些類之後,我們可以遞歸地為每個語法結構定義解析方法:例如 parseStatement 、 parseExpression 。
每個方法將接受一個 S-expression。它的主體將由一個大的 if-then-else 語句組成,這個語句檢查 SExp 的第一個元素,然後確定它符合哪個語法結構。在這一點上,我們簡單地檢查一下 SExp 的格式是否符合那個結構的有效格式(例如, if 語句由三個部分組成:一個表達式和兩個語句),然後調用適當的構造器,遞歸解析子部分。
例如,清單 6 顯示我們將如何解析 if 語句:
清單 6. 解析 if 語句
parseStatement(SExp sExp) {
...
}
else if (sExp.nth(0).equals("if") && sExp.length() == 4) {
return new IfStatement(parseExp(sExp.nth(1)),
parseStatement(sExp.nth(2)),
parseStatement(sExp.nth(3)));
}
...
}
在這個 if-then-else 語句結束部分是 else 子句,這個子句對應於 S-expression 與語法結構有效格式不匹配的情況。在此情況下, SyntaxError 連同適當的錯誤信息一同拋出。
下一步:求值
在腳本被解析為這種格式之後,我們可以輕易地實現解釋過程的其它階段。假如我們的語言包含靜態類型系統,則這時應該包含類型檢查程序。
同樣,此時應該包含用於任何其它語言約束的檢查程序。例如,如果我們的腳本編制語言包含類的層次關系,則我們應該希望檢查這個層次結構中不包含循環。
實現這些不同階段的一種好方法是對每個階段使用語法樹的訪問者。那樣的話,針對特定階段的所有代碼都放在一個地方。此外,在額外階段添加也較容易 ― 我們只要編寫另一個訪問者並將它放在這個序列中。這樣做的結果是,根本無需修改其它類。
但在我們的示例語言中,沒有添加這樣的約束,我們可以繼續到解釋的最終階段:求值。象解析之後的其它階段一樣,本階段也可以作為語法樹的訪問者實現,並且我由衷地推薦這麼做。
訪問者中每一個 for 子句將描述如何對特定形式的程序構造求值。對我們的語言中基本操作的求值將符合所支持應用程序的方法調用。
清單 7. for 子句描述了構造求值
class Evaluator extends SyntaxTreeVisitor {
Application app;
public Object forCutStatement(CutStatement that) {
app.cut(that.getName());
// A VoidObject is returned as the result of evaluating
statements, to meet the signature of the for methods.
return new VoidObject();
}
...
}
至於更復雜的操作,我們可以依靠 Java 語言的底層程序構造輕易地實現這些操作。例如,這裡是如何實現 if 和 while 構造的例子:
清單 8. 實現 if 和 while 構造
public Object forWhileStatement(WhileStatement that) {
while (that.getTest().accept(this).equals(new Boolean(true))) {
that.getBody().accept(this);
}
return new VoidObject();
}
public Object forIfStatement(IfStatement that) {
if (that.getTest().accept(this).equals(new Boolean(true))) {
that.getConsequent.accept(this);
}
else {
that.getAlternative.accept(this);
}
return new VoidObject();
}
}
讀取良好的腳本
現在,該是我們的語言所編寫的腳本的解釋了,這將涉及包含該腳本的文件(或其它流)中的簡單讀取,然後通過本文所描述的那些階段來處理它。
應用程序的用戶和開發人員都能以各種方法擴展這個應用程序而不必涉及源代碼。所以您擁有了:通過基於 S-expression 語言的黑箱可擴展性。
這是關於由四部分組成的添加應用程序可擴展性的小系列文章的最後一篇。我要再次強調的是:這些技術就象鋒利的雙刃劍 ― 有利也有弊。它們可以是實現代碼有效復用的功效強大的手段,但也會是非常危險的:如果您過於不加選擇和草率地使用它們添加可擴展性 ― 您的應用程序的復雜性會膨脹到失去控制。那時要小心了!