そのコード、なぜ読めなくなる?『役割』と『詳細度』で美しくなる実装術
1. はじめに
プログラムを適切に分割することは、品質の高いコードを生み出すために欠かせない重要な要素です。しかし、分割の目的や方法を誤ると、かえってコード全体が複雑化し、保守性や可読性を大きく損ねてしまう場合があります。
よくある失敗の一つが、分割を進めた結果、全体の意図が分からなくなるケースです。分割した直後は、そのコードを書いた本人にとって分かりやすく見えるかもしれません。しかし、数日後や数週間後に読み返したとき、分割の意図やコードの流れがまるで迷路のようになってしまい、「これ、何をしようとしていたんだっけ?」と困惑する場面に出くわすことがあります。
この記事では、私の経験から得たコード分割を考える上での重要な視点である「役割」に注目します。役割によって分割したコードは構造がシンプルになる上に、コメントがなくてもコードを見るだけでどのような処理をしているのかが一目わかるようになります。
この記事を通じて、「役割」を意識した分割の重要性を学んでいただければと思います。そして、最終的には自分のコードを見直し、より品質の高いコードを書くためのヒントを得てもらえれば幸いです。
2. コード分割における『抽象度』と『役割』
プログラムを分割していく上で重要になる視点として「抽象度」と「役割」について整理してみます。この2つは、それぞれ全く異なる軸を持つ概念であり、どちらもコードの保守性や可読性を高める上で欠かせないものです。しかし、これらを混同したり一方に偏ったりすると、逆に意図が読みづらいコードとなってしまうことがあります。
ここでは「抽象度」と「役割」とは何かをメリットを交え説明していきます。
2-1. 『抽象度』とは?
「抽象度」による分割とは、あるモジュール(関数)を責務のまとまり毎に小さなモジュール群に切り出すことを指します。モジュールの一部が切り出された代わり、分割もとは分割先モジュールを呼び出すようになります。
例えば、行数が膨らんだモジュールに対して、そのモジュールが持ついくつかの振る舞いをそれぞれ小さなモジュールとしてまとめることで、本体のモジュールの行数を削減します。だたし、分割元のモジュールの役割が変わっていない点に注意してください。
『抽象度』で分割することのメリットはモジュール単位での見通しが良くなることです。行数が少なくなることでモジュールがコンパクトになり、概要を理解しやすくなります。
2-2. 『役割』とは?
一方で、「役割」による分割とは、文字通りプログラム内の各部分が担う「役割」を基準として責務を分割することを指します。役割で分割されたモジュール同士の責務が被ることはありません。つまり、上位の役割や下位の役割という概念はありません。
『役割』を意識するメリット
1. 開発者体験の改善
実装者は役割で区切られたモジュールに集中して開発するこで、実装速度やコードの質を高めることができます。
2. 独立性の向上
モジュール同士が独立して動作できるようになります。独立していることで責任分界点が明確になり、再利用性やテスト容易性が大幅に向上します。
3. 可読性の向上
モジュールが持つ責務が小さくなるため、簡潔な命名でモジュールの振る舞いを言い表せるようになります。名前を見ればそのモジュールが何をしているのか予め把握することができるので、どのようなコードが書かれているのか予測しながら読み進めることができます。
また、全体の構造が整理されている上にモジュール同士の責任分界点がはっきりしているため、システム全体のフローが理解しやすくなります。理解のしやすさはプロダクトの保守やチーム開発において特に重要なポイントです。
『役割』を上手く分けている例
- レイヤードアーキテクチャ: 入出力を受け持つ層(プレゼンテーション層)、トランザクション等のアプリケーション都合のロジックを受け持つ層(アプリケーション層)、ドメイン知識を表現する層(ドメイン層)のように、役割によって層を分割するアーキテクチャ。MVPアーキテクチャの肥大化したModelをアプリケーション層とドメイン層に分割している例でもある。
- 関数型プログラミング: 副作用を持つ操作と副作用を持たない操作に分割し、それらを組み合わせることでプログラム全体を構成する。関数型プログラミングに対応した言語では予め頻出の操作を抽象化・一般化した関数が用意されることが多く、それらを駆使することで自然に役割を分割することが可能。
2-3. 『役割』と『抽象度』の違い
『役割』による分割と『抽象度』による分割を大まかに説明すると、以下のようになるでしょう。
- 役割: モジュールの同士の責務が被らない。呼び出し先のモジュールは元のモジュールの責務と異なる。
- 抽象度: あるモジュールが他のモジュールの責務を1つ以上包含する。内部のコードは役割で分割しているが、分割元のモジュールと分割先のモジュールは抽象関係。
3. 抽象度に偏った分割の問題点
役割を意識しなければ、途端に抽象度でのみモジュールが分割されていくことでしょう。コードのまとまりを抽象化し分割することは、行数を減らしモジュール単位での見通しを良くするという意味で効果的ですが、抽象度による分割に偏中すると却って複雑化を招き、コード全体が読みにくくなる場合があります。
3-1. 頭の中で整理できない
一般的に、責務が大きくなるほどモジュールの把握が爆発的に難しくなっていきます。責務が大きければその分コード量も大きくなりますが、コード同士の関係はコード増えた分以上にからみあっている可能性があり、その中で頭の中で整理していくことは至難の業です。似たような話で、「巨大なプルリクエスト」を思い浮かべればわかる通り、コードが増えれば増えるほど、コード同士の依存を読み解くために時間がかかります。
しかし、抽象度で分割したからといっても、元のモジュールの責務が減ることはありません。抽象度によってモジュールを分割した場合、分割元のモジュールは分割先の責務を含んだままです。たとえ分割先をさらに分割したモジュールであっても、大元のモジュールの責務はそのままです。言い換えれば、分割元が持つべき責務が各モジュールに分散してしまっている状態です。
3-1. モジュールの位置関係が把握できない
コードリーティングの肝は、如何にモジュールの位置関係を把握しているかだと考えています。仮に個々のモジュールの詳細を忘れていても、システムがどのようなモジュールの組み合わせで成り立っているかを把握していれば、圧倒的に読み進めるスピードが上がります。
では、抽象度でのみで分割したコードをどのように読み解いていくのでしょうか?おそらく、大元のモジュールの依存を手繰りつつ、目的のコードがありそうなモジュールにあたりをつけて追っていくはずです。あたりをつけるために詳細を眺め、コードジャンプによってまた遷移先のモジュールの詳細を眺める。抽象度が目まぐるしく移り変わる中でモジュールの位置関係を把握し続けるのは至難の技で、2,3回もコードジャンプすれば初めに見ていたモジュールのことは忘れていることでしょう。
また、一度完全に把握できたとしても、抽象度によってどのように分割されていたかなどすぐに忘れてしまいます。なぜなら、分割の基準は実装時の気分によって決まるからです。
このように、抽象度による過度な分割は全体像の把握を阻害する原因になってしまいます。
3-2. 巨大なテスト
上記で挙げた通り、分割元のモジュールの責務は一向に減っていません。ブラックボックステストを実現しようとすれば責務に対して正しい振る舞いをできているか確認する必要があるため、巨大なモジュールに対して巨大なテストで立ち向かうしかありません。
4. 『役割』と『抽象度』を組み合わせる
『役割』と『抽象度』は似た概念ですが、明確に区別して使い分ける必要があります。また、抽象度よる分割に偏ることが如何に悪いかを書き綴ってきましたが、一方で役割により分割し続けていくことにも限界があり、どちらもバランスよく使っていく必要があります。
このセクションでは、『役割』と『抽象度』を組み合わせて分割する方法を実践的に説明していきます。
4-1. 『役割』を基盤に『抽象度』を調整する
コード分割を行う際の基本的なアプローチは、まず『役割』によって複数のモジュールに分割した後、必要があれば『抽象度』によってさらに分解します。
『役割』と『抽象度』を組み合わせた設計のプロセス
- 役割を明確にする
- 役割を基に分割する
- 必要に応じて詳細化を行う
- 全体を見直す
1. 役割を明確にする
システムのフローを整理してみると分割するべき役割が見えてきます。
例えば、以下のような役割に分割することが考えられます。
- 入力・ビジネスロジック・出力
- 反復処理・条件分岐・順次処理
2. 役割を元に分割する
1.で洗い出した役割をベースにコードに落とし込んできます。実装中に新たに分割できそうな役割があれば、1.に立ち戻って再度整理してみると良いでしょう。
3. 必要に応じて詳細化を行う
役割を基に分割されたモジュールの行数が多い場合、『抽象度』に基づいて分割を行います。
単純に1関数あたりの行数を減らすために分割していっても良いですが、分割先のモジュールの抽象度を揃えておくことで、読み手の負担を減らすことができます。また、上位モジュールからの依存の深さは1にとどめておきましょう。抽象度により分割したモジュールがネストすると、デメリットでも紹介したようにコードが複雑化します。
4. 全体を見直す
役割による分割が適切に行われたかを確認し、全体の意図が失われていないかをチェックします。
分割が不足していたら分割し、過剰であれば統合しましょう。
4-2. 『役割』を意識した分割を実践してみる
役割を洗い出す
各モジュールが「何をするべきか」を明確にし、その範囲を超えた処理を持たないようにします。たとえば、条件分岐について役割を持つモジュールがある場合、そのモジュールは各分岐で行われる処理の詳細に影響してはいけません。
一つの役割を複数の役割に分割する
ある役割が複雑すぎる場合、その中に含まれる個別の責任を抽出し、それぞれを独立したモジュールとして再定義します。ただし、それらの役割をまとめるモジュールを作ると抽象度による分割となってしまうので、なるべくモジュールが一直線に依存し、依存し合うモジュールの役割が被らないようにします。
以下は与えられた指示リスト(作成・更新・削除)を順番に実行していく例です。
指示リストに対する反復処理、コマンドの種類により実行する処理を切り替える条件分岐、各コマンドに対応する処理を実行する逐次処理の三つに分けることができます。
分割前:
fun handleCommands(input: String) {
val commands = // 文字列からコマンドクラスにパースする長い処理...
// 複数のコマンドを順番に処理する
for (command in commands) {
when (command) {
is CreateCommand -> {
save(create())
}
is Update -> {
val target = getTarget(command.targetId)
val target.update(command)
save(target)
}
is Delete -> {
val target = getTarget(command.targetId)
delete(target)
}
}
}
// 処理結果を出力する長い処理...
}
役割による分割の例:
// 外部への入出力を扱う
/**
* 外部からの入力をパースしたものを処理する
*/
fun handleRequest() {
val commands = // リクエストからコマンド一覧にパースする長い処理...
processCommands(commands)
}
/**
* 終了時の出力
*/
fun finish() {
// 処理結果を出力する長い処理...
}
// 処理を扱う
/**
* コマンド一覧を捌く
*/
fun processCommands(commands: List<Commnad>) {
for (command in commands) processCommand(command)
finished()
}
/**
* 各コマンドに対応する処理を選択する
*/
fun processCommand(command: Command) {
when (command) {
is CreateCommand -> processCreate()
is UpdateCommand -> processUpdate(command.id)
is DeleteCommand -> procesDelete(command.id)
}
}
/**
* CreateCommandに対応する処理を実行する
*/
fun processCreate() {
save(create())
}
/**
* UpdateCommandに対応する処理を実行する
*/
fun processUpdate(id: String) {
val target = getTarget(command.targetId)
val target.update(command)
save(target)
}
/**
* DeleteCommandに対応する処理を実行する
*/
fun processDelete(id: String) {
val target = getTarget(command.targetId)
delete(target)
}
抽象度による分割の例:
/**
* 外部からの入力をパースしたものを処理し、結果を返す
* このモジュール自体の責務は変わっていない
*/
fun handleCommands(input: String) {
val commands = parse(input)
processCommands(commands)
finish()
}
/**
* 入力のパース
*/
fun parseCommand(input: String): List<Command> {
// 文字列からコマンドクラスにパースする長い処理...
}
/**
* コマンドの処理
*/
fun processCommands(commands: List<Command>) {
for (command in commands) {
when (command) {
is CreateCommand -> {
save(create())
}
is Update -> {
val target = getTarget(command.targetId)
val target.update(command)
save(target)
}
is Delete -> {
val target = getTarget(command.targetId)
delete(target)
}
}
}
}
/**
* 終了時の処理
*/
fun finish() {
// 長い処理
println("全ての処理が完了しました")
}
5. 品質の高いコードを生み出すためのプラクティス
これまで、『役割』と『抽象度』を基準としたコード分割の重要性と、それらを組み合わせる方法について解説してきました。このセクションでは、実際の開発において品質の高いコードを生み出すために活用できる具体的なプラクティスを紹介します。これらのプラクティスを通じて、『役割』と『抽象度』を意識した設計を実現する助けになります。
5-1. コーディング前のモデリング
コードを書く前にシステム全体や特定の機能をモデル化することは、役割を明確に定義するうえで非常に有効です。適切なモデリングを行うことで、分割の方向性が明確になり開発中の迷いを減らすことができます。
具体的なアプローチ
- システムの整理: システムのフローや構成を整理し、各役割の境界を明確にします。
- 役割のモデルの用意: フローチャートやクラス図を作成し、全体の流れや役割間の関係性を視覚的に確認します。
5-2. TDD(テスト駆動開発)
テスト駆動開発(Test-Driven Development)は、コードを書く前にテストケースを作成し、それを満たす形でコードを実装していく手法です。
TDDが役立つ理由
テストケースを先に書くことで、モジュールが担うべき役割を先んじて定義することができ、役割の範囲が明確になります。
TDDの基本プロセス(おまけ)
失敗するテストを書く
実装する機能や処理に対するテストケースを作成します。
この時点で、他のモジュールと連携しやすいようなインターフェースで呼び出せるように設計します。
テストを通過する最小限のコードを書く
テストケースを満たすためのコードを実装します。この段階では、詳細化や最適化を行う必要はありません。
リファクタリングを行う
コードの役割や抽象度を見直し、可読性や保守性を向上させるためのリファクタリングを行います。
6. まとめ
この記事では、コード分割における『役割』について重点的に解説してきました。
「役割」と「抽象度」はいずれもコードの可読性を向上させるために必要不可欠なものですが、役割による分割を優先して整理しましょう。抽象度による分割が加わることで、さらに高品質なコード設計が可能となります。
6-1. 『役割』と『抽象度』の要点
『役割』
各部分が担う目的や責任を明確にし、それに基づいてコードを分割します。役割で分割したモジュール同士で責務が被ることはなく、それぞれ独立しています。プログラム全体の設計や構造に関わる重要な基盤となります。
『抽象度』
コードを意味のある単位で切り出し、モジュール化していくことで分割します。上位モジュールはより抽象度の高い単一のモジュールを呼び出すだけで完結し、特に行数が膨らんだ関数やモジュールに対して適用されます。
6-2. 結び
コード分割に「絶対的な正解」はありません。しかし、自分たちの扱うプログラムの目的や特徴に応じて、適切な分割を模索していくことが重要です。この記事で解説した『役割』と『抽象度』という2つの視点を基に、より分かりやすく保守性の高いコードを目指していただければ幸いです。
「コードの分割方法を見直してみよう」という意識を持ち、一つ一つのコードがどのような役割を果たし、どのように他の部分と連携するかを意識して設計してみてください。それが、品質の高いコードへの第一歩となるでしょう。
Discussion