【徹底解説】『良いコード/悪いコードで学ぶ設計入門』の要約と活用方法
はじめに
© 技術評論社
今回の記事では、Twitterで話題になった『良いコード/悪いコードで学ぶ設計入門』(通称:ミノ駆動本)の要約と実務での活用方法を簡潔に解説する。
本書を読んでいく中で簡潔に要点を整理し、実務の現場でプログラムを書いたりコードをレビューしたりする際にどのようにして活用するべきかを自分なりにまとめた。本記事を通して、『良いコード/悪いコードで学ぶ設計入門』の要点と初級者から中級者目線で実務における活用方法を学べる。本記事の読者の対象は主に以下の通り。
- バグを最小限に開発を進めたい人
- 上手なプログラムの書き方・読み方がわからない人
- 効率的にソフトウェア開発を進めておきたい人
- 会社や組織内でコードレビューを頻繁に行う人
- 本書をすでに読んでいる人
本書について
『良いコード/悪いコードで学ぶ設計入門』は良質なコードを書く上での原則(マインドセット)やテクニックをソースコード付きで徹底解説されていて、悪いコードをサンプルに取り上げてそれを丁寧に修正する方法を解説されているような形式で書かれている。『リーダブルコード』と若干類似している部分はあるものの、『リーダブルコード』との最大の違いは設計に関する重要な原則や交渉術も同時に解説されている点にある。
これはあくまで私の独断と偏見に過ぎないが、本書はソフトウェア開発を進める上で重要な原則と具体的なテクニックをたった1冊で勉強できる良書である。『リーダブルコード』と同様に、本書は1度ではなく何度も読み返して実行するべきだ。
本記事で取り上げること
本書で取り上げる内容は次の2つ。
- 設計の初歩
- 条件分岐
本記事で取り上げるプログラムにはPythonとDart言語を採用する。採用する理由は以下の通り。
- 両者とも学習コストが低く、世界中で人気のあるプログラミング言語だから(Dart言語はFlutterアプリケーションの開発に多用されている)
- 両者ともオブジェクト指向に対応しており、本書のサンプルコードに使われているJavaと言語の特徴が類似しているから(ただし、PythonとJavaは言語の構造に決定的な違いがあるものの、記事が肥大化するので本記事では便宜上扱わない)
設計の初歩(第2章)
本書では、簡単なサンプルコードを具体例に設計に関する基本的な原則を徹底解説されている。設計においては、変数やメソッド(関数)のような単位を扱う。
省略せずに意図が伝わる名前を設計する
d = 0
d = d1 + d2
d = d - ((d1 + d2) / 2)
if d < 0:
d = 0
上記のプログラムでは何の処理をしているのかをまったく把握できない。上記のプログラムはゲームのダメージ計算のロジックで、各変数の役割は以下の表に示すとおりになる。
変数 | 名前 |
---|---|
d |
ダメージ数 |
p1 |
プレイヤーの攻撃力 |
p2 |
プレイヤーの武器の攻撃力 |
d1 |
敵の防御力 |
d2 |
敵の防具の防御力 |
変数の名前を省略することでタイピングの文字数が減って、少しは素早く実装できるかもしれない。ところが、プログラムを読み解くのに時間がかかってしまい読み勧めるのに時間と労力を要する。これを意図のわかる変数名に改善する。
damage_amount = 0
damage_amount = player_atk + player_weapon_atk
damage_amount = damage_amount - ((enemy_def + enemy_armor_def) / 2)
if damage_amount < 0:
damage_amount = 0
変更しやすいコードを実装する上で、コードが読みやすくなる名前を検討することは重要である。第三者に意図がわかるように変数の名前を設計しよう。
変数を使い回さない、目的ごとの変数を用意する
damage_amount = 0
damage_amount = player_atk + player_weapon_atk
damage_amount = damage_amount - ((enemy_def + enemy_armor_def) / 2)
if damage_amount < 0:
damage_amount = 0
上記のプログラムで読みやすくなったものの、課題が残っている。ダメージ量を表す変数damage_amount
に何回も値が代入されている。複雑な計算処理では、計算の途中経過を同じ変数に代入しがちである。本書ではこれを再代入と表現し、再代入をするのは読み手が混乱してバグを埋め込んでしまうことがありうる。再代入で変数を使い回さずに、目的ごとに変数を用意しよう。
上記のプログラムを以下のように変更する。
【変更点】
- プレイヤーの攻撃力の合計値を
total_player_atk
に変更 - 敵の防御力の合計値を
total_enemy_def
に変更
total_player_atk = player_atk + player_weapon_atk
total_enemy_def = enemy_def + enemy_armor_def
damage_amount = total_player_atk - (total_enemy_def / 2)
if damage_amount < 0:
damage_amount = 0
上述のようにまとめることで、ある値を算出するのに必要な値や処理をより理解しやすくなった。
ベタ書きせず、意味のあるまとまりでメソッド化する
前述のプログラムでは、一連の処理の流れにベタ書きする形にになっている。このような形式で計算のロジックが冗長に書かれると、どこからどこまでが何の処理をしているのか十分に理解できない。計算ロジックが更に複雑化すると、例えば攻撃力の計算に防御力が混同するなど、違う変数が紛れ込むなどのようなことが起こりうる。このような事態を未然に防ぐためには、意味のあるまとまりでロジックをまとめてメソッドとして実装しよう。
# NOTE: Pythonで単純計算を扱う関数の場合、defよりもlambdaを使ったほうが1行で簡潔にまとまる
total_player_atk = lambda player_atk, player_weapon_atk: player_atk + player_weapon_atk
total_enemy_def = lambda enemy_def, enemy_armor_atk: enemy_def + enemy_armor_def
damage_amount = lambda total_player_atk, total_enemy_def: total_player_atk - (total_enemy_def / 2) if damage_amount >= 0 else 0
# 変更前の当初のプログラム
d = 0
d = d1 + d2
d = d - ((d1 + d2) / 2)
if d < 0:
d = 0
詳細な計算ロジックをメソッドとして扱うことで、一連の流れをより把握できるようになった。更に、種類の異なる処理をそれぞれメソッドとして分離することで、異なる処理が紛れ込むことが少なくなった。変更前のプログラムに比較して、見た目や構造が大幅に変更されたのがわかるだろう。2つのプログラムを比較すると、変更した場合のプログラムのほうが文字数が大幅に増えている。しかし、理解のしやすさでは圧倒的に変更後のほうが上回る。
上述のように、保守しやすい、変更しやすいように変数の名前やロジックに工夫を凝らすことが設計をする上では重要になる。
関連するデータとロジックをクラスにまとめる
ゲームを例に考察する。ドラゴンクエストやファイナルファンタジーのような戦闘があるゲームではプレイヤーの生命力を数値化したHP(ヒットポイント)がある。HPが以下のプログラムのように変数で何らかの変数で定義されているとする。
hit_point = 0
ダメージを受けてHPが減少するロジックや、回復アイテム等でHPが回復するロジックを別途追加しておく必要がある。
hit_point = hit_point - damage_amount
if hit_point < 0:
hit_point = 0
hit_point = hit_point + recover_amount
if hit_point > 999:
hit_point = 999
さて、このような変数や変数を操作するロジックは別々に書かれがちである。小さなプログラムだと問題にはならないものの、何千や何万もあるような大規模なプログラムだと、関連するロジックを探し回るだけでも時間と労力を要する。さらに、変数hit_point
に負の値が入ってしまうなど、どこかで不正な値がうっかり入ってしまう可能性も否定できない。不正な値が入り込んだままプログラムが動作するとバグにつながる。
このようなバグを解決するのがクラスだ。クラスはデータをインスタンス変数として持ち、インスタンス変数を操作するメソッドを簡潔にまとめられる。HPのロジックはクラスを使って以下のように書き換えられる。(動的型付け言語と静的型付け言語で書き方が異なるので注意。最大の要因の1つに変数の型がある)
# 動的型付け(変数の型がプログラム上で自由に変更できる言語)の場合。PythonやRuby
# Pythonの場合、自分でエラーを定義する場合はエラーを継承する必要がある
class ArgumentException(Exception):
pass
class HitPoint:
MIN_POINT = 0
MAX_POINT = 999
def __init__(self, hit_point):
hit_point = self.hit_point
# 本書のサンプルコードをより簡潔にまとめてみた。指定されたHPの値が0以上999以下ではない場合はエラーを出力する
if hit_point <= 0 or hit_point > 999:
raise ArgumentException("Invalid Value")
# ダメージを受ける
def damage(self, damage_amount):
damaged = self.hit_point - damage_amount
if damaged >= MIN:
corrected = dameged
return damaged
# 回復する
def recover(self, recover_amount):
recovered = self.hit_point + recover_amount
if recovered <= MAX:
corrected = recovered
return recovered
// 静的型付け(変数の型を予め指定することで型を固定する言語)の場合。JavaやDart
class HitPoint {
// JavaやDartのような静的型付け言語では、最初に扱う変数の型を指定しなければならない
final int _min = 0;
final int _max = 999;
int value;
// Dart言語の場合はPythonと比較してコンストラクタの書き方が簡潔になる
HitPoint({required this.value}) {
if (this.value < _min or this.value > _max) throw new ArgumentException('Invalid Value');
}
// ダメージを受ける
int damage(final int damageAmount) {
final int damaged = value - damageAmount;
final int corrected = damaged < _min ? _min : damaged;
return new HitPoint(corrected);
}
// 回復する
int recover(final int recoveryAmount) {
final int recovered = value + recoveryAmount:
final int corrected = recovered > _max ? _max: recovered;
return new HitPoint(corrected);
}
}
関連性の高いデータとロジックを一つの箇所にまとめておくことで、プログラムの処理を簡潔に把握できる。上記のプログラムのように、変数、メソッドやクラスに設計した意図を含めて適切にプログラムを書くことで、保守や変更がしやすいプログラムになる。
条件分岐(第6章)
本書では、if
やswitch
等の条件分岐を扱う上での落とし穴やより適切に書くテクニックを丁寧に解説されている。条件分岐は条件に応じて処理内容を切り替えるためのプログラミングの基本制御である。条件分岐のおかげで、複雑な処理を高速かつ正確に実行できる。条件分岐は良質なサービスの開発や運営に恩恵をもたらしてくれる。
しかし、条件分岐をずさんに扱うとプログラムの可読性や質を下げてしまうことに繋がりかねない。条件が複雑になれば見通しが悪くなって理解に苦労する。されば、デバッグや仕様変更に時間を要することになる。条件分岐のロジックを正確に理解できずに仕様を変更すれば、バグが発生することになるだろう。
問題:条件分岐のネストによる多読性の低下
if (condition1) {
if (condition2) {
if (condition3) {
/// ....
}
}
}
上述はDartのサンプルプログラムである。上記のプログラムは条件文condition1
、condition2
とcondition3
をすべて満たすことで動くプログラムを示している。上述のような構造をネストと呼ぶ。このネストには重大な落とし穴がある。それは、どこからどこまでがif
文の処理ブロックなのか、読解を進めることが困難になる。このようなネスト構造が何十行や何百行にわたって書かれ続けていると第三者が理解するのに時間を浪費してしまう。
条件分岐でif
文を安易に使ってしまうと大量のネスト構造を作ってしまい、それがプログラムの可読性や質を大幅に下げることにつながることは十分に理解しておかなければならない。
return
文でネストを解消
解決策:早期多重のネスト構造を解決する方法として、本書では早期return
を取り上げている。これは、条件を満たしていない(条件文の結果がfalse
と表示されるとき)場合に、return
を出力する方法だ。上述のcondition_nest.dart
のネスト構造を解消するなら、以下のようにする。
// 条件ロジック
if (!condition1) {
print('must meet condition1');
return;
}
if (!condition2) {
print('must meet condition2');
return;
}
if (!condition3) {
print('must meet condition3');
return;
}
// ここでcondition1とcondition2、condition3をすべて満たしていることが前提になるのでそれらの条件を簡潔に書けばいい
// 実行ロジック
print('all conditions are met');
これでネストが解消されてロジックの見通しが大幅に改善された。条件ロジックと実行ロジックを分離できるのが早期return
の強みである。これに関しては、似たようなことがYouTubeチャンネルのFlutter Mappですでに解説されているので、詳細はこちらの動画を確認してほしい。動画の要点は本記事や本書で説明されていることとほぼ同じである。
条件分岐を書く時、思考停止でif
文を安易に書くのはプログラムの質や可読性を大幅に下げるので絶対にやめよう。
おわりに
今回の記事では、非常に簡潔ではあるものの主に初心者~中級者向けに『良いコード/悪いコードで学ぶ設計入門』の要点と活用方法を簡潔に解説した。本記事の要旨は以下の通りである。
【設計の重要な原則】
- 省略せずに意図が伝わる名前を設計する
- 変数を使い回さない、目的ごとの変数を用意する
- ベタ書きしないで、意味のあるまとまりでメソッド化する
- 関連するデータとロジックをクラスにまとめる
【条件分岐】
- ネスト構造には重大な落とし穴がある。それは、
if
文を重ねがけするネスト構造を作ることだ。何十行もあるif
文のネスト構造はプログラムの可読性や質を大幅に下げる - ネスト構造を解決する方法として最も有効な方法は早期
return
文だ。早期return
文の最大のメリットは、条件ロジックと実行ロジックを分離できることである - 条件分岐を書く時、思考停止で
if
文を安易に書くのはプログラムの質や可読性を大幅に下げるので注意が必要
本書を読んでいく中で言語化できていない部分が数多くあった。良質なコードを書くために必要なことは何回も何回も練習すること以外ないのだ。本記事以外にも、本書(ミノ駆動本)では学びになることが数多くあった。今回の記事では言語化できていない部分を省略して、言語化できている部分や腑に落ちた内容だけを中心に解説した。
本書を何度も読み直して、良質なコーディングやソフトウェア開発に対する自分の答えを十分に言語化できるようにこれからも開発に真摯に取り組んでいきたい。
参考文献
Discussion