繼承

December 20, 2021

在 Toy Lang 中,繼承從定義類別的一開始就存在了!

Toy 語法

你定義了一個 Account 類別,雖然沒有聲明,實際上已經包含了繼承:

class Account {
    def init(number, name) {
        this.number = number
        this.name = name
        this.balance = 0
    }

    def deposit(amount) {
        if amount <= 0 {
            throw new Exception('存入必須是正數')
        }

        this.balance += amount
    }

    def withdraw(amount) {
        if amount > this.balance {
            throw new Exception('餘額不足')
        }

        this.balance -= amount            
    }

    def toString() {
        return '{0}, {1}, {2}'.format(this.number, this.name, this.balance)
    }
}

上面的例子其實相當於:

class Account(Object) {
    ...
}

在 Toy Lang 中,查找類別的方法時,最上層的查找終點,都是 Object 類別,因此,只要是 Object 上的方法,像是 ownPropertieshasOwnPropertydeleteOwnProperty 等方法,Account 的實例都可以使用:

acct = new Account('123', 'Justin')
# 顯示 [[number,123],[name,Justin],[balance,0]]
println(acct.ownProperties()) 

若要指定繼承的類別,是在類別名稱旁使用括號表明要繼承的 Parent 類別。例如,你為以上的類別建立了一個支票帳戶:

class CheckingAccount(Account) {
    def init(number, name) {
        # 呼叫 Parent 類別 init()
        this.super(Account, 'init', [number, name])
        this.overdraftlimit = 30000
    }

    def withdraw(amount) {
        if amount <= self.balance + self.overdraftlimit {
            self.balance -= amount
        } 
        else {
            throw new Exception('超出信用')
        }
    }

    def toString() {
        return this.super(Account, 'toString') + ', ' + this.overdraftlimit
    }
}

在上例中,繼承了 Account 來定義 CheckingAccount 類別。如果在 Child 類別中,需要呼叫 Parent 類別的某個方法,則可以使用 Object 繼承下來的 super 方法,指定類別與方法名稱,以及呼叫時的引數清單。

在上例中,重新定義了 withdrawtoString 方法,在操作實例方法時,是從類別上開始尋找是否有定義,否則就搜尋 Parent 類別中是否有定義方法:

acct = new CheckingAccount('E1234', 'Justin Lin')
println(acct)
acct.deposit(1000)      # 使用 Account 的 deposit() 定義
println(acct)
acct.withdraw(2000)     # 使用 CheckingAccount 的 withdraw() 定義
println(acct)

在呼叫 acctdeposit 方法時,由於 CheckingAccount 並沒有定義,因此呼叫的是 Account 類別上定義的 deposit,而呼叫 withdraw 時,使用 CheckingAccount 有定義的 withdraw

在 Toy Lang 中,可以進行多重繼承,這個時候要注意搜尋的順序,是從 Child 類別開始,接著是同一階層 Parent 類別由左至右搜尋,再至更上層同一階層父類別由左至右搜尋,直到達到頂層的 Object 為止。例如:

class A {
    def method1() {
        println('A.method1')
    }

    def method2() {
        println('A.method2')   
    }
}

class B(A) {
    def method3() {
        println('B.method3')
    }
}

class C(A) {
    def method2() {
        println('C.method2')
    }
        
    def method3() {
        println('C.method3')
    }      
}

class D(B, C) {
    def method4() {
        println('D.method4')
    }
}

d = new D()

d.method4() # 在 D 找到,D.method4
d.method3() # 以 D->B 順序找到,B.method3
d.method2() # 以 D->B->C 順序找到,C.method2
d.method1() # 以 D->B->C->A 順序找到,A.method1

在 Toy Lang 中,類別都是 Class 的實例,Class 定義了 parents 方法,可取得繼承的 Parent 類別清單:

# 顯示 [<Class B>,<Class C>]
println(D.parents())

parents 方法也可以用來動態地改變繼承的 Parent 類別。例如:

class BB(A) {
    def method3() {
        println('BB.method3')
    }    
}

parents = D.parents()
parents.set(0, BB)

D.parents(parents)

# 顯示 BB.method3
d.method3()

在上例中,D 原本來繼承 BC 類別,透過 parents 方法修改為繼承自 BBC 類別,因此尋找 method3 方法時,也就改尋找 BB 類別,因此最後執行的是 Bmethod3 方法。

Toy 實作

在實作出語言的類別功能之後,緊接而來的需求就是,你會發現有些方法,在某類別上會需要,在另一個類別上也會需要,解決的方式之一是透過 Mixin,另外傳統上常見的方式之一就是繼承。

在沒有指定繼承的對象時,預設會是 Object,這實作在 line_parser.js 中:

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, 
				// 預設繼承 Object
				parentClzNames : parentClzNames.length === 0 ? ['Object'] : parentClzNames
			})
		),
		LINE_PARSER.parse(tokenableLines.slice(clzLineCount)),
		tokenableLines[0].lineNumber
	);   
}

類別是查找方法的依據,在實作繼承機制下的方法查找,就必須搞定查找順序,這也是 value.jslookupParentClzes 在做的事情:

