RAS Syndrome

冗長。

Ruby のリスト操作メソッド使い分け指針

なかなかリスト操作に慣れない初心者のために、よく使うメソッドを抜粋し、「どういう時に使うか」をすごい雑な言葉で添えながら列挙してみる。

なお、リストとか配列とか Enumerable とか、この辺の言葉の使い分けはよく分かんないので、とりあえず全部「リスト」と呼ぶことにする。

ちなみに、執筆時点での Ruby のバージョンは 2.6 である。

参考: docs.ruby-lang.org

docs.ruby-lang.org

使い分け

  • 操作対象リストと同じ要素数のリストが欲しい
    • Enumerable#map (collect)
  • 操作対象リストより要素数の少ないリストが欲しい
    • 条件が当てはまる要素だけを抽出
      • Enumerable#find_all (select, filter)
    • 特に、nil じゃない要素だけを抽出
      • Array#compact
    • 先頭から条件が当てはまらなくなるまでスライス
      • Enumerable#take_while
    • 先頭から N 個
      • Enumerable#take
    • 重複排除
      • Enumerable#uniq
  • 操作対象リストの中から 1 個抽出
    • 条件に当てはまる最初のやつ
      • Enumerable#find (detect)
    • 最初のやつ
      • Enumerable#first
    • 最後のやつ
      • Array#last
    • N 番目
      • Array#[]
      • Array#fetch
    • 最大最小
      • Enumerable#max (<-> min)
      • Enumerable#max_by (<-> min_by)
  • Bool 値が欲しい
    • 要素があるかどうか
      • Enumerable#member? (include?)
    • 条件を満たす要素があるかどうか
      • Enumerable#any?
    • 全要素が条件を満たすかどうか
      • Enumerable#all? (<-> none?)
  • 素数が欲しい
    • 全要素の数
      • Enumerable#count
      • Array#length (size)
    • 条件を満たす要素の数
      • Enumerable#count
  • 集計値が欲しい
      • Enumerable#sum
    • その他なんでも
      • Enumerable#reduce
  • 良いメソッドがないけど、とにかく全要素をイテレートしながら何かの値を得たい
    • Enumerable#reduce
    • Enumerable#each_with_object
  • 何も欲しくなくて、ただ副作用だけを各要素に行いたい
    • Enumerable#each

特に map はよく使う

リストに Enumerable#map して得られる結果もまたリストであるため、他のリスト操作を組み合わせて使うことも非常に多い。

例:

# 名前の最大文字数を調べる
max_length = ['Alice', 'Bob', 'CaptainFalcon'].map(&:length).max

puts max_length # -> 13

複雑なリスト操作の組み合わせが必要そうだと思った時は、「どんなリストに変換していけば良いだろうか」って考え方をすると捗るかもしれない。

&:length の意味はこちらを参照。 ref.xaio.jp

each は最小限に

副作用はなるべく切り離す。

悪い例:

# 整数の 2 乗を出力
(1..5).each { |num| puts num**2 }

良い例:

(1..5).map { |num| num**2 }    # 整数の 2 乗
      .each { |num| puts num } # 出力

理由

副作用のない部分(例でいうところの「整数の 2 乗を取り出す」)はテストがしやすい。テストしやすい部分が多いと嬉しい。

こんなしょぼいサンプルだとデメリットの方が大きく見えるかもしれないけど、まあ理念としてね。 「変換」(map)と「出力」(副作用)は別々の行為なので、分けた方が理念としては自然。

「関数型脳になろう 〜 脱 for 文編 〜」の解答例

「関数型脳になろう 〜 脱 for 文編 〜」はこちら

問題 1.

[ 10, 20, 30, 40 ] = [10 | [ 20, 30, 40 ] ] … (1)
[ 20, 30, 40 ] = [20 | [ 30, 40 ] ] … (2)
[ 30, 40 ] = [30 | [ 40 ] ] … (3)
[ 40 ] = [40 | [  ] ] … (4)

定義 1. より、 [ ] はリスト。
よって、 (4) と定義 2. より、 [40] はリスト。
よって、 (3) と定義 2. より、 [30, 40] はリスト。
よって、 (2) と定義 2. より、 [20, 30, 40] はリスト。
よって、 (1) と定義 2. より、 [10, 20, 30, 40] はリスト。

[ 10, 20, 30, 40 ][ 10 | [ 20, 30, 40 ] ] のことであり、 [ [ 10, 20, 30 ] | [ 40 ] ] とは完全に別物なことに注意しよう。

f:id:ikngtty:20190712212149p:plain
リストの図解

問題 2.

defmodule Exercise do
  def product([]), do: 1
  def product([head | tail]), do: head * product(tail)
end

問題 3.

defmodule Exercise do
  def len([]), do: 0
  def len([_ | tail]), do: 1 + len(tail)
end

length って名前で関数を作ろうとしたら、組み込みの length 関数と競合してエラーが出た。これは出題が悪かった。スマソ。

問題 4.

defmodule Exercise do
  def each_double([]), do: []
  def each_double([head | tail]), do: [head * 2 | each_double(tail)]
end

問題 5.

defmodule Exercise do
  def odd_only([]), do: []
  def odd_only([head | tail]) do
    if rem(head, 2) === 1 do
      [head | odd_only(tail)]
    else
      odd_only(tail)
    end
  end
end

問題 6.

defmodule Exercise do
  def map([], _), do: []
  def map([head | tail], f), do: [f.(head) | map(tail, f)]
end

問題 7.

defmodule Exercise do
  def reduce([], acc, _), do: acc
  def reduce([head | tail], acc, f), do: f.(head, reduce(tail, acc, f))
end

参考までに reduce_left のパターンも載せておく。「末尾呼出最適化」を利用したい時、このパターンの実装も必要になってくる。

defmodule Exercise do
  def reduce_left([], acc, _), do: acc
  def reduce_left([head | tail], acc, f), do: reduce_left(tail, f.(head, acc), f)
end

問題 8.

each_double 関数を作るとこうなる。

defmodule Exercise do
  def reduce([], acc, _), do: acc
  def reduce([head | tail], acc, f), do: f.(head, reduce(tail, acc, f))

  def each_double(a_list), do: reduce(a_list, [], fn (x, acc) -> [x * 2 | acc] end)
end

x * 2f.(x) に置き換えることで map 関数が作れる。

defmodule Exercise do
  def reduce([], acc, _), do: acc
  def reduce([head | tail], acc, f), do: f.(head, reduce(tail, acc, f))

  def map(a_list, f), do: reduce(a_list, [], fn (x, acc) -> [f.(x) | acc] end)
end

問題 9.

defmodule Exercise do
  def repeat(_, 0), do: ""
  def repeat(text, num), do: text <> repeat(text, num - 1)
end

-2 とか 1.5 とか入れると無限ループになる危険プログラムと化しているので、本当はもうちょっと色々なんとかした方がいい。

全部解けたら

他に押さえておきたい重要な関数型の概念として、「末尾呼出最適化」「メモ化」「遅延評価」などがある。適宜ググって掘っておいてほしい。

関数型脳になろう 〜 脱 for 文編 〜

「Elixir を始めたが、関数型の考え方がよく分からない」という知人に向けて書いた。

関数型プログラミングの世界では本当は、"代入"ではなく"束縛"、"返し値"ではなく"評価値"という言葉を使わないといけない。だけどその辺の説明までするのはめんどくさいので、今回は気にせずに手続き型プログラミングの世界の言葉を使った。

