BIGLOBEの「はたらく人」と「トガッた技術」

クラスが増えても大丈夫!成長するソフトウェアを支えるリファクタリングの技術

f:id:biglobe-editor2:20220224162846j:plain

開発部門(基盤本部)でエンジニアの育成を担当している高玉です。

基盤本部ではさまざまな勉強会を開催しています。先日も、BIGLOBE Styleでその様子をご紹介しました。

style.biglobe.co.jp

「クラスを増やすの、怖くないですか?」

オブジェクト指向プログラミング(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>

最初はメインプログラムにすべてのロジックを書き込んでいきます。

メインプログラム:

import java.util.*;
import java.lang.*;
import java.io.*;
import java.util.stream.*;

class Main {
    public static void main(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形式で出力することにしました。

class Main {
    public static void main (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クラスに切り出してみます。

class HtmlPrinter {
    void print(List<String> items) {
        System.out.println("<ul>");
        for (String item: items) {
            System.out.println("<li>" + item + "</li>");
        }
        System.out.println("</ul>");
    }
}

Mainクラスは、切り出したHtmlPrinterを使って次のようになります。

class Main {
    public static void main(String[] args) {
        List<String> items = List.of("first", "second", "third");
        HtmlPrinter p = new HtmlPrinter();
        p.print(items);
    }
}

メインプログラムを、MainとHtmlPrinterという2つのクラスに分けました。HtmlPrinterを切り出したので、Mainの行数は少なくなっています。

機能を追加

リファクタリングが終了したので、JSON形式を出力するJsonPrinterを新しく追加します。

class JsonPrinter {
    void print(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を使えるようにします。

class Main {
    public static void main(String[] args) {
        List<String> items = List.of("first", "second", "third");
        if (args[0].equals("--json")) {
            JsonPrinter p = new JsonPrinter();
            p.print(items);
        } else {
            HtmlPrinter p = new HtmlPrinter();
            p.print(items);
        }
    }
}

さらにリファクタリングをして構造を見直す

さて、これでHTMLもJSONも出力できるようになったのですが、もう一度、構造を見直してみます。

HtmlPrinterとJsonPrinterは、リストを画面に出力する、という機能は共通で、出力形式がHTMLかJSONかで異なります。

「リストを画面に出力する」という共通項をPrinterインターフェイスにまとめて、それを実装したのがHtmlPrinter、JsonPrinterである、と定義しなおしてみます。

interface Printer {
    void print(List<String> items);
}

class JsonPrinter implements Printer {
    public void print(List<String> items) {
...
}

class HtmlPrinter implements Printer {
    public void print(List<String> items) {
...
}

その上で、メインプログラムをPrinterを使って書き直します。

class Main {
    public static void main(String[] args) {
        List<String> items = List.of("first", "second", "third");
        Printer p;
        if (args[0].equals("--json")) {
            p = new JsonPrinter();
        } else {
            p = new HtmlPrinter();
        }
        p.print(items);
    }
}

このリファクタリングにより、インターフェイスがPrinterの1つ、クラスがMain、JsonPrinter、HtmlPrinterの3つになりました。

機能追加 2回目(リファクタリングしない場合)

HTML、JSONに続き、さらにMarkdown形式を出力することになりました。

期待する出力結果(Markdown):

- first
- second
- third

ひよっこエンジニアは、リファクタリングせずにそのまま機能を追加します。

class Main {
    public static void main(String[] args) {
        List<String> items = List.of("first", "second", "third");
        if (args[0].equals("--md")) {
            for (String item: items) {
                System.out.println("- " + item);
            }
        } else 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つのままですが、行数はさらに長くなりました。

機能追加 2回目(リファクタリングした場合)

1回目の機能追加で構造を見直しておいたので、今回はMarkdownPrinterクラスを追加すれば終了です。

class MarkdownPrinter {
    void print(List<String> items) {
        for (String item: items) {
            System.out.println("- " + item);
        }
    }
}

そして、MainでMarkdownPrinterを使えるようにします。

class Main {
    public static void main(String[] args) {
        List<String> items = List.of("first", "second", "third");
        Printer p;
        if (args[0].equals("--md")) {
            p = new MarkdownPrinter();
        } else if (args[0].equals("--json")) {
            p = new JsonPrinter();
        } else {
            p = new HtmlPrinter();
        }
        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倍は読んでいる時間なのだと考えると、得られる効果はとても大きなものです。

ソフトウェアにおいては、品質とコストはトレードオフではく両立するものだと言われています。普段の生活では「高品質なものほど高価である(コストが高い)」ことに慣れているので、おや?と思いますよね。ソフトウェアアーキテクチャーの大家であるマーチン・ファウラーさんが書かれた、とても素晴らしい記事の中で紹介されています。

bliki-ja.github.io

記事の中ではたとえ話を使って、ソフトウェアでは高品質と低コストが両立することを説明しています。台所が片付いていないまま次の料理を始めれば、効率が悪く、次の料理を作るまでに時間がかかってしまいますよね。それは、リリースを優先して、とりあえず動けばOKのままにした状態です。台所を片付けることがリファクタリングで、台所を片付けた後が高品質な状態です。すぐに料理を作り始められるので、低コストを実現できます。

高品質を保つ秘訣がリファクタリングですが、リファクタリングの指針を与えてくれるのがデザインパターン 1 です。デザインパターンはOOPが目指す「高凝集・低結合」な設計のサンプル集として使えます。今回の例題ではデザインパターンの1つであるStrategyパターンを適用して、利用される側(Printerインターフェイス)を切り出し、利用する側(Mainクラス)のソースコードを再利用できるようにしました。OOPのポリモーフィズムが役立つ例ですね。詳しく知りたい方は@hyukiさんの書籍「Java言語で学ぶデザインパターン入門」が入門書としてオススメです(第3版が出版されるとのことで、今から楽しみです!)。また @iwashi86 さんのテック系ポッドキャストfukabori.fmで @t_wada さんがリファクタリングとデザインパターンの関係についてとても分かりやすく解説されています。

fukabori.fm

fukabori.fm

また、高凝集・低結合を目指しつつデザインパターンを適用してリファクタリングする過程は、書籍「オブジェクト指向のこころ」にも例があります。

さて、この記事は「クラスを増やしたほうが楽になることがある」例になっていたでしょうか?追加する機能が恣意的だった点や、リファクタリングをする上でとても大事になる自動テストについて端折ってしまった点についてはどうぞご容赦ください🙇‍♂️

BIGLOBEでは、勉強会や業務を通じて、若手とベテランがお互いを高めあっています。私たちが大事にしている行動指針であるビッグローブマインド にあるように、これからも「世の中をみて、世の中から学ぶ」ことで「プロフェッショナルであれ」を目指していきたいです。ご興味のある方は、採用ページもご覧になっていってください。

www.biglobe.co.jp

hrmos.co


  1. デザインパターンを学ぶと、どんな場面にもとにかく学んだことを適用したくなる「デザインパターン厨」になりがちです。くれぐれもご注意ください。