RAS Syndrome

冗長。

「関数型脳になろう 〜 脱 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 と、リストを自称している [ ] をコンスしたものである。
  • [ ] は定義2.より明らかにリストである。

と、空リストさえ出てくれば議論を着地させることができるのである。以下、定義 1. を後ろから順に適用していけば、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 applyTo(state)
    raise 'Not implemented.'
  end
end

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

  def applyTo(target)
    target + @something
  end
end

add5 = AddSomethingAction.new(5)
puts add5.applyTo(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

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

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

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

退路を確保する

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



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

SI会社を退職した

新卒から約4年間、某中小SI会社に勤めてきたが、先月末に退職した。

次はWebサービス業界かゲーム業界に入りたいと思っているが、
その前にまずは無職をしばらく楽しむ予定である。
時間があればじっくり勉強してみたかったことがたくさんあるし、
やりたいゲームもたくさんある。

以下、なぜ辞めたか等をまとめておきたいと思う。

辞めた理由

喩えるなら私は、
サイゼリヤで働いてみたら料理の楽しさに目覚めてしまい、
 本格的なイタリアンレストランで料理人の道を目指したくなった。」
という状態なのではないかと思う。

ここで言う"料理"とは"プログラミング"のことであり、
"サイゼリヤ"はSI業界、"本格的なイタリアンレストラン"はWebサービス業界等のことだ。

別に馬鹿にしているわけではない。
もう少し具体的に説明する。

SI業界とサイゼリヤの共通点

SI業界において、正社員は上流SEになることが求められ、そして管理職になることが求められる。
初めの内はプログラマーもやらされるが、それはあくまで下流の業務を知るため。
キャリアステップの通り道としてのフェーズでしかないし、早めに卒業するべきものとされる。

正社員がやらない分、プログラミングはなるべくパートナー社員かオフショアに回す。
その方が安く済むからである。

サイゼリヤにおける料理人も、きっとこんな感じなのではないかと思う。
正社員ははじめに少しだけ厨房に入る体験をするが、それはマネージャーになるためのステップでしかない。
厨房で働くのは基本的にバイト料理人だ。

尚、私はサイゼリヤについて一般消費者以上の知識を何も持ち合わせていないので、
サイゼリヤに関しては全て憶測で喋っていることを了承頂きたい。
もしも実際のサイゼリヤにそぐわない話があれば、
サイゼリヤに似た架空の店の話だと思って欲しい。(喩えとして意味があるのか怪しくなってくるが。)

サイゼリヤで料理の修行はしない

一般的にSI業界の(プログラミングにおける)技術意識はとても低い。
普段から最新技術についての情報にアンテナを広げていて、家でもコードを書いていて、
というような社員はかなり希少というのが私の印象だ。

少なくとも前職では、GitHubのアカウントを持っていないどころの話ではなく、
GitHubって何?」という社員が先輩後輩問わず大多数のようだった。

そんな環境も、上で述べたようなサイゼリヤ的背景を考えれば不思議ではない。
「究極においしいイタリアンを作れるよう、日々精進します!」と言いながらサイゼリヤに入る人は、きっとごく少数だろうと思う。
多くの人はマーケティングとかマネジメントに着眼するのではないだろうか。
SI業界も概ね同様なのである。(業務知識とかマネジメントとか。)

前職では、「プログラムなんて結局動けばなんだって良いよね」というような声を何度か聞いた。
ある種のプロフェッショナリズムとして、それはきっと正解なのだろう。
しかしそこにはプログラミングへの思い入れだとか、職人的なこだわりだとか、
より良いものを作ろうという熱意だとかが欠けている。
極端かもしれないが、私にはまるで、
料理人が「食えればなんだっていい」と言っているかのように聞こえた。
(これはサイゼリヤとは関係ないことにしておく。失礼になりそうなので。)

やはり料理の腕を磨くなら、本格的なイタリアンレストランで働き、同じように腕を磨き続けるレベルの高い料理人に囲まれるべきだ。

Webサービス業界はなぜ"本格的なイタリアンレストラン"なのか

Webサービス業界はSI業界と真逆で、エンジニアを続けるキャリアステップが普通に存在するし、
日々勉強しているプログラマーが非常に多い。

これはネットでもたくさん書かれていることであるし、
転職エージェントからも聞いた話なのでかなり確度の高い事実だと思われる。

このように違いが現れる理由はきっと色々あるのだろうが、
1つ挙げるならば、
「SI業界はプロジェクトの成功を考える(短期的目線)。Webサービス業界はプロダクトの成功を考える(長期的目線)。」
という要素が大きいのではないかと私は思う。

長期的にメンテナンスするプロダクトを作る

メンテナンス性の高いプログラムを書けるプログラマーを集めたほうが良い

技術力の高いプログラマーが集まる、技術力を高め続けるキャリアステップが必要になる

という理屈だ。

前職への思い

ここまで、SIを否定するような意見を述べたように見えるかもしれないが、そういうつもりはない。
世の中にはサイゼリヤも本格的イタリアンも両方必要だ。
どっちが良くてどっちが悪いというわけではない。

また、「プログラマーとしての腕を磨きたい」という観点で私はWebサービス業界等を志向するようになったが、
逆に「SEとしてやっていきたい」という場合はSI業界を志向し続けていただろう。
SI業界の技術力が低いのは、あくまでプログラミングレベルの話だ。SEのコアスキルはそこではない。

実際、私は初めの内は漠然と上流SEへの道を進むつもりでいた。
私は文系学部卒であり、学生時代のプログラミング経験はほとんど無い。
入社して実際にプログラミングに触れるまで、
自分がここまでプログラマー志向になるとは思っていなかった。

前職の方々は、そんな私の志向の変化を敏感に察知してくれ、
なるべく私がプログラミング寄りの作業をできるよう取り計らってくれたし、
将来的にも「技術長」というような感じの、例外的なキャリアパスを用意してくれているようだった。

この点について私は本当に感謝しているし、
期待に添えず退職したことは今でも申し訳なく思っている。

それでも退職したのは、私の希望があくまで「プログラマーの技術レベルが高い環境」だったからだ。
私自身が(プログラミングレベルに関して)周りを引っ張っていくということは何度も考えたが、
社員のプログラミングへの意欲が足りない限りは限界があると思ったし、
何よりメインプログラマーが取っ替え引っ替えのパートナー社員な体制では、
技術教育による技術レベルの底上げというのは不可能であるように感じられた。
それにやはり、まだまだ若い自分に必要なのは、人を引っ張ることより周りから吸収することだと思う。

尚、前職には他にも感謝することがたくさんあるが、
ここではあくまで「前職は悪いところじゃなかったよ」ってフォローがしたかっただけなので、
残りは省略する。

落ち穂拾い

ここまでがメインの辞めた理由であるが、
次職に期待することは他にも色々ある。

  • BtoCで働いて、自分が作ったものの価値を自分で体感できる方が作り甲斐がありそう。
  • 私服を始めとして、ギーク企業のラフな雰囲気が性に合ってそう。

などなど。

私は今まで「どう作るか」に非常に興味があり、「何を作るか」にはあまり注意を払ってこなかったのだが、
業界の特色上、これからは作るもの自体にも興味を持っていこうかと思っている。

補足

SI業界、Webサービス業界などと一括りに扱ったが、
これはあくまで全体的な傾向の話であるし、
例外的な会社も存在しているのは承知している。

なので、例外的に技術意識の高いSI会社に入るという選択肢も考えたが、
上記の落ち穂拾いで述べた理由も意外と大きく、
やはり業界をガラッと変えてみたいと思っている。

まとめ

技術力の高い会社に入りたいニャン。

SI業界がプログラミングを軽視する理由

前回の記事と被るところは多いです。
ikngtty.hatenablog.com

SI業界はプログラミングを軽視している

SI業界にとって、プログラミングは"卒業"するものです。

入社して最初の2~3年ほどプログラミングを経験したら、内部設計を行うようになり、外部設計を行うようになり、要件定義を行うようになり…。
だんだん上流工程に携わるようになり、代わりにプログラミングは新人やオフショアパートナーに任せるようになっていきます。

「もうfor文の書き方も忘れちゃったよw」などと笑いながらプログラマー時代を懐かしむ。
それがSI業界のキャリアパスの先にあるものです。

一方、Web業界は全く逆のようで、いくつになってもコードを書ける力はかなり重要視されると聞いています。

SI業界からWeb業界に転職を試みる人は多いようですが、あまり年齢が行っている人だと
「設計書ばっか書いてきて、結局ずっとコード書いてないわけでしょ?そんな人ウチには要らないよ。」
と門前払いを食らうらしいです。

誇り高き上流工程の経験を聞いてもらえず、新人しかやらないようなプログラミングの腕ばかり見られる。
価値観が本当に真逆ですね。

システムを作る仕事という意味では両者は同じです。なのにどうしてこんな差が生まれるんでしょうか。
ちょっとそこについて考えてみます。

プログラミングは単純作業か否か

もう少し言えば、SI業界はプログラミングを単純作業と考えているところがあります。
設計書さえあれば、あとはそれを動くものに変換するだけという感じ。

だから2~3年学べばそれ以上やっても大してスキルは変わらない。
だから10年、20年とプログラマーを続けても賃金は増えない。
だから早く卒業しなきゃいけない。

一方でWeb業界はそうした熟練プログラマーを欲しがるので、当然逆の価値観を持っています。

プログラミングは2~3年で極められるほど単純なものではない。
やればやるほど奥が深いし、その腕を磨き続けた人では生産性の次元が違う。
だから何十年も腕を磨き続ける価値があるし、磨けば磨くほど賃金を出してもらえる。

プログラミングは果たして単純作業なのでしょうか。そうではないのでしょうか。
私は、見方を変えればどちらも正解になると思っています。

プログラマーの成長曲線

プログラミングの腕を何十年も磨いていくと、人はどう成長するのでしょうか。
私は下記のようなイメージを持っています。

f:id:ikngtty:20170107141931p:plain

動くものを速く正確に作る力は、5年、10年で頭打ちになっていきます。
一方で、保守性の高いものを作る力はいつまでも伸びていきます。

つまり、単純なものを作る作業ならば単純作業ですし、工夫した良いものを作ろうとする作業ならば、頭を使う作業で、腕が要る作業だということです。

プログラマーを青い曲線で評価するか、赤い曲線で評価するか。
それはプログラムに保守性を求めるかどうかにかかってきます。

保守性について

詳しい定義は調べていないですが、私は「プログラムの変更・追加にかかるコストがいかに低く収まるようにできているか」だと思っています。

これには「一箇所を変更・追加した時の影響範囲が小さいかどうか」も含まれますし、「プログラムが読みやすいかどうか」も含まれます。
(一応後者について説明しておくと、プログラムを弄る際にはそのプログラムを読んで理解する時間が発生します。
この時間が占める割合は結構大きいです。
なのでこの時間が短く収まるプログラムほど「保守性が高い」と言えます。)

いずれにせよ、保守性を高める目的は「後で楽をすること」です。
短期的には時間・労力をかけてでも、長期的にかかる時間・労力を小さくするのが保守性を重視したプログラミングです。

SI業界は短期最適に走りやすい

Web業界はプロダクトで収益を生むので、プロダクト中心で考えて行動します。
普通、プロダクトは長期的に運用していきます。
なのでプロダクトの保守性を重視するのは当然と言えるでしょう。

一方でSI業界の収益源は何でしょうか。
作るシステムは全て他社のものです。今自分たちの作っているシステムは、この先別の会社が保守していくかもしれません。
そういった意味で、大事なのは目の前の一つ一つのプロジェクトのみです。
長期的に運用するプロダクトではなく、SI業界は短期的なプロジェクトでものを考えます。
その結果、より短期最適な戦略として、保守性は捨てた方が賢明という話になってきます。

プロダクト中心か、プロジェクト中心か。
冒頭で挙げた差はこの差から始まっているのだと私は考えました。

SI業界がプログラミングを軽視する理由

SI業界は、プロジェクトの短期的な成功を重視して保守性を捨てます。
その結果、プログラミングを単純作業とみなし、プログラマーを青い曲線で評価するようになります。
なので5年勤めた社員に払える給料はもう頭打ち。
社員にはプログラミングを"卒業"してもらうことで、別の方向での生産性の向上を図ってもらいます。

年功序列的賃金体系のもとでは、高年齢のプログラマはコストが高すぎると考える企業がある(特にプログラミングを単純作業と考える企業に多い)。俗にIT土方とも呼ばれデスマーチとなった場合は徹夜が続いたり体力が必要となってくる。そのため、プログラマとしての限界は30~35歳前後であるという説が存在した。これは「プログラマ35(30)歳定年説」と呼ばれる。

プログラマ - Wikipedia

結果、プログラミングを行うのは5年未満の新人か、単純作業が得意なオフショアパートナー。
いずれも安い賃金で済むので、そのへんも"短期的には"最適戦略というわけです。

これがプログラミング軽視の文化ができあがっていく背景だと私は考えています。