Luaにおけるtableとmetatableとは何か?

API7.ai

October 11, 2022

OpenResty (NGINX + Lua)

今日は、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つのパラメータnarraynhashの意味です。自己拡張は、スペースの割り当て、resizerehashを含むコストの高い操作であり、可能な限り避けるべきです。

ここで注意すべきは、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.isemptytable.isarraytable.nkeystable.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関数が実行されます。

そして、getmetatablesetmetatableと対になる操作で、設定されたメタテーブルを取得します。以下のコードのように使用します。

$ 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をシミュレートするためにラッピング層を追加しているのでしょうか?この質問についてコメント欄で議論し、この記事を同僚や友人と共有して、コミュニケーションと進歩を共にしましょう。