なぜ for 文を脱するのか

関数型っぽい考え方になるからだ。少なくとも、関数型の感覚を掴むヒントにはなるんじゃないかな。ということで詳しくは略。体感してから考えよう。Elixir 以外の関数型とか、あと Ruby とかは for 文を使わないので、とりあえず for 文以外の考え方を身につけて損はないはず。

「こんぐらい分かってるわい!」と思っても、あまり読み飛ばさないで欲しい。せっかく書いたし。何より、なんとなく感覚で理解していたものを理屈でちゃんと捉え直すというのは、とても大事なことだ。

リスト操作

リストの実装

Elixir のリストは、見た感じ片方向連結リストっぽい。

リストの再帰的定義

「リストとは何か」を定義するやり方の一つとして、以下のような形式がある。

リストとは、以下のいずれかである。

1.  [ ] (空リスト)
2.  [ なんらかのオブジェクト1個 | リスト ]

上記 2. は、コンス(cons)と呼ばれる操作をした結果であることを表す。これは、左のオブジェクトを先頭要素として右のリストにくっつける、という操作だ。片方向連結リストをイメージすれば、マシンの中で具体的に何が行われるのかもイメージできるだろう。

さて、この定義の特徴として、「リストとは何か」を述べているのに、中にまた「リスト」が出てくる、という点が挙げられる。これが「再帰的」と言われる所以だ。

果たしてこの定義は成立しているのだろうか。似たような例として、以下のような「循環定義」を考えてみる。

1. 右とは左の逆のことである。
2. 左とは右の逆のことである。

これだと何も言っていることにならない。しかし、再帰的定義は大丈夫なのである。なぜなら、1. [ ] (空リスト) の定義があるからだ。

例えば、

  • リストを自称している A は、要素 a と、リストを自称している B をコンスしたものである。
  • リストを自称している B は、要素 b と、リストを自称している C をコンスしたものである。
  • リストを自称している C は、要素 c と、リストを自称している D をコンスしたものである。

と調べていくと、自称リストの彼らは本当にリストなのか、いつまで経っても調べられないように見える。しかし実は、

  • リストを自称している D は、要素 d と、リストを自称している [ ] をコンスしたものである。
  • [ ] は定義 1. より明らかにリストである。

と、空リストさえ出てくれば議論を着地させることができるのである。以下、定義 2. を後ろから順に適用していけば、D, C, B, A は全てちゃんとリストであると言える。

どんな大きさのリストであっても、このようにして「リストの再帰的定義」を用いて本当にリストであるかどうか調べることができる。循環定義と違って、きちんと有用な定義なのである。


問題 1. [10, 20, 30, 40] がリストであることを、「リストの再帰的定義」を使って示してみよう。(なお、 [a, b, c, …][a | [b, c, …] ] であることを表す。また、[a][a | [ ] ] であることを表す。)


再帰的に定義できるデータ構造を、再帰的に処理する

さて、リストが再帰的なデータ構造に見えてきただろうか。次にいよいよ、このリストを操作する関数について考える。

例えば、数字のリストに対して合計を返す関数を考えてみよう。なお、しばらくは Enum モジュールや List モジュールを使うのは禁止だ。

defmodule Sample do
  def sum(nums) do
    # ?
  end

  def test do
    IO.puts(sum([]) === 0)
    IO.puts(sum([10]) === 10)
    IO.puts(sum([10, 20, 30, 40]) === 100)
  end
end

Sample.test()

実行結果が以下のようになれば成功ということになる。

true
true
true

さて、引数 nums は数字のリストだ。それ以外が来ることはここでは考えないことにする。(Elixir は型指定とかできるっぽいので、それをすれば確実だ。俺はまだその辺詳しくないので、とりあえず放置。)

数字のリストということは、「リストの再帰的定義」が使えることになる。つまり、nums は以下の 2 パターンしかありえないわけだ。

  1. [ ]
  2. [ num | nokori_no_list ]

コンスされた左と右の呼び方は色々あるが、Elixir ではどうやら head, tail が主流っぽい。気をつけて欲しいのは、 head が要素 1 個なのに対し、 tail は残り全ての要素でできたリストということだ。猫みたいな生き物を思い浮かべてしまうと、上手くイメージとマッチしないかもしれない。ここは恐竜みたいな、尻尾だけ異様に長い生き物を思い浮かべよう。

とにかく、nums は 2 つのパターンしかない。なので、その 2 つのパターンそれぞれに対して何を返せばいいか考えてみよう。ここで役に立つのがパターンマッチだ。

defmodule Sample do
  def sum(nums) do
    case nums do
      [] -> # ?
      [head | tail] -> # ?
    end
  end
end

ちょっと待った!実は Elixir ではもっと洗練された書き方がある。Haskell でもよく出てくる書き方だ。

defmodule Sample do
  def sum([]), do: # ?
  def sum([head | tail]), do: # ?
end

さて、パターンマッチを使うと、どちらのパターンに当てはまるかを判断してくれるだけでなく、head, tail のそれぞれに該当する値を入れてくれる。例えば、sum([10, 20, 30, 40]) を実行すると、 head には 10tail には [20, 30, 40] が入るというわけだ。

では、それぞれのパターンについて考える。まず sum([ ]) だが、無いものの合計は 0 だろうということで、ここでは 0 とする。他の考え方もできるかもしれないが(例えばエラーを起こすとか)、そうすると途端に実装が面倒になる。ここではとりあえず、そういうものかと思って見て頂きたい。

defmodule Sample do
  def sum([]), do: 0
  def sum([head | tail]), do: # ?
end

次はいよいよ sum([ head | tail ]) だ。具体的に sum([10, 20, 30, 40]) について考えてみよう。ここでは、 tail (= [20, 30, 40] )もまた数字のリストであるという点に着目して欲しい。(「リストの再帰的定義」の定義 2. を考えればこれは当たり前のことである。 tail とはつまり、リストの定義の中に再帰的に出てくるリストのことだ。)

tail も数字のリストであるということは、 sum(tail) ができるということになる。ここで一つ、大事な方針の発表だ。これから、この sum(tail) を上手く利用することで、 sum 関数を定義する。

sum 関数の定義の中で sum 関数を使う。ここでは「リストの再帰的定義」と同様のことが起きている(あの時は、リストの定義の中でリストを使っていた)。つまりこれからやろうとしていることは、 sum 関数の再帰的定義というわけだ。

具体的に比較してみよう。

  • [10, 20, 30, 40] の定義には、 [20, 30, 40] が出てくる。( [10, 20, 30, 40] とは [10 | [20, 30, 40] ] のことであった 。)
  • sum([10, 20, 30, 40]) を定義するのに、 sum([20, 30, 40]) を使う。(sum(tail) のことだ。)

こうしてみると、 sum(tail) を使った sum 関数の定義というのは、リストの再帰的構造に合わせたごくごく自然な定義だと思えては来ないだろうか。

まあ、とにかくやってみよう。コツとしては、未来の自分がもう sum(tail) の実装を完成させてくれているとイメージすることだ。今の自分は、 sum 関数の仕様として「引数の合計を返す」と考えている。ということは、未来の自分は、「 tail の合計を返す」ものとして sum(tail) を実装したはずだ。具体的にいえば、sum([20, 30, 40]) を実行すれば 20 + 30 + 40 ( = 90 ) が返ってくるはず、ということだ。

