ひとり勉強会

ひとり楽しく勉強会

VM命令:ループ

while文やrepeat文は特別なことは何もなく、条件分岐を使って実現されてます。問題はfor文で、これには専用命令が用意されています。まずは数値に関するループから。

for i=1,100 do
  print(i)
end

これは、ループの頭にFORPREP命令と、実際にループを司るFORLOOP命令にはさまれる形でコンパイルされます。こんなの。

1  [1]   LOADK        0 -1    ; 1     初期値
2  [1]   LOADK        1 -2    ; 100   終了値
3  [1]   LOADK        2 -1    ; 1     1ステップごとの増分
4  [1]   FORPREP      0 3     ; to 8
5  [2]   GETGLOBAL    4 -3    ; print
6  [2]   MOVE         5 3
7  [2]   CALL         4 2 1
8  [1]   FORLOOP      0 -4    ; to 5  ここでループ処理
9  [3]   RETURN       0 1

それぞれの行う処理は for i=a,b,c だったとすると

  • FORPREP:初期値や終了値がnumberかどうかチェックする役目。1度だけ実行される。チェックが通ったら一度 i=a-c してから FORLOOP にジャンプ
  • FORLOOP:i+=c。i<=bならFORPREPの次の命令までジャンプで戻る。そうでなければそのままループ脱出

こうなります。FORPREPのソース

      case OP_FORPREP: {
        const TValue *init = ra;
        const TValue *plimit = ra+1;
        const TValue *pstep = ra+2;
        L->savedpc = pc;  /* next steps may throw errors */
        if (!tonumber(init, ra))
          luaG_runerror(L, LUA_QL("for") " initial value must be a number");
        else if (!tonumber(plimit, ra+1))
          luaG_runerror(L, LUA_QL("for") " limit must be a number");
        else if (!tonumber(pstep, ra+2))
          luaG_runerror(L, LUA_QL("for") " step must be a number");
        setnvalue(ra, luai_numsub(nvalue(ra), nvalue(pstep)));
        dojump(L, pc, GETARG_sBx(i));
        continue;
      }

FORLOOPのソース

      case OP_FORLOOP: {
        lua_Number step = nvalue(ra+2);
        lua_Number idx = luai_numadd(nvalue(ra), step); /* increment index */
        lua_Number limit = nvalue(ra+1);
        if (luai_numlt(0, step) ? luai_numle(idx, limit)
                                : luai_numle(limit, idx)) {
          dojump(L, pc, GETARG_sBx(i));  /* jump back */
          setnvalue(ra, idx);  /* update internal index... */
          setnvalue(ra+3, idx);  /* ...and external index */
        }
        continue;
      }

/* update internal index... */ と /* ...and external index */ となっているのがちょっとしたポイントで、R[A]がループ命令内部で使うカウンタで、一方、R[A+3] はループ変数をLuaのコードで読み書きするための値が入っているわけです。なので、ループ中にループ変数を書き換えても内部カウンタはかわらないので、

for i=1,100 do
  i = i * 100
  print(i)
end

こんなループを書いてもちゃんと100回繰り返して終わるようですね。

generic for loop

問題はこっちのパターンのforループです。

for k,v in pairs({}) do
  print(k,v)
end

コンパイルするとこうなります。FORPREPに当たる処理は、特にチェックも必要ないので、ただのJMP命令になります。そして、ループ継続処理がTFORLOOPという命令で表現されています。

1       [1]     GETGLOBAL       0 -1    ; pairs
2       [1]     NEWTABLE        1 0 0
3       [1]     CALL            0 2 4

4       [1]     JMP             4       ; to 9
5       [2]     GETGLOBAL       5 -2    ; print
6       [2]     MOVE            6 3
7       [2]     MOVE            7 4
8       [2]     CALL            5 3 1
9       [1]     TFORLOOP        0 2
10      [2]     JMP             -6      ; to 5

11      [3]     RETURN          0 1

というわけでTFORLOOPの実装を見てみましょう。

      case OP_TFORLOOP: {
        StkId cb = ra + 3;  /* call base */
        setobjs2s(L, cb+2, ra+2);
        setobjs2s(L, cb+1, ra+1);
        setobjs2s(L, cb, ra);
        L->top = cb+3;  /* func. + 2 args (state and index) */
        Protect(luaD_call(L, cb, GETARG_C(i)));
        L->top = L->ci->top;
        cb = RA(i) + 3;  /* previous call may change the stack */
        if (!ttisnil(cb)) {  /* continue loop? */
          setobjs2s(L, cb-1, cb);  /* save control variable */
          dojump(L, pc, GETARG_sBx(*pc));  /* jump back */
        }
        pc++;
        continue;
      }

ええとややこしいですが。要するに、luaD_call でループ用の関数を呼び出して、返値がnilでなければ、まずループ変数を保存してから、次が確実にJMP命令であると仮定して(条件分岐の実装と同じですね!)それを実行します。これはループの最初の方に戻るジャンプです。返値がnilならば、ループ終了なのでpc++して、次のJMP命令は無視。
luaD_callは第一回にちょっとでてきましたが、要するにLuaもしくはCの関数を呼び出すためのAPIで、Luaの関数だった場合、内部でluaV_executeを呼んでVMループが始まります。これ、Luaの関数を呼ぶときにもCのスタック使っちゃってるのですけど、いいんでしょうか。スタックレスじゃないよー。
・・・と思ったので、luaD_callを使わずに、OP_CALLの実装のようにVMループ内でこの関数呼び出しを処理する実装をしばらく考えてみました。もしかしたらluaD_callでないといけない理由があるのかもしれないので、思いっきり的外れかもです。
ここで普通のOP_CALLのような呼び出しができないのは、CALLから戻ってきた後に、次のジャンプ命令をスキップしたりしなかったりという特殊なJMP処理をしないといけないのが一点。あと、その分岐の時には、このTFORLOOP命令のAオペランドを参照しないといけないのも特殊です。特殊なJMP命令なので、オペコードから変えて別命令にしちゃえ!ということで、LuLu(Lua on Lua)の実装では、TFORLOOP命令が来たら直後のJMP命令のopcodeを未使用の52番に置き換えて続きをやる実装にしてしまいました。

    elseif OP == 52 then
       A = bits(proto.code[pc-2], 6, 8) + 3 -- 直前の命令のAオペランドを参照
       if getreg(A) ~= nil then
         setreg(A-1, getreg(A))
         pc = pc + sBx
       end

結構無理矢理。あるいは、元々JMP命令のAオペランドは使われていないので、TFORLOOPから引っ張ってこなくてもAオペランド付きJMP命令にすることもできると思います。そうすればもうちょい綺麗かも。こうなっていない理由はなんだろう・・・謎でした。