(Lua)読み取り専用テーブルモジュールを本気出して作った

Lua readonly module (https://gist.github.com/nyaocat/7380582)

このモジュールを介して作成されたテーブル・ユーザーデータのフィールドは絶対に変更出来ません. (debugライブラリを除く)

他の類似モジュールとの違い

いくつか "Lua Read Only" で検索しても似たのが出てくる.

  • newproxy を使用してるので rawset でも変更できない
  • getmetatable されても変更出来ない
  • メタメソッドも全て対応
  • #演算子対応
  • pairs,ipairs,next に半分対応
  • メンバ関数経由でのみ更新可能

これらを全部満たすリードオンリーテーブルモジュールはぱっと検索した限り見当たらなかった

サンプルコード

ERROR! とコメントされた行でエラーが発生します

readonly = require "readonly"
arr = readonly.readonly {3, 9, 7, 4}
print(arr[2]) -- OK
arr[1] = 98   -- ERROR!


t1 = readonly.readonly {
  val     = 10,
  getVal  = function(self)         return self.val   end,
  setVal  = function(self, newval) self.val = newval end
}
t1:getVal()   -- OK
t1:setVal(20) -- ERROR!
t1.val = 30   -- ERROR!

t2 = readonly.readonly2 {
  val     = 10,
  getVal  = function(self)         return self.val   end,
  setVal  = function(self, newval) self.val = newval end
}
t2:setVal(20) -- OK
t2:getVal()   -- OK
t2.val = 30   -- ERROR!

for k, v in ipairs(arr) do end -- ERROR!
for k, v in pairs(t2)   do end -- ERROR!
for k, v in readonly.ipairs(arr) do end -- OK
for k, v in readonly.pairs(t2)   do end -- OK

readonly(tbl) は与えられたテーブル,ユーザデータを読み込み専用にして返します.

readonly2(tbl) はメンバ関数経由での更新のみを許可します.

readonly(), readonly2() で作成された読み込み専用テーブルは標準の next, pairs, ipairs で使用出来ません.

代わりに, require("readonly").next, require("readonly").pairs, require("readonly").ipairs を使用して下さい.

これらの関数はこのモジュールで作成された読み込み専用テーブル意外のテーブルに対しては標準の next, pairs, ipairs を使用するため, これらの関数で標準の関数を置き換えても問題ありません.

フィールドが更新出来ない以外は元のテーブルと全く同じようにアクセス出来ます. __index を用いてLuaでクラスを表現している場合でも問題ありません.

このモジュールは Lua5.1 で実行してもグローバル変数にモジュールをセットしません. これは Lua5.2 での一般的なモジュール挙動と合わせたためです. local readonly = require "readonly" として実行してください.

このモジュール自体は Lua5.1, Lua5.2 両方で動くはず(多分)です.

このファイルを単体でlua readonly.luaとして実行すると簡単なテストが実行されます. http://ideone.com/soulZG require した時にはテストは実行されません.

以下実装の解説というかなんというか読み込み専用モジュールの作り方

簡単な実装

Lua には強力なメタテーブル機構があるので簡単に実現できる.

function readonly(tbl)
  return setmetatable({}, {
    __index = tbl,
    __newindex = function()
      error("value of a read-only table can not be updated.")
    end
  })
end

arr = readonly {99, 55, 33}
print(arr[2]) -- 55
arr[2] = 10 -- error!

が,これでは元のテーブルのメタメソッドにアクセスされない, rawset を使えば更新出来る,といった欠点がある. そこで,もう少しマシな定義を続きに書く.

絶対変更不可能なテーブル

function readonly(tbl)
  assert(type(tbl) == "table" or type(tbl) == "userdata",
         "Only user data or table is possible to read-only.")
  local ret = newproxy(true)  -- ※
  local mt = getmetatable(ret)
  local tbl_mt = getmetatable(tbl)
  if tbl_mt then  -- 元のメタテーブルに定義された関数に転送する
    for k, v in pairs(tbl_mt) do
      if k:match("^__") then
        mt[k] = function(...) return v(...) end
      end
    end
  end
  mt.__len   = function() return #tbl end
  -- mt.__index = tbl でも良いが,その場合 getmetatable(arr).__index で
  -- 元のテーブルが取得されてしまう
  mt.__index = function(ud, key) return tbl[key] end
  mt.__newindex = function()
    error("value of a read-only table can not be updated.")
  end
  return ret
end

arr = readonly({1, 2, 3, 4})

-- ok
for i = 1, #arr do
  print(arr[i]) 
end

-- error
arr[2] = 10

Luaのnewproxyはアンドキュメントな関数.サイズ0のフルユーザデータを作成する.

欠点として, pairs(), ipairs(), next() に読み込み専用テーブルを渡せなくなることが挙げられる. これはもうどうしようもない.

また,debugライブラリを用いて, select(2, debug.getupvalue(getmetatable(arr).__index, 1))[2] = 10 と書けば更新出来てしまう.

……が,元々debugライブラリは本来変更不可能なものを変更出来るものなので別にこっちは良いと思う.

ちなみに,これだとメンバ関数経由での更新も不可能になる.

t = readonly {
  val = 10,
  showVal = function(self) print(self.val) end,
  setVal = function(self, newval) self.val = newval end
}

t:showVal()
t:setVal(20) -- error!

メンバ関数経由での更新は許可したい場合(けっこうあると思う) はこちらを使うと良い.

メンバ関数経由での更新のみ許可する

function readonly2(tbl)
  assert(type(tbl) == "table" or type(tbl) == "userdata",
         "Only user data or table is possible to read-only.")
  local ret = newproxy(true)
  local mt = getmetatable(ret)
  local tbl_mt = getmetatable(tbl)

  if tbl_mt then  -- 元のメタテーブルに定義された関数に転送する
    for k, v in pairs(tbl_mt) do
      if k:match("^__") then
        mt[k] = function(...) return v(...) end
      end
    end
  end

  mt.__len   = function() return #tbl end
  mt.__index = function(ud, key)
    if (type(tbl[key]) == "function") then
      return function(self, ...) return tbl[key](tbl, ... ) end
    else
      return tbl[key]
    end
  end
  mt.__newindex = function()
    error("value of a read-only table can not be updated.")
  end

  return ret
end

t = readonly2 {
  val = 10,
  showVal = function(self) print(self.val) end,
  setVal = function(self, newval) self.val = newval end
}

t:setVal(20) -- OK!
t:showVal() -- 20
t.val = 30 -- error!

これに pairs 対応などを付けてモジュール化したものが冒頭のGistになります.

開発時のみこれを使って,リリース時には function readonly(tbl) return tbl end とするのも良いと思います.

ライセンス

この記事のコードはNYSLライセンスで提供するので好きに使って下さい