Lua VMに見る多値の扱い
こんにちは、びしょ〜じょです。
1. はじめに
さて、最近は多値に関する議論がホットだったようです。 ホットスポットはこちらの様子。
なるほど。こちらも見ておこう。
どうやらGoは多値が使えたり使えなかったりするらしい。 個人的には使えるか使えない(tupleにするとか)かのどちらかのほうが良い言語デザインだと思うけど、ユーザが問題ないなら…。
本稿ではLua VMが使える代表的な言語Luaを例に、Goとの多値の違いに付いて見ていき、Lua VMおよびLuaの多値の扱いが良い感じなことを確認したい。 拙者Goは知らない侍につき、Goに関してはご容赦。
2. Luaで様子見
Lua VMのバイトコードを吐き出してくれるコンパイラの代表といえばluacだと思います。 むしろほかを知りませんが。 luacはLuaをLua VMバイトコードにコンパイルしてくれるすごいやつだよ。
// 多値を返す関数minmax
func minmax(x int, y int) (int, int) {
if x < y {
return x, y // 条件演算子がないのも割り切りだとわかっていてもつらい。
}
return y, x
}
// 多値の受け取り
min, max := minmax(20, 10)
fmt.Println("min:", min, ", max:", max) // => min: 10, max: 20
よくあるやつですね。 Luaで書くとこうなる。
local function minmax(x, y)
if x < y then
return x, y
else
return y, x
end
end
ありがちですね。
もう一つ。
tuple := minmax(20, 10) // コンパイルエラー
local tuple = minmax(20, 10)
print(tuple) -- 10
最初の方だけ返ってきました。 もう一個はどこへ行った???
local min, max = minmax(20, 10)
print(min, max) -- 10 20
虚空に消えたようで、拾ってやれば出てきます。 call by valueなので虚空に消えても値なので特に問題ありません。
func minmax2(x int, y int) (int, int) {
return minmax(x, y) // そのまま返す
}
これは
local function minmax2(x, y)
return minmax(x, y)
end
print(minmax2(20, 10)) -- 10 20
問題ないね。
2-1. "多値っぽい構文なのに多値ではない機能"はLuaには無いぜ
1.1では "多値っぽい構文なのに多値ではない機能"としてfor rangeが例に出されています。
ys := make([]int, len(xs))
for i, x := range xs {
ys[i] = x * 2
}
i, x
の部分は多値のように見えますが、これは多値ではなく構文です。
なるほど。Luaにもfor-in
で同じようなことがかける。
local ys = {} -- Lua唯一のデータ構造tableにサイズ指定なんてないぜ
for i, x in ipairs(xs) do
ys[i] = x
end
i
, x
はLuaでは多値となる。
へーそうなんだ。
ipairs
関数はジェネレータを作る関数であり、ジェネレータが多値を返す関数なんです。
へーそうなんだ。
ジェネレータがn個の値を返せば、for文でもn個の値を取れる。
ジェネレータとはいえ、実態はただの関数である。
local function generator(top)
local acc = 0
return function()
acc = acc + 1
if acc < top then
return acc, acc - 1, acc - 2, acc - 3
else return nil
end
end
end
for a, b, c, d in generator(10) do
print(a, b, c, d)
end
--[[
1 0 -1 -2
2 1 0 -1
3 2 1 0
4 3 2 1
5 4 3 2
6 5 4 3
7 6 5 4
8 7 6 5
9 8 7 6
]]
Go言語では、mapへのアクセスにはスライスや文字列などと同様、 [] を使います。 その結果として、値のほかにキーが存在したかどうかを表す bool 値が受け取れます。
m := map[string]int { "a": 10, "b": 20 }
n, ok := m["c"] // 2nd valueとしてbool値が受け取れる
if !ok {
n = 0
}
fmt.Println("n:", n) // => n: 0
Lua唯一のデータ構造tableはmap機能を持っているが、アクセスしたキーに対応する値が無い場合はnil
を返すため、比較はできない。
茶番になるが、metatableを使ってGoっぽいことをしてみよう。
local t = {}
do
local store = {}
setmetatable(t, {
__newindex = store,
__index = function(_, k)
local v = store[k]
if v then
return v * 10 --[[ __indexメタメソッドを経由したことを確認するため ]] , true
else
return nil, false
end
end
})
end
__newindex
でt
への値の格納をstore
に回すことで、、t
へのアクセスは必ず__index
メタメソッドを経由するようになる。
これでいいんじゃないですか…?
t.x = 3
local v, ok = t.x
print(v, ok) -- 30 nil
アイエエナンデニンジャ、Luaはどうやらtableへのアクセスに対して多値を返せないようになっているようだ。
"多値っぽい構文なのに多値ではない機能"はLuaには無いぜ
なんて啖呵切ったけどどうしますか、言い訳しますか?
すみませんでした。
そもそもtableへのアクセスで多値を要求することがなかったのでこんなことになるとは上記コードを書くまで知りませんでした。
茶番おわり
2-2. error
最も述べるべきはエラー処理だろう。 Luaのエラー処理はGoと似たような方法となる。 まずはGo
x, err := f()
if err != nil {
return nil, err
}
y, err := g(x)
// ...
照井君、ワイもLuaでみたことあるで!!
local content, err = f()
if err then
return nil, err
end
もっとLuaっぽく書く場合は、pcall
関数を使う。
local content, err = pcall(f)
if not content then
return nil, err
end
pcall
は、第1引数の関数に第2引数以降の値を適用して実行する。
このとき第1引数の関数がエラーを吐いたとき、pcall
がnil, error_message
の2値を返す。構文ではなく単に多値ですね。
何もなければf
の戻り値をそのまま返す。
もちろんf
の戻り値は多値の可能性もある。
そのため、最初の戻り値がnil
だった場合(not content
で検査している)にエラーを返すようになる。
Luaは再代入可能なので他は割愛
2-3. Luaでもうすこし多値
多値はLuaでは頻出パターンである。 たとえばパターンマッチ(Luaでパターンというと正規表現風のアレでマッチはマッチや、これは重要なので注釈じゃなくここに記述する)
local m, rest = ("Hello, world"):match("%S+(.*)$")
print(([[match: "%s"; rest: "%s"]]):format(m, rest)) -- match: "Hello,"; rest: "world"
もちろん多値
他にも…あまり思いつかなかった。
例えばオプショナルな値を返したい時とか、なんとか、ともかくLuaで多値は非常に自然に使われるんや。
2-4. 多値が値になる瞬間
Luaでは可変長引数が使える。 こういった瞬間、多値が単一の値になるような瞬間がある。
とりあえず多値を返す関数を用意しておく。
local function f()
return 1, 2, 3, 4
end
可変長引数をとる関数は、関数の仮引数に...
と書く。
local function g(...)
-- ...
end
...
は多値なのでこう書けるはずだ。
local function g(...)
local x, y = ...
print(x, y)
end
g(1, 2, 3, 4, 5) -- 1 2
いいな。よし。
さて、他に...
をどうするのかというとこれをtableに突っ込む。またtableか。
local t = {...}
は??? 多値がお前…
local function g(...)
local t = {...}
print(t[1], t[2]) -- Luaは1オリジン!!!!!
end
g(1, 2, 3, 4, 5) -- 1 2
冷静になるとlocal x, y = ...
もたいがいやぞ。
3. Lua VMと多値
さて、Luaは多値がGoよりも頻出しており、多値っぽいなと思ったものはだいたい多値であることが分かった。 Luaのランタイムとして多く採用されているLua VMについて、多値をどうさばいてるのか見てみよう。
3-1. Lua VMとは
PUC-Lua 5.0からレジスタマシンアーキテクチャを採用している1。
Lua VM 5.3では47個の命令を持ち、5.4では四則演算に関して5.2から導入されたinteger
型を高速に処理するための命令が追加されそうだ2。
まずはバイトコードに触れてみよう。
お手元にluacをご用意ください。
パッケージマネージャでLuaをインストールすれば使えるはずだ。
luac-5.3
とかluac5.3
とか、単にluac
など。
$ cat hoge.lua
print("Hello")
$ luac -o /dev/null -l -l hoge.lua
main <hoge.lua:0,0> (4 instructions at 0x55f888496c70)
0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
1 [1] GETTABUP 0 0 -1 ; _ENV "print"
2 [1] LOADK 1 -2 ; "Hello"
3 [1] CALL 0 2 1
4 [1] RETURN 0 1
constants (2) for 0x55f888496c70:
1 "print"
2 "Hello"
locals (0) for 0x55f888496c70:
upvalues (1) for 0x55f888496c70:
0 _ENV 1 0
-l -l
はverboseみたいなもので、デバッグ情報を全部だしてくれる。
-o /dev/null
は生成されたluac.out
を/dev/nullに捨てているだけである。
今回luac.outに興味はないのでご退場いただいた。
あらためて出力を見てみる。 この出力にはクロージャの情報が並んで表示される。 Luaのトップレベルも関数である。
main <hoge.lua:0,0> (4 instructions at 0x55f888496c70)
0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
関数の情報がかかれている。
1 upvalue
は上位値の数、2 constants
は定数の数、0 functions
はクロージャの数。
0+ params
は引数の数であり、この+
は上記の可変長引数をとることを意味している。
トップレベルは可変長引数を取るようだな。
1 [1] GETTABUP 0 0 -1 ; _ENV "print"
2 [1] LOADK 1 -2 ; "Hello"
3 [1] CALL 0 2 1
4 [1] RETURN 0 1
ここが命令列
2 [1] LOADK 1 -2 ; "Hello"
2
が命令のインデックス、[1]
が対応するソースコードの行、LOADK
がニーモニック、1 -2
がオペランド。
; "Hello"
はコメントである。
Lua VMは三番地コードを採用している。
レジスタは0〜255まで用意されている。
constants (2) for 0x55f888496c70:
1 "print"
2 "Hello"
定数のリスト。
locals (0) for 0x55f888496c70:
ローカル変数のリスト。 これはデバッグ情報としてのみ、スタックトレースなどに利用される。
upvalues (1) for 0x55f888496c70:
0 _ENV 1 0
上位値のリスト。
トップレベルだと雰囲気でないが、クロージャを書く時はスコープ内に外側で定義した変数を使いますよね、ソレです。
_ENV
はグローバル変数が格納されているtable。
グローバル変数はtableなんですねぇ。
あとはこれが関数ごとに表示される。
4. CALL
& RETURN
命令列に戻ってみる。
1 [1] GETTABUP 0 0 -1 ; _ENV "print"
2 [1] LOADK 1 -2 ; "Hello"
3 [1] CALL 0 2 1
4 [1] RETURN 0 1
GETTABUP
命令で_ENV
に格納されているprint
をレジスタ0に引っ張っている。
第1レジスタで格納するレジスタを指定し、第2オペランドで上位値を指定する。
第3オペランドがtableへのアクセスに使う値を決めるものであり、負のときは定数リストの第3オペランド * -1 - 1
番目のキーを使う。
LOADK
で定数リストから"Hello"
を取り出し、レジスタ1に格納する。
CALL
で関数呼出しをおこなう。
命令の定義に書かれたコメントを見てみる。
OP_CALL,/* A B C R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1)) */
分かる、分からない…。 レジスタAの関数にレジスタ(A + 1), ..., レジスタ(A + B - 1)を渡して呼び出し、 結果をレジスタA, ..., レジスタ(A + C - 2)に格納する、という感じだな。 A, B, Cは第1オペランド、第2オペランド、第3オペランドに対応する。 確かに、戻り値の処理などが多値にうまく対応している。
これよりCALL 0 2 1
はレジスタ0にあるprint
にレジスタ0の"Hello"
を適用している。
戻り値はレジスタ(A+C-2)だから…あれ、レジスタ-1とはなんですか。
A+C-2 < 0なら戻り値を気にしない、という予想で読んでいこう。
RETURN
も難しい。
OP_RETURN,/* A B return R(A), ... ,R(A+B-2) (see note) */
これも同様にA+B - 2 < 0はvoid3のような関数ということでしょう。
他の命令も、特に条件分岐あたりは面白いので1つ1つ見ていきたいが、ここでは多値が絡む部分だけ見ていこう。 気になった方はluaのソースコードをご覧ください。
ほかにも例を見てみよう。
$ cat huga.lua
local x = 3
local y = 5
local z = x + y
print(z)
$ luac -o /dev/null -l -l huga.lua
......
1 [1] LOADK 0 -1 ; 3
2 [2] LOADK 1 -2 ; 5
3 [3] ADD 2 0 1
4 [4] GETTABUP 3 0 -3 ; _ENV "print"
5 [4] MOVE 4 2
6 [4] CALL 3 2 1
7 [4] RETURN 0 1
......
1、2行目をみると、ローカル変数もレジスタに突っ込まれることが分かる。
つぎいってみよ〜
local function f(x)
local ret = {}
for i = 1, x do
table.insert(ret, i)
end
return table.unpack(ret)
end
print(f(10)) -- 1 2 3 4 5 6 7 8 9 10
tableを受け取り、配列部分を全て多値としてぶちまけるtable.unpack
という関数を使う。
ちょっと待てよ、f
は何個値を返すんだ、まさか処理系がf
の呼び出しする部分ごとに解析してサイズを決めるんですか?
そんなわけない。
CALL
の引数はどうなる、俺達の未来はー
$ luac -o /dev/null -l -l unpack.lua
main <unpack.lua:0,0> (7 instructions at 0x55d6a2333c70)
0+ params, 4 slots, 1 upvalue, 1 local, 2 constants, 1 function
1 [9] CLOSURE 0 0 ; 0x55d6a2333e50
2 [11] GETTABUP 1 0 -1 ; _ENV "print"
3 [11] MOVE 2 0
4 [11] LOADK 3 -2 ; 10
5 [11] CALL 2 2 0
6 [11] CALL 1 0 1
7 [11] RETURN 0 1
constants (2) for 0x55d6a2333c70:
1 "print"
2 10
locals (1) for 0x55d6a2333c70:
0 f 2 8
upvalues (1) for 0x55d6a2333c70:
0 _ENV 1 0
function <unpack.lua:1,9> (17 instructions at 0x55d6a2333e50)
1 param, 9 slots, 1 upvalue, 6 locals, 4 constants, 0 functions
1 [2] NEWTABLE 1 0 0
2 [4] LOADK 2 -1 ; 1
3 [4] MOVE 3 0
4 [4] LOADK 4 -1 ; 1
5 [4] FORPREP 2 5 ; to 11
6 [5] GETTABUP 6 0 -2 ; _ENV "table"
7 [5] GETTABLE 6 6 -3 ; "insert"
8 [5] MOVE 7 1
9 [5] MOVE 8 5
10 [5] CALL 6 3 1
11 [4] FORLOOP 2 -6 ; to 6
12 [8] GETTABUP 2 0 -2 ; _ENV "table"
13 [8] GETTABLE 2 2 -4 ; "unpack"
14 [8] MOVE 3 1
15 [8] TAILCALL 2 2 0
16 [8] RETURN 2 0
17 [9] RETURN 0 1
constants (4) for 0x55d6a2333e50:
1 1
2 "table"
3 "insert"
4 "unpack"
locals (6) for 0x55d6a2333e50:
0 x 1 18
1 ret 2 18
2 (for index) 5 12
3 (for limit) 5 12
4 (for step) 5 12
5 i 6 11
upvalues (1) for 0x55d6a2333e50:
0 _ENV 0 0
先頭の関数情報がトップレベル、2つ目のほうがf
の情報である。
f
のほうをまず見てみよう。
一気にreturn部分を見る。
15 [8] TAILCALL 2 2 0
16 [8] RETURN 2 0
17 [9] RETURN 0 1
TAILCALL
ってなんですか?
関数の末尾で関数呼出ししてその戻り値をそのまま返す場合はTAILCALL
になる。
いわゆる末尾呼び出し最適化みたいなものですね。
偉い。
なので16, 17番目のRETURN
は実際には使われない命令となっている。
最適化で消えてほしいですね。
OP_TAILCALL,/* A B C return R(A)(R(A+1), ... ,R(A+B-1)) */
第3オペランドCは便宜上あるだけで使われないようです。
内部的な話をすると、バイトコードの1命令は32bitである。
TAILCALL
の引数はレジスタの指定だけに使うので、各オペランドのサイズは8bitあれば十分である。
ABC型の命令は各オペランドは8, 9, 9bit(ニーモニックは全部等しく6bit)となっており、サイズがちょうど良さげなので、Cを無視してA,Bだけ使っている、ということだろうか。
TAILCALL 2 2 0
は、レジスタ2にあるtable.unpkacp
に、レジスタ3のret
を適用してその戻り値(多値含む)を返す、ということですね。
まずf
と意気込んで見てみたものの、サラッとしてるのであまり多値みがないですね。
print(f(10))
はどうなったかを見よう。
1 [9] CLOSURE 0 0 ; 0x55d6a2333e50
2 [11] GETTABUP 1 0 -1 ; _ENV "print"
3 [11] MOVE 2 0
4 [11] LOADK 3 -2 ; 10
5 [11] CALL 2 2 0
6 [11] CALL 1 0 1
7 [11] RETURN 0 1
CLOSURE
はクロージャを作っている。
今回は特になにもクロージングしてないですね。
5つ目のCALL 2 2 0
がf
の呼び出しになっている。
CALL
振り返り
OP_CALL,/* A B C R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1)) */
A+C-2は0ですが、一体なにがどうなって? lopcode.hの下の方になんかかいてあるな。
Notes:
(*) In OP_CALL, if (B == 0) then B = top. If (C == 0), then 'top' is
set to last_result+1, so next open instruction (OP_CALL, OP_RETURN,
OP_SETLIST) may use 'top'.
ホゲッ!?
このtop
というのは、中身がnil
でないレジスタの最大インデックス、という感じだろうか。
luacはレジスタを0から順番に使っているので、スキマがあったりなんだかよくわからない感じになったりという事故は(多分)おきないので問題ない。
CALL 2 2 0
は、レジスタ2に入ってる関数にレジスタ3の値を適用して、そして…?
情報が足りてないのだが、この場合戻り値をレジスタ2から全部順番に格納する。
そしてtop
が更新される。
次のCALL 1 0 1
も面白い。B == 0
なのでB = top
となる。
レジスタ1にあるprint
に、レジスタ2からtop
までを適用する。
なるほど。
Lua VMの命令は面白いことがわかった。 レジスタベースでも多値をガンガン扱えるぜ、Lua VMは多値をうまく扱ってるぜ、ということでした。
5. 継続と多値
余談。 Luaにファーストクラスの継続はないが、だいたい継続であるところのコルーチンはある。
local co = coroutine.create(function()
print("init")
print(coroutine.yield())
print(coroutine.yield())
end)
coroutine.resume(co) -- init
coroutine.resume(co, 1, 2, 3) -- 1 2 3
coroutine.resume(co, 4, 5, 6, 7, 8, 9, 10) -- 4 5 6 7 8 9 10
はい。
6. おわりに
ひさしぶりにLuaに思いを馳せたので思い出したり資料をのぞいたりで執筆に結構時間がかかってしまった。
ところで! VM型インタプリタ面白いなと思った方は、ぜひこちらをご覧ください。
宣伝おわり
-
Ierusalimschy, Roberto, Luiz Henrique de Figueiredo, and Waldemar Celes. "The evolution of Lua." Proceedings of the third ACM SIGPLAN conference on History of programming languages. ACM, 2007. ↩
-
https://github.com/lua/lua/blob/b43300c14f562bcdc1050f2c05e52fac3f6c99b7/lopcodes.h#L219 ↩
-
void-unit戦争が世界の何処かでおこなわれていますが、これはまさにvoidですね。Luaでこういった関数の戻り値を受け取るとnilが返ってきますが、これは"無い値"を参照しているので、nilは正しい(オタク早口)。 ↩