161
153

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

NRI OpenStandiaAdvent Calendar 2020

Day 10

オブジェクト指向は、こう設計しよう

Last updated at Posted at 2020-12-09

はじめに

はい、何番煎じか分からないオブジェクト指向によるクラス設計の話です。

オブジェクト指向の設計のはなしは、ネット上ググるといくらでも出てくるし、私もいくつかは見たのですが、正直私はあまり理解できませんでした。理由ははっきりしていて、話が抽象的で具体的な手順については何も書かれていないからです(いくらオブジェクト指向の中心は抽象化だったとしても、説明まで抽象化しなくていいんですよ)。さらに言うと、自分の考え方とかなり違うなぁ、とも思いました。

そんなわけで、クラス設計のはなしでも書こうと思ったわけです。ただし注意点として、ここに書いた方法は完全に私の自己流です。そのため、この方法はおかしい、この方法は合わない、こんなの見たことない(自己流だから当たり前です)から意味不明、という人が少なくない数でいると思います。なので、はなし半分、ポエム要素半分で読んでもらえればと思います。ちなみに、私は人並みにUMLの本とかも読んだのですが、理解できなくて投げ捨てましたw。そのため、この記事にはクラス図などという高等技術はでてきません(書くほどのクラス構成ではないですが)。

また、具体的に手順を書いていくため、プログラム言語も特定のものを使います。Javaにしました。型付けの強いオブジェクト指向言語ですので、手順もこれに即したものになります。C# も型付けの強いオブジェクト指向言語なので、大体同じように設計できます。しかし型付けの弱い Ruby や Python は少し設計方法が異なるので、この記事と同じようにはできないかもしれません(特にポリモーフィズムの表現方法が違っていたり、リフレクションをどの程度積極的に利用するか、など)。

問題

ネット上に公開されている問題は、(オブジェクト指向の問題、と書きつつ)アルゴリズムの問題だったり、単に~のクラスを作れだったり、ただの文法問題だったりと、なかなか良い問題が見つからなったので、オブジェクト指向の問題としてはあまり良問ではないですが、情報処理技術試験の午後問題を使うことにしました。

~~転載可能なのかが分からないので、リンクだけ張ることにします。~~過去問は、商用利用でなければ特に許諾無く転載可能みたいなので、問題を載せておきます。問3の問題をやりますが、読むべき問題文は [食券購入時の要件] だけでいいです。状態遷移やイベントコードは使わないので。これをコンソールアプリケーションで作ります。なお、超長文になりそうなので、UnitTestとエラー処理については何も書きません。また、アルゴリズムの説明も割愛させていただきます(たいしたアルゴリズムは出てきませんが)。

2020-11-24_093957.jpg
2020-11-24_094331.jpg

出展:平成31年度 春期 応用情報技術者試験 午後 問3

作成アプリケーション

先ほど書いたように、コンソールアプリケーションで作ります。内容は、メニューの表示 → メニューの入力 → 合計金額の表示、です。メニューの入力はコンソールアプリケーションなので、番号で指定するようにします。

設計する

まず、クラスを抽出しよう

最初にやることは、クラスを抽出することです。JavaやC#のようなオブジェクト指向言語はクラスがすべてなので、クラス抽出から始めないと何も始められません。

では、何をクラスにするか? 目に入ったものは片っ端からクラスにします。名詞はクラスで動詞はメソッドで… と書いてある本やサイトもあるっぽいですが、GoFのデザインパターンで言う Command パターンは、処理をクラスにしているわけだから、多分そういう考え方は間違っているのでしょう。

問題文を読んで、手当たり次第クラスを抽出していきます… と言っても、おそらく設計の中心になりそうなのは「表1」の部分に見えるので、ここからクラスを抽出していこう。クラスになりそうなのは、メイン商品とかオプションとか書かれている「カテゴリー(Category)」とそのカテゴリーが持っている「メニュー(Menu)」があるので、これはクラスにします。それと図1の長い日本語をナナメ読みすると、なんか「利用者(User)」と「券売機(TicketMachine)」が出てくるので、この辺がクラスになりそうです。とりあえずこの4つをクラスにします。