function lookupParentClzes(context, clz, name) {
	// BFS
	const parentClzName = clz.parentClzNames.find(
		clzName => clzNode(context, clzName).hasOwnMethod(name)
	);
	if(parentClzName) {
		return clzNode(context, parentClzName).getOwnMethod(name);
	}
					
	const grandParentClzName = grandParentClzNames(context, clz.parentClzNames).find(
		clzName => clzNode(context, clzName).hasMethod(context, name)
	);

	context.RUNTIME_CHECKER.refErrIfNoValue(grandParentClzName, name);
	
	const grandParentClzNode = clzNode(context, grandParentClzName);
	const method = grandParentClzNode.getOwnMethod(name);
	if(method) {
		return method;
	}
	return lookupParentClzes(context, grandParentClzNode, name);
	//return clzNode(context, grandParentClzName).getOwnMethod(name);
}

function clzNode(context, clzName) {
	return context.lookUpVariable(clzName).internalNode;
}

function grandParentClzNames(context, parentClzNames) {
	return parentClzNames.filter(clzName => clzName !== 'Object') // Object is the top class. No more lookup.
							.map(clzName => clzNode(context, clzName))
							.map(clzNode => clzNode.parentClzNames)
							.reduce((acct, grandParentClzNames) => acct.concat(grandParentClzNames), [])
}

因為想要故意挑戰多重繼承,因而查找上必須多費些功夫,這時就覺得函數式程式設計真是好用啊!遞迴時只要處理當時的任務,下個遞迴呼叫就不用理了… XD

之前曾經談過,如果語言不用支援物件導向,實作上就會簡單許多,那麼為什麼物件導向實作麻煩呢?與其說麻煩,不如說,很容易搞不清楚,你現在處理的是實作層面的元素,還是語言層面的元素,或者是你寫的程式中的元素。

Toy Lang 中定義的類別,實作層面有個 Class 節點,這是語法樹節點,Toy Lang 中每個使用 class 定義出來的類別,類別名稱、方法等,都是由 Class 節點的實例管理著。

Toy Lang 中每個類別,都會是 Toy Lang 中 Class 的實例,例如,Toy Lang 中每個物件都可以呼叫 class 方法,取得建構該物件的類別,像是若 acct = new Account('123', 'Justin')acct.class() 就會取得 Account,那麼 Account.class() 呢?會取得 Toy Lang 中的 Class(不是語法節點的 Class)。

Toy Lang 中每個物件對應的語法節點是 Instance,例如,acct = new Account('123', 'Justin')acct 參考的物件,實作面上就是使用 Instance 的實例來表示。

每個 Instance 的實例,都會有 clzOfLang 特性,代表它是 Toy Lang 中哪個類別建構而來,例如方才 acct 參考的物件,實作面上的 Instance 實例,clzOfLang 會是 Account 的實例,有趣的是,這個實例,在實作面上也是使用 Instance 的實例來表示,也就是 clzOfLang 參考的,也是個 Instance 的實例。

還有一個有趣的問題,Toy Lang 中 Class 是個類別,它會是誰的實例?當然也是 Class!那在語法節點上,Toy Lang 中 Class 的實例,也會有個 Instance 節點實例代表,那這個 InstanceclzOfLang 會是誰呢?

答案就是自己,這實作在 classes.js 中 …XD

const CLZ = ClassClass.classInstance(null, clzNode({name : 'Class', methods : ClassClass.methods}));
// 'Class' of is an instance of 'Class'
CLZ.clzOfLang = CLZ;

搞清楚了嗎?畫一條線,左邊是語法樹,右邊是 Toy Lang,兩邊要有什麼,各對應自什麼,都要搞清楚,這個活像是雞生蛋、蛋生雞的問題,在實作類別時會遇上一次,在實作繼承時又會遇上一次!

也就是 … Object 是類別,因此它必須是 Class 的實例,然而 Class 的 Parent 類別是 Object,這 … XD

實際上這是發生在實作層面的關係組合!如果你曾經接觸過 meta programming 之類的機制,像是 Java 的反射,就會遇上類似的事情,追溯到某個層面,再要往上就是原生實作的部份了。

就 Toy Lang 的實作面,也就是這個部份:

const CLZ = ClassClass.classInstance(null, clzNode({name : 'Class', methods : ClassClass.methods}));
// 'Class' of is an instance of 'Class'
CLZ.clzOfLang = CLZ;

const BUILTIN_CLASSES = new Map([
	// Object 的 clzOfLang 是 CLZ
	ClassClass.classEntry(CLZ, 'Object', ObjectClass.methods),
	ClassClass.classEntry(CLZ, 'Function', FunctionClass.methods),
	['Class', CLZ],
	ClassClass.classEntry(CLZ, 'Module', ModuleClass.methods),
	ClassClass.classEntry(CLZ, 'String', StringClass.methods),
	ClassClass.classEntry(CLZ, 'List', ListClass.methods),
	ClassClass.classEntry(CLZ, 'Number', NumberClass.methods, NumberClass.constants),
	ClassClass.classEntry(CLZ, 'Traceable', TraceableClass.methods)
]); 

CLZ 參考的會是語法節點 Instance 的實例,方才已經看過,CLZclzOfLang 就是自身,而上面可以看到,ObjectclzOfLangCLZ,這就解釋了「Object 是類別,因此它必須是 Class 的實例」這件事,這是 InstanceInstance 節點之類的關係。

至於「Class 的 Parent 類別是 Object」,這是 ClassClass 節點之間的關係,也就是 createAssignClass 時指定了 Parent 類別建立的關係,後續透過 lookupParentClzes 查找時,就是在 ClassClass 之間找尋。