ToyLang 是動態定型語言,變數本身並沒有型態資訊,只是用來對應至值或物件,要建立變數,只要命名變數並指定值給它就可以了:
n = 10
n = 'Justin'
由於變數本身沒有型態,之後可以將字串 'Justin'
指定給 n
;在建立變數之前,嘗試存取變數會發生 ReferenceError
。例如:
println(x)
若 x
變數不存在,會出現以下錯誤訊息:
ReferenceError: x is not defined
at println(x) (/main.toy:1)
=
是個指定陳述,除了它之外,還有 +=
、-=
、*=
、/=
、%=
、&=
、|=
、>>
=、<<=
等指定陳述,與其他語言的指定陳述作用相同。例如:
x = 10
x += 2 # 相當於 x = x + 2
x -= 3 # 相當於 x = x - 3
想要建立變數,一定得指定某個值才有可能,沒有方式可以建立一個變數而沒有值,你會想到其他語言中可能有 null
、undefined
、None
、nil
之類的實字或名稱,ToyLang 中沒有這類東西。
變數在語言實作時,也是一個語法節點,基本上只是用來包含名稱:
class Variable {
constructor(name) {
this.name = name;
}
evaluate(context) {
return context.lookUpVariable(this.name);
}
}
例如,new Variable('x')
代表著建立了一個 x
變數的對應節點,變數沒有任何值的資訊,在執行時想要取得變數的對應值,意義就是查找環境變數中,是否有名稱對應的節點,也就是 evaluate
方法的實作內容。
那麼,指定陳述又是什麼意思?以 =
為例,其對應的節點可以如下定義:
class VariableAssign {
constructor(variable, value) {
this.variable = variable;
this.value = value;
}
evaluate(context) {
return context.assign(this.variable.name, value);
}
}
例如,對於底下的程式碼:
x = 10
會建立如下的語法節點:
const variableAssign = new VariableAssign(new Variable('x'), new Primitive(10))
實際執行 variableAssign.evaluate(context)
時,會需要一個環境物件 context
,環境物件的作用之一,就是保存變數名稱與對應的值節點,這可以使用一個簡單的 Map
:
class Context {
constructor(variables = new Map()) {
this.variables = variables;
}
assign(variable, value) {
this.variables.set(variable, value);
return this;
}
lookUpVariable(name) {
return this.variables.get(name);
}
}
const context = new Context();
variableAssign.evaluate(context);
因此 variableAssign.evaluate(context)
之後,環境物件保存變數的 Map
,就會有個 x
至 Primitive
實例(內含原生值 10)的對應,之後 new Variable('x').evaluate(context)
時,自然就可以取得 Primitive
實例(內含原生值 10)。
在這邊剛好可以說明一下運算式(expression)與陳述句(statement)的差異性,如先前的文件中看到的,運算式基本上不會改變環境物件的狀態,而在這邊可以看到,陳述句可能改變環境物件狀態(或者是外部狀置狀態),也就是會產生副作用(Side effect)。
(如果實作的是函數式語言,像指定陳述的執行結果,會是產生一個新的環境物件,內含指定陳述之後的結果。)
問題接著來了,如何將兩個陳述句串起來執行呢?畢竟,程式會是由許多行陳述句構成,語法樹上,必須將這些陳述句對應的節點銜接起來,這時需要個 StmtSequence
之類的東西:
class StmtSequence {
constructor(firstStmt, secondStmt) {
this.firstStmt = firstStmt;
this.secondStmt = secondStmt;
}
evaluate(context) {
return this.secondStmt.evaluate(this.firstStmt.evaluate(context));
}
}
例如:
x = 10
y = x + 20
語法樹會是:
new StmtSequence(
new VariableAssign(
new Variable('x'),
new Primitive(10)
),
new VariableAssign(
new Variable('y'),
new Add(
new Variable('x'),
new Primitive(20)
)
)
)
在執行 StmtSequence
實例的 evaluate
方法時,就會依序執行各個節點的 evaluate
方法,因而可以發現,實作語言的重點之一,就是每個節點一定只關心自己的 evaluate
運算,只要定義好各節點獨立的職責,就可以(依語言規則)自由組合節點,這就是為什麼程式語言可以應付各式各樣的需求之原因。
當然,不可能自行建立語法樹,這是 Parser 的工作,也就是從程式碼切割單詞、判斷單詞並建立對應的語法樹,寫 Parser 很麻煩,然而,如果是以行為單位的剖析的話,可以令工作簡單一些,而這也就是 ToyLang 的作法。
然而,這並不是正確的作法,Regex 應該用於單詞分析,而不是用來判斷數個單詞組成的語法結構代表什麼,隨著語言的文法越複雜,ToyLang 的這個做法,對語言也會造成更多限制,這就是我土炮這門語言得到的教訓,之後會談到。
無論如何,就現有的做法來說,對於 =
指定陳述,基本的模式會是:
new RegExp(`^(${VARIABLE_REGEX.source})\\s*=\\s*(.*)$`)]
隨著你的 Regex 模式增多,記得將 Regex 作適當管理,如上頭的 VARIABLE_REGEX
,實際上就是 /[a-zA-Z_]+[a-zA-Z_0-9]*/
,然而,用個變數並如上頭的組合方式,會讓以 VARIABLE_REGEX
為基礎的 Regex 較易閱讀。
由於是基於行的剖析,指定陳述的剖析在 =
之後,可以簡化為多個任意字元,=
之後捕捉到的部份,交給運算式剖析器處理就好了。
可以在 line_parser.js 中看到,裏頭有各種陳述句剖析之後的語法節點建立方式,而指定陳述的建立,被封裝在 createAssign
之中:
function createAssign(tokenableLines, clzNode, target, operatorTokenable, assignedTokenable) {
return new StmtSequence(
new clzNode(
target,
EXPR_PARSER.parse(assignedTokenable),
operatorTokenable.value
),
LINE_PARSER.parse(tokenableLines.slice(1)),
tokenableLines[0].lineNumber
);
}
createAssign
不會去關心 =
右邊的事,這部份被交給了 EXPR_PARSER
,也就是運算式剖析器,EXPR_PARSER
會對右邊剖析,建立相對應的運算式各語法節點。
也就是說,如同語法樹節點只關係自己的職責,剖析器也是,指定陳述只關心 =
左邊是否為變數,右邊是否為運算式,其他的陳述句也會有各自關心的事,這樣文法才能具有遞迴性。
方才談到 StmtSequence
,程式碼一定有結束的時候,因此,必須有個空的 StmtSequence
,這可以在 statement.js 看到:
StmtSequence.EMPTY = {
lineCount : 0,
// We don't care about emtpy statements so the lineNumber 0 is enough.
lineNumber : 0,
evaluate(context) {
return context;
}
};
空陳述句什麼都不做,只是將傳入的環境物件傳回,程式碼的結束、區塊的結束等,都可以用空的 StmtSequence
來代表,這之後會看到。
以上的描述是經過簡化的,實際的情況會比較複雜一些,例如,你可以想一下,那 +=
、-=
怎麼做,而之後還會看到,像是 if
、while
等,並不是只有單行,而是會有一整個區塊,之後也會談到如何處理整個區塊的陳述句。