TypeScriptToLua如何支持循環引用

TypeScriptToLua如何支持循環引用

循環引用

循環引用(Circular Require, Circular dependencies),在lua環境中,指的是這樣的情況:

有兩個lua文件A和B,文件A中require了B,文件B中require了A,這樣在lua解析時會陷入死循環。

很容易想到,在文件require(也就是加載)的時候,應該有三種狀態。

  1. 未加載
  2. 加載中
  3. 加載完成

但是lua原生的代碼 package.loaded 僅支持1、3兩種狀態。

  1. LOADED[name] = nil
  2. 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函數,僅做了兩件事情:

  1. 讀取LOADED[name],如果爲有效值,則直接返回該值。

lua_toboolean最終會走到 !l_isfalse

#define l_isfalse(o)	(ttisnil(o) || (ttisboolean(o) && bvalue(o) == 0))
  1. 如果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],也就是兩級關係:

  1. LOADED[name]臨時空表。__index功能:找到LOADED[name],獲取Class。
    當然,能走到__index就表示LOADED[name]未加載完,不可能得到實際的Class,所以這裏返回的是第二個空表。
  2. 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)

兩個新問題

  1. CClassD.____super是CClassA,這裏訪問到了CClassA.prototype
    較上例,這裏多了一級關係 exportA -> CClassA -> CClassA.prototype
    所以這裏要準備三張臨時空表和元表。

  2. 這裏會拿臨時空表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),來考察應聘者的專業等級:

  1. 用 lua 腳本語言實現面向對象
  2. 是否閱讀過 lua 源碼
  3. require 機制是如何實現
  4. 如何避免文件之間循環引用造成的死循環:A文件 require B文件,B文件 require A文件
  5. 如何解決繼承關係中出現的循環引用:上題環境中,B文件有個類 ClassB,其父類 ClassA 定義在A文件中。
  6. 對於第5題給出的方案,評價空間複雜度,並嘗試改進
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章