これは、2022年の Ruby アドベントカレンダー1 の9日目の記事です。
昨日は @universatoさんによる8日目の記事「【Ruby】# frozen_string_literal: trueマジックコメントは必要?【RuboCop】」でした。
明日は石谷 太一さんによる10日目の記事「YAML上の位置を取得する」です。
はじめに
タイトルの「マトリョーシカ人形」ですが、伊藤淳一さんのブログに掲載された「マトリョーシカ人形のようなメソッド設計を避ける」という記事にて、コードの構造の話として出てくるものです。
上記の記事(元記事と呼びます)を読んで思うところがあったので、本記事を書くことにしました。
ふたつのコード
詳しくは、元記事をご覧いただくとして、そこでは以下の2種類のコードを比較しています。
マトリョーシカ人形なコード
本記事では(A)と呼びます:
def main
パンを焼く(粉, 水)
end
def パンを焼く(粉, 水)
焼く(パンを発酵させる(粉, 水))
end
def パンを発酵させる(粉, 水)
発酵させる(パンを整形する(粉, 水))
end
def パンを整形する(粉, 水)
整形する(パンをこねる(粉, 水))
end
def パンをこねる(粉, 水)
こねる(粉, 水)
end
main
工程の全体像がつかみやすいコード
本記事では(B)と呼びます:
def main
生地 = パンをこねる(粉, 水)
整形された生地 = パンを整形する(生地)
発酵した生地 = パンを発酵させる(整形された生地)
パンを焼く(発酵した生地)
end
def パンをこねる(粉, 水)
こねる(粉, 水)
end
def パンを整形する(生地)
整形する(生地)
end
def パンを発酵させる(整形された生地)
発酵させる(整形された生地)
end
def パンを焼く(発酵した生地)
焼く(発酵した生地)
end
元記事では、この(A)のコードよりも(B)のコードの方が、より分かりやすいと説明しています。
確かに私が見ても、(A)の方はいまひとつイケてないと感じます。ですが、私が感じているコード(A)の問題点としては、次の2点だと思いました。
- 引数の引き廻し
- メソッドの命名
と同時に、元記事で指摘されたような、コード自体の構造に関しては「これはこれで問題ではない」とも思っています。
マトリョーシカ人形の、その先へ
コード(A)の構造には問題がないこと説明するために、まず前記の問題点2つを以下の様に修正してみましょう。
本記事では(C)とします:
class 製造機
def initialize(粉, 水)
@粉 = 粉
@水 = 水
end
attr_reader :粉, :水
def パン
焼く(発酵した生地)
end
def 発酵した生地
発酵する(整形した生地)
end
def 整形した生地
整形する(生地)
end
def 生地
こねる(粉, 水)
end
end
製造機.new(粉, 水).パン
このコード(C)は、構造としては元の(A)と基本的に同じで、前記の2点についてのみ、修正を加えています。
ひとつ目の、「粉と水」を延々とメソッドの引数として引き廻している点は、全体を製造機クラスとしてまとめ、「こねる(粉, 水)」でしか使わない「粉と水」を、インスタンス変数を介して直接渡す形に変えています。
この修正により、(A)でメソッドに付いていた引数が、(C)では全く無くなっています。
ふたつ目の、メソッドの命名に関しては、元の(A)では「動作」を表していたのに対して、修正した(C)では「動作」の結果できた「もの」に変えています。
よく言われている通り「クラス名は名詞、メソッド名は動詞」となるケースが多いのですが、ここではメソッド名が「名詞」(もの)になっています。
ひとつ目の修正により、メソッドに引数が付かなくなったことと、ふたつ目の修正により、メソッド名が名詞に変わったことで、(C)におけるメソッドの意味合いが「処理手続きの記述」ではなく、「定義の詳細化(具体化)」へと変化しています。
つまり、(C)のコードの構造というのは、
- 「製造機」の説明
- 「パン」とは「発酵した生地」を「焼いた」ものです
- 「発酵した生地」とは「整形した生地」を「発酵した」ものです
- 「整形した生地」とは「生地」を「整形した」ものです
- 「生地」とは「粉と水」を「こねた」ものです
- 「製造機」に「粉と水」を入れて「パン」を得ます
このような日本語での説明(仕様)を、そのまま Ruby に書き下しています。この「製造機」の説明には、処理手順的な話が、まったく現れておらず、中で登場する「もの」の定義が並んでいる構造になっています。
そして元々のコード(A)は、前記の2点のようにイケてないところはあるものの、本来はコード(C)のような形を目指していたのではないか、と推測しています。
だとすると、元記事の様に(A)を手直して(B)の形にすることは、元々のコードの構造を別の形へ変えてしまっていて、全くの別ものを持ってきた様に、私には思えます。
もし(A)のコードを「構造を保ったままで」書き直すのであれば、(A)の先にある完成形(C)のような形へと誘導するやり方になると思います。
私は10人いれば100通りの実装が出てくるのが ruby の醍醐味だと思っているので、元々のコードの構造は尊重したいのです。大きく間違っていない限りは。
処理の流れを追うということ
コードの構造として見た場合、(A)やその完成形である(C)は、単に(B)とは方向性が異なっているだけで、間違っている訳ではない、と思う理由を説明しましょう。
元記事では(A)のコードに対して「処理の流れが直感的に理解しにくくなる」と指摘されています。
私自身も実際に(C)のようなコードを書くと、他者から「処理の流れが読めない」と言われることが良くあります。
確かに、この構造だと処理の流れは読めません。ですが上記のとおり、そもそもコードを処理の流れに沿って実装している訳ではないので、それはある意味、当然なことだと言えます。
それは必要ですか?
そして、ここが重要なのですが「そもそも処理の流れを読む必要があるのか?」という想いがあります。
いま、コードの処理の流れを追うときの目的を考えてみましょう。
多くのケースでは、対象のコードが正しく実装できているか確認したい筈です。
まず(B)の場合、処理の流れに沿った記述になってるので、実行時と同じように、順番に処理内容の確認を繰り返していきます。
一方の(C)の場合、「定義の詳細化(具体化)」を繰り返す構造になっているので、個々のメソッドにおいて定義している内容を確認していけば、それらを組み上げて作った総体としてのコードが正しく出来ているかも確認できます。
つまり、(C)ではコード全体を追わなくても、個々のメソッド単位でチェックすれば目的が達せられます。
この場合、コードの流れを追う場合と比べて、覚えておくべきことが減るため、脳に対する負荷を大幅に下げてくれます。
特にコードの規模が大きくなってくると、処理の流れを追うことが段々とつらくなってくる(覚えておくことが増えてくる)ため、実際かなりありがたいです。
仕様をコードへ
また、(C)のようなコードを書く際、一番最初に(一番上位に)くるメソッドは、仕様に書かれた内容を、ほぼそのまま書き下した形になります。
そのままだと実行できないため、その定義に登場する個々の要素について、段階的に詳細化(具体化)するメソッド定義を記述していく、トップダウンな流れで実装する感じです。
こうした、仕様に書かれた内容が、ほぼそのままコード上に記述されている点は、コードが正しく仕様に沿って実装しているのか、を確認する際の助けになります。
名前重要
ところで、処理の流れが読みにくい構造だと、調べたり直したりする対象の処理の在処を、どうやって見つけるのか、という疑問が湧いてくるかと思います。
これについては、目的となる対象の処理を、メソッドの名前をもとに探し出します。
各メソッドは、登場する要素の定義を記述する形になっているので、調べたい要素の名前(または要素に関連する名前)を、ソースコード上から検索して見つけることが出来ます。
もちろん各メソッドには、適切な命名がなされていることが大前提です。
おわりに
以上の様に(C)のような実装形式には、採用するに足りうるメリットがあると考えています。
もちろん(B)のような手続きを記述する構造が必要なケースもありますし、「処理の手順」自体が重要な意味を持つ場合は、コード上で明確に記述するべきでしょう。
でも、そういうケースばかりではないのも、また事実です。
手続きを並べる書き方も、定義を詳細化する書き方も、どちらかが優れている、ではなくて、自身が書きやすい方で書けば良いのだと、私は思っています。