RAS Syndrome

冗長。

プログラミングにおける"フック"とは

フックの意味

フックという言葉には色々な意味がある。

  • 鉤。物を引っかける器具。
    • 電話機で受話器を置く部分にあるボタン。フックスイッチを参照。
    • 衣服の留めかぎ。ホック。こはぜ。
    • 釣り針。
    • 義手の手先金具、またはそれのついた義手。装飾義手(ハンドタイプ)ではない方の実用義手(フックタイプ)をいう。両腕義手の場合、複数形hooksとなる。
    • BDSMで、使うフック。鼻フックが有名だが、他にも肛門や陰部に引っ掛けるプレイもある。
  • フック (打撃) - ボクシングなどの攻撃の一種。引っ掛けるように横から打つ。
  • ゴルフで右打者の打球が左に、また左打者の打球が右に大きく曲がること。
  • フック (プログラミング) - コンピュータの処理を割り込ませる(引っかける)こと。
  • つかみ - 芸能で、客を引きつけるためのしかけ。
  • フック (音楽) - 覚え易い音楽の一節。
  • フック - 航空機の機動の種類で、旋回中に急激に機首を旋回円の中心へ向けたまま機体の進行方向を変更しない機動。
  • フックつき文字 - 文字の一部分を伸ばし曲げるようにして付け足した部分。
  • フック符号 - 「?」の下の点を取ったような形のダイアクリティカルマークベトナム語アルファベット(クオック・グー)で母音字の上に付ける。

フック - Wikipedia

共通するのは、「引っかける」という動作に関連したものということだ。

プログラミングにおけるフックの意味

プログラミングに関する部分だけ、もう一度引用する。

  • フック (プログラミング) - コンピュータの処理を割り込ませる(引っかける)こと。

フック - Wikipedia

では、この意味のフックについて掘り下げてみよう。

フック(Hook)は、プログラム中の特定の箇所に、利用者が独自の処理を追加できるようにする仕組みである。また、フックを利用して独自の処理を追加することを「フックする」という。

フック (プログラミング) - Wikipedia

つまり、利用者が独自の処理を"引っかける"こと、または"引っかける"ことができる仕組みのことをフックというわけだ。

念の為、英語版の Wikipedia も確認してみよう。

In computer programming, the term hooking covers a range of techniques used to alter or augment the behaviour of an operating system, of applications, or of other software components by intercepting function calls or messages or events passed between software components.

Hooking - Wikipedia

関数呼出、メッセージング、イベントなどを"引っかける"ことを hooking (動詞:フックする)という。

Code that handles such intercepted function calls, events or messages is called a hook.

Hooking - Wikipedia

"引っかけられる"部分を hook (名詞:フック)というわけだ。

フックのコード例

Wikipedia の例はシステムレベルの話が多い。もっと平凡なコードでフックを使ってみよう。
以下のような動きをするプログラムを Ruby で書く。

f:id:ikngtty:20200606114626p:plain

題材は、インタビューを取り扱うプログラムにしてみた。
プログラムが質問して、ユーザーが答える。それだけのやつ。
なるべく単純なコンソールアプリにしたいと思ったらこうなった。

そんなわけでコードがこれ。

# frozen_string_literal: true

InterviewInfo = Struct.new(:question, :answer, keyword_init: true)

class Interviewer
  def initialize
    @questions = %w[
      調子どうだ?カラダ?
      あなたは赤い部屋が好きですか?
      おまえは今まで食ったパンの枚数をおぼえているのか?
      てかLINEやってる?
      おいィ?お前らは今の言葉聞こえたか?
      何いきなり話かけて来てるわけ?
      質問文に対し質問文で答えるとテスト0点なの知ってたか?
      あなた…『覚悟して来てる人』……ですよね?
      小便は済ませたか?神様にお祈りは?部屋の隅でガタガタふるえて命乞いする心の準備はOK?
      これはミラーシェード=サンのケジメ案件では?
    ]
    @hooked_funcs = []
  end

  # フックに処理を引っかける
  def add_to_hook(func)
    @hooked_funcs << func
  end

  def interview
    @questions.shuffle.take(5).each do |question|
      puts question

      print '> '
      answer = gets
      puts

      info = InterviewInfo.new(question: question, answer: answer)
      call_hooked_funcs(info)
    end
  end

  private

  # フックに引っ掛けられた処理を全部呼ぶ
  def call_hooked_funcs(info)
    @hooked_funcs.each { |f| f.call(info) }
  end