おそらくこの時点で疑問に思うことは、この段階で 100% クラスを抽出しなければならないのか、だと思います。結論を言うと、全く不要です。もし後でクラスが足りないと気づいたら… そのとき足せば良いです。もし不要なクラスだったら… そのとき消せば良いのです。重要なことは、この段階で 100% クラス抽出するぞ、と頑張らないことです。というか、この段階で 100% クラス抽出は絶対不可能です。それは、仕様や問題文にない、クラスとクラスをつなぐようなユーティリティークラスが必ず発生するからです。こういったクラスはいわゆる非機能にあたるので、仕様や問題文をいくら読んでも機能に当たるクラスしか抽出できません。オブジェクト指向の設計は、70%~80%できたら先に進む、後で間違っていたらその都度修正する、としたほうがいいでしょう。

作ったクラスのインスタンス変数とメソッドを決めよう

次は、作ると決めたクラスに対して、インスタンス変数とメソッドを決めていきます。ここも 100% 決める必要はありません。さらに言うと、メソッドの引数や戻り値型も厳密に決めておく必要はありません。ぱっと分かる部分だけ決めればいいです。これも必要ならあとから追加したり変更したりすればいいです。

まず Category クラスから始めます。表1を眺めていると、分類番号とメニューというデータを持っているので、この辺がインスタンス変数として必要そうです。そしてこのインスタンス変数の値の設定をどこでやるか、ですが、一般的には、仕様で与えられているものはコンストラクタ(あるいはファクトリーメソッド)にしたほうがいいです。その理由は、こういった値は大抵不変だからです。ただ、Category クラスでの Menu は、メニューの数が多くコンストラクタがごたつきそうなので、しぶしぶメソッドにしておきます。

Category.java
public class Category {
    private final int no;
    private List<Menu> menus = new ArrayList<>();

    public Category(int no) {
        this.no = no;
    }

    public void addMenu(Menu menu) {
        menus.add(menu);
    }

    ...
}

「分類番号」は不変であることを表すために、final を付けるべきです。menus のほうも付けていいですが、Javaの文法ではあまり意味がないのでそのままにします。

ここで1つ気になるのは、Categoryinterface/abstract class にして、メイン商品やサイドメニューなどはサブクラスにしたほうがいいのでは、ということです。私の基準では次のようにしています。

  • 処理が変わりそうなら、サブクラスを作る(継承を使う)。
  • データ部分(=インスタンス変数の値)しか変わりそうにないなら、サブクラスは作らない(継承は使わない)

今回は、表1を見た限りでは、「分類番号」と「メニュー」というデータ部分しか差がなさそうなので、継承は使わないでおきます。ここも、もし後で継承が必要になったら、そのとき interface/abstract class にすればいいのです。

話を戻して、Category クラスと同様の方法で Menu クラスも作っていきます。インスタンス変数は「名称」「N」「S」「O」「価格」あたりでしょうか。ここで気になるのが、「N」「S」「O」とは何なのか、です。「N」「S」「O」という名前だけ見ても意味不明だし、この値は数値ですが 12 という値に(数量のような)意味があるのか、何かのコード値なのか、単に ON/OFF を表しているのかが良く分からないことです(正直、この仕様は良くないと思う)。こういう場合は、とりあえずありのままにしておくのが無難です。

Menu.java
public class Menu {
    private final String name;
    private final int n;
    private final int s;
    private final int o;
    private final int price;

    public Menu(String name, int n, int s, int o, int price) {
        this.name = name;
        this.n = n;
        this.s = s;
        this.o = o;
        this.price = price;
    }

    ...
}

変数名に s とか o とか使いたくないのですが、今の時点では意味が分からないのでこうしておきます。

次に券売機(TicketMachine)クラスですが、Categoryを4つ持っているので、これがインスタンス変数(categories)になりそうです。ここで、categoriesjava.util.List にするのがいいのか、配列にするのがいいのか、という問題があります。私の基準では次のようにしています。

  • 基本的には java.util.List を使う。
  • サイズが固定であり、変更の可能性がなさそうな場合のみ、配列を使う。