それにしても、今の自分と未来の自分を同時に考えるというのは奇妙な感じだ。まるで、タイムマシンで過去の自分に干渉しているという、 SF でよくあるシチュエーションみたいじゃないだろうか。今の自分が作ろうとしている関数を変えてしまうと、未来の自分が完成させる関数も変わってしまう。君はタイムパラドックスが生じないように関数を考えないといけない。だから、関数を作る前には仕様をしっかり考えて固めてしまうことが大切だ。

さて、今我々の手元には、 head の 10 と、 sum(tail) の 20 + 30 + 40 がある。これらを使って、引数の合計、つまり 10 + 20 + 30 + 40 を返そう。どうすればいいだろうか。もちろん、10 と 20 + 30 + 40 を足せば良い。

defmodule Sample do
  def sum([]), do: 0
  def sum([head | tail]), do: head + sum(tail)
end

リスト操作関数を作るコツをまとめよう。

  • リストの再帰的定義に合わせ、 2 つのパターンに対して何を返せばいいか考える。
  • f([ head | tail ]) のパターンでは、headf(tail) をどう使うか考える。(これは裏技みたいなものでは無い。リストが headtail リストでできている以上、fheadf(tail) で作れると考えるのは、非常に自然なことだ。)


問題 2. 数字のリストを受け取ったら、その積を返す product 関数を作ってみよう。

問題 3. リストを受け取ったら、要素数を返す length 関数を作ってみよう。もちろん、組み込みの length 関数を使うのは禁止だ。ちなみに、length([ [1, 2, 3], 4 ]) === 2 で良い。(リストの中の子リストの要素数までは考えなくて良い。)(注釈の注釈になるが、「子リスト」と「コンスされたリスト」は別の概念だ。)

問題 4. 数字のリストを受け取ったら、各要素を 2 倍にしたリストを返す each_double 関数を作ってみよう。

defmodule Exercise do
  def test do
    IO.puts(each_double([]) === [])
    IO.puts(each_double([10]) === [20])
    IO.puts(each_double([10, 20, 30, 40]) === [20, 40, 60, 80])
  end
end

問題 5. 数字のリストを受け取ったら、奇数だけを取り出してリストにして返す odd_only 関数を作ってみよう。 if 文を使えば実装できるが、ガード節 when を使えるともっとかっこいいぞ。

defmodule Exercise do
  def test do
    IO.puts(odd_only([]) === [])
    IO.puts(odd_only([10, 20, 30, 40]) === [])
    IO.puts(odd_only([50, 51, 59, 60]) === [51, 59])
  end
end

問題 6. map 関数を自分で作ってみよう。ちなみに本家の仕様はこれだ。

defmodule Exercise do
  def test do
    double = &(&1 * 2)
    IO.puts(map([], double) === [])
    IO.puts(map([10, 20, 30, 40], double) === [20, 40, 60, 80])

    square = &(&1 * &1)
    IO.puts(map([10, 20, 30, 40], square) === [100, 400, 900, 1600])
  end
end

問題 7. reduce/3 関数を自分で作ってみよう。初期値を指定する方のやつだ。本家の仕様はこれ

defmodule Exercise do
  def test do
    plus = &(&1 + &2)
    IO.puts(reduce([], 1000, plus) === 1000)
    IO.puts(reduce([10, 20, 30, 40], 1000, plus) === 1100)

    bigger = &(if &1 > &2 do &1 else &2 end)
    IO.puts(reduce([5, 5432, 999, 1234], 0, bigger) === 5432)
    IO.puts(reduce([5, 5432, 999, 1234], 100_00, bigger) === 100_00)
  end
end

【注記】 一般的に reduce 関数には、右から関数を適用していくパターンと左から関数を適用していくパターンがある。前者を仮に reduce_right 、後者を reduce_left と名付けよう。以下のような違いが現れる。

reduce_right(["a", "b", "c"], "1", fn(x, acc) -> acc <> x end) |> IO.inspect
# output: "1cba"

reduce_left(["a", "b", "c"], "1", fn(x, acc) -> acc <> x end) |> IO.inspect
# output: "1abc"

上の問題では、話の流れから reduce_right を答えとして期待していた。しかし、本家の Enum.reduce はどうやら reduce_left だったようだ。紛らわしい問題文になってしまって申し訳ない。ここでは reduce_right が作れれば問題ない。【注記ここまで】

問題 8. 問題 7. で作った reduce 関数を使って、問題 6. と同じ map 関数を作ってみよう。もしも難しかったら、まずは each_double 関数を reduce 関数で作ったあと、 2 倍している部分を関数に置き換えて一般化すると分かりやすいかもしれない。


数字操作

自然数再帰的定義

自然数を 1, 2, 3, … とする流儀と、0, 1, 2, 3, … とする流儀があり、前者は数論などでよく使われ、後者は集合論、論理学などでよく使われる

自然数 - Wikipedia

プログラミングでは 0 始まりの数字をよく使う。ということで、ここでは後者の流儀を採用する。

リストと同様、自然数も実は再帰的に定義することができる。

自然数とは、以下のいずれかである。

1. 0
2. 自然数の次の数字

次の数字っていうのは、要は 1 足した数字のことだ。つまり 1 + 自然数 ということだ。リストの時は、ここが [ オブジェクト | リスト ] であった。オブジェクトをコンスする代わりに 1 を足す、というわけだ。

自然数の定義の中に「足す」だとか「 1 」だとか使って大丈夫なんだろうか。これはなんというか、言葉の綾だ。実際は「次の数字」って概念が先にあって、それを使っているだけだ。多分。実のところ、詳しいことは俺もよく分からない。ただ、この発想をプログラミングに落とし込むことならできる。まあ、詳しくはペアノの公理とかを参照して欲しい。

自然数再帰処理

というわけで、みんな大好き階乗を実装しよう。

defmodule Sample do
  def fact(num) when num < 0, do: :error
  def fact(0), do: # ?
  def fact(num), do: # ?

  def test do
    IO.puts(fact(-1) === :error)
    IO.puts(fact(0) === 1)
    IO.puts(fact(1) === 1)
    IO.puts(fact(2) === 2)
    IO.puts(fact(3) === 6)
    IO.puts(fact(4) === 24)
    IO.puts(fact(5) === 120)
  end
end

Sample.test()

引数 num が負の場合は :error というアトムを返すことにした。本当は負の数の階乗も数学的定義があるのだろうが、今回はそこまで考えない。

というわけで、最初のパターンは引数が 0 のパターンだ。これは数学的定義を紐解けば一発で決まる。 0! = 1 だ。

defmodule Sample do
  def fact(num) when num < 0, do: :error
  def fact(0), do: 1
  def fact(num), do: # ?
end

問題は 2 番目のパターン。自然数再帰的定義に合わせれば、 num の 2 番目のパターンは 1 + 自然数 だ。

リストの時と同じようにパターンマッチしてみよう。

defmodule Sample do
  def fact(num) when num < 0, do: :error
  def fact(0), do: 1
  def fact(1 + pre), do: # ?
end

1 つ前の自然数ということで、 pre と名付けた。さて、パターンマッチの結果は … コンパイルエラーだった。「パターンマッチングの中で + は使えましぇ〜〜ん!!!」と怒られてしまった。

