Lua Readonly Table

环境是Lua5.3。

需求

当table已存在的key/value被修改时,报错提示。当table新增的key/value时,报错提示。其他时候,能够像普通的table一样使用。

简单实现

建一个空的table,设置元方法__index,读数据时从原来的table读。再设置__newindex元方法,方法内调用error函数。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local metatable = {}

function metatable.__index(ro_table, key)
return table.____ro_raw_table[key]
end

function metatable.__newindex(ro_table, key, value)
error(string.format("inaccessible due to its readonly. key = %s, value = %s", key, value))
end

function read_only(data)
local retValue = {
____ro_raw_table = data
}
setmetatable(retValue, metatable)
return retValue
end

调用read_only函数后,我们得到了一个新的table。我们去访问原来的data中的key时,由于retValue没有这个索引,就会触发__index元方法调用。当我们尝试修改retValue的某个值,因为retValue没有对应的索引,所以会触发__newindex元方法。这里发现我们retValue里有____ro_raw_table这个变量,我主要是想用这个变量来判断表是不是一个只读表。我们可以对read_only做个优化,如果一个表已经是只读的了,直接返回就行了,避免套娃行为。

简单优化

优化一下read_only,代码如下:

1
2
3
4
5
6
7
8
9
10
function read_only(data)
if type(data) ~= "table" or data.____ro_raw_table then
return data
end
local retValue = {
____ro_raw_table = data
}
setmetatable(retValue, metatable)
return retValue
end

__index也可以优化一下,同时对返回值做一下处理。代码如下:

1
2
3
4
5
6
7
function metatable.__index(ro_table, key)
local value = ro_table.____ro_raw_table[key]
if value == nil and type(key) == "table" and key.____ro_raw_table then
value = ro_table.____ro_raw_table[key.____ro_raw_table]
end
return read_only(value)
end

还可以加一个read_only_cast方法,用于将只读表强转成非只读的。项目里可能用得上。代码如下:

1
2
3
4
5
6
function read_only_cast(data)
if type(data) ~= "table" or not data.____ro_raw_table then
return data
end
return data.____ro_raw_table
end

一切都很顺利,现在我们可以用pairs和ipairs试试遍历只读表。发现问题了。pairs只能遍历出____ro_raw_table这一个元素。并不能把原来表中的键值对遍历出来。ipairs倒是没有问题,遍历出来的就是原表中的数据。原因是ipairs遍历会固定从索引1开始访问,所以调用__index元方法,因此结果是正确的。pairs遍历时会调用__pairs元方法,如果没有,就会调用全局的next函数来完成遍历。next方法内部并不会调用任何元方法,所以会遍历不到数据。知道原因后,我们继续实现__pairs元方法。

实现pairs

__pairs元方法需要返回3个参数,第一个是遍历时使用的next方法,我们需要自定义一个。第二个和第三个参数是第一次调用next方法时传的两个参数。第二个我们传____ro_raw_table就好。第三个我们传nil,表示从头开始遍历。实际使用时,我们需要定义一个闭包函数,用一个局部变量来跟踪上一次遍历的index。代码如下:

1
2
3
4
5
6
7
8
9
function metatable.__pairs(ro_table)
local pairsIndex = nil
local read_only_next = function(raw_table)
local nk, nv = next(raw_table, pairsIndex)
pairsIndex = nk
return read_only(nk), read_only(nv)
end
return read_only_next, ro_table.____ro_raw_table, nil
end

next方法需要两个参数,第一个是table,第二个是index。返回两个参数,第一个是nextKey,第二个是nextValue。如果要用next来遍历table,index为nil时,代表从头遍历。下一次调用next需要将上一次返回的nextKey作为index传进去,这一步是pairs自动完成的。我们自己定义的read_only_next也需要遵守这个规则。这里发现我没有写第二个参数,因为我对nextKey做了read_only处理,所以pairs自动传过来的index是不正确的,我干脆就不写了。我们需要用闭包变量来记录真实的nextKey。

让只读表更自然

核心问题已经在上边都解决了。这一段就是添加一些边边角角的东西。根据自己的需要来。

实现__len,支持#操作。代码如下:

1
2
3
function metatable.__len(ro_table)
return #ro_table.____ro_raw_table
end

实现__tostring,字符串打印时能加上readonly前缀。代码如下:

1
2
3
function metatable.__tostring(ro_table)
return string.format("readonly %s", ro_table.____ro_raw_table)
end

实现__eq,项目内有判等的操作,需要支持一下。代码如下:

1
2
3
4
5
6
7
8
9
function metatable.__eq(left, right)
if type(left) == "table" and left.____ro_raw_table then
left = left.____ro_raw_table
end
if type(right) == "table" and right.____ro_raw_table then
right = right.____ro_raw_table
end
return left == right
end

如果原始table的元表也实现过__eq方法,也需要修改下。因为lua优先使用左边变量的__eq方法。

贴一下github地址:readonly.lua

临走前别忘了点赞哦。