end

# フックに引っかける処理1
# 概要:インタビュー内容を逐次ログファイルに出力。
realtime_report = lambda do |info|
  File.open('realtime.log', 'a') do |file|
    file.puts "Q: #{info.question}"
    file.puts "A: #{info.answer}"
  end
end

# フックに引っかける処理2(store メソッドを引っかける)
# 概要:インタビュー内容を store メソッドで蓄え、report メソッドで一気に出力。
class StoreReporter
  def initialize
    @interview_infoes = []
  end

  def store(info)
    @interview_infoes << info
  end

  def report
    File.open('summary.log', 'a') do |file|
      file.puts '質問まとめ'
      @interview_infoes.each_with_index do |info, i|
        file.puts "#{i + 1}: #{info.question}"
      end

      file.puts '回答まとめ'
      @interview_infoes.each_with_index do |info, i|
        file.puts "#{i + 1}: #{info.answer}"
      end
    end
  end
end
store_reporter = StoreReporter.new

# 準備:2つの処理をフックしておく
interviewer = Interviewer.new
interviewer.add_to_hook(realtime_report)
interviewer.add_to_hook(store_reporter.method(:store))

# インタビューの実行
interviewer.interview

# 処理2によって蓄えたインタビュー内容も出力
store_reporter.report

質問を行うオブジェクトは関数呼出をフックできるようになっており、実際に処理を 2 つフックしている。
ユーザーが 1 回質問に答える度に、フックした処理が 1 回ずつ呼ばれる形だ。

f:id:ikngtty:20200606114351p:plain

f:id:ikngtty:20200606114409p:plain

実行時のコンソールはこんな感じ。

【console】

小便は済ませたか?神様にお祈りは?部屋の隅でガタガタふるえて命乞いする心の準備はOK?
> ミレニ……アム

おいィ?お前らは今の言葉聞こえたか?
> 俺のログには何もないな

てかLINEやってる?
> 笑    

調子どうだ?カラダ?
> 俺が楽天斎

質問文に対し質問文で答えるとテスト0点なの知ってたか?
> マヌケ

出力結果はそれぞれ以下。

【realtime.log】

Q: 小便は済ませたか?神様にお祈りは?部屋の隅でガタガタふるえて命乞いする心の準備はOK?
A: ミレニ……アム
Q: おいィ?お前らは今の言葉聞こえたか?
A: 俺のログには何もないな
Q: てかLINEやってる?
A: 笑
Q: 調子どうだ?カラダ?
A: 俺が楽天斎
Q: 質問文に対し質問文で答えるとテスト0点なの知ってたか?
A: マヌケ
【summary.log】

質問まとめ
1: 小便は済ませたか?神様にお祈りは?部屋の隅でガタガタふるえて命乞いする心の準備はOK?
2: おいィ?お前らは今の言葉聞こえたか?
3: てかLINEやってる?
4: 調子どうだ?カラダ?
5: 質問文に対し質問文で答えるとテスト0点なの知ってたか?
回答まとめ
1: ミレニ……アム
2: 俺のログには何もないな
3: 笑
4: 俺が楽天斎
5: マヌケ

無事、フックした2つの処理が呼ばれていることを確認できた。


話は少し脱線して、Ruby 固有のポイントに少し触れておく。

Ruby では 関数っぽいもの (例えばメソッドや、lambda 構文で生成されるやつ等)を変数に入れることができる。
そして、変数 f関数っぽいもの を入れた時、その 関数っぽいもの の呼び方は f(arg) ではなく、f.call(arg) となる。
なぜかというと、f に入っている 関数っぽいもの は、実際には 関数 ではなく、関数っぽい オブジェクト だからだ。
オブジェクト である以上は、なんらかのメソッドを呼ばない限り使うことはできない、というわけ。

