🧙

闇の魔術に対する防衛術 Lua 編 ~ load() による文字列評価 ~

2024/01/18に公開

はじめに

闇の魔術から身を守るためには、闇の魔術について知ることが必要です。

ということで今回は Lua の load() 関数について学んでいきましょう。
いわゆる eval() 関数に近いものです。

この技術を使うと、擬似的な lambda 式などが実現できるようになります。

local add = lambda "x, y: x + y"
print(add(1, 2))
-- output: 3

基本知識

chunk

まずは chunk について説明します。

マニュアルから引用します。

https://www.lua.org/manual/5.1/manual.html#2.4.1

Lua の実行単位は chunk と呼ばれます。
これは単純に文 (statement) の並びのことで、順次実行されます。

Lua は chunk を可変の引数を持つ無名関数の本体として扱います。
そのため、chunk はローカル変数を定義したり、引数を受け取ったり、戻り値を返したりすることができます。

この用語を知らずとも、モジュール化をやったことがある人ならイメージはしやすいと思います。
Lua はトップレベルで return を使え、この戻り値は require() でそのモジュールを読み込むことで利用できますよね。
その理由はモジュールのファイル全体が chunk であり、一つの関数として扱われているからです。

load (loadstring)

require(), loadfile(), load(), loadstring() は比較的似た機能を持ちます。

Lua 5.1 と 5.2 以降では load() の仕様が異なります (5.2 以降は loadstring() と統合)。
筆者のよく使う Luajit では Lua 5.2 仕様の load() を実装しているので、こちらの仕様で話を進めます。

マニュアルはこちら。

https://www.lua.org/manual/5.2/manual.html#pdf-load

require()loadfile() ではファイルを指定し、その中身を chunk とします。
一方 load() を使うと、chunk を直接文字列で渡すことができます。

文字列に構文エラーがなければコンパイルした chunk を返し、エラーがある場合は nil とエラーメッセージを返します。
そして、この形式は assert() にそのまま突っ込めます。

assert()

https://www.lua.org/manual/5.1/manual.html#pdf-assert

assert() は第一引数が truthy (false でも nil でもない) のときは、引数をそのまま返します。
第一引数が falsy (false か nil) のときはエラーを発生させます。
このとき第二引数も与えられていると、これをエラーメッセージとして利用します。

なので成功時にはそのまま結果を返し、失敗時には nil とエラーメッセージを返す、という形式の組み込み関数が幾つかあるんですね。

chunk は先程説明したとおり、全体をラップした無名関数として扱われます。
例えば

local one = assert(load("return 1"))

local one = function()
  return 1
end

は等価です。

ですので、いわゆる eval() のイメージとは少し違うかもしれません。
return を付けないと値を返さないので、eval("2 + 2") で 4 を返してほしければ次のようにする必要があります。

local function eval(str)
  return assert(load("return " .. str))()
end

応用例

load() を使うことで、動的に文字列を評価できることが分かりました。

いくつか応用例を考えてみましょう。

疑似 lambda 式

python の lambda 式 (のようなもの) を作ってみましょう。

local chunk = [[
return function(%s)
  return %s
end
]]

---@param str string
---@return function
local function lambda(str)
  local arg, body = str:match("(.*):(.*)")
  return assert(load(chunk:format(arg, body)))()
end

lambda() 関数は引数の文字列を : で区切り、仮引数と戻り値としてテンプレートの文字列に埋め込みます。
この chunk は関数を返すので lambda() の戻り値も関数です。

例えば lambda "x, y: x + y" であれば (Lua の関数呼び出しにおいて、実引数が1つのときは括弧を省略できます)

function(x, y)
  return x + y
end

これが返ってきます。

ちなみに chunk は可変長引数の関数なので ... で引数にアクセスできます。
引数に名前を付けないのであればわざわざ関数を返す必要はありません。

local join_space = assert(load("return table.concat({ ... }, ' ')"))
print(join_space("Hello", "world!"))
--- output: Hello world!

increment (不可能)

C 言語などのマクロのイメージだと、次のようなことができそうな気がします。

local function inc(name)
  return assert(load(("%s = %s + 1"):format(name, name)))()
end
local i = 0
inc("i")
print(i)
-- output: 1

しかし上記のコードを実行すると、以下のようなエラーが生じます。

Error while calling lua chunk: [string "i = i + 1"]:1: attempt to perform arithmetic on global 'i' (a nil value)

その場に文字列を展開するマクロとは違い、load() は異なる chunk を生成します。
chunk ごとにローカルのスコープは別れるため、外のローカル変数にはアクセスできないのです。

おわりに

load() は隔離された環境で実行されるため、周囲のローカル変数にはアクセスできません。
そのため、JS の eval() 関数と比べればまだ安全です。
しかし文字列を動的に評価するため、多用するとバグの温床にもなりうる技術です。
あまり複雑なことはさせず、ユーザーには触れられない場所でだけ使うのがいいのかなと思います。

GitHubで編集を提案

Discussion