1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lua で deep readonly する

Last updated at Posted at 2025-12-07

この記事は 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 に辿り着く気がしますが、ともかく書いていきます。

※完成品はこちらになります

readonly.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
テストコード
readonly.spec.lua
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

実装開始

まず失敗するテストを書きます

テスト駆動でやってみようと思うので、失敗するテストを書いて実行してみましょう

readonly.spec.lua
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)
readonly.lua
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

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

readonly (1/1)✔ All tests passed.

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)

readonly (1/2)✘ try to modify a readonly table ✔ deep readonly behavior ✘ readonly (1/2)✘ 1 test(s) failed. Error in readonly > deep readonly behavior ./readonly.spec.lua:26: refused modification rt.b.c = 20

期待した通りテストが失敗したので実装に移ります

__index メタメソッドを実装したものがこちらになります

        __index = function(_rt, k)
            local v = t[k]
            if type(v) ~= "table" then return v end
            return readonly(v)
        end,

readonly (1/2)✘ try to modify a readonly table ✔ deep readonly behavior ✘ readonly (1/2)✘ 1 test(s) failed. Error in readonly > deep readonly behavior ./readonly.lua:8: attempt to call a nil value (global 'readonly')

Lua のローカル変数のスコープは「その宣言の次の文」から始まるので、readonly.lua の先頭行を以下のように書き換えます

readonly.lua
local readonly
readonly = function(t)

readonly (2/2)✔ try to modify a readonly table ✔ deep readonly behavior ✔ readonly (2/2)✔ All tests passed.

__index, __newindex 以外の振る舞いを追加

__len

readonly.spec.lua
    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 が使われるようにすればいいのでこうします

readonly.lua
        __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 の比較を行っていないことを祈ります

readonly.lua
    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) 関数の外側に置いて同じものが返るようにします

Reference Manual 5.1 §2.8 "eq"
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

readonly (5/5)✔ try to modify a readonly table ✔ deep readonly behavior ✔ shallow readonly behavior ✔ __len ✔ other metatable behaviors (3/3)✔ __add ✔ t == readonly(t) ✔ __eq ✔ other metatable behaviors (3/3)✔ readonly (5/5)✔ All tests passed.

$ ./lua51 ./readonly.spec.lua

readonly (4/5)✘ try to modify a readonly table ✔ deep readonly behavior ✔ shallow readonly behavior ✔ __len ✔ other metatable behaviors (2/3)✘ __add ✔ t == readonly(t) ✘ __eq ✔ other metatable behaviors (2/3)✘ readonly (4/5)✘ 1 test(s) failed. Error in readonly > other metatable behaviors > t == readonly(t) ./readonly.spec.lua:65: original table equals readonly table

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
  1. https://zenn.dev/ambr_inc/articles/eddd76512016c1

  2. https://www.lua.org/manual/5.1/manual.html#2.8

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?