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题给出的方案,评价空间复杂度,并尝试改进