defmodule Sample do
  def fact(num) when num < 0, do: :error
  def fact(0), do: 1
  def fact(num), do: # ?
end

仕方ないので、元に戻してやり直そう。 pre 変数の中身は自分で考えることにする。 num = 1 + pre ということは、すなわち pre = num - 1 だ。これがリストで言う tail にあたる部分になる。

リストの時と同じように議論を進めよう。データの再帰的構造に合わせて、関数も再帰的に定義する。fact 関数の定義には、 fact(pre) を活用すればいいことになる。

fact(5) を求める手順を考えよう。これが 5 * 4 * 3 * 2 * 1 を返すようにしたい。利用できるのは fact(pre)fact(num - 1)fact(4) だ。これは未来の自分が実装していて、 4 * 3 * 2 * 1 を返してくれる。我々はこれを使って 5 * 4 * 3 * 2 * 1 を返すことを考えればいい。もちろん、 5 (= num ) をかけるだけでそれは達成できる。

defmodule Sample do
  def fact(num) when num < 0, do: :error
  def fact(0), do: 1
  def fact(num), do: num * fact(num - 1)
end

これで完成だ。まあ、階乗の実装なんて見慣れているかもしれない。でも、リストと自然数を同じようなものとして見たことはあまりないんじゃないかな。その感覚を養って欲しくて、ちょっとしつこすぎるぐらいに説明させてもらった次第だ。


問題 9. 文字列 text自然数 time を受け取ったら、texttime 回分繰り返した文字列を返す関数を作ってみよう。文字列のリストを返すのではなく、連結した 1 つの文字列を返して欲しい。名前は repeat にしよう。なお、 time に負の数が入ってくることはないものとする。

defmodule Exercise do
  def test do
    IO.puts(repeat("abc", 0) === "")
    IO.puts(repeat("abc", 1) === "abc")
    IO.puts(repeat("abc", 2) === "abcabc")
    IO.puts(repeat("abc", 3) === "abcabcabc")
  end
end


他の再帰的データ構造

例えば、木構造なんかは有名な再帰的データ構造だ。木構造の具体例としては、一般的なファイルシステムを思い浮かべて欲しい。フォルダとは、複数のファイルと複数のフォルダを含む入れ物だ。ほら、再帰的な定義だろう?だから、フォルダの中のフォルダの中のフォルダの中の方まで何か処理をしたい時は、データ構造に合わせて再帰で書くと良い。

みんな大好き rm -rf-r は正確には —recursive 、つまり「再帰的」を意味する。つまり、フォルダの中のフォルダの中のフォルダの中の方まで削除するということだ。

Enum モジュールの使用

演習問題は全部解いてくれただろうか。これらを解いたなら、 Enum モジュールの filtermapmax も、全部再帰を使って自力で実装できるようになったということになる。そして、それらを全部 reduce を使って実装することもできるようになったはずだ。(え、できるようになってない!?じゃあ、やってみよう! map は問題 8. でやってもらったから、後は filtermax だ。)

ここまできたら、 Enum モジュールと List モジュールの使用禁止令は解除だ。これからは存分に利用して良い。ここまでずっと再帰の練習をしてきたが、実際には繰り返し処理の 95% はこれらのモジュールを使って解決できるだろう。( Range を使えば自然数処理もリスト処理に落とし込めるぞ。)でも、残りの 5% を解決するためにも、再帰の練習は避けちゃダメだ。

それにしても、 Enum モジュールの関数はなんでこんなにいっぱいあるんだろう。もちろんそれは便利だからだ。しかし、ただ便利なだけではなく、リスト処理を細かく分類できるのが大きい。 filter は要素を絞り込む処理だし、 map は要素毎に変換する処理だし、 max はもちろん最大値を求める処理だ。 for 文の場合、これらはどれも同じ for 文になってしまう。もちろん中身を見れば、具体的にどんな処理かは分かる。でも、 Enum モジュールの各関数は、中身を見る前に関数名からも情報が伝わってくる。リスト処理関数は、 for 文よりも表現力があるんだ。

再帰と for 文の比較

最後に、今まで書いた再帰処理と for 文を比較してみよう。今回は for 文を書くのに Python を使用する。でも、 Python といえば for 文と決まっているわけじゃない。本当は Python にはリスト内包表記っていう便利なものがあって( Elixir にもあるらしいぞ)、大体の場面ではそっちを使う方が良いとされているので、勘違いしないで欲しい。

def sum(nums):
    total = 0
    for num in nums:
        total += num
    return total

上のコードで sum([10, 20, 30, 40]) を実行すると、以下のようなステップを踏むことになる。

  • total = 0
  • total = 0 + 10 = 10
  • total = 10 + 20 = 30
  • total = 30 + 30 = 60
  • total = 60 + 40 = 100

total の値は刻一刻と変わっていく。では、再帰ではどうだろう。

defmodule Sample do
  def sum([]), do: 0
  def sum([head | tail]), do: head + sum(tail)
end

同じく sum([10, 20, 30, 40]) で実行されるステップを考えてみよう。

  • sum([10, 20, 30, 40])
  • 10 + sum([20, 30, 40])
  • 10 + ( 20 + sum([30, 40]) )
  • 10 + ( 20 + ( 30 + sum([40]) ) )
  • 10 + ( 20 + ( 30 + ( 40 + sum([]) ) ) )
  • 10 + ( 20 + ( 30 + ( 40 + 0 ) ) )
  • 10 + ( 20 + ( 30 + 40 ) )
  • 10 + ( 20 + 70 )
  • 10 + 90
  • 100

記号を「→」から「=」に変えたことに気づいただろうか。つまり、値はずっと変わっていないってことだ。ここでは式変形しか行われていない。まるで数学の問題を解くみたいに、問題を順番に変形していって変形していって、最後にこれ以上簡単にできない形になれば、それが解というわけ。

"数学みたい"。これは関数型プログラミングを捉える上でとても大事な視点だ。関数型プログラミングは、いわば優秀な生徒の数学の解答みたいなものだ。実際、上で表したステップは丸っきり数学の解答として通用するだろう(数学というよりは算数だが)。

for 文のステップは数学の解答と捉えることができない。無理やり数学の解答だと思って読んでみよう。

  • 10 + 20 + 30 + 40 を求める。 total = 0 とする。
  • やっぱり 10 を足して、 total = 10 とする。
  • やっぱり 20 を足して、 total = 30 とする。
  • やっぱり 30 を足して、 total = 60 とする。
  • やっぱり 40 を足して、total = 100 とする。
  • 答えは 100 。

意味が分からないだろう。普通に考えて、 10 + 20 + 30 + 40 を求める上での total100 でしかない。 total = 0total = 10 も、言ってしまえば嘘だ。この total は数学の解答に書くようなものではなくて、どちらかというと計算用紙に書いてあるような、筆算のメモみたいなものだ。

for 文を使った手続き型プログラミングでは、このように変数の中身を変えていく(破壊的代入と呼ばれる)。数学の解答ではこれはまずありえない。x = 60 だとして、そこに 40 を足したらもうそれは別の値だ。x + 40 = x なんて数学では普通出てこない。 x + 40x じゃなくて x + 40 だし、それに名前をつけたいなら x じゃなくて y とかにするだろう。

