この記事は Lua Advent Calendar 2025 の 7 日目の記事です。
みなさん metatable 使ってますか?
Lua のメタテーブルの使用例として table を変更不可にするものがあると思います。
(そうそう、luau には読み取り専用プロパティを表すマーカーがあるらしいですね1。それと Lua 5.4 で追加された const 変数は local t<const> = { 1 }; t = { 2 }; のように変数への再代入を防ぐもので今回のようなプロパティの変更はスコープ外です)
ところで普通に実装する場合、readonly は一段階の保護になり t.a = 10 は防ぎますが t.a.b = 20 のような書き込みは保護されません。今日はネストした深いプロパティも含め readonly にする関数を実装していきます。
……最終的に https://stackoverflow.com/questions/28312409/how-can-i-implement-a-read-only-table-in-lua に辿り着く気がしますが、ともかく書いていきます。
※完成品はこちらになります
local proxies_deep = setmetatable({}, { __mode = "k" })
local proxies_shallow = setmetatable({}, { __mode = "k" })
local originals = setmetatable({}, { __mode = "kv" })
-- for Lua 5.1 compatibility
local __eq = function(a, b)
return (originals[a] or a) == (originals[b] or b)
end
local readonly
readonly = function(t, deep)
if type(t) ~= "table" then return t end
if deep == nil then deep = true end
local proxies = deep and proxies_deep or proxies_shallow
if proxies[t] then return proxies[t] end
local readonly_table = {}
if _VERSION == "Lua 5.1" then
-- For Lua 5.1, __len metamethod is not called for table
for i = 1, #t do
readonly_table[i] = true -- dummy value
end
end
local mt = {
__newindex = function(_, _k, _v)
error("Attempt to modify readonly table", 2)
end,
__len = function(_rt)
return #t
end,
__eq = __eq,
}
if deep then
mt.__index = function(_rt, k)
local v = t[k]
if type(v) ~= "table" then return v end
return readonly(v, true)
end
else
mt.__index = t
end
local original_mt = getmetatable(t)
if original_mt then
for k, v in pairs(original_mt) do
mt[k] = mt[k] or v
end
end
originals[readonly_table] = t
proxies[t] = setmetatable(readonly_table, mt)
return readonly_table
end
return readonly
テストコード
local describe = require("test").describe
local test = require("test").test
local expect = require("test").expect
local readonly = require("readonly")
describe("readonly", function()
test("try to modify a readonly table", function()
local t = { a = 1, b = 2 }
local rt = readonly(t)
pcall(function()
rt.a = 10
end)
expect(rt.a):toBe(1, "refused modification rt.a = 10")
end)
test("deep readonly behavior", function()
local t = { a = 1, b = { c = 2 } }
local rt = readonly(t)
pcall(function()
rt.b.c = 20
end)
expect(rt.b.c):toBe(2, "refused modification rt.b.c = 20")
end)
test("shallow readonly behavior", function()
local t = { a = 1, b = { c = 2 } }
local rt = readonly(t, false)
pcall(function()
rt.a = 10
end)
pcall(function()
rt.b.c = 20
end)
expect(rt.a):toBe(1, "refused modification rt.a = 10")
expect(rt.b.c):toBe(20, "allowed modification rt.b.c = 20")
end)
test("__len", function()
local t = { 1, 2, 3 }
expect(#t):toBe(3, "length works on original table")
local rt = readonly(t)
expect(#rt):toBe(3, "length works on readonly table")
end)
describe("other metatable behaviors", function()
test("__add", function()
local t1, t2 = { val = 1 }, { val = 2 }
local mt = { __add = function(a, b) return { val = a.val + b.val } end }
t1, t2 = setmetatable(t1, mt), setmetatable(t2, mt)
expect((t1 + t2).val):toBe(3, "addition works on original tables")
local rt1, rt2 = readonly(t1), readonly(t2)
expect((rt1 + rt2).val):toBe(3, "addition works on readonly tables")
end)
test("t == readonly(t)", function()
local t = {}
local rt = readonly(t)
assert(t == rt, "original table equals readonly table")
assert(rt == t, "readonly table equals original table")
end)
test("__eq", function()
local t1, t2 = { val = 1 }, { val = 1 }
local mt = { __eq = function(a, b) return a.val == b.val end }
t1, t2 = setmetatable(t1, mt), setmetatable(t2, mt)
assert(t1 == t2, "equality works on original tables")
local rt1, rt2 = readonly(t1), readonly(t2)
assert(rt1 == rt2, "equality works on readonly tables")
end)
end)
end)
testing framework に関して
テストには
を使うこととします。 リリース の Assets から test.lua をダウンロードしてきて配置します。
ついでに lua-language-server あたりもセットアップしておきます。
VERSIONS
$ ./lua51 -v
Lua 5.1.5 Copyright (C) 1994-2012 Lua.org, PUC-Rio
quartz@Obsidian-2022 ~/Workspaces/junk/lua-readonly
$ ./lua52 -v
Lua 5.2.4 Copyright (C) 1994-2015 Lua.org, PUC-Rio
quartz@Obsidian-2022 ~/Workspaces/junk/lua-readonly
$ ./lua53 -v
Lua 5.3.6 Copyright (C) 1994-2020 Lua.org, PUC-Rio
quartz@Obsidian-2022 ~/Workspaces/junk/lua-readonly
$ lua -v
Lua 5.4.8 Copyright (C) 1994-2025 Lua.org, PUC-Rio
実装開始
まず失敗するテストを書きます
テスト駆動でやってみようと思うので、失敗するテストを書いて実行してみましょう
local describe = require("test").describe
local test = require("test").test
local readonly = require("readonly")
describe("readonly", function()
test("try to modify a readonly table", function()
local t = { a = 1, b = 2 }
local rt = readonly(t)
pcall(function()
rt.a = 10
end)
assert(rt.a == 1, "refused modification rt.a = 10")
end)
end)
local readonly = function(t)
return t
end
return readonly
$ lua ./readonly.spec.lua
readonly (0/1)✘
try to modify a readonly table ✘
readonly (0/1)✘
1 test(s) failed.
Error in readonly > try to modify a readonly table
./readonly.spec.lua:15: refused modification rt.a = 10
-- 後から気づきましたが lua-testing-library に実装されている expect() を用いて expect(rt.a):toBe(1, "refused modification rt.a = 10") と書いたほうが出てくるメッセージが親切になります
__newindex を用いて単純な代入を防ぐ
local readonly = function(t)
if type(t) ~= "table" then return t end
local readonly_table = {}
local mt = {
__index = t,
__newindex = function(_, _k, _v)
error("Attempt to modify readonly table", 2)
end,
-- TODO: これ以外のメタメソッド
}
setmetatable(readonly_table, mt)
return readonly_table
end
return readonly
deep readonly にするために
__index で返ってきた値がテーブルであった場合に再帰的に readonly を適用する必要があります。例によってテストを先に書きます
test("deep readonly behavior", function()
local t = { a = 1, b = { c = 2 } }
local rt = readonly(t)
pcall(function()
rt.b.c = 20
end)
assert(rt.b.c == 2, "refused modification rt.b.c = 20")
end)
期待した通りテストが失敗したので実装に移ります
__index メタメソッドを実装したものがこちらになります
__index = function(_rt, k)
local v = t[k]
if type(v) ~= "table" then return v end
return readonly(v)
end,
Lua のローカル変数のスコープは「その宣言の次の文」から始まるので、readonly.lua の先頭行を以下のように書き換えます
local readonly
readonly = function(t)
__index, __newindex 以外の振る舞いを追加
__len
test("__len", function()
local t = { 1, 2, 3 }
assert(#t == 3, "length works on original table")
local rt = readonly(t)
assert(#rt == 3, "length works on readonly table")
end)
こいつはオリジナルのテーブルの __len が使われるようにすればいいのでこうします
__len = function(_rt)
return #t
end,
…… Lua 5.1 では __len が呼ばれずに素の長さが優先されるために処理を追加します
if _VERSION == "Lua 5.1" then
-- For Lua 5.1, __len metamethod is not called for table
for i = 1, #t do
readonly_table[i] = -i -- dummy value
end
end
__add 等
諸々面倒なので元のテーブルのメタメソッドをそのまま持ってきます。ここの実装はもう少し改善のしようがありますが、ひとまず元のメタメソッドが function(t, ...) if t == ほにゃらら then の比較を行っていないことを祈ります
local original_mt = getmetatable(t)
if original_mt then
for k, v in pairs(original_mt) do
mt[k] = mt[k] or v
end
end
キャッシュテーブルを追加
スタックオーバーフローの回答 にならってキャッシュを追加します
local proxies = setmetatable({}, { __mode = "k" })
(略)
__eq を実装
local originals = setmetatable({}, { __mode = "kv" })
local __eq = function(a, b)
return (originals[a] or a) == (originals[b] or b)
end
※lua 5.1 および lua 5.2 では以下のような挙動を示すため2、 __eq の参照は readonly(t) 関数の外側に置いて同じものが返るようにします
function getcomphandler (op1, op2, event)
if type(op1) ~= type(op2) then return nil end
local mm1 = metatable(op1)[event]
local mm2 = metatable(op2)[event]
if mm1 == mm2 then return mm1 else return nil end
end
このあと shallow / deep の切り替えをできるように機能を組み込んだり、その過程でバグ入れたり、lua-testing-library の expect() を使えてなかったので書き換えたりしました。
結果
$ lua ./readonly.spec.lua
$ ./lua51 ./readonly.spec.lua
Lua 5.1 および 5.2 ではメタメソッドのアクセスの仕様上 t == readonly(t) の比較を上書きできませんが、それ以外は概ね期待した動きをしてくれるようになりました
おまけ
lua 5.1, 5.2 で test.lua を動かすために、先頭に以下コードを差し込みました。
if _VERSION == "Lua 5.1" then
_ENV = getfenv(0)
package.searchers = package.loaders
table.unpack = unpack
table.pack = function(...)
return { n = select("#", ...), ... }
end
local original_print = print
print = function(...)
local args = { ... }
for i = 1, #args do
args[i] = tostring(args[i]):gsub("\x1b", "\027")
end
original_print(table.unpack(args))
end
end
if _VERSION <= "Lua 5.2" then
table.move = function(src, f, e, t, dest)
t = t or 1
dest = dest or src
local tmp = {}
for i = f, e do
table.insert(tmp, src[i])
end
for i = 1, #tmp do
dest[t + i - 1] = tmp[i]
end
return dest
end
end






