定義類別
December 20, 2021有些操作與某些資料是息息相關的,把它們放在一起會更容易使用。
Toy 語法
在 Toy Lang 中要定義類別,必須使用 class
,例如,可以定義一個帳戶(Account
)類別:
class Account {
balance = 0
def init(number, name) {
this.number = number
this.name = name
}
def deposit(amount) {
if amount <= 0 {
throw new Exception('must be positive')
}
this.balance += amount
}
def withdraw(amount) {
if amount > this.balance {
throw new Exception('balance not enough')
}
this.balance -= amount
}
def toString() {
return '{0}, {1}, {2}'.format(this.number, this.name, this.balance)
}
}
acct = new Account('123', 'Justin')
acct.deposit(100)
acct.withdraw(20)
println(acct)
類別中的方法一樣使用 def
來定義,因為 Toy Lang 中,方法本質上就是個函式,而方法中若出現 this
,基本上代表建立的類別實例(this
也可以出現在函式中,這在之後還會談到)。
類別本體中可以有陳述句,若是 =
指定陳述,變數會成為類別實例上的特性,例如上例中的 balance
就是一個例子,後續在類別本體中使用變數時,也不需要使用 this
。
init
方法是個特定的名稱,用來定義類別的實例建立之後,要進行的初始化動作,如果要建立類別的實例,可以使用 new
關鍵字,例如 new Account('123', 'Justin')
,這會執行類別本體陳述,接著建立一個物件,將類別本體陳述中建立的變數,指定為物件上之特性,之後物件會成為方法中 this
參考之對象,呼叫 init
方法,'123'
會指定給 number
參數,而 'Justin'
指定給 name
參數,然後執行 init
本體完成初始化。
每個建構出來的 Account
實例,都會擁有自己的特性,可以直接透過物件及 .
運算子來存取特性:
println(acct.name) # 顯示 Justin
println(acct.hasOwnProperty('name')) # 顯示 true
從上例中可以看到,可以透過 hasOwnProperty
來測試,某個特性是否為某實例擁有,hasOwnProperty
是繼承至 Object
,之後文件還會談到繼承。
雖然呼叫方法也是透過 .
運算子,然而,方法並不屬於物件本身,而是屬於類別本身:
println(acct.deposit) # 顯示 <Function deposit>
println(acct.hasOwnProperty('deposit')) # 顯示 false
println(Account.hasOwnMethod('deposit')) # 顯示 true
每個類別都會是 Class
的實例,而 Class
定義了 hasOwnMethod
方法,可用來測試某個類別是否擁有某個方法。
某些場合需要取得物件的字串描述時,建議定義 toString
方法,內建的 println
等函式,遇到物件時,就會呼叫 toString
來取得描述,若自定義類別時沒有定義 toString
,會使用從 Object
繼承下來的 toString
方法,這之後還會討論。
Toy 實作
說穿了,物件導向也不過就是看待資料與函式的一種方式!只不過若語法上支援的話,當想要以物件導向看待資料的方式來撰寫程式時,會比較輕鬆罷了。
舉例來說,如果沒有 class
等語法支援的話,要怎麼定義帳戶呢?就目前來說,Toy Lang 只有 List
這個資料結構,那就這樣好了:
def acct_init(number, name) {
return [number, name, 0]
}
def acct_deposit(acct, amount) {
if amount <= 0 {
throw new Exception('must be positive')
}
balance = acct.get(2)
acct.set(2, balance + amount)
}
def acct_withdraw(acct, amount) {
if amount > acct.get(2) {
throw new Exception('balance not enough')
}
balance = acct.get(2)
acct.set(2, balance - amount)
}
def acct_toString(acct) {
return '{0}, {1}, {2}'.format(acct.get(0), acct.get(1), acct.get(2))
}
acct = acct_init('123', 'Justin')
acct_deposit(acct, 100)
acct_withdraw(acct, 20)
println(acct_toString(acct))
這當然也是物件導向中「類別」的概念,雖然使用 List
,然而,每一個 List
都代表著一個實際的帳戶資料,這個資料在初始化時,都是經由 acct_init
的流程,存款或提款則分別經由 acct_deposit
、acct_withdraw
流程等,每個函式中以特定的方式存取 List
,就這邊的例子就是索引,而且每個索引位置有其特定之意義。
當然,使用索引並不方便,也許你可以實作 Map
之類的資料結構,這樣就可以有具體的鍵名稱來取得對應的值;另一方面,acct_
名稱前置,用意是在提示,這些函式是屬於帳戶這類資料使用,基本上,你會將這類有 acct_
名稱前置的函式,儘量集中放在程式中某個位置管理,以便需要時,知道要到哪個地方去找尋適當的函式,來操作帳戶這類資料結構。
如果有門語言,只要撰寫 class
之類的語法,將方法定義在類別之中,就可以有具體的特性名稱來取得對應的特性值,透過 .
運算子之類的語法,就會自動知道要到哪些地方找出指定的函式來執行,豈不是美事一件嗎?這就是物件導向語法存在的意義。
然而,自動這件事並不是魔法,實作面上,就是要將上述的需求實現出來,首先就是在使用者定義類別後,語言實作品必須能收集類別本體中的方法定義以及陳述句,這並不難,因為之前在實現函式時,早就做過這件事了。
因為函式本身就像是一種類別容器,方法定義就像是函式中的區域函式,而陳述句自然就是函式本體中的東西了,這聽來像是 JavaScript?沒錯!在 ECMAScript 6 之前,本質上,JavaScript 就只是將這件事明確地實現出來而已。
因此若看 line_parse.js 中剖析類別的部份,幾乎是與剖析函式是相同的:
function createAssignFunc(tokenableLines, argTokenable) {
const [fNameTokenable, ...paramTokenables] = argTokenable.tryTokenables('func');
fNameTokenable.errIfKeyword();
const remains = tokenableLines.slice(1);
const bodyStmt = LINE_PARSER.parse(remains);
const bodyLineCount = bodyStmt.lineCount;
return new StmtSequence(
new DefStmt(
Variable.of(fNameTokenable.value),
new Func(
paramTokenables.map(paramTokenable => Variable.of(paramTokenable.value)),
bodyStmt,
fNameTokenable.value
)
),
LINE_PARSER.parse(tokenableLines.slice(bodyLineCount + 2)),
tokenableLines[0].lineNumber
);
}
function createAssignClass(tokenableLines, argTokenable) {
const [fNameTokenable, ...paramTokenables] = argTokenable.tryTokenables('func');
fNameTokenable.errIfKeyword();
const remains = tokenableLines.slice(1);
const stmt = LINE_PARSER.parse(remains);
const clzLineCount = stmt.lineCount + 2;
const parentClzNames = paramTokenables.map(paramTokenable => paramTokenable.value);
const [fs, notDefStmt] = splitFuncStmt(stmt);
return new StmtSequence(
new ClassStmt(
Variable.of(fNameTokenable.value),
new Class({
notMethodStmt : notDefStmt,
methods : new Map(fs),
name : fNameTokenable.value,
parentClzNames : parentClzNames.length === 0 ? ['Object'] : parentClzNames
})
),
LINE_PARSER.parse(tokenableLines.slice(clzLineCount)),
tokenableLines[0].lineNumber
);
}
不同的地方在於,在剖析完類別本體之後,還做了區別 def
陳述與其他陳述的動作,def
陳述被挑出來,作為方法來看待:
class Class extends Func {
constructor({notMethodStmt, methods, name, parentClzNames, parentContext}) {
super([], notMethodStmt, name, parentContext || null);
this.parentClzNames = parentClzNames || ['Object'];
this.methods = methods;
}
...
hasOwnMethod(name) {
return this.methods.has(name);
}
...
}
Class
節點定義在 value.js 中;除了剖析時有很大部份與函式類似,執行面上也有大部份是雷同,因此 Class
節點繼承了 Func
節點,非 def
陳述的部份,使用 super
交給了 Func
建構式,至於 def
陳述部份,由 Class
節點本身來管理,像是判斷類別有無定義某個方法,就實現為 hasOwnMethod
。
建構類別的實例時,使用的是 new
運算子,它對應的節點是 NewOperator
,實現在 operator.js 中:
class NewOperator {
constructor(operand) {
this.operand = operand;
}
instance(context, args) {
const clzInstance = clzInstanceFrom(context, this.operand);
// run class body
const ctx = clzInstance.internalNode.call(context, args);
return ctx.notThrown(c => {
c.variables.delete('arguments');
return new Instance(
clzInstance,
c.variables
);
});
}
evaluate(context) {
const args = argsFrom(this.operand);
const maybeContext = this.instance(context, args);
return maybeContext.notThrown(ctx => {
if(ctx.clzNodeOfLang().hasOwnMethod('init')) {
const maybeCtx = new MethodCall(maybeContext, 'init', [args]).evaluate(context);
return maybeCtx.notThrown(c => maybeContext);
}
return ctx;
});
}
}
在 instance
方法就可以看到先執行類別本體,取得環境物件上的變數並建立 Instance
節點的動作,執行類別本體本質上就是呼叫函式,因而會有個 arguments
,這對類別的實例來說,並非需要的特性,因此將之刪除。
在 instance
方法過後,就是看看類別本身是否定義了 init
,若有才會執行,也就是建立一個 MethodCall
節點並執行,這實現在 callable.js 中:
class MethodCall {
constructor(instance, methodName, argsList = []) {
this.instance = instance;
this.methodName = methodName;
this.argsList = argsList;
}
evaluate(context) {
return methodBodyStmt(context, this.instance, this.methodName, this.argsList[0])
.evaluate(methodContextFrom(context, this.instance, this.methodName))
.notThrown(c => {
if(this.argsList.length > 1) {
return callChain(context, c.returnedValue.internalNode, this.argsList.slice(1));
}
return c.returnedValue === null ? Void : c.returnedValue;
});
}
}
function methodBodyStmt(context, instance, methodName, args = []) {
const f = instance.hasOwnProperty(methodName) ?
instance.getOwnProperty(methodName).internalNode :
instance.clzNodeOfLang().getMethod(context, methodName);
const bodyStmt = f.bodyStmt(context, args.map(arg => arg.evaluate(context)));
return new StmtSequence(
new VariableAssign(Variable.of('this'), instance),
bodyStmt,
bodyStmt.lineNumber
);
}
留意到 methodBodyStmt
,其中會看看實例上有沒有方法,沒有的話就到實例的類別上取,是的,實例確實也可以擁有方法,就像 JavaScript 那樣,這之後還會看到,扣除這點不談,就如前所述,類別的目的是作為方法的容器,真的單純只是查找方法的地方。
至於真正代表類別實例的節點,是 value.js 中的 Instance
,某些程度上,它就只是個包裹 Map
的節點:
class Instance extends Value {
constructor(clzOfLang, properties, internalNode) {
super();
this.clzOfLang = clzOfLang;
this.properties = properties;
this.internalNode = internalNode || this;
this.value = this;
}
clzNodeOfLang() {
return this.clzOfLang.internalNode;
}
nativeValue() {
return this.internalNode.value;
}
hasOwnProperty(name) {
return this.properties.has(name);
}
...
}
properties
參數接受的就是 Map
,另一個重要的部份,就是 clzOfLang
了,每個實例必須知道它是屬於哪個類別,如此在使用 .
運算子時,才會知道要到哪個類別上尋找是否有定義方法。
.
運算子的部份,就等到談 this
的細節時再來討論了;實際上,類別在處理上有很多的細節,這邊談到的節點,其實也都省略了不少程式碼,主要是先知道有這些節點的存在,以及它們各自在哪些地方,之後有機會,也會來看看那些被省略的程式碼,到底各自負責了什麼。