木匣子

Web/Game/Programming/Life etc.

Lua 深拷贝的实现

使用 PureMVC 构建游戏项目 Part III 的 Model 章节,我提到了使用对象拷贝(copy 或克隆/Clone)来实现值对象(Value Object)的保护。那么要如何在 lua 中实现对象拷贝呢?

0x00 定义

首先思考一下拷贝的本质是什么?简单说就是创建出一个和本体一样的副本,并且对副本的任何操作都不影响本体。以最基本的数值类型为例:

foo = 1
bar = foo -- assign to foo
bar = 2 -- make a change
print(foo, bar) -- 1, 2

可见通过直接赋值,将 foo “拷贝”到 bar 后,修改 bar 并不会影响到 foo 。这完成符合上述对拷贝的定义。然而在 lua 中并不是所有类型的变量都可以通过直接赋值来实现这样的拷贝——比如 table 类型就不行:

foo = {key = 1}

bar = foo
bar = {key = 2} -- assign to another table
print(foo.key, bar.key) -- 1, 2
assert(foo.key ~= bar.key) -- ok

baz = foo
baz.key = 2 -- make a change by key
print(foo.key, baz.key) -- 2, 2
assert(foo.key ~= baz.key) -- error

可以看到,将 foo 赋值给 baz 后,修改了 baz.key 之后,foo.key 也被改动了。通常我们叫这种类型的赋值为:浅拷贝,它不符合我们对拷贝的定义:对副本的任何操作都不影响本体;以之相对的就是深拷贝。

0x01 Lua 类型

在 lua 中有 8 种基本类型,分别是:nil、boolean、number、string、userdata、function、thread、table。

赋值拷贝

其中通过直接赋值能满足我们对拷贝的定义的类型有:nil、boolean、number、string、function。对于前三者没有什么好说的,它们就是简单值类型,不会有什么操作能间接影响它们的副本。

对于 string,虽然它本身不是简单值类型,但在 lua 中有特殊的内存管理方式,不能直接去修改它的值,而且任何影响它的操作都会创建新的副本——不会影响本体,所以它符合我们对拷贝的定义。

还有 function,它也不是简单值类型,但是即使多个不同的变量引用它也没有关系,因为没有什么操作能修改或影响它。同一段代码,在程序中只需要有一个实例即可。所以我认为它也符合我们对拷贝的定义。

非赋值拷贝

另外三种 lua 基本类型 userdata、thread、table 都是非简单值类型。其中 table 可以说是 lua 数据结构的根基,要实现其它的数据结构都要依赖它,在 lua 程序中模拟面向对象类也离不开它,然而它不能简单通过赋值进行拷贝,而是需要创建一个新的 table 并将原 table 的 key-value 递归拷贝,此外还要考虑元表等相关处理,具体代码在后面讨论。

thread 和 userdata 比较复杂。在我们的游戏项目中通常只对值对象(Value Object)进行深拷贝,且通常值对象只存放一些简单值类型和 table,并不会包括这两种值类型。所以如果遇到这样的类型的话,只对其使用浅拷贝不作其它处理。

thread 可以通过 coroutine.create 创建并通过 coroutine.resume/coroutine.yield 影响其状态,但不能直接通过源 thread 创建其副本——即使知道它的源 function ,也不能保证新创建的 thread 的内部状态与源 thread 一致。
对于 userdata 情况就更复杂了,userdata 是由 luavm 的宿主程序管理的数据,其行为不在本文讨论范围。

0x02 实现

通过对 lua 基本类型的分析,可知只需要对 table 类型进行递归拷贝即可。其它所有类型直接用赋值拷贝或浅拷贝。

但是 table 有一些特性需要注意:

  1. 拷贝后的 table 应与原 table 具有相同的元表;
  2. 元表不需要递归拷贝;

《Lua 简单面向对象模型》一文中设计的“类”,即是一种作为元表的 table,拷贝这个类的实例,只要深拷贝实例的 table 最后将元表指向同一个类表就行了。

此外,还有注意到一类用例,在参数文献[1]中就犯了这个错误:

foo = {}
bar = {foo, foo}
assert(bar[1] == bar[1])

baz = deepCopy(bar)
assert(bar[1] ~= baz[1]) -- it is a clone
assert(baz[1] == baz[2]) -- important

注意到 bar 里的两个元素,其实是同一个 table,所以进行深拷贝的时候要保持这种联系。而不是在深拷贝后里面变成两个独立的 table。在最终的实现代码里,使用了 lookup_table 来保存已经拷贝过的对象。

另外还要注意一点就是 table 的 key 可以是任意基本类型,所以不仅需要对 value 进行递归拷贝,也要对 key 进行递归拷贝。

以下是最终实现的代码:

function deepCopy(object)
    local lookup_table = {}
    local function _copy(object)
        if type(object) ~= "table" then
            return object
        elseif lookup_table[object] then
            return lookup_table[object]
        end

        local new_table = {}
        lookup_table[object] = new_table
        for key, value in pairs(object) do
            new_table[_copy(key)] = _copy(value)
        end
        return setmetatable(new_table, getmetatable(object))
    end

    return _copy(object)
end

参考文献

  1. Lua实现浅拷贝和深拷贝: (P.S.该实现没有处理表中存在相同元素的问题)