變數與指定陳述

December 18, 2021

Toy Lang 是動態定型語言,變數本身並沒有型態資訊,只是用來對應至值或物件。

Toy 語法

要建立變數,只要命名變數並指定值給它就可以了:

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

想要建立變數,一定得指定某個值才有可能,沒有方式可以建立一個變數而沒有值,你會想到其他語言中可能有 nullundefinedNonenil 之類的實字或名稱,Toy Lang 中沒有這類東西。

Toy 實作

變數在語言實作時,也是一個語法節點,基本上只是用來包含名稱:

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,就會有個 xPrimitive 實例(內含原生值 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 很麻煩,然而,如果是以行為單位的剖析的話,可以令工作簡單一些,而這也就是 Toy Lang 的作法。

然而,這並不是正確的作法,Regex 應該用於單詞分析,而不是用來判斷數個單詞組成的語法結構代表什麼,隨著語言的文法越複雜,Toy Lang 的這個做法,對語言也會造成更多限制,這就是我土炮這門語言得到的教訓,之後會談到。

無論如何,就現有的做法來說,對於 = 指定陳述,基本的模式會是:

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 來代表,這之後會看到。

以上的描述是經過簡化的,實際的情況會比較複雜一些,例如,你可以想一下,那 +=-= 怎麼做,而之後還會看到,像是 ifwhile 等,並不是只有單行,而是會有一整個區塊,之後也會談到如何處理整個區塊的陳述句。