もちろん再帰の方のコードでも、 sum([10, 20, 30, 40]) の時と sum([20, 30, 40]) の時では head の中身は変わる。でも、これは良いんだ。sum([10, 20, 30, 40]) を実行している時と sum([20, 30, 40]) を実行している時では名前空間が違う。数学の解答で喩えるなら、問 1 と問 2 の解答欄で x に違う値を入れても誰も気にしないのと一緒だ。手続き型プログラミングの場合、同じ解答欄で x の値を変えてしまうから変な解答になってしまう。

さて、数学の解答みたいなプログラミングをすると何が嬉しいんだろう。申し訳ないが冒頭でも宣言した通り、ここの説明には踏み込まないことにする。「関数型プログラミング メリット」とかでググればたくさん出てくるだろうし、きっともういくつか目を通しているだろう。その調子で頑張って調べて欲しい。キーワードは、"イミュータブル"、"参照透過性"、"副作用の分離"、"宣言的"とかだろう。きっと。

一つだけ注意しておくと、関数型プログラミングは良いことばかりじゃない。なんせ、プログラミングっていうのは本来はコンピューターへの命令を記すものなんだ。まさに手続きを記すものであって、決して数学の解答なんかじゃない。そこを無理やり数学の解答みたいに書こうとすると、色々と大変な場面も出てくる。

まあ、そんな感じだ。これで関数型脳に少しでも近づけたなら、俺がこの文章に費やした労力も報われるってものだ。なのでまあ、頑張って欲しい。

追記

解答例作りました。

ボードゲームはプログラミングしやすい

前回の記事でゲームについて考えた。

ikngtty.hatenablog.com

結論だけ抜き出そう。

ゲームとは、 「プレイヤーが、以下のルールに沿って、アクションを選択し、結果を決める営み」 である。

// ルール1: 初期状態
def initialState(): GameState

// ルール2: アクション定義
def createAction(args: Any): GameState => GameState

// ルール3. 可能/禁止アクション
def isAvailable(action: GameState => GameState, state: GameState): Boolean

// ルール4. 結果判定
def getGameResult(state: GameState): GameResult

// ルール5. ゲームの状態のアクセシビリティ
def getGameStateInformation(state: GameState, player: Player): GameStateInformation

今回は、ボードゲームをプログラミングに落とし込むことについて少し考えてみる。

ゲームについてプログラミングでできること

以下が考えられるように思う。

機能1. ゲームの状態の管理

ボードゲームで言えばボードの代わりである。 ルールの演算などは全てプレイヤーに任せ、コンピューターは関知しない。 真っ白なキャンバスとして使うようなものだ。 アクションの可能/禁止も勝ち負けも気にせず、自由に状態を弄れる。

例えば詰将棋を作る時などは、この機能だけの方が都合が良いだろう。

機能2. ルールの施行

審判や、いわゆるゲームマスターのような役割を担う。 開始状態をセットアップし、プレイヤーの可能なアクションのみを受け付け、勝敗を判定する。

例えば将棋のオンライン対戦アプリなどは、機能1と2の組み合わせと言える。

機能3. プレイヤーの代行

いわゆるCPUプレイヤーだ。

例えばCPU対戦ができる将棋アプリは、機能1, 2, 3の組み合わせとなる。 また、CPU同士で戦わせて棋譜を量産しながらCPUのAIを強化する、なんてのは最近のトレンドだろう。

プログラミングが難しい/向いていないパターン

思いつくままに挙げてみる。 尚、私が個人で開発することを想定した難易度判定なので、最先端の商業ゲームが普通に実現しているようなことも挙げる。

アクションが肉体を使った物理的なものである

スポーツ全般が当てはまる。

肉体の操作は基本的にパラメータが非常に多く、情報量が多い。 まず、肉体の操作を正確にコンピューターにインプットさせるのも大変だ。 最低限、kinectだとかswitchのJOYコンだとかが必要になる。 インプット後も、物理演算等で計算量は多い。

代わりに情報量を減らすと、今度は再現度が減り、物理世界で行うゲームとは段々かけ離れていってしまう。 例えば、CLIの腕相撲ゲームを想像してみてほしい。 「相手は80kgの力で押してきています。あなたは何kgの力で返しますか?キーボードで数値を入力してください。」 紛うことなきクソゲーである。

また、CPUとの対戦は、やりがいが無いかもしれない。 何せ相手の力が80kgが800kgかはマシンパワーと設定変数次第なのだ。 こちらが苦労して何年も筋トレしたところで、CPUは一瞬のチューニングで追い抜いてしまう。

アクションがトークである

テーブルトークゲームというジャンルだろうか。 人狼やキャット&チョコレートなどが当てはまる。

機能1は簡単だ。 チャットでのやり取りをそのまま表示すれば良い。

機能2も簡単である。 ゲームマスターの真似をして、「それではみなさん投票してください。」と問いかけるだけだ。 しかしそれは即ち、大事な判定処理を人間に委譲するということである。 結果、コンピューターは大してやることが無い。 簡単だが効果が薄いのである。 コンピューターの最も得意なことは計算であるが、ここでは計算処理は出てこない。

では、判定処理をCPUが行うとどうなるか。 例えばキャット&チョコレートで言えば、説得力をCPUが判定するのである。 すると途端に難易度が跳ね上がる。 自然言語処理をした上で、人間の感情などを扱わなければならない。

また、機能3も非常に難易度が高いことは説明不要だろう。

アクションが絵である

「アクションがトークである」に近いが、プレイヤーの入力がめんどくさくなりやすいことも挙げられる。

ゲームの状態が複雑である

物理世界や現実世界を利用する類のゲームが当てはまる。 物理世界の難易度は「アクションが肉体を使った物理的なものである」でも述べているので、 ここではそれ以外の現実世界に関係するゲームを考える。

例えば、「ツイッターで先に100RT稼いだ方が先」みたいなゲームが当てはまる。 実際にツイートさせて測定できるなら簡単だが、コンピューターがRT数をシミュレーションしなければいけないとしたら大変だ。 フォロワー数、各フォロワーの趣味・嗜好、フォロワーとの関係性、世の中のトレンド、自身のツイート履歴による文脈、 などなど挙げだしたらキリがない。

また、仮想世界における政治シミュレーションゲームなども、リアルにすればするほど状態変数が増える。 かといって単純にするとクソゲーになる。

アクションにリアルタイム性が求められる

のは、実はそんなに難しくない。 例えば以下ではリアルタイム性のある早押しクイズをシミュレートしている。

早押しクイズでNode.jsの非同期処理とかイベント駆動とかを試す

まあでも、ターン性だともっと簡単なのは確かだ。

ボードゲームは簡単か

ボードゲーム(board game)とは、ボード(盤)上にコマやカードを置いたり、動かしたり、取り除いたりして遊ぶゲームの総称。盤上ゲーム盤上遊戯とも呼ばれる。

ボードゲーム - Wikipedia

とのことである。

ボード上の駒を指で弾いて相手のゴールにシュート!みたいなゲームだと大変だが、そうでない限り物理が出てくることは少ない。 サイコロやルーレット等は物理と言えば物理だが、これはランダムな値をコンピューターが出すだけで問題無いだろう。

ゲームの状態は基本的にボードで表現できる範囲の静的な情報しかない。 アクションもほとんどがコマやカードの操作だ。パターンは限られる。

総じて情報量が少ないのがボードゲームの特徴だ。 加えて、ターン性であることも多い。

