Luaにおけるtableとmetatableとは何か?
API7.ai
October 11, 2022
今日は、LuaJITの唯一のデータ構造であるtable
について学びます。
他のスクリプト言語には豊富なデータ構造がありますが、LuaJITにはtable
という唯一のデータ構造しかありません。これは、配列、ハッシュ、コレクションなどと区別されず、むしろそれらが混ざったようなものです。以前に触れた例を振り返ってみましょう。
local color = {first = "red", "blue", third = "green", "yellow"}
print(color["first"]) --> 出力: red
print(color[1]) --> 出力: blue
print(color["third"]) --> 出力: green
print(color[2]) --> 出力: yellow
print(color[3]) --> 出力: nil
この例では、color
テーブルは配列とハッシュの両方を含んでおり、互いに干渉せずにアクセスできます。例えば、ipairs
関数を使ってテーブルの配列部分だけをイテレートすることができます。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
for k, v in ipairs(color) do
print(k)
end
'
table
の操作は非常に重要であるため、LuaJITは標準のLua 5.1のテーブルライブラリを拡張し、OpenRestyはさらにLuaJITのテーブルライブラリを拡張しています。これらのライブラリ関数を一つずつ見ていきましょう。
テーブルライブラリ関数
まずは標準のテーブルライブラリ関数から始めます。Lua 5.1には多くのテーブルライブラリ関数がないので、簡単に確認できます。
table.getn
要素数を取得
Standard Lua and LuaJITの章で述べたように、LuaJITではテーブルの全要素数を正しく取得することが大きな問題です。
シーケンスの場合、table.getn
または単項演算子#
を使用して正しい要素数を返すことができます。以下の例では、期待通りに3が返されます。
$ resty -e 'local t = { 1, 2, 3 }
print(table.getn(t))
シーケンシャルでないテーブルでは正しい値を返すことができません。2番目の例では、返される値は1です。
$ resty -e 'local t = { 1, a = 2 }
print(#t) '
幸いなことに、このような理解しにくい関数はLuaJITの拡張によって置き換えられています。そのため、OpenRestyのコンテキストでは、シーケンスの長さを取得することが明らかな場合を除き、table.getn
関数や単項演算子#
を使用しないでください。
また、table.getn
と単項演算子#
はO(1)の時間複雑度ではなくO(n)であるため、可能であれば避けるべきです。
table.remove
指定された要素を削除
次にtable.remove
関数です。これはテーブルの添字に基づいて要素を削除します。つまり、テーブルの配列部分の要素のみを削除できます。color
の例をもう一度見てみましょう。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.remove(color, 1)
for k, v in pairs(color) do
print(v)
end'
このコードは、添字1のblue
を削除します。では、テーブルのハッシュ部分を削除するにはどうすればよいでしょうか?キーに対応する値をnil
に設定するだけです。したがって、color
の例では、third
に対応するgreen
が削除されます。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
color.third = nil
for k, v in pairs(color) do
print(v)
end'
table.concat
要素の結合関数
3つ目はtable.concat
要素結合関数です。これはテーブルの要素を添字に基づいて結合します。これも添字に基づいているため、テーブルの配列部分に対して機能します。再びcolor
の例を見てみましょう。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
print(table.concat(color, ", "))'
table.concat
関数を使用すると、blue, yellow
が出力され、ハッシュ部分はスキップされます。
さらに、この関数は添字の開始位置を指定して結合することもできます。例えば、以下のように書くことができます。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow", "orange"}
print(table.concat(color, ", ", 2, 3))'
今回はyellow, orange
が出力され、blue
はスキップされます。
この一見無駄に見える関数を軽視しないでください。パフォーマンスの最適化において予期せぬ効果を発揮することがあり、後のパフォーマンス最適化の章で主役の一つとなります。
table.insert
要素を挿入
最後にtable.insert
関数を見てみましょう。これは指定された添字に新しい要素を挿入し、テーブルの配列部分に影響を与えます。color
の例を使って説明します。
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.insert(color, 1, "orange")
print(color[1])
'
color
の最初の要素がorange
になったことがわかります。もちろん、添字を指定しない場合、デフォルトでキューの最後に挿入されます。
table.insert
は広く使われる操作ですが、パフォーマンスは良くありません。指定されたスクリプトに基づいて要素を挿入しない場合、毎回LuaJITのlj_tab_len
を呼び出して配列の長さを取得し、キューの最後に挿入する必要があります。table.getn
と同様に、テーブルの長さを取得する時間複雑度はO(n)です。
そのため、table.insert
操作はホットコードで使用しないようにするべきです。例えば:
local t = {}
for i = 1, 10000 do
table.insert(t, i)
end
LuaJITのテーブル拡張関数
次に、LuaJITのテーブル拡張関数を見てみましょう。LuaJITは標準のLuaを拡張し、テーブルの作成とクリアに役立つ2つの関数を追加しています。
table.new(narray, nhash)
新しいテーブルを作成
1つ目はtable.new(narray, nhash)
関数です。この関数は、要素を挿入する際に自身を拡張するのではなく、指定された配列とハッシュのスペースサイズを事前に割り当てます。これが2つのパラメータnarray
とnhash
の意味です。自己拡張は、スペースの割り当て、resize
、rehash
を含むコストの高い操作であり、可能な限り避けるべきです。
ここで注意すべきは、table.new
のドキュメントはLuaJITのウェブサイトにはなく、GitHubプロジェクトの拡張ドキュメントに深く埋もれているため、Googleで検索しても見つけにくく、多くのエンジニアが知らないことです。
以下は簡単な例で、その動作を示します。まず、この関数は拡張されているため、使用する前にrequire
する必要があります。
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
このコードは、100の配列要素と0のハッシュ要素を持つ新しいテーブルを作成します。もちろん、必要に応じて100の配列要素と50のハッシュ要素を持つ新しいテーブルを作成することも可能です。
local t = new_tab(100, 50)
または、事前に設定したスペースサイズを超えても通常通り使用できますが、パフォーマンスが低下し、table.new
を使用する意味がなくなります。
以下の例では、事前に設定したサイズは100ですが、200を使用しています。
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 200 do
t[i] = i
end
table.new
では、実際のシナリオに応じて配列とハッシュのスペースサイズを事前に設定する必要があり、パフォーマンスとメモリ使用量のバランスを見つけることができます。
table.clear()
テーブルをクリア
2つ目はクリア関数table.clear()
です。これはテーブル内のすべてのデータをクリアしますが、配列とハッシュ部分が占有するメモリを解放しません。そのため、Luaテーブルをリサイクルする際に非常に役立ち、テーブルの作成と破棄を繰り返すオーバーヘッドを避けることができます。
$ resty -e 'local clear_tab =require "table.clear"
local color = {first = "red", "blue", third = "green", "yellow"}
clear_tab(color)
for k, v in pairs(color) do
print(k)
end'
ただし、この関数を使用できるシナリオは多くなく、ほとんどの場合、このタスクはLuaJITのGCに任せるべきです。
OpenRestyのテーブル拡張関数
最初に述べたように、OpenRestyは独自のLuaJITブランチを維持しており、テーブルも拡張しています。いくつかの新しいAPIが追加されています:table.isempty
、table.isarray
、table.nkeys
、table.clone
。
これらの新しいAPIを使用する前に、OpenRestyのバージョンを確認してください。これらのAPIのほとんどは、OpenResty 1.15.8.1以降のバージョンでのみ使用できます。これは、OpenRestyがバージョン1.15.8.1以前の約1年間新しいリリースがなかったためで、これらのAPIはそのリリース間隔で追加されました。
記事にリンクを記載したので、table.nkeys
を例として使用します。他の3つのAPIは命名から理解しやすいので、GitHubのドキュメントを見れば理解できるでしょう。OpenRestyのドキュメントは非常に高品質で、コード例、JITが可能かどうか、注意点などが含まれています。LuaやLuaJITのドキュメントよりも数段優れています。
さて、table.nkeys
関数に戻りましょう。その命名は混乱を招くかもしれませんが、これはテーブルの長さを取得し、テーブルの要素数(配列とハッシュ部分の要素を含む)を返す関数です。そのため、table.getn
の代わりに使用できます。例えば、以下のように使用します。
local nkeys = require "table.nkeys"
print(nkeys({})) -- 0
print(nkeys({ "a", nil, "b" })) -- 2
print(nkeys({ dog = 3, cat = 4, bird = nil })) -- 2
print(nkeys({ "a", dog = 3, cat = 4 })) -- 3
メタテーブル
テーブル関数について話した後、table
から派生したmetatable
について見てみましょう。メタテーブルはLuaの独特な概念で、実際のプロジェクトで広く使用されています。lua-resty-*
ライブラリのほとんどで見つけることができると言っても過言ではありません。
Metatable
は演算子のオーバーロードのように動作します。例えば、__add
をオーバーロードして2つのLua配列の連結を計算したり、__tostring
をオーバーロードして文字列に変換する関数を定義したりできます。
一方、Luaはメタテーブルを扱うための2つの関数を提供しています。
- 1つ目は
setmetatable(table, metatable)
で、テーブルにメタテーブルを設定します。 - 2つ目は
getmetatable(table)
で、テーブルのメタテーブルを取得します。
これらを説明した後、具体的に何をするのかに興味があるかもしれません。実際のプロジェクトからのコードを見てみましょう。
$ resty -e ' local version = {
major = 1,
minor = 1,
patch = 1
}
version = setmetatable(version, {
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
まず、version
という名前のテーブルを定義します。このコードの目的は、version
のバージョン番号を出力することです。しかし、version
を直接出力することはできません。これを試してみると、直接出力するとテーブルのアドレスしか出力されないことがわかります。
print(tostring(version))
そのため、このテーブルの文字列変換関数をカスタマイズする必要があり、それが__tostring
です。ここでメタテーブルが登場します。setmetatable
を使用して、テーブルversion
の__tostring
メソッドをリセットし、バージョン番号1.1.1を出力します。
__tostring
以外にも、実際のプロジェクトでは以下の2つのメタメソッドをメタテーブルでオーバーロードすることがよくあります。
1つ目は__indexです。テーブル内の要素を検索する際、まずテーブルから直接検索し、見つからない場合はメタテーブルの__index
に進みます。
以下の例では、version
テーブルからpatch
を削除します。
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = function(t, key)
if key == "patch" then
return 2
end
end,
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
この場合、t.patch
は値を取得できないため、__index
関数に進み、1.1.2が出力されます。
__index
は関数だけでなくテーブルにもなり、以下のコードを実行すると同じ結果が得られることがわかります。
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
もう1つのメタメソッドは__callです。これはテーブルを呼び出し可能にするファンクターに似ています。
バージョン番号を出力する上記のコードを基に、テーブルを呼び出す方法を見てみましょう。
$ resty -e '
local version = {
major = 1,
minor = 1,
patch = 1
}
local function print_version(t)
print(string.format("%d.%d.%d", t.major, t.minor, t.patch))
end
version = setmetatable(version,
{__call = print_version})
version()
'
このコードでは、setmetatable
を使用してテーブルversion
にメタテーブルを追加し、その中の__call
メタメソッドは関数print_version
を指しています。そのため、version
を関数として呼び出そうとすると、ここでprint_version
関数が実行されます。
そして、getmetatable
はsetmetatable
と対になる操作で、設定されたメタテーブルを取得します。以下のコードのように使用します。
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(getmetatable(version).__index.patch)
'
今日話したこれら3つのメタメソッド以外にも、あまり使われないメタメソッドがあります。それらに出会ったときは、ドキュメントを参照して詳細を学ぶことができます。
オブジェクト指向
最後に、オブジェクト指向について話しましょう。ご存知の通り、Luaはオブジェクト指向言語ではありませんが、メタテーブルを使用してOOを実装することができます。
実際の例を見てみましょう。lua-resty-mysqlはOpenRestyの公式MySQLクライアントで、メタテーブルを使用してクラスとクラスメソッドをシミュレートしています。以下のように使用されます。
$ resty -e 'local mysql = require "resty.mysql" -- まずlua-restyライブラリを参照
local db, err = mysql:new() -- クラスの新しいインスタンスを作成
db:set_timeout(1000) -- クラスのメソッドを呼び出す
上記のコードはresty
コマンドラインで直接実行できます。これらのコード行は理解しやすいですが、唯一の問題は次の点です。
クラスメソッドを呼び出すとき、なぜドットではなくコロンなのか?
実際、ここではコロンもドットも問題ありません。db:set_timeout(1000)
とdb.set_timeout(db, 1000)
は完全に等価です。コロンはLuaのシンタックスシュガーで、関数の最初の引数self
を省略できます。
ソースコードの前には秘密はありませんので、上記のコード行に対応する具体的な実装を見て、メタテーブルを使用してオブジェクト指向をモデル化する方法をよりよく理解しましょう。
local _M = { _VERSION = '0.21' } -- テーブルを使用してクラスをシミュレート
local mt = { __index = _M } -- mtはメタテーブルの略で、__indexはクラス自体を指す
-- クラスのコンストラクタ
function _M.new(self)
local sock, err = tcp()
if not sock then
return nil, err
end
return setmetatable({ sock = sock }, mt) -- テーブルとメタテーブルを使用してクラスのインスタンスをシミュレート
end
-- クラスのメンバー関数
function _M.set_timeout(self, timeout) -- self引数を使用して操作したいクラスのインスタンスを取得
local sock = self.sock
if not sock then
return nil, "not initialized"
end
return sock:settimeout(timeout)
end
テーブル_M
は、単一のメンバー変数_VERSION
で初期化されたクラスをシミュレートし、その後_M.set_timeout
などのメンバー関数を定義します。コンストラクタ_M.new(self)
では、メタテーブルがmt
であるテーブルを返し、mt
の__index
メタメソッドは_M
を指しているため、返されるテーブルはクラス_M
のインスタンスをシミュレートします。
まとめ
今日の主な内容は以上です。テーブルとメタテーブルは、OpenRestyのlua-resty-*
ライブラリやOpenRestyベースのオープンソースプロジェクトで広く使用されています。このレッスンが、ソースコードを読み理解するのに役立つことを願っています。
Luaにはテーブル以外にも標準関数がありますが、それらは次のレッスンで一緒に学びます。
最後に、考えさせられる質問を残します。なぜlua-resty-mysql
ライブラリはOOをシミュレートするためにラッピング層を追加しているのでしょうか?この質問についてコメント欄で議論し、この記事を同僚や友人と共有して、コミュニケーションと進歩を共にしましょう。