こんにちは、びしょ〜じょです。

1. はじめに

さて、最近は多値に関する議論がホットだったようです。 ホットスポットはこちらの様子。

先日のエントリの反応として、多値の批判をしているように受け取られた方がいました。 実際には、多値の批判をしているのではなく、Go言語の「多値とそう見えるけど違うものがある」という仕様を批判したものでした。 また、タプルにこだわっているという受け取り方をした方もいました。 このエントリでは、「タプルにこだわっているのでは…

なるほど。こちらも見ておこう。

最近色々あって仕事でGo言語を使っています。 色々割り切っている言語なので、こんなこと言ってもしゃーないんですが、言語設計はミスってるんじゃなかなぁ、と思わざるを得ない点が多々あります。 使い始めて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

__newindextへの値の格納を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引数の関数がエラーを吐いたとき、pcallnil, 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

...は多値なのでこう書けるはずだ。

in g
local function g(...)
  local x, y = ...
  print(x, y)
end

g(1, 2, 3, 4, 5) -- 1 2

いいな。よし。

さて、他に...をどうするのかというとこれをtableに突っ込む。またtableか。

in g
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行目をみると、ローカル変数もレジスタに突っ込まれることが分かる。

つぎいってみよ〜

unpack.lua
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 0fの呼び出しになっている。

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型インタプリタ面白いなと思った方は、ぜひこちらをご覧ください。

技術書典5で配布した「Dragon University 技術書典5」のPDF版です。 ※紙媒体を持っている場合は別場所でダウンロードできるように作業中です。お待ち下さい。 記事一覧: GLSLでリアルタイムレンダリングするカド - @rairyugo GANとの付き合い方 デプロイ編 - @rizaudo CTF 問題で学ぶcurl SSRF Hacks - @xrekkusu つくってかんたんVirtual Machine-based Interpreter - @nymphium 動作環境 Acrobat Proで互換性を確認したPDFです。EPUBは対応未定です。

宣伝おわり


  1. 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. 

  2. https://github.com/lua/lua/blob/b43300c14f562bcdc1050f2c05e52fac3f6c99b7/lopcodes.h#L219 

  3. void-unit戦争が世界の何処かでおこなわれていますが、これはまさにvoidですね。Luaでこういった関数の戻り値を受け取るとnilが返ってきますが、これは"無い値"を参照しているので、nilは正しい(オタク早口)。