上記のプログラミングが難しいパターンはほとんど回避できる。 また、ほとんどのボードゲームにおいて、先の展開を読んだ上での戦略的決定が重要となる。 そのため、コンピューターの演算能力が発揮できる場面は多い。

結論

ボードゲームは個人の趣味プログラミング対象としてとてもコスパが良い。 まあ、特に考えなくてもなんとなく分かっていたことではある。 だからなんだという感じだ。 なんだったんだろうこの考察。

おしまい。

ゲームとは何か

趣味の一環で、ボードゲームに関するプログラムを書いた。

書きながら、「色々なボードゲームに汎用的に適用できる書き方はどんなだろう」と思考を巡らせ続けた。 あまりに凝りすぎて、「ボードゲームとは何なのか?」「そもそもゲームって何?」ってところまで考える羽目になった。

ゲームといっても色々だ。 デジタルなテレビゲームもあれば、アナログなボードゲームもある。 スポーツだってゲームな気がするし、鬼ごっこも多分ゲームだ。 私が学生時代に友人とよくやっていた、「1から20の中から好きな数を同時に言い合い、一番大きかった人が勝ち」というゲームだってゲームだろう。

学術的な定義とかは、おそらくもう決まっている。 だが、私自身はそんな定義を知らなくてもなんとなく、「これはゲームだ」「これはゲームじゃない」と判断している。

今回はその感覚を分析し、整理し、"個人的なゲームの定義"としてまとめてみた。

以下は目次。

要素の洗い出し

ゲームに一番必須な要素とは何か。

おそらくプレイヤーでは無いだろうか。

じゃあプレイヤーが集まって何かわちゃわちゃやってたら全部ゲームだろうか。 そんなことは無い。 プレイヤーは皆、ルールに基づいて行動している。

ルールとは何だろう。 基本的にまずは、「何をして良いか」「何をしてはいけないか(又は、何をしたらペナルティが与えられるか)」だと思う。 一言で言えば、可能/禁止アクションといったところだろう。

さて、ここまでの定義で言えば"おままごと"もゲームに該当することになる。 しかし、私には"おままごと"がゲームであるようには思えない(人によって違うかもしれない)。 なぜなら、"おままごと"には勝敗が無いからだ。

では、勝敗を競うことがゲームの必須条件だろうか。 これもまた違うように思う。 例えば賭け麻雀は、何位になるか以上に「何点勝つか」「何点負けるか」をプレイヤーは意識する。 また、一人用ゲームでは競う相手が居ないが、それでも「クリアできたか」とか「クリアまでのタイム」だとかをプレイヤーは意識する。

要するに、必要なのはゲームに対する明確な結果の定義なのだ。 そしてこれはルールで定めるべきもう一つの大事な要素でもある。

プレイヤーはより良い結果の為に競いあう。 中には、「結果よりも楽しむ過程が大事」というプレイヤーがいたり、勝つことよりゲームを混乱させることを優先するトリッキーなプレイヤーもいたりするだろう。 しかし、だからといって結果の定義が無くてもいい訳では無い。 結果の定義がなければ、大多数のプレイヤーはアクション選択の指針を失い、ゲームはほとんど成立しなくなる。

尚、結果が必須だとしてしまうと、"おままごと"と同様に『どうぶつの森』などもゲームでは無いということになってしまう。 しかし私はそう言ってしまっても良いかなと思っている。 『どうぶつの森』は確かにテレビゲーム機をプラットフォームとしているが、趣旨は「バーチャル空間での生活体験の提供」だ。 私にとってそれは狭い意味での"ゲーム"では無い。 (一応注釈しておくが、『どうぶつの森』の価値を貶める為に言っているのでは無い。『どうぶつの森』は楽しい。)

要素の整理

ここまでで出てきたキーワードを並べよう。

  • プレイヤー
  • アクション
  • ゲーム結果
  • ルール
    1. 可能/禁止アクション
    2. 結果判定

ルールを少し深掘りする。

ルール1. 可能/禁止アクション

とはつまり、アクションに対し可能か禁止か判定する規則のことだ。 つまり、アクションに対しTrue or Falseのブール型を返す関数として表現できる。

何かプログラミング言語で表現してみよう。 私が知っている中で型の示し方が一番綺麗なのがScalaなので、Scalaで書いてみる。 知らない方でも雰囲気は伝わるだろう。

尚、私のScala歴は10分ぐらいだ。

def isAvailable(action: Action): Boolean

すると、これでは足りないことに気付く。 よく考えれば、Actionが可能かどうかはそれ単体では判断できないのだ。全てはゲームの状況次第である。 即ち、ここまでのアクションの積み重ねを考慮し、現在のゲームの状況を算出してから検討する必要がある。

def isAvailable(currentAction: Action, actionHistory: Array[Action])

とりあえずこれで進む。*1

ルール2. 結果判定

同様にScalaで表現する。

def getGameResult(actionHisotry: Array[Action]): GameResult

これもゲームの状況を算出する必要がある。算出したらそれに対し、ゲームの結果を返す。 上記の通り、結果は単純な勝敗とは限らないので、ゲームの種類に応じてGameResultは柔軟に形を変えることになる。

この返し値が、nullとか、NullObjectとか、"結果が出ない事を示すオブジェクト"とかであった場合に、ゲームが続行されるイメージだ。

説明変数「ゲームの状態」を導入

さて、上の手順において優秀なプログラマーの方々は、「ゲームの状況を算出する」という処理を早く共通化したくてうずうずしていることだろう。 せっかくなのでやってみる。 あまり深い意味はないが、呼び方をゲームの状態に変える。

// ルール3. ゲームの状態の算出法
def getGameState(actionHistory: Array[Action]): GameState

// ルール1. 可能/禁止アクション
def isAvailable(action: Action, state: GameState): Boolean

// ルール2. 結果判定
def getGameResult(state: GameState): GameResult

以下の図式となる。

旧ルール1 = 新ルール1 + 新ルール3
旧ルール2 = 新ルール2 + 新ルール3

これで各ルールは、より現実に即した形となった。

例えば将棋で、「1手目はこう指して、2手目はこう指して、...、71手目にこう指した時(=アクション履歴)は先手勝ちですか?」という聞き方は普通しないだろう。 それよりは、盤面(=ゲームの状態)を指し示して、「先手番でこうなったら先手勝ちですか?」と聞くのが普通だ。 つまり、ルール2.はゲームの状態を引数とする方が普通というわけだ。 この場合、ルール3.として「1手目はこう指して2手目は...」がどのような盤面に繋がっていくのかを追加で定義しなければならない。

注意したいのは、これはただのリファクタリングだということだ。 ルールの使い勝手がよくなったというだけで、これらのルールで表現できるゲームの幅が広がったというわけではない。

新ルールで表現されたゲームを旧ルールで表現し直すには、今のリファクタリングの逆を行えば良い。 例えばオセロなら、

## ルール1. 可能な手
8x8マスのボードがあるとする。(マス目の表記法については省略。)
はじめにe4d5に黒石、d4e5に白石が置いてあるとする。
石が置かれると、同じ色で挟まれた石は、挟んだ石と同じ色に変わる。

棋譜を「置かれた石の位置の履歴」と解釈し、上記規則に則ってボードに石が置かれたと想定すると、手番プレイヤーは以下の手を打つことが可能である。(手番の説明は省略。)

* 自色の石を置くと敵石の色を挟めるような位置を宣言する。(又は、ボードがある場合そこに石を置き、ボードの状態を上記規則に従って変更する。)

