Rubyアンチパターン: なんでもprivateメソッド
「大きなメソッドは細かく分割しよう」「外部から使わないメソッドはpublicにしない方がいい」といったコーディングのアドバイスに従って、とりあえずprivateメソッドを増やしていった場合に陥りがちなパターンです。
確かに巨大なメソッドばかりのクラスに比べると、メソッドは適宜分割されている方がマシですが、ここで安心するべきではありません。そのうち収拾がつかなくなります。
典型的な症状
- クラスのソース行が長く、メソッドが多い
- privateメソッドの方がpublicメソッドよりずっと多い
- privateメソッドの粒度や処理内容がバラバラで追いかけにくい
- 似たような名前のprivateメソッドが並んでいる
生じる問題
可読性が低くなる
いろんな責務が混在しているため、メソッド間の依存関係がわかりにくくなります。
テストが困難になる
内部ロジックにアクセスする手段がなくなるため、無理やりprivateメソッドを呼び出すか、テストの粒度が粗い結合テストでしのぐことになります。
再利用性しづらい
似たような機能を別のところで使いたくなっても、コードをコピー&ペーストせざるをえなくなります。
そもそも対象クラス内でも似たようなコードが頻発したりします。
変更しづらい
どこかの機能を修正すると、その影響がどこまで及ぶかわかりにくくなります。
問題が生じる原因
privateは言うほどprivateではない
何より 同一クラスにあるprivateメソッド同士は自由に依存しあえる というのが大きいです。もちろん publicメソッドからも依存し放題です。その結果、privateメソッドが複雑さの温床になってしまうわけですが、privateの性質上これを防ぐことはできません。
つまり解決するべきことは「同一クラスにある」という点です。
解決策
クラスを分割する
複数の役割が混ざっているのであれば、分割するに越したことはありません。
そもそも「外部から使わないメソッドはpublicにしない方がいい」というのは、そのようなメソッドが対象となるクラスに存在しなければ問題ごと解消します。
であれば、単純にprivateにするのではなく、役割ごとにメソッドをまとめ、それを別クラスに抽出しておきます。そうすればメソッドが元のクラスからなくなるため、元のクラスの「外部から使われないメソッドがある」という問題も自動的に解決します。
例
ChatGPT先生に作ってもらったコードを手を入れたサンプルです。
改善前
レポートを生成するReportGenerator
クラスの例です。
外向けに提供したいのはgenerate
メソッドのみなので、それ以外はprivateにしています。
が、いろんな責務が混ざっているため、何が何に依存しているのか分からないですし、そもそもReportGenerator
クラス内にロジックを書くべきなのかも怪しいです。
class ReportGenerator
def generate(data)
parsed_data = parse(data)
header = build_header(parsed_data)
body = build_body(parsed_data)
"#{header}\n#{body}"
end
private
def parse(data)
# 複雑な解析ロジック
end
def build_header(data)
# 複雑なフォーマットロジック
end
def build_body(data)
# 集計→フィルタ→マッピング
end
def sanitize_text(str)
# HTMLエスケープ
end
# ...
end
改善後
フォーマットするためのクラスReportFormatter
と、dataを解析するためのクラスReportParser
を導入し、それぞれにメソッドを振り分けます。
整理すると、ReportGenerator
にはprivateメソッドがいらなかった、ということになるかもしれません。
class ReportFormatter
def initialize(escaper:)
@escaper = escaper
end
def header(data)
# build_header 相当のロジック
end
def body(data)
# build_body 相当のロジック
end
private
def sanitize(text)
@escaper.escape(text)
end
# ...
end
class ReportParser
def parse(data)
# parseロジック
end
private
# ...
end
class ReportGenerator
def initialize(
formatter: ReportFormatter.new(escaper: HtmlEscaper.new),
parser: ReportParser.new
)
@formatter = formatter
@parser = parser
end
def generate(data)
parsed_data = @parser.parse(data)
header = @formatter.header(parsed_data)
body = @formatter.body(parsed_data)
"#{header}\n#{body}"
end
end
分割して抽出したクラスが専用の場合はネストさせてもよいでしょう。
class ReportGenerator
class Parser
# ...
end
class Formatter
# ...
end
# ...
end
参考にしたい知見
データ構造に注目する
上の例では、dataとparsed_dataは別物です。ということは、dataの詳細を扱うメソッドとparsed_dataの詳細を扱うメソッドは関係性が遠くなります。つまり同じクラスになくても構わない、ということです。
このように、扱うデータ・変数ごとにメソッドを分けて、それを反映したクラスを抽出するというのはよい習慣です。オブジェクト指向から離れた一般的な知見としていうと、「データ構造に着目し、役割に応じたデータ構造を導入するようにしよう」とも言えそうです。
ここからさらに敷衍すると、parsed_dataが複雑になってきた場合には、ParsedDataクラスの導入も検討しよう、ということでもあります。
Discussion