クラスが増えても大丈夫!成長するソフトウェアを支えるリファクタリングの技術
2021年11月24日(水)11時0分 BIGLOBE Style
開発部門(基盤本部)でエンジニアの育成を担当している高玉です。
基盤本部ではさまざまな勉強会を開催しています。先日も、BIGLOBE Styleでその様子をご紹介しました。
「クラスを増やすの、怖くないですか?」
オブジェクト指向プログラミング(OOP)を学んでいた時に聞かれたことです。業務ではJavaやドメイン駆動設計を活用しているので、クラスベースのOOPが題材になることが多いのです。OOPに慣れていない人からすると、クラスの数が増えることで全体を把握しづらくなったり、適切なクラスを見つけるのが大変になりそう、と感じるそうです。
「大丈夫!クラスを増やしたほうが楽になることがあるよ!」
と伝えたくて、この記事を書かせていただきました。何が楽になるのでしょう?それは、ソースコードを読むこと、です。「クラスを増やすと、ソースコードを読むのが楽になる???」とハテナマークがたくさん出てきそうですが、背景を含めて説明していきます。
- 成長し続けるのがソフトウェアの宿命
- 繰り返し起こる機能追加を再現してみる
- クラスを増やしたメリット・デメリット
- まとめ
成長し続けるのがソフトウェアの宿命
ソフトウェア開発は建築の例えを用いて説明されることがありますが、まったく違うことが1つあります。それは、ソフトウェアは成長し続けることです。建築物は物理的な制約があるので、一度建てたら大きく変更することはできません。その一方、ソフトウェアは作り変えることができます。使ってみて、はじめてやりたかったことが分かったり、どんどん変化するビジネス環境への追随が求められるため、常に作り変える必要に迫られます。
成長を前提とすると「新しい機能をすぐに追加できるかどうか」はソフトウェアにとってとても大事な性質になります。そこで一昔前は、どんな機能が追加されるか先に予測して設計しておく、という戦略が取られました。しかし、未来予知は外れるものです。その結果、ムダな拡張性を持つ複雑なソフトウェアがたくさん生まれてしまいました。その失敗を教訓として、YAGNI(You ain't gonna need it:そんなの必要ないって。必要になったら作ろうぜ)という標語も生まれました。結局は、追加したい機能が明らかになったタイミングで、それをどう実装するか?がとても大事になります。
そして、機能を追加するタイミングでエンジニアの力量の差がハッキリと現れます。ひよっこエンジニアは「スピード優先!」でいきなりコーディングを始めてしまいます。しかし先ほども述べた通り、ソフトウェアは成長し続けるので機能追加は終わりません。今後も機能追加は続きます。今のソフトウェアの構造を見直すことなく、ただ単に建て増しを重ねていくと、複雑さは増す一方です。その結果、後に続く機能追加はどんどん難しくなっていきます。
できるエンジニアは、機能追加をする前に、リファクタリングするのがクセになっています。リファクタリングとは、ソフトウェアが実現する機能は変えずに、内部構造を作り直すことです。できるエンジニアは、これ以降も機能追加が続くことを知っています。なので今後の機能追加が楽にできるよう、一度ソフトウェアの中身を作り直した上で、新しい機能を追加するのです。
リファクタリングをすることでクラスの数は増え、ソースコードの総量も多くなります。しかし、内部構造が整理されてソースコードを読みやすくなるので、結果的に新しい機能を追加しやすくなります。ここからはソースコードを使って具体的な例を示していきます。あくまで簡単な例のため、リファクタリングの有無による効果の違いは微々たるものです。しかし、これが重なっていった結果、大きな違いになっていきます。
繰り返し起こる機能追加を再現してみる
例題として取り上げるのは、とあるデータをHTMLとしてコンソールに表示するプログラムです。first, second, thirdという文字列リストをHTMLで表示します。
期待する出力結果:
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
最初はメインプログラムにすべてのロジックを書き込んでいきます。
メインプログラム:
importjava.util.*;
importjava.lang.*;
importjava.io.*;
importjava.util.stream.*;
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
System.out.println("<ul>");
for(String item: items) {
System.out.println("<li>"+ item + "</li>");
}
System.out.println("</ul>");
}
}
機能追加 1回目(リファクタリングしない場合)
長年、出力するのはHTMLだけで十分だったのですが、JSON記法でも出力できるようにして欲しい!という要望があがりました。
期待する出力結果:
[
"first", "second", "third"
]
ひよっこエンジニアはスピード最優先でメインプログラムに機能を追加します。Mainクラスのソースコードを全部読んだ上で、実装に取り掛かります。コマンドライン引数に--jsonと指定した場合に、JSON形式で出力することにしました。
classMain {
publicstaticvoidmain (String[] args) {
List<String> items = List.of("first", "second", "third");
if(args[0].equals("--json")) {
System.out.println("[");
String output = items.stream()
.map(item -> String.format("\"%s\"", item))
.collect(Collectors.joining(","));
System.out.println(" "+ output);
System.out.println("]");
} else{
System.out.println("<ul>");
for(String item: items) {
System.out.println("<li>"+ item + "</li>");
}
System.out.println("</ul>");
}
}
}
でき上がったソースコードはMainクラス1つのままですが、機能を追加した分、行数は長くなっています。
機能追加 1回目(リファクタリングする場合)
できるエンジニアは、機能を追加するまえにリファクタリングをして構造を直します。複数のステップで見直すため、いきなり機能を追加するのに比べると多くの手間がかかります。けれど経験上、その手間が後々自分を助けることになることを知っています。
リファクタリングで構造を直す
まず「これから追加する新しい機能は、既存の機能とどう関係しているのか?」を考えます。今回は、既存の表示機能にバリエーションを加えたい、という要望です。Mainクラスを読み直し、まずは既存の表示機能をHtmlPrinterクラスに切り出してみます。
classHtmlPrinter {
voidprint(List<String> items) {
System.out.println("<ul>");
for(String item: items) {
System.out.println("<li>"+ item + "</li>");
}
System.out.println("</ul>");
}
}
Mainクラスは、切り出したHtmlPrinterを使って次のようになります。
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
HtmlPrinter p = newHtmlPrinter();
p.print(items);
}
}
メインプログラムを、MainとHtmlPrinterという2つのクラスに分けました。HtmlPrinterを切り出したので、Mainの行数は少なくなっています。
機能を追加
リファクタリングが終了したので、JSON形式を出力するJsonPrinterを新しく追加します。
classJsonPrinter {
voidprint(List<String> items) {
System.out.println("[");
String output = items.stream()
.map(item -> String.format("\"%s\"", item))
.collect(Collectors.joining(","));
System.out.println(" "+ output);
System.out.println("]");
}
}
メインプログラムからJsonPrinterを使えるようにします。
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
if(args[0].equals("--json")) {
JsonPrinter p = newJsonPrinter();
p.print(items);
} else{
HtmlPrinter p = newHtmlPrinter();
p.print(items);
}
}
}
さらにリファクタリングをして構造を見直す
さて、これでHTMLもJSONも出力できるようになったのですが、もう一度、構造を見直してみます。
HtmlPrinterとJsonPrinterは、リストを画面に出力する、という機能は共通で、出力形式がHTMLかJSONかで異なります。
「リストを画面に出力する」という共通項をPrinterインターフェイスにまとめて、それを実装したのがHtmlPrinter、JsonPrinterである、と定義しなおしてみます。
interfacePrinter {
voidprint(List<String> items);
}
classJsonPrinter implementsPrinter {
publicvoidprint(List<String> items) {
...
}
classHtmlPrinter implementsPrinter {
publicvoidprint(List<String> items) {
...
}
その上で、メインプログラムをPrinterを使って書き直します。
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
Printer p;
if(args[0].equals("--json")) {
p = newJsonPrinter();
} else{
p = newHtmlPrinter();
}
p.print(items);
}
}
このリファクタリングにより、インターフェイスがPrinterの1つ、クラスがMain、JsonPrinter、HtmlPrinterの3つになりました。
機能追加 2回目(リファクタリングしない場合)
HTML、JSONに続き、さらにMarkdown形式を出力することになりました。
期待する出力結果(Markdown):
- first
- second
- third
ひよっこエンジニアは、リファクタリングせずにそのまま機能を追加します。
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
if(args[0].equals("--md")) {
for(String item: items) {
System.out.println("- "+ item);
}
} elseif(args[0].equals("--json")) {
System.out.println("[");
String output = items.stream()
.map(item -> String.format("\"%s\"", item))
.collect(Collectors.joining(","));
System.out.println(" "+ output);
System.out.println("]");
} else{
System.out.println("<ul>");
for(String item: items) {
System.out.println("<li>"+ item + "</li>");
}
System.out.println("</ul>");
}
}
}
クラスの数はMain 1つのままですが、行数はさらに長くなりました。
機能追加 2回目(リファクタリングした場合)
1回目の機能追加で構造を見直しておいたので、今回はMarkdownPrinterクラスを追加すれば終了です。
classMarkdownPrinter {
voidprint(List<String> items) {
for(String item: items) {
System.out.println("- "+ item);
}
}
}
そして、MainでMarkdownPrinterを使えるようにします。
classMain {
publicstaticvoidmain(String[] args) {
List<String> items = List.of("first", "second", "third");
Printer p;
if(args[0].equals("--md")) {
p = newMarkdownPrinter();
} elseif(args[0].equals("--json")) {
p = newJsonPrinter();
} else{
p = newHtmlPrinter();
}
p.print(items);
}
}
結果的に、インターフェイスがPrinterの1つ、クラスがMain、MarkdownPrinter、JsonPrinter、HtmlPrinterの4つに分かれました。
クラスを増やしたメリット・デメリット
さて、スピード優先でリファクタリングをしなかった場合と、リファクタリングでクラスを増やしてから機能を追加した場合で比較してみます。
書いたソースコードの行数
リファクタリングしない | リファクタリングする | |
---|---|---|
Main | 23行 | 14行 |
Printer | - | 3行 |
HtmlPrinter | - | 7行 |
JsonPrinter | - | 10行 |
MarkdownPrinter | - | 7行 |
合計 | 23行 | 34行 |
ソースコードは、リファクタリングをしない方が、リファクタリングをした場合よりも11行短くなりました。
リファクタリングしない | リファクタリングする | |
---|---|---|
2回目の機能追加で書いたソースコードの行数 | Main 5行 | MarkdownPrinter 7行、Main 3行 |
合計 | 5行 | 10行 |
Markdownによる出力を追加した2回目の機能追加ですが、書いたソースコードの行数はリファクタリングをしない方が、リファクタリングをした場合よりも5行少なくて済みました。
書く量が少ないので、実はひよっこエンジニアのアプローチが優秀なのでは?と思ってしまいますが、機能追加をするときの大事な視点が抜けています。それは、ソースコードを読む量です。
読んだソースコードの行数
リファクタリングしない | リファクタリングする | |
---|---|---|
2回目の機能追加前に読んだソースコードの行数 | Main 19行 | Printer 3行、Main 12行 |
合計 | 19行 | 15行 |
機能追加前に調査するソースコードの量は、リファクタリングしない場合の方が4行多くなっています。これはとても重要なことです。書籍「Clean Code」によれば、プログラマーがソースコードを読む時間は、書く時間の10倍と言われています。つまり、読む量を少なくすれば大きな効果が得られます。今回の例題は簡単なのでよいのですが、通常のプログラムはもっともっと複雑です。少しでも調査を間違えば即障害につながってしまうため、ソースコードの調査は細心の注意が必要な作業です。できるだけ負担を下げたいものですね。
さらにパッケージ構造も以下のようにすれば、このプログラムには、Html、Json、Markdownの3つの出力形式があることもすぐに分かります。
- appパッケージ
- Mainクラス
- Printerインターフェイス
- printerパッケージ
- HtmlPrinterクラス
- JsonPrinterクラス
- MarkdownPrinterクラス
もし3回目の機能追加でYAML形式の出力を増やすことになれば、YamlPrinterを作ればいいこともこのパッケージ構造から直感的に分かります。
まとめ
ソフトウェアは成長し続けます。開発が終わることはなく、後から新しい機能が追加されるものだと考えておく必要があります。
この記事では、新機能を追加する直前にリファクタリングすることで、クラスの数を増やしたとしても、読まなければならないソースコードの量を減らせる例を示しました。簡単な例を用いたのでその差はわずかなものでしたが、普段の仕事でソースコードを書いている時間の10倍は読んでいる時間なのだと考えると、得られる効果はとても大きなものです。
ソフトウェアにおいては、品質とコストはトレードオフではく両立するものだと言われています。普段の生活では「高品質なものほど高価である(コストが高い)」ことに慣れているので、おや?と思いますよね。ソフトウェアアーキテクチャーの大家であるマーチン・ファウラーさんが書かれた、とても素晴らしい記事の中で紹介されています。
記事の中ではたとえ話を使って、ソフトウェアでは高品質と低コストが両立することを説明しています。台所が片付いていないまま次の料理を始めれば、効率が悪く、次の料理を作るまでに時間がかかってしまいますよね。それは、リリースを優先して、とりあえず動けばOKのままにした状態です。台所を片付けることがリファクタリングで、台所を片付けた後が高品質な状態です。すぐに料理を作り始められるので、低コストを実現できます。
高品質を保つ秘訣がリファクタリングですが、リファクタリングの指針を与えてくれるのがデザインパターン 1です。デザインパターンはOOPが目指す「高凝集・低結合」な設計のサンプル集として使えます。今回の例題ではデザインパターンの1つであるStrategyパターンを適用して、利用される側(Printerインターフェイス)を切り出し、利用する側(Mainクラス)のソースコードを再利用できるようにしました。OOPのポリモーフィズムが役立つ例ですね。詳しく知りたい方は@hyukiさんの書籍「Java言語で学ぶデザインパターン入門」が入門書としてオススメです(第3版が出版されるとのことで、今から楽しみです!)。また @iwashi86さんのテック系ポッドキャストfukabori.fmで @t_wadaさんがリファクタリングとデザインパターンの関係についてとても分かりやすく解説されています。
また、高凝集・低結合を目指しつつデザインパターンを適用してリファクタリングする過程は、書籍「オブジェクト指向のこころ」にも例があります。
さて、この記事は「クラスを増やしたほうが楽になることがある」例になっていたでしょうか?追加する機能が恣意的だった点や、リファクタリングをする上でとても大事になる自動テストについて端折ってしまった点についてはどうぞご容赦ください🙇♂️
BIGLOBEでは、勉強会や業務を通じて、若手とベテランがお互いを高めあっています。私たちが大事にしている行動指針であるビッグローブマインドにあるように、これからも「世の中をみて、世の中から学ぶ」ことで「プロフェッショナルであれ」を目指していきたいです。ご興味のある方は、採用ページもご覧になっていってください。