今回は、Category が4つと固定なので、配列にします。ここで、将来仕様変更でサイドメニュー3が追加されたときのことを考えて、List のほうがいいのでは? と思う人がいるがいるかもしれません。個人的には、あるかどうか分からない仕様変更については実装しない、という方針にしています。その理由は、大抵予想しない方向に仕様変更が起こるからです。例えば、サイドメニュー3が追加されると思っていたら、期間限定割引メニューを追加する、といった感じです。そして経験上、予想通りに仕様変更が起きたことはありませんでした。現実は予想よりずっと複雑だということでしょう。こういう予想できない仕様変更があるとき、あまり凝った実装をするより、極小の実装にしておいたほうが修正しやすいです。オブジェクト指向の設計について書かれたサイト/本では、やたらと抽象化したがるのですが、なんでも(不要な)抽象化をすることがオブジェクト指向の設計ではありません。仕様を満たすように使う道具がオブジェクト指向です。(オブジェクト指向は目的ではなく手段、ということね)

TicketMachine.java
public class TicketMachine {
    /** カテゴリー */
    private Category[] categories = new Category[4];
    /** 選択されたメニュー */
    private List<Menu> selectedMenus = new ArrayList<>();

    public void setMainMenu(Category category) { categories[0] = category; }
    public void setSide1(Category category) { categories[1] = category; }
    public void setSide2(Category category) { categories[2] = category; }
    public void setOption(Category category) { categories[3] = category; }
}

setXxx() というメソッドがダサいですが、今は思い付きでどんどん実装していきます。

最後に利用者(User)クラスですが、実装すべきものが思い当たらないので、箱だけ用意しておきます。

User.java
public class User {
    // なにも実装するものがない
}

道具がそろったら、処理の順番にならべよう

必要な道具(クラス)がそろったら、処理順にプログラムを書いていきます。今回はコンソールアプリケーションなので、mainメソッドに処理を書きます。

Main.java
public class Main {
    public static void main(String[] args) throws Exception {
        // 初期化
        TicketMachine machine = new TicketMachine();
        Category mainMenu = new Category(1);
        mainMenu.addMenu(new Menu("牛丼", 1, 1, 1, 380));
        mainMenu.addMenu(new Menu("豚丼", 1, 1, 1, 350));
        mainMenu.addMenu(new Menu("鮭定食", 1, 1, 0, 450));
        machine.addCategory(mainMenu);
        (...以下長いので省略...)

        // メニューを注文する
        (ここには何を書けばいいのだろうか)

        // 合計金額を算出する
        (ここには何を書けばいいのだろうか)
    }
}

もちろん、これで完成するわけないですよね。不足している部分を追加していきます。

足りないものを追加していこう

合計金額を算出する

足りない部分はどこから始めてもいいのですが、まずは合計金額を求めるメソッド(getTotal())を追加します。このメソッドはどのクラスに追加すればいいでしょうか?

オブジェクト指向の分析(?)だと、(特に理由が書かれることもなく)券売機クラスに実装する、となりそうだし、そのように設計してもいいのですが、どのクラスに実装すべきかはっきりしない場合はどうすればいいでしょうか? この場合ちょっと視点を変えて、どのクラスに実装すべきか、ではなく、どのクラスで実装できるか、で考えてみます。具体的にやりましょう。合計金額を求めるには、注文したメニュー一覧が必要です。そして注文したメニュー一覧を知っているクラスは券売機クラスだけです。つまり、そもそも券売機クラスにしか実装できない、ということになります。このやり方であれば、分析どうこうとか考えなくても、必然から実装箇所が分かるでしょう。なお、記事中ではアルゴリズムの説明までする余裕がないので、合計金額算出はこうなるんだ、くらいに思ってくれればいいです。

TicketMachine.java
public class TicketMachine {
    ...

    public int getTotal() {
        // メニューの合計
        int total = orderedMenus.stream().map(m -> m.getPrice()).sum();
        // 割引処理
        boolean c1 = orderedMenus.stream().anyMatch(m -> m.getN() == 1);
        boolean c2 = orderedMenus.stream().anyMatch(m -> m.getN() == 2);
        boolean c3 = orderedMenus.stream().anyMatch(m -> m.getN() == 3);
        if (c1 && c2 && c3)
            total -= 50;

        return total;
    }
}

Menu クラスに getPrice(), getN() も追加して、合計金額算出の呼び出し部分はこうなります。

Main.java
        ...

        // 合計金額を算出する
        int total = machine.getTotal();
        System.out.printf("合計金額: %d 円\n", total);
    }
}

メニューの表示