## ルール2. 結果判定
8x8マスのボードがあるとする。(マス目の表記法については省略。)
はじめにe4d5に黒石、d4e5に白石が置いてあるとする。
石が置かれると、同じ色で挟まれた石は、挟んだ石と同じ色に変わる。

棋譜を「置かれた石の位置の履歴」と解釈し、上記規則に則ってボードに石が置かれたと想定すると、以下のように勝敗を判定することができる。
* ボードに石が置かれていないマスがある場合、勝敗未決(試合続行)。
* 上記以外の場合、自色の石がより多くボードに置かれていた方のプレイヤーが勝ち。

というWETなルールとなる。 まるでボードを実際に使ってはいけないかのような、脳内オセロ限定みたいな書き方になった。 (しかしボードを置いてはいけないとは言っていない。普通のオセロにも対応できる。)

今やったことはまるでトランスパイルである。 ES6のコードをES5で表現し直しているようなものだ。 しかし、ES6とES5でできることは本質的には変わらない。 それと同じだ。 新ルールは旧ルールと比べ、シンタックスが増えて便利になっただけなのである。 表現の幅は本質的には変わっていない。

まあ、だからどうしたということは特に無いのだが。 そんなわけでとりあえず、リファクタリングを更に進める。

アクションの再解釈

これまでアクションの定義は特にしなかった(=プリミティブな型として扱っていた)。 しかしゲームの状態という変数を導入した今、アクションの意味合いが表現できるようになる。 アクションとは、ゲームの状態を変更するもの。 つまり変更前のゲーム状態に対して変更後のゲーム状態を返す関数だ。

def action(state: GameState): GameState

ちなみにGameStateはイミュータブルなものとして考えている。 GameStateがミュータブルで、自由に書き換えていいのなら、別に値を返す必要はない。

さて、ゲームによってアクションは色々考えられる。 将棋なら「76歩」もあれば「投了」もある。 ボクシングなら「右手を相手のこめかみに向かって右上水平角30°から時速40kmでぶつける」等といくらでも複雑になるだろう。

とりあえず、ゲーム毎にアクションのバリエーションはある程度決まってくる。 ここではアクションの生成法を定義することで、アクションに固有の形式を与えてみよう。

def createAction(args: Any): GameState => GameState

アクションの型は基本的にGameState => GameStateな関数なのだが、実際はGameState => GameStateの中でもある一定のパターンしかあり得ない。 それをこの高階ファクトリ関数によって示している。 これはつまり、より狭い意味での型を定義している、ってことになるんじゃないかなぁと思ったり思わなかったり。 ファクトリ関数が型の役割をするというのは、JavaScriptの発想から影響を受けた。

まあしかし、この辺は言語によって表現方法が変わってくるだろう。 関数をファーストクラスとして扱うのが難しい場合は、アクションをオブジェクトにしてしまっても良い。 っていうかその方が綺麗な気もする。 以下は、「関数っぽい役割だけど実際はオブジェクト」という形でアクションを実装したRubyの例。

class Action
  def apply_to(state)
    raise 'Not implemented.'
  end
end

class AddSomethingAction < Action
  def initialize(something)
    @something = something
  end

  def apply_to(target)
    target + @something
  end
end

add5 = AddSomethingAction.new(5)
puts add5.apply_to(100)  # -> 105

ちなみにPythonだと、__call__メソッドをオーバーライドすることで、関数っぽく呼べるオブジェクトが作れる。 おそらくこれが最も綺麗だろう。

さて、これでアクションは、どういう状態変化を起こせるかを自身に定義できるようになった。 以前はこれらの定義は、ルール3の中で表現される想定であった。

// ルール3. ゲームの状態の算出法
def getGameState(actionHistory: Array[Action]): GameState

今やルール3の中身は、全てアクションの定義に委譲されている。

// ルール3-1: 初期状態
def initialState(): GameState

// ルール3-2: アクション定義
def createAction(args: Any): GameState => GameState

// ルール3. ゲームの状態の算出法
def getGameState(actionHistory: Array[GameState => GameState]): GameState = {
  actionHistory.foldLeft(initialState())((state, action) => action(state))
}

Scalaの実装に立ち入り過ぎて、さすがに勉強時間の追加を強いられた。 まあしかし、言いたいことは 「初期状態にアクション履歴内のアクションを全部順番に適用すれば、今のゲームの状態が得られるよ。」 ということだけだ。 もちろん、図式は以下のようになる。

ルール3 = ルール3-1 + ルール3-2

というわけで、ルールを分解して番号を振り直し、新体系としてまとめ直そう。

// ルール1: 初期状態
def initialState(): GameState

// ルール2: アクション定義
def createAction(args: Any): GameState => GameState

// ルール3. 可能/禁止アクション
def isAvailable(action: GameState => GameState, state: GameState): Boolean

// ルール4. 結果判定
def getGameResult(state: GameState): GameResult

先程と同様、こちらもリファクタリングに過ぎない。

物理ルールの存在

ルール2(アクション定義)について。 例えばジェンガについて考えてみよう。

def createAction(args: 力加減とか): GameState => GameState = {
  // 力加減が弱い場合:ジェンガの山がよほどグラグラしてない限り崩すことはないような関数を返す
  // 力加減が強い場合:どんなに安定していたジェンガの山もあっという間に崩すような関数を返す
}

みたいなイメージだ。 しかし、ジェンガの説明書にこんなことは書いていない。 書いてあるのは「抜いて崩れたら負け」ということだけだ。 これはつまり、「ジェンガを抜く」というアクションによって「ジェンガの山」という状態がどう変化するかを、物理法則に一任しているのである。

今回の思索の最終的な目標は、定義したゲームのルールをプログラムに落とし込むことである。 なので、物理空間では自明のことであっても、サイバー空間において自明でないことは全て明記する必要がある。 具体的に言えば、物理エンジンを実装しなければいけない。

これを踏まえた時に、特に注意しなければいけないのはゲームの状態の時間変化だ。 プレイヤーがアクションをせずとも状態は勝手に推移してしまうのである。 そのため、先程使用した以下の前提が使えなくなる。

// ゲームの状態の算出法
def getGameState(actionHistory: Array[GameState => GameState]): GameState = {
  actionHistory.foldLeft(initialState())((state, action) => action(state))
}

まあしかし、この辺を完全に反映させるのは骨が折れる。 というか折れたのは私の心です。 この辺の明記は今回は諦めることにする。

追加ルール「ゲームの状態のアクセシビリティ

物理法則について色々なパターンを考えてみると、その中の一つに 「伏せられているカードは見えない」 「相手の手札(手牌)は見えない」 というような、認知に関する物理法則があることに気付く。

プレイヤーが何を認知できるかは、ここまで見落としていた大事なファクターだ。 将棋やオセロ等は完全情報ゲームなので関係ない話だが、麻雀やトランプにおいては欠かせない。

一言で言うと、ゲームの状態のアクセシビリティである。 以下のように定義してみよう。

def getGameStateInformation(state: GameState, player: Player): GameStateInformation

PlayerGameStateにアクセスことはできない。 代わりにgetGameStateInformation関数を通し、GameStateInformationを得ることができる。

