読者です 読者をやめる 読者になる 読者になる

Minecraftとタートルと僕

PCゲームMinecraftのMOD「ComputerCraft」の情報を集めたニッチなブログです。

クロージャとコルーチン(1)-状態遷移関数をつくってみた

はじめに

最近、
グローバル変数に頼ることなく「関数単独で状態遷移を実現できる関数」を作って遊んでいます。

具体的には次のようなの。

  • 配列を与えると、それを環状配列(左端と右端がつながっているリング状配列、ドラクエのワールドマップをイメージするといいかも)と解釈し、実行するたびにその環状配列を右、または左に順に辿って参照できる関数を返す関数。
--initRingは、状態遷移関数を作成する関数
--元となる配列と初期参照位置を与える
ring = initRing({'a', 'b', 'c'}, 1)

-- 状態遷移関数に与える引数で参照位置を動かす
-- 正の数ならば参照位置をその値だけ右にずらす、マイナスは左にずらす
-- 環状配列なので'c'の次は'a'、'a'の前は'c'となる
ring(0) =>a
ring(1) =>b
ring(1) =>c
ring(1) =>a
ring(-1) =>c

関数自身が現在の遷移状態を保持しているので、シンプルに使えるところが特徴ですね。

たとえば関数の外側に状態保持用の変数(たとえば、pos = 1など)を作って関数内でそれを操作すれば同じことを実現できますが、他の場所で変数posに直接アクセスされて書き換えられると困るんですよね。
データの隠蔽は近代プログラミング作法の基本ですし(大げさ)。
それに、たとえば状態遷移関数を10個作成(ring1~ring10)したら、状態保持用の変数も10個管理(pos1~10)しなくてはならず、管理が面倒ですし。

状態遷移関数の実装-クロージャ版

実装方法としては、クロージャーを使う方法とコルーチンを使う方法の2通りがあるのですが、
とりあえず両方並べて見ます。

ソースコード

クロージャを使って作ったのがこちら。

function initRing1(array, i)  -- iは参照する初期位置
  return function (x)
    i= i+x
    return array[((i-1)%#array)+1]
  end
end

cl = initRing1({'a','b','c'}, 1)

print(cl(0))
print(cl(1))
print(cl(1))
print(cl(1))
print(cl(-1))

関数initRing1()は、無名関数を作成する関数です。
しかも作成された無名関数は、arrayとiという2つの特殊な変数を持っています。

クロージャについてもっと詳しく!

これだけじゃ何言っているかわからないですよね。つまりぶっちゃけて言いますと、
上記でinitRing1によって作成された関数「cl」は、実質的に、以下のdo~endのスコープ内の記述と同じような働きをする関数です。

do
  local array = {'a','b','c'}
  local i = 1
  return function(x)
    i= i+x
    return array[((i-1)%#array)+1]
  end
end

initRing1()を実行するたびに、上記do~endスコープの間にあるような、「特殊なローカル変数2つ」と「無名関数」をセットでまとめた関数を作成します。
ちなみにこのローカル変数の何が特殊かと言うと、initRing1()で新しい関数を作成するたびに、その関数に付属する形でローカル変数が作成されることです。

  • a = initRing1({'a', 'b', 'c'}, 1)
    • 関数aは、ローカル変数Aと無名関数Aを含む
  • b = initRing1({'a', 'b', 'c'}, 1)
    • 関数bは、ローカル変数Bと無名関数Bを含む
  • c = initRing1({'a', 'b', 'c'}, 1)
    • 関数cは、ローカル変数Cと無名関数Cを含む

たとえば、上記のように3個の関数「a」「b」「c」を作ったときに、それぞれに含まれるローカル変数A,B,Cは全て個別に作られ、独立しています*1。そのため、関数a,b,cはすべて独立して動かすことができるのです。

Luaでは、このような特殊なローカル変数のことを「上位値」と呼んでいます。
そしてこのような「上位値」と「無名関数」をセットにした関数を作成する「機能」のことをクロージャと呼んでいます*2

このクロージャはとても強力な機能でして、関数型言語では「データの隠蔽」「再帰関数における状態保持」などの用途で使われます。
今回は、「データ隠蔽」のためにクロージャを使い、状態遷移関数を作成したわけです。

補足

上記コード内で暗号のように見える以下の計算式は、変数iの値によって最小値1から最大値が配列の要素数(#array)という範囲の整数を返します。

((i-1) % #array)+1

サンプルコードの例では、与える配列{'a','b','c'}の要素数が3なので、iの値が何であっても、1~3までの値を返します*3
array[4]では値がありませんからね。array[4]をarray[1]に変換することで、循環しているように見せかけているわけです。

状態遷移関数の実装-コルーチン版

コルーチンを使って作ったのがこちら。
さすがに説明するスペースがないので、今回は解説を省略します。
コルーチンについては、そのうちしっかりと解説しましょう。

function initRing2(array,i)  -- iは参照する初期位置
  return coroutine.wrap(function()
    while true do
      i= i + coroutine.yield(array[((i-1)%#array)+1])
    end
  end)
end

-- funcは、中断したスレッドを再開させる関数
func = initRing2({'a','b','c'}, 1)  

func()   --関数実行スタート

--以下、実行のたびに中断したスレッドを再開
--引数は参照位置をずらすための値
print(func(0))  
print(func(1))
print(func(1))
print(func(1))
print(func(-1))

coroutine.wrap()は便利だけど、作られる関数funcの挙動が・・・・・・ちょっとだけきもいw
関数実行スタートするとき(初回)と、中断したスレッドを再開するとき(二回目以降)で、引数の意味が違ってくるとかね・・・・・・。
coroutine.create()やcoroutine.resume()を使った方が理解しやすいコードになるとは思うけれど、今回はこれが本題ではないので省略。


まとめ

Lua公式本によると、パフォーマンス(実行スピード)はcoroutine再開と関数呼び出しで差はほとんどないとのことなので、どちらを採用するかは好み次第でしょうね。

シンプルさではやはりクロージャーかな。
coroutineも嫌いではないけど、デバッグが面倒すぎるのでw

え?このような状態遷移関数を何に使うかですって?

以前作った、turtle.selectNext()関数を書き換えられるかなと思いまして。
【補足2】既存の関数を上書きして拡張しよう - Minecraftとタートルと僕

以前の実装だと、現在の選択スロット番号を保持する隠しパラメータ(グローバル変数)を必要としていたけれど、
このような状態遷移関数があれば隠しパラメータが必要なくなりますよね。データ隠蔽。データ隠蔽。

具体的なコードについては、次回紹介しましょう。

*1:オブジェクト指向言語をたしなんでいる人には、関数のインスタンスごとにローカル変数が割り当てられる、と言った方が通じやすいかもしれません

*2:慣習的に、この機能を使って作ったセット関数自体をクロージャと呼ぶことがあります

*3:Luaの配列添え字最小が1という縛りがちょっと気持ち悪い。CやJavaなら0なのにね。