はじめに
こんにちは。株式会社divxのエンジニア高橋です。
エンジニアとして働いていると、このような経験はありませんか?
- 1つ修正したら、違うところで不具合が出た。その不具合を修正したらまた不具合が出た。
- コードが複雑過ぎて、理解するのにかなり時間がかかる。
- 1つの修正なのに、影響範囲が広すぎてたくさん修正しないといけない。
はい!これらはすべて私が経験してきたことです。
ただし、逆にこのような経験もあります。
- 修正しても他に影響がでない。
- コードが読みやすく、短時間で理解できる。
- 修正では影響範囲がすぐに特定でき、その範囲も狭いので修正がすぐ終わる。
なぜ?こんなにも違うのでしょうか?
私は「コードのきれいさ」だと感じています。
コードのきれいさってなんなのさ?
「コードのきれいさ」と言っても抽象度が高いので、もう少し具体的に定義してきます。
コードのきれいさを評価する指標として、「SOLID原則」というものがあります。
- 単一責任の原則 (single-responsibility principle)
- 開放閉鎖の原則(open/closed principle)
- リスコフの置換原則(Liskov substitution principle)
- インターフェース分離の原則 (Interface segregation principle)
- 依存性逆転の原則(dependency inversion principle)
これらはオブジェクト指向で用いられる5つの原則で、ソフトウェア設計をより平易かつ柔軟にして保守しやすくすることを目的にしています。
つまり「コードのきれいさ」とは下記の内容を満たしているコードと定義できます。
SOLID原則は少しとっつきにくい、できる所から
SOLID原則を使ってコードをきれいにしていくぞ!と思っても、いきなりSOLID原則に従っているコードをイメージすることは難しいですよね。
私自身も最初は「SOLID原則を知ったけど、どうやって書いていけばいいんだ…」と頭を悩ませていました。
なので、いきなりSOLID原則から入るんではなく、
- コードを読むのに苦労しない?
- 簡単に変更できる?
- 複雑なコードではなく、整理されている?機能追加しやすい?
このあたりを意識して書いていくことで、自然とSOLID原則に近づいていけると思います。
なので今回は「SOLID原則」の入口として、下記を中心に「具体的なコードの書き方」「メリット」をお話していきたいと思います。
- 読みやすい書き方
- 変更しやすい書き方
- 複雑なコードを整理し、機能追加しやすくする書き方
コードをきれいにすることのメリット
コードの書き方の前に「なぜコードをきれいにすると良いのか?」のメリットにも触れたいと思います。
最大のメリットは、エンジニアの生産性が上がり、結果「ユーザーや先方に素早く価値を届けられる」ことだと私は思っています。
それを感じさせてくれた実際のエピソードがあるのでお話します。
◎コードが複雑な開発のエピソード
コードが複雑で理解に時間がかかる開発では、修正箇所を特定するのに、平均で3日~7日以上の工数がかかっていました。
また、頻繁にバグ報告も上がってくるため、なかなか新規開発を進めることができず、スピード感を持って開発をすることができませんでした。
◎コードがきれいな開発のエピソード
コードがきれいな開発では、コードの理解が早く、修正箇所を特定するのに平均で1日以内には終わらせることができました。また、影響範囲も少ないため、修正をした場所からバグが発生するというのは少なかったです。そのため、新規開発にもスピード感を持って開発することができました。
こちらの開発では、毎週チームでfindy team+を使い「変更のリードタイム」を測定しながら開発をおこなっていましたが、常に速いスピードを維持しながらユーザーに価値を届けることができました。結果、先方からは「開発スピードが早くて嬉しい!」という言葉をいただくことができました。
※ 変更リードタイムとは最初のコミットから本番反映するまでの時間
コードをきれいにする方法
では、ここから「具体的にどうやってコードをきれいにしていくか?」というお話をしていきます。
読みやすくなる書き方
◎わかりやすい変数名を使う
わかりやすい変数名とは、「名前と中に入っているデータが一致すること」です。
■ 車のナンバー
string n = "あ12-34" ❌ 何を指しているかわからない
string num = "あ12-34" ❌ 省略形ということはわかるが、何を指しているかわからない
string number = "あ12-34" ❌ ナンバーという事はわかるが、どんなデータかわからない
string carNumber = "あ12-34" ⭕ 具体的にどんなデータがわかる
◎変数を代入して使い回さない
「目的ごと新しい変数を用意してコードを書く」書き方です。
これを「説明変数」と呼んだりします。では少し例を使ってみてみます。
例えば、「文字を受け取って、5文字以上なら大文字、5文字以下なら小文字になる」プログラムがあるとします。
■ 読みにくい例
import java.util.Scanner;
public class Main {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
userInput = userInput.trim();
if (userInput.length() < MIN_LENGTH_THRESHOLD) {
userInput = userInput.toUpperCase();
} else {
userInput = userInput.toLowerCase();
}
System.out.println(userInput);
}
}
上記のコードでは、「userInput」という変数を使いまわしています。
このように変数を使い回すと「常にuserInputの中身はどんなデータの形だろう?」と意識しないといけません。これが長いコードになればなるほど、読むのが大変になります。
■ 読みやすい例
import java.util.Scanner;
public class Main {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
String trimmedInput = userInput.trim();
String processedInput;
if (trimmedInput.length() < MIN_LENGTH_THRESHOLD) {
processedInput = trimmedInput.toUpperCase();
} else {
processedInput = trimmedInput.toLowerCase();
}
System.out.println(processedInput);
}
}
上記のコードでは、「userInput」「trimmedInput」「processedInput」をそれぞれ目的ごとに変数を定義しています。
例えば、空白をなくすという目的を「user_input.strip()」 で達成した場合、その形にふさわしい変数「trimmedInput」を用意することで、空白が無くなった状態という事が一瞬でわかるので読みやすくなります。
◎コメントを入れる
「コメントにはなぜ?を書く」です。
■ コメントを入れる悪い例
import java.util.Scanner;
public class Main {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
String trimmedInput = userInput.trim();
String processedInput;
if (trimmedInput.length() < MIN_LENGTH_THRESHOLD) {
processedInput = trimmedInput.toUpperCase();
} else {
processedInput = trimmedInput.toLowerCase();
}
System.out.println(processedInput);
}
}
コメントを書く時によくあるのが、「何をやっているか」を書いてしまう場合です。
例えば、「前後の空白を削除」というコメントを残していますが、これはコードを見れば分かってしまいます。この様に、コードを読めばわかるコメントは書く必要はありません。
大切なのは、後から修正する人が「なぜこのコードが必要なの?」という疑問を解消できる、コメントになります。そうすることで、コードが読みやすく理解が深まり、安全に修正できるようになります。
■ コメントを入れる良い例
import java.util.Scanner;
public class Main {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
String trimmedInput = userInput.trim();
String processedInput;
if (trimmedInput.length() < MIN_LENGTH_THRESHOLD) {
processedInput = trimmedInput.toUpperCase();
} else {
processedInput = trimmedInput.toLowerCase();
}
System.out.println(processedInput);
}
}
変更しやすい書き方
◎メソッド化をする
「1つの機能を「機能の塊」として閉じ込める」です。
import java.util.Scanner;
public class Main {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
String trimmedInput = userInput.trim();
String processedInput = changeCaseByLength(trimmedInput);
System.out.println(processedInput);
}
public static String changeCaseByLength(String input) {
if (input.length() < MIN_LENGTH_THRESHOLD) {
return input.toUpperCase();
} else {
return input.toLowerCase();
}
}
}
上記の例では、「入力された文字を判定して大文字、小文字に変換する」という機能を1つの塊として、changeCaseByLengthメソッドとして切り出しています。
このように、メソッド化をすることで処理の変更がしやすくなります。
たとえば「MIN_LENGTH_THRESHOLD」 という定数名を条件として使っていますが、「MINIMUM_LENGTH」に変更した場合、メソッド化をしていれば、変更はchangeCaseByLengthメソッド内だけで済みます。
しかし、メソッド化をしていなければ、下記のコードが書いてあるすべての場所を変更する必要があります。変更するのを忘れてしまったらバグの原因にもなってしまいます。
if (trimmedInput.length() < MIN_LENGTH_THRESHOLD) {
processedInput = trimmedInput.toUpperCase();
} else {
processedInput = trimmedInput.toLowerCase();
}
◎共通化する
「アプリケーション全体で使えるようにする」です。
もし、メソッドを様々な場所から使いたいという場合は、全体で共通化をするのが良いです。共通化することで「変更する場所は1箇所になる」「修正場所もすぐ特定できる」ようになり、変更しやすくなります。
■ 共通化
public class CustomStringUtil {
private static final int MIN_LENGTH_THRESHOLD = 5;
public static String changeCaseByLength(String input) {
if (input.length() < MIN_LENGTH_THRESHOLD) {
return input.toUpperCase();
} else {
return input.toLowerCase();
}
}
}
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String userInput = scanner.nextLine();
String trimmedInput = userInput.trim();
String processedInput = CustomStringUtil.changeCaseByLength(trimmedInput);
System.out.println(processedInput);
}
}
※ コードが重複しているからという理由で安易に共通化はおすすめできません。しっかりと共通化しても問題ないかを見極めることが大切です。
複雑なコードを整理し、機能追加しやすくする書き方
ここは少し複雑なので、実際の開発にありそうな例で考えて行きます。
◎例:車の排気量から税金を出す。
![自動車税種別割](https://ferret-one.akamaized.net/images/66b5a9f074813e3a3106b446/large.png?utime=1723181553)
引用元:東京都主税局 | 自動車税種別割
例えば、車の排気量情報を受け取って、そこから税金を出すような機能はあるあるですよね。
上の表を元に、自家用車の税金を出す処理を記述してみましょう。
※ 料金などはマジックナンバーになっているため、TAX_UP_TO_1_LITER = 25000; のように定数定義するべきですが、記述量が増え見にくくなってしまうので、今回はそのまま料金を数値で記述しています。
import java.util.Scanner;
public class CarTaxCalculator {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
double engineDisplacement = scanner.nextDouble();
int taxAmount = calculateTax(engineDisplacement);
System.out.println(taxAmount);
}
public static int calculateTax(double displacement) {
int taxAmount;
if (isUpTo1Liter(displacement)) {
taxAmount = 25000;
} else if (isUpTo1_5Liter(displacement)) {
taxAmount = 30500;
} else if (isUpTo2Liter(displacement)) {
taxAmount = 36000;
} else if (isUpTo2_5Liter(displacement)) {
taxAmount = 43500;
} else if (isUpTo3Liter(displacement)) {
taxAmount = 50000;
} else if (isUpTo3_5Liter(displacement)) {
taxAmount = 57000;
} else if (isUpTo4Liter(displacement)) {
taxAmount = 65500;
} else if (isUpTo4_5Liter(displacement)) {
taxAmount = 75500;
} else if (isUpTo6Liter(displacement)) {
taxAmount = 87000;
} else {
taxAmount = 110000;
}
return taxAmount;
}
public static boolean isUpTo1Liter(double displacement) {
return displacement <= 1.0;
}
public static boolean isUpTo1_5Liter(double displacement) {
return displacement > 1.0 && displacement <= 1.5;
}
...以下省略
}
上記のコードでは、排気量を受け取り条件分岐を使って税金を出しています。
しかし、elseが多すぎて読みにくいですよね。また、追加の条件がある場合、さらにネストが深くなりコードが複雑になりそうです。
なので、コードの整理として「else」を使わない書き方に変更しましょう。
■ガード節
import java.util.Scanner;
public class CarTaxCalculator {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
double engineDisplacement = scanner.nextDouble();
int taxAmount = calculateTax(engineDisplacement);
System.out.println(taxAmount);
}
public static int calculateTax(double displacement) {
if (isUpTo1Liter(displacement)) return 25000;
if (isUpTo1_5Liter(displacement)) return 30500;
if (isUpTo2Liter(displacement)) return 36000;
if (isUpTo2_5Liter(displacement)) return 43500;
if (isUpTo3Liter(displacement)) return 50000;
if (isUpTo3_5Liter(displacement)) return 57000;
if (isUpTo4Liter(displacement)) return 65500;
if (isUpTo4_5Liter(displacement)) return 75500;
if (isUpTo6Liter(displacement)) return 87000;
return 110000;
}
public static boolean isUpTo1Liter(double displacement) {
return displacement <= 1.0;
}
public static boolean isUpTo1_5Liter(double displacement) {
return displacement > 1.0 && displacement <= 1.5;
}
...以下省略
}
この様に「else」を書かないことで、ぐっと読みやすくなります。
また、それぞれが独立(疎結合)するので変更する時に他に影響を与えず、安全に行うことができます。これを「ガード節」「早期リターン」と呼んだりします。
では次に、「営業用の金額も出してほしい」という要望があった場合の処理を考えていきます。
import java.util.Scanner;
public class CarTaxCalculator {
public enum CarType {
COMMERCIAL,
PRIVATE
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
double engineDisplacement = scanner.nextDouble();
int carTypeInput = scanner.nextInt();
CarType carType = (carTypeInput == 1) ? CarType.COMMERCIAL : CarType.PRIVATE;
int taxAmount = calculateTax(engineDisplacement, carType);
}
public static int calculateTax(double displacement, CarType carType) {
if (carType == CarType.COMMERCIAL) {
if (isUpTo1Liter(displacement)) return 7500;
if (isUpTo1_5Liter(displacement)) return 8500;
if (isUpTo2Liter(displacement)) return 9500;
if (isUpTo2_5Liter(displacement)) return 13800;
if (isUpTo3Liter(displacement)) return 15700;
if (isUpTo3_5Liter(displacement)) return 17900;
if (isUpTo4Liter(displacement)) return 20300;
if (isUpTo4_5Liter(displacement)) return 22700;
if (isUpTo6Liter(displacement)) return 27000;
return 40400;
} else {
if (isUpTo1Liter(displacement)) return 25000;
if (isUpTo1_5Liter(displacement)) return 30500;
if (isUpTo2Liter(displacement)) return 36000;
if (isUpTo2_5Liter(displacement)) return 43500;
if (isUpTo3Liter(displacement)) return 50000;
if (isUpTo3_5Liter(displacement)) return 57000;
if (isUpTo4Liter(displacement)) return 65500;
if (isUpTo4_5Liter(displacement)) return 75500;
if (isUpTo6Liter(displacement)) return 87000;
return 110000;
}
}
public static boolean isUpTo1Liter(double displacement) {
return displacement <= 1.0;
}
public static boolean isUpTo1_5Liter(double displacement) {
return displacement > 1.0 && displacement <= 1.5;
}
...以下省略
}
上記のコードでは、CarTypeを新たに定義し、条件分岐を使い「自家用の税金」と「営業用の税金」を振り分けています。
ただ、このように書くと記述量は増え「〇〇用」と増えたときに、永遠に記述が増えていき、冗長なコードになっていきます。
そのようなときは、それぞれ別のクラスに分け、「インタフェース」を呼び出すというやり方に変更しましょう。
それぞれ
- 税金計算のためのインタフェース
- 営業用車の税金を計算するクラス
- 自家用車の税金を計算するクラス
を作成します。
■ 税金計算のためのインタフェース
public interface TaxCalculator {
int calculateTax(double displacement);
}
■ 営業用車の税金を計算するクラス
public class CommercialTaxCalculator implements TaxCalculator {
@Override
public int calculateTax(double displacement) {
if (isUpTo1Liter(displacement)) return 7500;
if (isUpTo1_5Liter(displacement)) return 8500;
if (isUpTo2Liter(displacement)) return 9500;
if (isUpTo2_5Liter(displacement)) return 13800;
if (isUpTo3Liter(displacement)) return 15700;
if (isUpTo3_5Liter(displacement)) return 17900;
if (isUpTo4Liter(displacement)) return 20300;
if (isUpTo4_5Liter(displacement)) return 22700;
if (isUpTo6Liter(displacement)) return 27000;
return 40400;
}
private boolean isUpTo1Liter(double displacement) {
return displacement <= 1.0;
}
private boolean isUpTo1_5Liter(double displacement) {
return displacement > 1.0 && displacement <= 1.5;
}
...以下省略
}
■ 自家用車の税金を計算するクラス
public class PrivateTaxCalculator implements TaxCalculator {
@Override
public int calculateTax(double displacement) {
if (isUpTo1Liter(displacement)) return 25000;
if (isUpTo1_5Liter(displacement)) return 30500;
if (isUpTo2Liter(displacement)) return 36000;
if (isUpTo2_5Liter(displacement)) return 43500;
if (isUpTo3Liter(displacement)) return 50000;
if (isUpTo3_5Liter(displacement)) return 57000;
if (isUpTo4Liter(displacement)) return 65500;
if (isUpTo4_5Liter(displacement)) return 75500;
if (isUpTo6Liter(displacement)) return 87000;
return 110000;
}
private boolean isUpTo1Liter(double displacement) {
return displacement <= 1.0;
}
private boolean isUpTo1_5Liter(double displacement) {
return displacement > 1.0 && displacement <= 1.5;
}
...以下省略
}
【呼び出す側のメインクラス 】
■ 呼び出す側のメインクラス
import java.util.Scanner;
public class CarTaxCalculator {
public enum CarType {
COMMERCIAL,
PRIVATE
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
double engineDisplacement = scanner.nextDouble();
int carTypeInput = scanner.nextInt();
CarType carType = (carTypeInput == 1) ? CarType.COMMERCIAL : CarType.PRIVATE;
TaxCalculator taxCalculator = getTaxCalculator(carType);
int taxAmount = taxCalculator.calculateTax(engineDisplacement);
System.out.println(taxAmount);
}
public static TaxCalculator getTaxCalculator(CarType carType) {
switch (carType) {
case COMMERCIAL:
return new CommercialTaxCalculator();
case PRIVATE:
return new PrivateTaxCalculator();
default:
throw new IllegalArgumentException("存在しない車タイプです");
}
}
}
コードの解説
- 排気量(engineDisplacement)と車タイプ(carTypeInput)を受け取る
- carTypeをgetTaxCalculator に渡し、該当するインタンスを作成する(自家用車ならPrivateTaxCalculatorのインスタンス)
- 作成したインスタンスをインタフェースの型(TaxCalculator)として扱う
- インタフェースの型のインスタンスからcalculateTaxメソッドを呼び出す
- インタフェース(TaxCalculator)のcalculateTaxメソッドが呼ばれるが、実態はそれぞれのインスタンスが持っているため、インスタンスのクラスのcalculateTaxメソッドが呼ばれる(インスタンスが自家用車ならPrivateTaxCalculatorクラスのcalculateTaxメソッド)
- それぞれのインスタンスが持つcalculateTaxメソッドで税金を返す
- 税金を出力する
このように「インタフェースを使用」「クラスをそれぞれ独立させる」ことで、
- 呼び出し側は、インタフェースのメソッドを呼び出すだけで良いので、記述がスッキリし読みやすい
- それぞれのクラスが独立(疎結合)しているので、変更や追加に対応しやすい
- それぞれのクラスが独立していて「どこに何があるか」がすぐ特定できるので保守しやすい
というメリットがあります。
例えば、自家用で「令和元年10月1日以後」と「令和元年9月30日以前」で税金が変わる。これを追加したいときはPrivateTaxCalculatorクラスの修正となります。そのため、営業用の税金計算には影響がありません。
また、「〇〇用」と車タイプが増えたときも、〇〇TaxCalculatorと新しいクラスを追加するだけで、既存のコードを変更せず追加することができます。
実は、「〇〇用」と車タイプが増えたときも、〇〇TaxCalculatorと新しいクラスを追加するだけで、既存のコードを変更せず追加することができます。というのは、SOLID原則の「オープン/クローズドの原則」に従っています。
また、CommercialTaxCalculator クラスと PrivateTaxCalculator クラスは、それぞれ営業用車と自家用車の税金計算という単一の責任を持っているため、SOLID原則の「単一責任の原則」に従っています。
このように、SOLID原則を意識していなくても
- 読みやすい
- 変更しやすい
- 整理されていて、機能追加しやすい
を意識していくと、自然とSOLID原則に近づいていくことができます。
最後に
本記事では「SOLID原則」の入口として、
- 読みやすい
- 変更しやすい
- 整理されていて、機能追加しやすい
コードの書き方を紹介しました。
今まで「SOLID原則に抵抗を感じていた方」や「コードのきれいさを意識していなかった方」に、コードの書き方を見つめ直すきっかけとなれば嬉しいです。
お悩みご相談ください
参考書類
http://www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html https://blog.cleancoder.com/uncle-bob/2017/02/23/NecessaryComments.html
Clean Architecture
現場で役立つシステム設計の原則
良いコード/悪いコードで学ぶ設計入門