次に、処理の本体となるメニュー注文部分です。入力をどうするかに先に頭が向いてしまいがちですが、画面に表示しないと何を入力してよいか分からないので、先に表示から考えます。メニューは Category が持っているので、表示するメソッド(onDraw())はここに定義します。引数は出力先オブジェクトにします。標準出力に表示するなら引数なしでもいいのでは、と思うかもしれませんが、経験上出力先は抽象化しておいたほうが良いことが多いです。これは、「出力内容を生成する処理」と「実際に出力する処理」は分離したほうが良い場合が多いからです。この記事では、きちんと分離できていないのですが。

話を戻して表示処理ですが、ここで、オプションは選択したメイン商品やサイドによって変わる、と書かれていることに気づきます。先に言ってくれ…(※問題文をちゃんと読んでないだけです)。つまり、表示方法がオプションとそれ以外で異なる=処理が異なるので、継承を使う必要がありそうです。Categaoryクラスは、abstract classにし、MainCategory, Side1Category, Side2Category, OptionCategoryクラスを作ります。なお、interface ではなく abstract class にしたのは、おそらく各サブクラスで共通で保持する変数「分類番号」がありそうだからです(あと「メニュー」も)。

Category.java
public abstract class Category {
    protected final int no;
    protected List<Menu> menus = new ArrayList<>();

    public Category (int no) { this.no = no; }
    public final void addMenu(Menu menu) { menus.add(menu); }
    public abstract void onDraw(PrintStream out) throws IOException;
}
MainCategory.java
public class MainCategory extends Category {
    public MainCategory(int no) { super(no); }

    @Override
    public void onDraw(PrintStream out) throws IOException  {
        out.println("*** メイン商品 ***");
        menus.stream().forEach(m -> out.printf("%d: %s (%d 円)\n", m.getNo(), m.getName(), m.getPrice())
    }
}

(サイドメニュー1、サイドメニュー2も同様なので省略)

OptionCategory.java
public class OptionCategory extends Category {
    public OptionCategory(int no) { super(no); }

    @Override
    public void onDraw(PrintStream out) throws IOException {
        out.println("*** オプション ***");
        // メイン商品、サイド1、サイド2で選択されたメニューに応じて、出力を変えたい
    }
}

ここで、オプションのメニュー表示には、選択されたメニュー情報が必要だということに気づきます。選択されたメニューは、TicketMachine が持っているため、その値がもらえるように引数に追加します(当然、Category クラスなどにも追加します)。

OptionCategory.java
public class OptionCategory extends Category {
    ...