全てがオブジェクトでできているというのは、 Ruby の大きな特徴の一つと言える。

フックとイベント駆動型プログラミングは似ている

コード例

上で書いたプログラムは、「イベント駆動型プログラミング」(日本版 wikipedia)の発想で書かれていると捉えることもできる。

試しに C# の event 構文を使ってプログラムを書き換えてみよう。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Interview
{
    // ビルトインに足りない関数を追加している
    static class Extensions
    {
        private static readonly Random random = new Random();

        public static IOrderedEnumerable<T> Shuffle<T>(this IEnumerable<T> list)
        {
            return list.OrderBy(item => random.Next());
        }

        public static IEnumerable<(T, int)> WithIndex<T>(this IEnumerable<T> list)
        {
            return list.Select((item, index) => (item, index));
        }
    }

    class InterviewFinishedEventArgs : EventArgs
    {
        public string Question { get; set; }
        public string Answer { get; set; }

        public InterviewFinishedEventArgs(string question, string answer)
        {
            Question = question;
            Answer = answer;
        }
    }

    delegate void InterviewFinishedEventHandler(InterviewFinishedEventArgs e);

    class Interviewer
    {
        private readonly string[] questions;
        public event InterviewFinishedEventHandler OnInterviewFinished;

        public Interviewer()
        {
            questions = new string[] {
                "調子どうだ?カラダ?",
                "あなたは赤い部屋が好きですか?",
                "おまえは今まで食ったパンの枚数をおぼえているのか?",
                "てかLINEやってる?",
                "おいィ?お前らは今の言葉聞こえたか?",
                "何いきなり話かけて来てるわけ?",
                "質問文に対し質問文で答えるとテスト0点なの知ってたか?",
                "あなた…『覚悟して来てる人』……ですよね?",
                "小便は済ませたか?神様にお祈りは?部屋の隅でガタガタふるえて命乞いする心の準備はOK?",
                "これはミラーシェード=サンのケジメ案件では?"
            };
        }

        public void Interview()
        {
            foreach (var question in questions.Shuffle().Take(5))
            {
                Console.WriteLine(question);

                Console.Write("> ");
                var answer = Console.ReadLine();
                Console.WriteLine();

                var info = new InterviewFinishedEventArgs(question, answer);
                OnInterviewFinished(info);
            }
        }
    }

    class StoreReporter
    {
        private readonly List<InterviewFinishedEventArgs> interviewInfoes = new List<InterviewFinishedEventArgs>();

        public void StoreInterviewInfo(InterviewFinishedEventArgs info)
        {
            interviewInfoes.Add(info);
        }

        public void Report()
        {
            using (var w = new StreamWriter("summary.log", append: true))
            {
                w.WriteLine("質問まとめ");
                foreach (var (info, index) in interviewInfoes.WithIndex())
                {
                    w.WriteLine($"{index + 1}: {info.Question}");
                }

                w.WriteLine("回答まとめ");
                foreach (var (info, index) in interviewInfoes.WithIndex())
                {
                    w.WriteLine($"{index + 1}: {info.Answer}");
                }
            }
        }
    }

    class MainClass
    {
        public static void Main(string[] args)
        {
            InterviewFinishedEventHandler realtimeReport = info =>
            {
                using (var w = new StreamWriter("realtime.log", append: true))
                {
                    w.WriteLine($"Q: {info.Question}");
                    w.WriteLine($"A: {info.Answer}");
                }
            };
            var storeReporter = new StoreReporter();

            var interviewer = new Interviewer();
            interviewer.OnInterviewFinished += realtimeReport;
            interviewer.OnInterviewFinished += storeReporter.StoreInterviewInfo;

            interviewer.Interview();

            storeReporter.Report();
        }
    }
}

実行結果は変わらないので省略。

