定義類別

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_depositacct_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 的細節時再來討論了;實際上,類別在處理上有很多的細節,這邊談到的節點,其實也都省略了不少程式碼,主要是先知道有這些節點的存在,以及它們各自在哪些地方,之後有機會,也會來看看那些被省略的程式碼,到底各自負責了什麼。