TypeScriptToLua如何支持循環引用
循環引用
循環引用(Circular Require, Circular dependencies),在lua環境中,指的是這樣的情況:
有兩個lua文件A和B,文件A中require了B,文件B中require了A,這樣在lua解析時會陷入死循環。
很容易想到,在文件require(也就是加載)的時候,應該有三種狀態。
- 未加載
- 加載中
- 加載完成
但是lua原生的代碼 package.loaded 僅支持1、3兩種狀態。
- LOADED[name] = nil
- LOADED[name] = loader返回值 / true
/**
* package.loaded[name] 有三種情況 nil loader的返回值 TRUE(1)
* 1. nil 表示文件未加載
* 2. 如果loader有返回值,會賦值;否則,LOADED[name]爲TRUE
*
* 考慮下循環引用的問題
* 1. load文件a require b時,b require a,此時package.loaded沒有a,會陷入循環
* 這個問題可以通過標記解決
* 2. require獲取返回值的問題
* 3. 熱更新的問題
*/
static int ll_require (lua_State *L) {
//1. 如果LOADED[name]不爲false,則不做處理
const char *name = luaL_checkstring(L, 1);
lua_settop(L, 1); /* LOADED table will be at index 2 */
lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
lua_getfield(L, 2, name); /* LOADED[name] */
if (lua_toboolean(L, -1)) /* is it there? */
return 1; /* package is already loaded */
/* else must load package */
lua_pop(L, 1); /* remove 'getfield' result */
//2. 加載文件,賦值LOADED[name]
findloader(L, name);
lua_pushstring(L, name); /* pass name as argument to module loader */
lua_insert(L, -2); /* name is 1st argument (before search data) */
lua_call(L, 2, 1); /* run loader to load module */
if (!lua_isnil(L, -1)) /* non-nil return? */
lua_setfield(L, 2, name); /* LOADED[name] = returned value */
if (lua_getfield(L, 2, name) == LUA_TNIL) { /* module set no value? */
lua_pushboolean(L, 1); /* use true as result */
lua_pushvalue(L, -1); /* extra copy to be returned */
lua_setfield(L, 2, name); /* LOADED[name] = true */
}
return 1;
}
閱讀ll_require代碼可知,這個全局的C函數,僅做了兩件事情:
- 讀取LOADED[name],如果爲有效值,則直接返回該值。
lua_toboolean最終會走到 !l_isfalse 宏
#define l_isfalse(o) (ttisnil(o) || (ttisboolean(o) && bvalue(o) == 0))
- 如果LOADED[name]無效,加載文件,設置LOADED[name]
這裏很明顯沒有處理【加載文件】操作中,存在循環引用的問題。
即A文件加載途中的時候requireB文件,此時B文件requireA文件,LOADED[A]判斷爲空,又會走到加載A文件的邏輯,從而陷入死循環。
解決循環引用
解決方法,是在兩個操作之間,設置一個LOADED[name]的臨時標記阻斷死循環。
也就是說,LOADED[name]會賦值兩次。僞代碼如下
function require(name)
if not package.loaded[name] then
local loader = findloader(name)
if loader == nil then
error("unable to load module " .. name)
end
package.loaded[name] = true
local res = loader(name)
if res ~= nil then
package.loaded[name] = res
end
end
return package.loaded[name]
module的做法
這樣的做法在【module時代】是可行的,所以在erro信息裏你會看到"unable to load module "字眼。
也就是說,所有的require操作,按照依賴關係,統一放到一個init.lua文件中。
module的概念,是在執行完module操作後,在_G存在一個全局table,用來記錄模塊信息(變量、函數)。一個文件中可以有多個module調用,獲取_G[module_name],寫在module調用下方的代碼用來填充模塊。一個module也可以放到多個文件中進行填充。而且通常這些module文件,沒有全局的返回值,LOADED[name] = true。
這樣看來,module很像namespace的概念,一個全局環境中的作用域table。
要獲得module,也可以在init.lua執行完之後,從_G環境中獲取。LOADED[name]設置臨時標記確實奏效。但是lua新版本幹掉module之後,很多文件獲取一個全局環境中的作用域table是這麼做的:
local a = require(“xxx”)
這在發生循環引用時,hold在該文件中的局部變量是個LOADED[name]的臨時標記(上例中爲true),而非該文件的實際返回值。
local require的做法
參考TypeScriptToLua的做法,B文件requireA文件,並獲取A文件中的CClassA類定義,翻譯後會變成如下格式:
RequireB.lua
-- ____exports:B文件返回值(LOADED[nameB])
-- CClassB:B文件定義的CClassB類
local ____exports = {}
____exports.CClassB = {}
-- ____RequireA:A文件返回值(LOADED[nameA])
-- CClassA:A文件定義的CClassA類
local ____RequireA = require("Game.RequireTest.RequireA")
local CClassA = ____RequireA.CClassA
local CClassB = ____exports.CClassB
CClassB.name = "CClassB"
CClassB.__index = CClassB
CClassB.prototype = {}
CClassB.prototype.__index = CClassB.prototype
CClassB.prototype.constructor = CClassB
...
-- CClassB調用CClassA的靜態函數
function CClassB.prototype.showObj(self)
if self.mem_obj then
print(
"name of obj from CClassB is " .. tostring(
self.mem_obj:name()
)
)
end
print(
"ClassA foo ",
CClassA:foo()
)
end
return ____exports
如上看來,____RequireA 和 ____RequireA.CClassA 在發生循環引用時,都會是臨時的值。一個文件使用其他文件的定義時,使用了大量的local聲明。雖然這樣的操作可以提高虛擬機定位變量的效率,但是在發生循環引用時,讀取到的臨時的值,再訪問其內容,可能會讀到空值。
以上的例子,是在CClassB中調用CClassA的靜態方法foo。
RequireB.ts
import { CClassA } from "./RequireA";
export class CClassB {
mem_name: string;
mem_obj: CClassA | undefined;
constructor() {
this.mem_name = "Instance of B";
}
setObj(obj:CClassA): void {
this.mem_obj = obj;
}
name():string {
return this.mem_name;
}
showObj():void {
if (this.mem_obj)
console.log("name of obj from CClassB is " + this.mem_obj.name());
console.log("ClassA foo ", CClassA.foo())
}
static foo(): string {
return "hello B";
}
}
其中____RequireA就是LOADED[nameA]。爲了保證邏輯走通,需要重寫require函數,做兩個空表配合元表進行索引。爲了方便通用性,在臨時表裏可以記下一些臨時信息,把____RequireA 當成導出表exportTb,把____RequireA.CClassA當成類表classTb。索引順序就成了
package.loaded[exportName][className][memberName],也就是兩級關係:
- LOADED[name]臨時空表。__index功能:找到LOADED[name],獲取Class。
當然,能走到__index就表示LOADED[name]未加載完,不可能得到實際的Class,所以這裏返回的是第二個空表。 - Class臨時空表。__index功能:找到LOADED[name],獲取Class,找到Class的成員。
爲了__index方便。這裏的空表不是真的空,記錄了一些臨時的值:__exportName __className
local _require = _G.require
local mt_class_member = {
__index = function(intermedia, memberName)
local exportName = intermedia.__exportName
local className = intermedia.__className
local exportTb = package.loaded[exportName]
if exportTb and type(exportTb) == "table" then
local classTb = exportTb[className]
if classTb and type(classTb) == "table" then
return classTb[memberName]
end
end
return nil
end
}
_G.require = function(name)
if not package.loaded[name] then
local filename = package.searchpath(name, package.path)
if filename == nil then
error("unable to load file " .. name)
end
local __exports = {}
local mt = {
__index = function(exports, className)
local intermedia = {
__exportName = name,
__className = className,
}
setmetatable(intermedia, mt_class_member)
return intermedia
end,
}
setmetatable(__exports, mt)
package.loaded[name] = __exports
__exports = loadfile(filename)()
if __exports ~= nil then
package.loaded[name] = __exports
else
package.loaded[name] = true
end
end
return package.loaded[name]
end
local require 實現的繼承關係
上面實現了在循環引用中,實現了B文件類訪問A文件類。繼承的實現會更加複雜,涉及到類的元表prototype(提供給實例化和子類)。這裏舉例B文件中有個D類繼承自A文件的A類,我們來看一下 TypeScriptToLua 之後的結果
RequireB.lua
____exports.CClassD = {}
local CClassD = ____exports.CClassD
CClassD.name = "CClassD"
CClassD.__index = CClassD
CClassD.prototype = {}
CClassD.prototype.__index = CClassD.prototype
CClassD.prototype.constructor = CClassD
CClassD.____super = CClassA
setmetatable(CClassD, CClassD.____super)
setmetatable(CClassD.prototype, CClassD.____super.prototype)
...
關鍵在於這幾句
CClassD.____super = CClassA
setmetatable(CClassD, CClassD.____super)
setmetatable(CClassD.prototype, CClassD.____super.prototype)
兩個新問題
-
CClassD.____super是CClassA,這裏訪問到了CClassA.prototype
較上例,這裏多了一級關係 exportA -> CClassA -> CClassA.prototype
所以這裏要準備三張臨時空表和元表。 -
這裏會拿臨時空表setmetatable。
我們知道臨時空表是空的 {} --------> __index,我們給臨時空表設置了__index,也方便它在外部local後能訪問到正確的table中。也就是說表本身是沒有元方法的。
也就是說上例的
setmetatable(CClassD, CClassD.____super)
實際上是
setmetatable(CClassD, {}),只是這個{}表有個metatable罷了
如果這裏沒有出現循環引用,其本意應該是
setmetatable(CClassD, {__index = ...})
CClassA聲明時有一句
CClassA.__index = CClassA
所以這句的意思是,如果從CClassD找不到k-v,就去上層CClassA找。也就是CClassD[k]的訪問。這樣的情況,通常是類的new函數和靜態函數。
同理
setmetatable(CClassD.prototype, CClassD.____super.prototype)
CClassA.prototype = {}
CClassA.prototype.__index = CClassA.prototype
做的是local obj = CClassD()後obj[k]的訪問,也就是類的成員函數都寫到prototype裏了。
這個問題的關鍵就在於,我們如何將一張臨時的空表,變成原來的CClassA.prototype。也就是說,需要重寫setmetatable,如果元表mt是一張加載中的表,在__index中重新設置元表。
最終示例如下
local _require = _G.require
local _setmetatable = _G.setmetatable
_G.setmetatable = function(tb, mt)
if not mt.__isloading then
return _setmetatable(tb, mt)
end
mt.__index = function(tb, k)
local t = get_real_table(mt)
assert(t, "can't find table!")
setmetatable(tb, t)
return tb[k]
end
_setmetatable(tb, mt)
end
function get_real_table(tb, class_name, member_name, field_name)
local export_name = rawget(tb, "__export_name")
local class_name = rawget(tb, "__class_name") or class_name
local member_name = rawget(tb, "__member_name") or member_name
local t = package.loaded[export_name]
if not t or t.__isloading then
return nil
end
if class_name then
t = t[class_name]
end
if member_name then
t = t[member_name]
end
if field_name then
t = t[field_name]
end
return t
end
function member_find_field(member_tb, field_name)
return get_real_table(member_tb, nil, nil, field_name)
end
function class_find_member(class_tb, member_name)
local t = get_real_table(class_tb, nil, member_name)
if t then return t end
local ret = {
__isloading = true,
__export_name = class_tb.__export_name,
__class_name = class_tb.__class_name,
__member_name = member_name,
}
local mt = {
__index = member_find_field,
}
setmetatable(ret, mt)
return ret
end
function export_find_class(export_tb, class_name)
local t = get_real_table(export_tb, class_name)
if t then return t end
local ret = {
__isloading = true,
__export_name = export_tb.__export_name,
__class_name = class_name,
}
local mt = {
__index = class_find_member,
}
setmetatable(ret, mt)
return ret
end
function get_table(export_name)
-- 此時 LOADED[name] 爲 nil
local ret = {
__isloading = true,
__export_name = export_name,
}
local mt = {
__index = export_find_class,
}
setmetatable(ret, mt)
return ret
end
_G.require = function(name)
if not package.loaded[name] then
local filename = package.searchpath(name, package.path)
if filename == nil then
error("unable to load file " .. name)
end
package.loaded[name] = get_table(name)
local __exports = loadfile(filename)()
if __exports ~= nil then
package.loaded[name] = __exports
else
package.loaded[name] = true
end
end
return package.loaded[name]
end
優化
上例代碼中,發生一次循環引用,創建的臨時空表和元表加起來要6張,空間複雜度過高。
這裏使用將兩張表合併成一張表的 luaer trick 做法,並且對於 prototype 臨時表的處理避免修改 setmetatable。
最終成品如下,註釋中【】的內容都是知識點。
local __require = require
local checkLoading = {}
function require(path)
if package.loaded[path] then
-- 已加載
return package.loaded[path]
elseif checkLoading[path] then
-- 加載中
return checkLoading[path]
else
-- 未加載,創建臨時表
local __exports = get_table()
-- 【在不修改原生 LOADED[name] 的基礎上進行修改,這樣才能保證原生 require 的正常調用】
checkLoading[path] = __exports
local r = __require(path)
-- 【在循環引用發生時,對於 require 返回非 table 的 local 值的延遲定位表示無能爲力】
-- 此處應該是個 assert 纔對。即編碼規範要求:所有lua文件結尾都要 return 一張表。
assert(type(r) == "table", "require(xxx) must return table!")
-- 加載完,填充臨時表
for k, v in pairs(r) do
__exports:set_cache(k, v)
end
__exports:reset()
package.loaded[path] = __exports
checkLoading[path] = nil
return r
end
end
--[[
延遲定位:
moduleTable 就是 require 文件過程中的臨時表
__cache 維護的是臨時表中的內容,通常爲 Class 所以會在索引時初始化 prototype。
【moduleTable 的元表是其自身,這樣可以節省一張表(luaer trick!)】
發生循環引用時,臨時表 moduleTable 會在 require 完成後填充,require 得到的表被棄用。
三層關係 __exports -> ClassA -> prototype 在臨時表中表現爲
moduleTable -> __cache[k] -> prototype
]]
function get_table()
local moduleTable = {
__cache = {},
__index = function(t, k)
if not t.__cache[k] then
t.__cache[k] = {
prototype = {}
}
end
return t.__cache[k]
end,
--[[
這是從原生 require 讀出的數據,實現臨時表 __cache[k] 的 meta 中轉
k:類名
tab:類表
]]
set_cache = function(self, k, tab)
self[k] = tab
local t = self.__cache[k]
if t then
t.__index = tab
setmetatable(t, t)
t.prototype.__index = tab.prototype
setmetatable(t.prototype, tab.prototype)
end
end,
--[[
reset 清空臨時數據。
之後 require 能拿到正常值。
循環引用中的 local require 拿到的還是 moduleTable __cache[k] prototype 臨時表
]]
reset = function(self)
self.__cache = nil
self.__index = nil
self.set_cache = nil
self.reset = nil
setmetatable(self, nil)
end,
}
setmetatable(moduleTable, moduleTable)
return moduleTable
end
總結
圍繞循環引用,可以整理出一套面(tiao)試(xi)試(zhi)題(nan),來考察應聘者的專業等級:
- 用 lua 腳本語言實現面向對象
- 是否閱讀過 lua 源碼
- require 機制是如何實現
- 如何避免文件之間循環引用造成的死循環:A文件 require B文件,B文件 require A文件
- 如何解決繼承關係中出現的循環引用:上題環境中,B文件有個類 ClassB,其父類 ClassA 定義在A文件中。
- 對於第5題給出的方案,評價空間複雜度,並嘗試改進