C# 版のプログラムから event 構文に関係する部分を抜粋し、Ruby 版のプログラムから対応する部分を抜粋して並べてみる。

    class InterviewFinishedEventArgs : EventArgs
    {
        public string Question { get; set; }
        public string Answer { get; set; }

        public InterviewFinishedEventArgs(string question, string answer)
        {
            Question = question;
            Answer = answer;
        }
    }

    delegate void InterviewFinishedEventHandler(InterviewFinishedEventArgs e);

    class Interviewer
    {
        public event InterviewFinishedEventHandler OnInterviewFinished;

        public void Interview()
        {
                OnInterviewFinished(info);
        }
    }

    class MainClass
    {
        public static void Main(string[] args)
        {
            interviewer.OnInterviewFinished += realtimeReport;
            interviewer.OnInterviewFinished += storeReporter.StoreInterviewInfo;
        }
    }
InterviewInfo = Struct.new(:question, :answer, keyword_init: true)

class Interviewer
  def add_to_hook(func)
    @hooked_funcs << func
  end

  def interview
      call_hooked_funcs(info)
  end

  private

  def call_hooked_funcs(info)
    @hooked_funcs.each { |f| f.call(info) }
  end
end

interviewer.add_to_hook(realtime_report)
interviewer.add_to_hook(store_reporter.method(:store))

こうしてみると、使っている言葉こそイベント駆動型プログラミング独特のものになっているが、やっていることはほとんど変わらないことが分かると思う。

詳しく見てみよう。

C# 版のプログラムで event キーワードを使って宣言している OnInterviewFinished。これがフックに相当する。
「InterviewFinished」というイベントが発生した時に呼び出すフック、というイメージでこういった命名をした。
event キーワードを使用したことで、フックへの処理の登録は interviewer.OnInterviewFinished += realtimeReport; という形で行えるようになっているし、登録された処理は OnInterviewFinished(info); の形で呼び出すことができるようになっている。
Ruby 版で書いた add_to_hook(func) のような処理や call_fooked_funcs(info) のような処理は必要ない。全部 event キーワードがよしなにやってくれている。

InterviewFinishedEventHandler というものを定義している行があるが、これはフックに登録できる関数の型みたいなものだ。
フックに登録できる関数は InterviewFinishedEventArgs 型の変数 e を受け取る、ということがここで読み取れる。
詳しく理解するためには、C# 特有の delegate 構文について知る必要があるだろう。
ここでは説明しないので ggrks。

InterviewFinishedEventArgs。これは Ruby 版で言うところの InterviewInfo に相当するクラスとなっている。
「InterviewFinished イベント」に関する情報を集めた変数(arguments)、というイメージの命名だ。
このクラスは EventArgs クラスを継承している。event キーワードを使う際にはこうしないといけない決まりになっている。

まあとにかく、C#event キーワードを使ったこのプログラムは、Ruby で書いたフックプログラムと同じことをやっているということだ。

語彙の整理

上のプログラムでは EventHandler という言葉を使った。
これはイベント駆動型プログラミングの語彙だ。

イベントが発生した時に呼び出される処理のことを、「イベントハンドラ」(Event Handler)とよく言う。
フックから渡されるイベント情報を処理(ハンドリング)するイメージだ。
「イベントリスナ」(Event Listener)、「イベントレシーバ」(Event Reciever)等と言うこともある。

イベントハンドラをフックに引っ掛けること/フックから解除することについては、以下のような語彙を使う。
Ruby 版のプログラムでは add_to_hook と書いた部分だ。)

  • register/unregister
  • add/remove
  • subscribe/unsubscribe

イベントを発火させること(すなわち、フックされた関数にイベント情報を渡して呼び出すこと)については、以下のように語彙がたくさんある。
Ruby 版のプログラムでは call_hooked_funcs と書いた部分。)

  • raise
  • trigger
  • emit
  • send
  • invoke
  • fire
  • publish

語彙を置換してみる

フックを使った処理の説明を、イベント関係の言葉で置き換えてみよう。

「任意の処理を
フックに登録しておけば、
フックを持つ側のプログラムが
ある時点で
その処理を呼び出してくれる。」

