概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Clocks are Monoids Too! - DEV Community
- 原文公開日: 2020/08/06
- 著者: Brandon Weaver — RubyとJSとキツネザルと言葉遊びとアートを愛しています。
日本語タイトルは内容に即したものにしました。
Rubyでわかる「時計もモノイドの一種」(翻訳)
私がたまたまモノイドやモナドのチュートリアル動画を視聴したときに「時計もモノイドの一種です」という指摘を目にしました。このトピックはまさに私が皆さんにご紹介するのにうってつけと思えたので、ここに記事にいたしました。
免責事項: 本記事は少々込み入ったトピックを扱いますのでご了承ください(特に以下のセクションでご紹介している私の記事を読んだことのない方へ)。
「ちょっと待った、そもそもモノイドって何?」
モノイドについてもう少し一般的な概要を知りたい方向けに、私が以前書いた記事のリンクをご紹介しておきます。
こちらの記事ではもう少し一般性の高い形で説明しています。記事そのものは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でわかる「時計もモノイドの一種」(翻訳) https://t.co/KZNppmSHJm
— Otsuka (@mopin) August 20, 2020