    @Override
    public void onDraw(PrintStream out, List<Menu> selectedMenus) throws IOException {
        out.println("*** オプション ***");
        // 表示するメニューの取得
        List<Menu> shownMenus = menus.stream()
            .filter(m -> selectedMenus.stream().anyMatch(m2-> m.getO() == m2.getO()))
            .collect(Collectors.toList());
       // 取得したメニューの表示
       shownMenus.stream().forEach(m -> out.printf("%d: %s (%d 円)\n", m.getNo(), m.getName(), m.getPrice()));
    }
}

呼び出し元も修正します。

TickerMachine.java
public TickerMachine {
    ...

    public void onDraw(PrintWriter writer) throws IOException {
        for (int i = 0; i<menus.length, ++i) {
            categories[i].onDraw(writer, selectedMenu);
        }
    }

メニューの入力

表示ができたので、ようやく入力処理に移れます。メニューの入力処理の場所ですが、ぱっと思いつくのは、入力用のメソッド waitFor() を実装する、出力処理 onDraw() の中に一緒にしてしまう、の2通りが考えられます。ポイントは(メニューの出力 → メニューの入力)という一連の処理を各カテゴリーで行うのですが、そのループ終了条件をどこに書くか、になります。waitFor() にすると main 側、onDraw() に入れると TicketMachine の中に書くことになります。どのカテゴリーの処理を行っているかは、TicketMachine が知っているので(TicketMachine がカテゴリーを持っているから)、今回は onDraw() の中で処理することにします。

onDraw() に、出力と同様、入力用のオブジェクトを引数に追加します。

TickerMachine.java
public TickerMachine {
    ...

    public void onDraw(PrintWriter writer, BufferedReader reader) throws IOException {
        for (int i = 0; i<menus.length; ++i) {
            categories[i].onDraw(writer);
            writer.print("> ");
            int selected = Integer.parseInt(reader.readLine());
            selectedMenus.add(categories[i].getMenu(selected));
        }
    }
}

Category には、メニューを取得するメソッドを追加しておきます。メニューの取り出し方はすべての Category で同じであるため、スーパークラスで実装すればいいでしょう。また、メニューの取り出し方が Category によって変わる可能性が低いので、オーバーライドを防ぐ final をつけておきます。final を付けるかどうかは賛否両論あると思いますが、

Category.java
public abstract class Category {
    ...

    public final Menu getMenu(int no) { 
        menus.stream().filter(m -> m.getNo() == no).findFirst().orElseThrow();
    }
}

細かい処理を TicketMachine に実装したので、main は呼び出すだけで済みます。

Main.java
    ...
    // メニューを注文する
    machine.onDraw(System.in, new BufferedReader(new InputStreamReader(System.in)));

    ...

これでいったん動くものが一通り実装できました。しかし実際動かしてみると、何か足りないような…

足りないものを追加していこう(2周目)

問題文をよく読むと、サイドメニューは複数選択可能と書いてあることに気づきます(問題はよく読もう)。実際の発券機では「次へ」みたいなボタンがあって、次のカテゴリーのメニューを選択する画面に行くのでしょうが、CLIなので、「9」を入力したら次のカテゴリーへ進む、という仕様にします。メニューが9個以上になったらどうするんだ、という心配性な人は、別に A でも N にしても構いません。

ただし、複数メニューが選べるのはサイド1とサイド2だけなので、サイド1とサイド2だけ「9」を表示するようにします。

Side1Category.java
public class Side1Category extends Category {
    ...

    @Override
    public void onDraw(PrintWriter out) {
        out.println("*** サイドメニュー1 ***");
        menus.stream().forEach(m -> out.printf("%d: %s (%d 円)\n", m.getNo(), m.getName(), m.getPrice());
        out.println("9: 次のメニューへ進む");
    }
}

Side2Category も同様です。

TicketMachine#onDraw() はこんな風になるのですが…

TickerMachine.java
public TickerMachine {
    ...

    public void onDraw(PrintWriter writer, BufferedReader reader) throws IOException {
        for (int i = 0; i<menus.length; ) {
            // メニューの出力
            categories[i].onDraw(writer);
            // メニュー入力
            writer.print("> ");
            // 選択されたメニューを追加
            int selected = Integer.parseInt(reader.readLine());
            if (selected != 9) {
                selectedMenus.add(categories[i].getMenu(selected));
            }

            (...次のカテゴリーへ進む判定をしたい...)
        }
    }
}

ここで、「次のカテゴリーへ進む判定をしたい」の部分をどうするか、が問題になります。メイン商品とオプションのときは常に次のカテゴリーへ進み、サイド1とサイド2は「9」が選択されたら次のカテゴリーへ進む処理になります。ここでやってはいけないことは、面倒だからメイン商品とオプションにも「9」を実装することや、categories[i] がサイド1かサイド2であるかを instanceof など if で判定しようとすることです。

「9」を実装してしまうと、メイン商品とオプションが複数のメニューが選択できてしまいます。複数のメニューが選択されたらエラーにすればいいのでは、と考える人もいるかもしれませんが、それは「複数メニューが選択できない」のではなく、「複数メニューを選択しようとしたらエラーになる」実装です。複数メニューが選択できない仕様なら、複数メニューが選択できないように実装すべきです。

また、instanceof などによる判定が悪いのは、あまり説明はいらないでしょう。何のために継承を使ったのか、というそもそも論になってしまいます。type check による分岐は最終手段とすべきです。

ではどうすればいいのか? もう一度やろうとしている処理を見ると、「メイン商品とオプションのときは常に次のカテゴリーへ進進み、サイド1とサイド2は「9」が選択されたら次のカテゴリーへ進む」です。つまり、サイド1、2とメイン商品、オプションのときと動作が異なっています。だから、ポリモーフィズムを使うところです。

つまり、次のカテゴリーへ進むかどうかを判定するメソッドを追加すればよい、ということになります(Javaでポリモーフィズムの実現方法はメソッドしかないから)。各 Categorynext() を実装しましょう。MainCategoryOptionCategory は常に次のカテゴリーへ進むので、true を返すだけです。

MainCategory.java
public class MainCategroy extends Category {
    ...

    @Override
    public boolean next(int no) {
        return true;
    }

サイドは「9」が選択されたら次のカテゴリーへ進みます。

Side1Category.java
public class Side1Category extends Category {
    ...

    @Override
    public boolean next(int no) {
        return no == 9;
    }
}

これで、呼び出し元はこうできます。

TickerMachine.java
public TickerMachine {
    ...

    public void onDraw(PrintWriter writer, BufferedReader reader) throws IOException {
        for (int i = 0; i<menus.length; ) {
            // メニューの出力
            categories[i].onDraw(writer);
            // メニュー入力
            writer.print("> ");
            int selected = Integer.parseInt(reader.readLine());
            // 選択されたメニューを追加
            if (selected != 9) {
                selectedMenus.add(categories[i].getMenu(selected));
            }

            // 次のカテゴリーへ進む
            if (categories[i].next()) {
               ++i;
            }
        }
    }
}

ちなみに、サイド1、2で同じ商品を何度も注文できてしますが、特に問題文には「同じ商品を注文できない」とは書かれていないので、許容することにします。2つ注文したい人がいるかもしれないしね。

完成? その前にプログラムの掃除をしよう

これで一通り問題文の仕様を実装した(はず)です。動くプログラムもできました。完成でしょうか? いえ、ここで終わりにしてはいけません。最後にプログラムをきれいにします。ボトムアップで設計すると、その場の思い付きでの実装になり、全体から見ると命名などに一貫性が無かったりすることが多いです。そのため、動いたら終わり、ではなく、最後にプログラムを掃除するフェーズを入れたほうがいいです。

やり方は、一般的なリファクタリングと変わりません。基本的に動作が変わらないので、リファクタリングの手法が使えます。汚そうな箇所を見つけたら、リファクタリングのマニュアル通りに進めます(今回はユニットテストを作っていませんが)。今回の記事で気になるところをピックアップして修正していきます。ちなみに、どの部分を汚く感じるかは、完全に主観です。つまり、汚いと思った箇所を修正していけばいいです。ちなみに私は、このフェーズでJavadocコメントを付けていきます。

汚い箇所をきれいにする

個人的に一番気にくわないのは、TicketMachine の生成です。カテゴリーはすでに TichektMachine の内部にしかなく、main 側が知る必要もないので、このクラスの構築を main でやりたくないです。TicketMachine のコンストラクタに移してしまいましょう。

TicketMachine.java
public class TicketMachine {
    ...

    public TicketMachine() {
        catogories[0] = new MainCategory();
        catogories[0].addMenu(new Menu("牛丼", 1, 1, 1, 380));
        catogories[0].addMenu(new Menu("豚丼", 1, 1, 1, 350));
        catogories[0].addMenu(new Menu("鮭定食", 1, 1, 0, 450));

        catogories[1] = new Side1Category();
        (...以下長いので省略...)

    }

    ...

これにより、TicketMachine から setXXX メソッドがすべて削除できます。また呼び出し元の main 全体はこうなります。

Main.java
public class Main {
    public static void main(String[] args) throws Exception {
        // 初期化
        TicketMachine machine = new TicketMachine();

        // メニューを注文する
        machine.onDraw(System.out, new BufferedReader(new InputStreamReader(System.in));

        // 合計金額を算出する
        int total = machine.getTotal();
        System.out.printf("合計金額: %d 円\n", total);
    }
}

使用していないクラス、メソッド、インスタンス変数を削除する

  • メソッド名の変更

メニューの入力と出力は、メソッドを分ける可能性もあったため、出力を onDraw() としましたが、入力も行うことにしたため、このメソッド名はどこか浮いています。show() くらいのメソッド名にしておきましょう。

  • User の削除

存在自体覚えてないかもしれませんが、一回も出てこなかったので、削除してしまいましょう。このクラスが不要だった理由ですが、後付けですが、MainUser の役割になりましたが。

  • Category クラスはこのまま

Side1CategorySide2Category は全く同じことをしているので、AbstractSideCategory を作って共通化したほうがいいのでは、と考えた人もいるかもしれません。作れば「サイド3を追加」という仕様変更にも対応できますし。正直悩ましいところなのですが、私は作らないと思います。私の共通化の指針は次のようにしているからです。

  • 同じ処理が2回までなら、コピペを許す(共通化する場合もあり)
  • 同じ処理が3回以上出てきたら、共通化する

今回は、サイド1とサイド2の2回なので、自分の指針としては許容範囲であり、なおかつ Side1Category 自体が大した規模のクラスではないので、メンテ可能、という判断です。もちろん、AbstractSideCategory を作るのが間違いということはありません。

まとめ

自己流オブジェクト指向の設計方法をまとめておきます。

    1. まずはトップダウンでクラスの抽出とメソッドやインスタンス変数を決めていく
    • 分かる範囲で抽出する。足りないものは後で追加すればいい、くらいの気持ちで気軽にやる
    1. 足りないクラスやメソッドなどをボトムアップで追加、不要なものは削除していく
    • クラスやメソッドがごっそり削除、ということもある。もったいないから、とか考えない。使えない実装はあるだけで害悪、という意識を持とう。
      1. を繰り返す。
    • 普通は1回では終わらない。この記事では2回で完走したが、通常は3回以上かかる。
    1. 完成、終了、ではない。最後にプログラムの掃除を。
    • 一般的なリファクタリングの手法で行う。単体テストを作ってない? じゃあ、ここで作ってしまおう!

また、記事中に出てきた、自己流設計指針も整理しておきます。

  • java.util.List か配列か?
    • 固定長だとはっきりしている場合は配列
    • それ以外では List を使う
  • 継承を使う? 使わない?
    • 処理(メソッド)が変わるときはポリモーフィズムで表現するので継承を使う。
    • データ(インスタンス変数/プロパティ)だけしか変わらない場合は、継承は使わない。
  • interface? abstract class
    • 実はクラス設計上はどちらも差がない。(だからどちらでもいいし、文法上の制約でしかない)
    • インスタンス変数を持たせたいなら、(文法上の制約で)abstract class にする。
    • そうでないなら interface にする。
  • コンストラクタで設定したインスタンス変数も値が変わらないなら、final を付ける。
    • このクラスが不変(immutable)であることを明示する。primary type ではないと効果半減だが、明示するため primary type ではなくても付けておく。
  • いつ共通化する?
    • 2回まではコピペを許容
    • 3回以上は必ず共通化

おわりに

オブジェクト指向の初心者がこの記事を読んでいたら、クラス設計は難しい、と感じたかもしれません。はい、難しいです。なので、最初は1つずつゆっくり丁寧にやり、慣れてきたら少しずつ速くしていくといいと思います。特に 1. は何%くらいまでやればいいのか、疑問があるかもしれませんが、最初は 70% ~ 80% くらいを目標にしておくといいと思います。慣れてくると 60% 程度でも 2. で修正が効くのでなんとかなります。もし、仕事でプログラムをする人ならば、こんなことを数年続けていれば、息をするようにできるようになります。私もこの規模の設計ならば、直感と感覚で(考えてない、とも言う)設計しています。というか、実際私はあまりクラス設計を考えてしていません。今回記事にするにあたり、がんばって言語化したのですが、文字として書き出してみるまで、どう考えているか自分でも分かってなかった…

なお、元の問題には、掲載した部分の後に状態を表す3桁コードや状態遷移図を使って… と続くのですが、この記事を読んでいただいた通り、こんなものは一度も出てきません。その理由は、状態の管理は各クラス(TicketMachineや各Categoryクラス)に分散したからです。各クラスが自分の責任/役割を果たしてくれている限り、状態遷移を考慮してプログラムする必要はないでしょう(テストでケースを起こすときには、ああいった2次元の表は必要になるでしょうが)。個人的に複雑な要素を一か所にまとめて管理するやり方は好きではないです、特に多次元配列をつ使うやり方は。読み解くのが大変だし、大抵「1列見ているところ間違った」とかなりますし(メニューが増えました、からの1列ずれていました、までテンプレ)。

本当は、この後に作ったクラスの分類や、OOP(Object Oriented Principals/オブジェクト指向原理)の話もしたかったのですが、さすがに記事長すぎなので、別の機会があったら、にしたいと思います。長文おつきあいありがとうございました。

161
153
10

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
161
153

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?