「任意のイベントハンドラ
イベントを購読させておけば、
イベントを持つ側のプログラムが
イベント発生時に
そのイベントハンドラを呼び出してくれる。」

というわけで、ふわっとした帰納的な説明ではあったが、フックとイベントは近い概念だということがなんとなく伝わったんじゃないかと思う。

"Web フック"とは

ここからが本番のつもりだったが、長くなっちゃったのでここで一旦区切ることにする。

続く。

作図ツール「draw.io」を使ってみたので紹介

概要

以下が draw.io 。
https://www.draw.io/

つまり、Web ブラウザ上で使える作図ツールということだ。
完全無料。すごい。

ファイルを開く/保存する

ファイルの参照/保存先として、Google DriveDropboxGitHub 等のクラウドサービスが使える。

だが、一番簡単なのはローカルデバイスを指定することだ。

「ブラウザからどうやってローカルにファイルを保存するのか」と気になるかもしれないが、答えはすごく単純で、「保存」をする度にファイルのダウンロードが開始されるだけである。
なので毎回「名前をつけて保存」をしている感覚になる。「上書き保存」のような体験はできないので注意。(保存先がクラウドならできそう。試してない。)
なお、ファイルを開く際ももちろん、ローカルからアップロードすることになる。

ちなみに、保存しないままブラウザのタブを閉じようとすれば、ちゃんと確認ウィンドウが出てくれる。
ブラウザツールでありがちな「間違えて閉じてしまう」問題の心配もない。

ファイルの保存形式

drawio ファイルという独自の形式で保存される。中身は XML なようだが、肝心のコンテンツ部分は単一タグ内に暗号化された文字列がダーっと入っているだけだった。

その他、以下の形式に変換して保存(Export)することもできる。

  • PNG, JPEG, SVG
  • PDF
  • HTML
  • XML
    • 試してみたが、まんま drawio ファイルと同じだった。ちなみに Compressed のチェックを外すと、コンテンツ部分もそれっぽい XML できちんと表現されているのが観察できる。差分管理とかする場合はこの方がいいかも。
  • URL
    • ホスティングをしているのではなく、ファイルを暗号化した文字列を無理やり URL 内のパラメータに詰め込んでいるだけな気がする。

PNG, JPEG, SVG, PDF, HTML ファイルは、逆変換して開き直すこともできる(Import)。
もう少し確実な方法として、Export 時に「Include a copy of my diagram」にチェックを入れておくという方法がある。これをしておけば、drawio ファイルで無くとも変換無しで開く(Open)ことができる。

ちなみに、「Include a copy of my diagram」が具体的に何をやっているのか個人的に気になったので、チェックをオンにした SVG とオフにした SVG をそれぞれ Export して比較してみた。
その結果、どうやらチェックをオンにした場合は、非表示な領域に上手く drawio 形式の文字列を詰め込んでくれる、という感じのようだ。

drawio ファイルのままだと、例えば GitHub に上げた時などに、画像ファイルとして表示されなかったりして使い勝手が悪い。
かといって、単純な画像ファイルに Export してしまうと、再編集が困難になってしまう。
「Include a copy of my diagram」はこのジレンマを上手く解消してくれる。オススメ。

その他豆知識

PlantUML 変換

Arrange -> Insert -> Layout -> Advanced -> PlantUML
で、PlantUML データを図にして挿入することができる。

GitHub 連携

GitHub 上で draw.io の連携サービスを認可すると色々便利らしい。詳細は良く分からなかった。
プライベートリポジトリに関する権限を与えるのが怖いので、念のため試すのはやめた。

ツール

デスクトップツールが公式にある。
あとは非公式で VSCodeプラグインとかもあるっぽい。詳細は調べてない。

気になったリンク

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)と「出力」(副作用)は別々の行為なので、分けた方が理念としては自然。

コミットメッセージ絵文字プリフィックスの自分的使い分け

をここに記していたんですが、GitHub 上で管理することにしたのでこっちはリンクだけにします。リンク先はこちら

「関数型脳になろう 〜 脱 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

とのことである。

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

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

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

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

結論

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

おしまい。