Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rubyでわかる「時計もモノイドの一種」(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rubyでわかる「時計もモノイドの一種」(翻訳)

私がたまたまモノイドやモナドのチュートリアル動画を視聴したときに「時計もモノイドの一種です」という指摘を目にしました。このトピックはまさに私が皆さんにご紹介するのにうってつけと思えたので、ここに記事にいたしました。

免責事項: 本記事は少々込み入ったトピックを扱いますのでご了承ください(特に以下のセクションでご紹介している私の記事を読んだことのない方へ)。

「ちょっと待った、そもそもモノイドって何?」

モノイドについてもう少し一般的な概要を知りたい方向けに、私が以前書いた記事のリンクをご紹介しておきます。

「モノイド」マジックでRubyとRailsをパワーアップしよう(翻訳)

こちらの記事ではもう少し一般性の高い形で説明しています。記事そのものはRubyやRailsに寄ってはいますが、モノイドの一般的なルールの面白さについても触れています。

「じゃあモノイドを3行でまとめて」

モノイド(monoid)とは、以下の3つのルールに従う「何か」です。

連結(join)
(closure: 閉包)
2つの項目を連結して、同じ型の項目を1つ返す方法
零(empty)
(identity: 単位法則)
空の項目、つまり同じ型のどんな他の項目と結合しても、同じ項目を必ず返す
順序(order)
(associativity: 結合法則)
項目の順序が保たれている限り、どんなに好きなようにグループ化しても必ず同じ結果を返す

何やら小難しそうですが、皆さんが既にお馴染みの「配列の合計」にも実装されています。

配列の合計では、以下のように3つのルールがすべて実現されます。

# 連結(join):(closure: 閉包): 2つの項を連結して、同じ型の項を1つ返す方法
1 + 1

# 零(empty):(identity: 単位法則): 空の項、つまり同じ型のどんな他の項と結合しても、同じ項を必ず返す
1 + 0 == 1

# 順序(order):(associativity: 結合法則): 項の順序が保たれている限り、どのようにグループ化しても必ず同じ結果を返す
1 + 2 + 3 == 1 + (2 + 3) == (1 + 2) + 3

このちっぽけで便利なパターンから多くのものを生み出せます。そのひとつが「時計」です。

モノイドで時計を作る

この記事では、よくあるシンプルな12時間制の時計を前提とします。日付など、その他の要素については一切考慮しません。

では以下のようなシンプルなクラスを作るところから始めましょう。

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end
end

法(modulo)を12にしたのは誰かが変にいじらないようにするためと、手持ちの24時間制時計を流用したいからです。

時計同士をjoinする

さて、関数同士を結合しようとすると、ひとつ興味深いことに気づきます。結合のための演算子は必ずしも必要ではなく、結合操作自体をひとつの関数で表せるという点です。

ところで時計同士をjoinする場合、最初に興味深い壁が立ちはだかります。時計が12時を超えるとどうなるのでしょう?

答えは「初めに戻る」です。

この振る舞いは、プログラミングで法を用いることで上限を超えたときに冒頭に戻るように実装できます。この場合は法=12というわけです。

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end

  def join(other_clock)
    new_time = (time + other_clock.time) % 12
    Clock.new(new_time)
  end
end

とりあえず動かしてみると以下のような結果になるでしょう。

clock_one = Clock.new(12)
clock_two = Clock.new(5)
clock_three = Clock.new(6)

clock_one.join(clock_two)
# => #<Clock:0x00007f88faa8d998 @time=5>

clock_two.join(clock_three)
# => #<Clock:0x00007f88fa11b9a0 @time=11>

最初のコードにほんの数行を追加しただけでできました。違いは、返す必要のある時計に型があるということだけです。このあたりは一貫させる必要があります。

ルールは効いているか

では時計と時計をがっちゃんこしたら新しい時計はできるでしょうか?

clock_one = Clock.new(12)
clock_two = Clock.new(5)

clock_one.join(clock_two).is_a?(Clock)
# => true

できましたね。ルール1は一丁上がりです。

追加の手順

法を扱うときに、初期化に頼る方法もあるといえばあるのですが、ここでは演習のためにもう少し明示的に法を扱ってみたいと思います。なお、join+でできるようにすることも可能ですが、これは読者への練習課題としておきましょう。

空の時計

では以下のように数行のコードを足しているということは、時刻が0の場合でもちゃんと動くということでしょうか?はい、そのとおりです!

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end

  def join(other_clock)
    new_time = (time + other_clock.time) % 12
    Clock.new(new_time)
  end

  def self.empty
    Clock.new(0)
  end
end

実際にやってみれば、以下のように期待どおりの結果が得られるでしょう。

clock_two = Clock.new(5)

clock_two.join(Clock.empty)
# => #<Clock:0x00007f88fa1242a8 @time=5>

ルールは効いているか

例のルールはちゃんと守られているでしょうか?

clock_two = Clock.new(5)

clock_two.join(Clock.empty) == clock_two
# => false

う、これはまずい。そこで以下のように等価性の判定を時刻ベースに変えます。

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end

  def join(other_clock)
    new_time = (time + other_clock.time) % 12
    Clock.new(new_time)
  end

  def ==(other)
    time == other.time
  end

  def self.empty
    Clock.new(0)
  end
end

今度はどうでしょうか。

clock_two = Clock.new(5)

clock_two.join(Clock.empty) == clock_two
# => true

ルール2もクリアです。だいぶよくなりましたね。

結合性

ルール1と2をクリアしたので、次は複数の時計を足したらどうなるかをやってみましょう。既にクラスでもそのための準備はできています。

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end

  def join(other_clock)
    new_time = (time + other_clock.time) % 12
    Clock.new(new_time)
  end

  def ==(other)
    time == other.time
  end

  def self.empty
    Clock.new(0)
  end
end

後はテストするだけです。

ルールは効いているか

a + b + c == a + (b + c) == (a + b) + cという式を思い出しましょう。

同じことを何度も書くのはダルいので、時計を生成するついでに名前を付けちゃいましょう。

a = Clock.new(12)
b = Clock.new(5)
c = Clock.new(6)

いちいちjoinを書くのもダルいので、+joinのエイリアスに設定しちゃいましょう。

class Clock
  attr_reader :time

  def initialize(current_time)
    @time = current_time % 12
  end

  def join(other_clock)
    new_time = (time + other_clock.time) % 12
    Clock.new(new_time)
  end

  alias_method :+, :join

  def ==(other)
    time == other.time
  end

  def self.empty
    Clock.new(0)
  end
end

これで以下を実行してみます。

a = Clock.new(12)
b = Clock.new(5)
c = Clock.new(6)

a + b + c == a + (b + c)
# => true

a + (b + c) == (a + b) + c
# => true

大成功!これで3つのルールをすべて実現できたことになります。すごいと思いませんか?

時計をreduceしたらどうなる?

3つのルールを実装したということは、reduceのようなメソッドもひととおり使えるということですね。

[a, b, c].reduce(Clock.empty) { |clock, next_clock| clock + next_clock }
# => #<Clock:0x00007f88f86112b8 @time=11>

# 上の短縮形
[a, b, c].reduce(Clock.empty, :+)
# => #<Clock:0x00007f88f8640090 @time=11>

# さらに短縮!
[a, b, c].sum(Clock.empty)

細かな点に目をつぶれば、モノイド(monoid)という言葉には「〜のようなもの(oid)」という語が含まれていることからわかるように、ささやかながら面白い振る舞いがいくつもあります。実際、私はこれらを「reducible」「foldable」その他どう呼んでも構わないものとして扱っています。

モノイドはよくできた概念です。ひとたび「これはモノイドなんだな」ということに気づければ、ありとあらゆるところで続々とモノイドを見いだせるようになるでしょう。String、Hash、Array、Integer、Float、Active Recordクエリ、どれもこれもみんなモノイドです。

まとめ

本記事は少々込み入った内容でしたが、そのとき私が動画で目にした素敵なものを、その一端だけでも皆さんにご紹介したくて書き上げました。ちょっとした楽しい思考実験と思っていただければ幸いです。そして本記事をお読みいただいた皆さんに感謝を申し上げたいと思います。

おたより発掘

関連記事

Ruby: Enumerableをreduceで徹底理解する#1 基本編(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。