尚、これは実装面の話になるが、GameStateInformationGameStateと同じインターフェースにするのが良さそうだ。 プレイヤーがアクセスできない情報はnullにしたりNullObjectにしたり、アクセスするとエラーを吐くような仕組みにする。 これにより、GameStateGameStateInformationも今までのルール関数を共通で使用することができるようになる。

追加しないルール「ゲームフロー

ボードゲーム等の説明書では、よくはじめに「ゲームの流れ」と言うものがある。 これは私のゲーム定義には載せないことにする。 なぜならこれまでの定義で、ゲームの流れ(以下、ゲームフロー)は表現できるからだ。

例えば、もう一度オセロのルールを定義してみよう。

## ルール1: 初期状態
8x8マスのボードがある。(マス目の表記法については省略。)
e4d5に黒石、d4e5に白石が置いてあるとする。
**黒番である。**

## ルール2: アクション定義
* 自色の石を1つ置く。
この際、同じ色で挟まれた石は、挟んだ石と同じ色に変わる。
**また、白番の場合は黒番に、黒番の場合は白番に変わる。**
* パスする。

## ルール3. 可能/禁止アクション
* **自分の手番でない場合、取れるアクションは無い。**
* 敵石の色を挟めるような位置にしか自石を置けない。
* 自石を置けない場合のみ、パスをすることができる。

## ルール4. 結果判定
* **ボードに石が置かれていないマスがある場合、勝敗未決(試合続行)。**
* 上記以外の場合、自色の石がより多くボードに置かれていた方のプレイヤーが勝ち。

## ルール5. ゲームの状態のアクセシビリティ
プレイヤーはボードを自由に見て良い。

以前と比べ、随分と整理できているように感じる。まあそれは置いといて。

オセロのゲームフローは以下の通りだろう。

1. 黒が打つ
2. 白が打つ
3. ボードに石が置かれていないマスがある場合、1.へ。そうで無い場合、勝敗を判定する。

このフローは全て、上のルール定義の強調部分から完全に読み取れる情報である。 というわけで、ゲームフローはゲームの状態等と同じく説明変数にすぎない。 リファクタリングと称してまた括り出してもいいが、今回は特に意義を感じないのでなんとなくやらないことにする。

結論

ゲームとは、 「プレイヤーが、以下のルールに沿って、アクションを選択し、結果を決める営み」 である。

// ルール1: 初期状態
def initialState(): GameState

// ルール2: アクション定義
def createAction(args: Any): GameState => GameState

// ルール3. 可能/禁止アクション
def isAvailable(action: GameState => GameState, state: GameState): Boolean

// ルール4. 結果判定
def getGameResult(state: GameState): GameResult

// ルール5. ゲームの状態のアクセシビリティ
def getGameStateInformation(state: GameState, player: Player): GameStateInformation

おわり。

*1:オブジェクト指向信者の方は、早くisAvailableをActionクラスのメソッドにしたくてうずうずするかもしれない。 しかし今はまだ色々と整理中の段階だ。その辺の最適化は待っていただきたい。

ただ、Action型の中に色々とアクションについての情報を示す属性が入っていることはこの段階で想定している。 例えば、player(誰のアクションか)、timing(ゲーム開始後何秒後に行われたアクションか)等が考えられる。

このような単純な属性の整理は"データ型"の設計であり、メソッドを含めた"クラス"設計よりも前の段階で行えると考えている。 具体的に言えば、以下のような手順を踏むのがクラス設計への基本的な道のりだと私は考えている。

  1. データ型の整理
  2. やりたい事を関数に分解しながら洗い出し
  3. 関数とデータ型をどう組み合わせてクラスとして固めるか決定

【スプラトゥーン2】立ち回りメモ

スプラトゥーン2やってます。
1はちょっとしか触ってないです。初心者です。

先日、スプラトゥーンをやったことがないという友人の家に遊びに行き、
「これがスプラトゥーンや!!おもしろいやろ!!!」
というのを見せつけるため、友人の前でガチマッチをやってみせました。
レベルはB帯です。

友人の反応はというと、
「そこ左潜って!オブジェクトの影に隠れる!はい、敵が通るから後ろから撃って!倒したらすぐ引く!」
「ほらまた正面から撃ち合ってる!その癖直して!」
めちゃくちゃアドバイスしてきました。

というのも、友人はFPS(というかオーバーウォッチ)ガチ勢だったので、
初めて見たスプラトゥーンにおいてもどういう動きが強いか理解していたのです。
やったことないのに既に俺より上手そうでした。

そんなわけで、友人から受けたアドバイスをメモ代わりにまとめときます。

敵の背後を取る

「敵の進行方向と同じ向きを向いて倒す」
「敵を巻き込むように動いて倒しながら前線を進めていく」
とも言われました。
図にすると次のような感じです。

f:id:ikngtty:20170820235012p:plain

特にB帯レベルで言えば、敵も味方もエリアやヤグラやホコに向かってガーッとまっすぐ向かって行きがちです。
まさに図の赤い矢印の動きですね。
(自分もそうでした。)
この動きを冷静に読んで、自分は青い矢印の動きをするよう心がけると、おもしろいように刺さります。

例えば海女美ガチエリアで言うと、左の段差下を辿って敵陣スロープ下あたりに潜伏するのが強いみたいです。
敵が中央に向けてやんややんや突撃するのを横から見守った後、後ろから追いかけることで敵の背後が取れます。
このルートは遮蔽物も多いので、危なくなっても上手く遮蔽物を使いながら切り抜けられるっぽいです。
(後述の「オブジェクトの影に隠れる」「退路を確保する」も参照。)

敵の斜から撃つ

f:id:ikngtty:20170821000921p:plain

敵と正面から撃ち合うと、対等なエイム勝負になります。
それだとなかなか安定したキルには繋がりません。
常にこちらが優位な形のエイム勝負に持ち込むのが、安定したキルを取る立ち回りみたいです。

図のように斜から撃てば、
敵は自分にエイムを合わせるまでにワンテンポ遅れる状態で、
自分だけエイムがほぼほぼ合った状態から撃ち始めることができます。
これが自分優位な形のエイム勝負です。

斜から撃つのは常に意識する、というか、癖にしておかないとダメですね。
私の場合、試し撃ちでバルーンを正面から撃ってるだけでもう友人から叱られました

なお、これを意識するようになってから、潜伏からの奇襲の成功率が大きく上がりました。
せっかく奇襲しても、正面から襲いかかると意外と迎撃されちゃうんですよね。

エリアは対角線に沿って制圧する

f:id:ikngtty:20170821003455p:plain

これはガンガゼガチエリアで言われました。
理由は上図の通り、対角線方向を向いた方が狭い視野に集中できるからです。

オブジェクトの影に隠れる

オブジェクトっていうのは、マップに生えてるモノとか高台とかのことですね。
要は遮蔽物です。
上手い人ほどこれをちゃんと利用して敵の弾を遮り、敵地の中であっても上手く安全を確保してます。

退路を確保する

「自分優位なエイム勝負に持ち込む」と前述しましたが、裏を返せば「不利なエイム勝負は全て避ける」ということになります。
そのためには、危ない場面ではすぐに引けるようにすることが大事。
なので、退路が確保できているかどうかは常に意識し、退路がなければきちんと塗って、あらかじめ退路を作っておくことが大事です。



印象に残ってるアドバイスはこんな感じです。
当たり前のことも多いかもしれませんが、どれも初心者の自分は指摘されるまで気づかなかったことです。
他の初心者の方にも役立てば幸い。