クラス分割・メソッド分割の基準

本日はクラス分割・メソッド分割の基準について書こうと思います。

昨日の続きとして「潔癖症プログラマは面倒くさい (メソッド行数編)」みたいなタイトルにしようかと思ったのですが

私の分割基準を書く方が有意義だと思ったのでタイトルを「クラス分割・メソッド分割の基準」としました。

ちなみに私は仕事で主に Java を利用していますので、以下は Java での話だとご理解ください。

想定読者はプログラミング歴1~2年程度でしょうか。

クラスの分割基準について

1クラス50行にすべき?

いいえ、そんなことはありません。

1クラス50行というのは全くもってアホな基準です。

たぶん世に出ている基準として一番厳しい基準がこの「1クラス50行」だと思うのですが、一昔前に『オブジェクト指向なんとかエクササイズ』みたいな名前の本の中で書かれてて、ちょっと話題になったんですよね。

で、当時の若い人がそれを信じて実践しちゃったりしていました。かわいそうに。

気になる人は「1クラス50行」でググると結構出てくるので読んでみるといいかもしれません。

ただ行数でクラスを分割するのは全くもって本質的な分割基準ではないので騙されないように。

クラスの形態

一口にクラスといっても使われ方は様々です。

私は大きく以下の 2+1 のタイプに分類して考えています。

  • データ (抽象データ型) を表現する箱
  • 処理 (メソッド) を書く箱
  • 定数を置く箱 (これは無い場合もある)

データ (抽象データ型) を表現する箱

すごく乱暴に言えば、どんなプログラムも何らかの値 (データ) に対し、何らかの処理をするだけです。

つまりプログラムはデータと処理の2要素でできています。

この内、データについて、抽象データ型として表現する機能がクラスにはあります。

抽象データ型とは

抽象データ型というのは、抽象化されてデータ型のことです。そのままですね。

例えば String について考えてみます。

おそらく教科書などでは String = 文字列型 と書いてあると思うのですが、文字列型とは何でしょうか?

そもそもコンピュータは0か1しか扱えません。

それを 8bit ごとに区分けて 1byte とし、

1byte の数列を byte型配列とし、

byte 型配列を一定の基準によって char 型配列に変換し、

char 型配列を文字列型として扱いやすくしたのが String というクラスです。

元のデータは0と1でしかないのに、なぜ我々は String クラスを文字列型として操作できるのでしょうか?

それは String クラスを作った人が、データの処理の仕方をクラス内に隠蔽しつつ、文字列型として必要なメソッドのみを公開しているからです。

つまり「文字列型としてこちらでよろしく処理しておくから、あなたは何をしたいかだけを教えてください」という形式にしているからです。

これにより、我々は String を文字列型として扱えます。これが抽象データ型の考え方です。

そしてこの抽象データ型は、我々プログラマ自身が定義することも可能です。

簡単な例で言うと以下の Point は自分で定義した抽象データ型です。

class Point2D {
    private final double x;
    private final double y;

    Point2D(double x, double y) {
        this.x = x;
        this.y = y;
    }

    double getX() { return x; }

    double getY() { return y; }
}

この例においては、Point2D は中身自体は double の x と y しか持っていません。

しかしその2変数は private 変数として隠蔽され、Point2D は外から見れば2次元座標データとしての役割を果たすことができます。

これだけだと「x と y が getter で外に露出してるから隠蔽されてないじゃん」と言われそうなので、変化形も出しておきます。

class Point2D {
    private final double length;
    private final double radian;

    Point2D(double length, double radian) {
        this.length = length;
        this.radian = radian;
    }

    double getX() { return Math.cos(radian); }

    double getY() { return Math.sin(radian); }
}

変化させた上の例では原点からの距離と角度で Point2D を初期化していますが、getX() と getY() は独自に計算しています。

メソッドと変数は必ずしも対応している必要はなく、「そのデータは何であるか」「どのような操作ができるか」を定義しているのが抽象データ型です。

ここまで読んで「このクラスは物足りない。初期化して値を取り出しているだけではないか」と思った人もいるかもしれません。

そうですね、メソッドを増やしてみましょう。

class Point2D {
    private final double x;
    private final double y;

    Point2D(double x, double y) {
        this.x = x;
        this.y = y;
    }

    double getX() { return x; }

    double getY() { return y; }

    Point2D add(Point2D other) {
        return new Point2D(this.x + other.x, this.y + other.y);
    }
    Point2D minus(Point2D other) {
        return new Point2D(this.x - other.x, this.y - other.y);
    }
}

加算の add() と 減算の minus() を足してみました。

しかし要件によってはこれでも足りないかもしれません。

2点間の距離を求めたり、中間点を求めるメソッドが要求されるかもしれません。

場合によっては Point2D をベクトルとみなした上で、内積を求めたり外積を求めるメソッドを追加することも考えられます。

抽象データ型のクラス設計

前述したように、抽象データ型についてどのような操作を行えるべきかによって実装するメソッドが変わってきます。

通常は、必要と思われるメソッドを (無駄にならない範囲で) すべて実装することになります。

必要なメソッドが少なければ50行以内に収まることもありますし、必要なメソッドが多ければ軽く数百行に達することもあります。

このように抽象データ型のクラスは概念ありきでクラスを定義しますので、行数が増えたからクラスを分割するという発想はありえません。

処理 (メソッド) を書く箱

データを定義したら、残りは処理です。

処理のクラス分けは、簡単に言えば整理術です。

例えば20行程度の極小さいプログラムであれば1クラス1メソッドで収まりますが、これを分類して分かりやすくする技術です。

これについては説明が少し難しいのですが、私の場合はおおよそ以下の方針でなんとなく分けています。

  • 依存関係が高い処理は同じクラスに、依存関係が低い処理は別クラスに
  • 似ている性質を持つ処理は同じクラスに、性質が似ていない処理は別クラスに

依存関係が高い処理は同じクラスに、依存関係が低い処理は別クラスに

2つの例を考えてみましょう。

(a). すべての処理が1つのクラスに詰め込まれて記述されている状態。 (b). 1クラス1メソッドとして、すべての処理がばらばらに記述されている状態。 どちらも望ましいと言えないとすぐに分かると思います。

(a) については、1クラスが肥大化して、それぞれの処理がどのような関係になっているのか分からないです。

それに加えて、例えばメソッドがメソッドA~メソッドZまで26個あったとして、

  • メソッドCはメソッドX を呼び出している。
  • メソッドXはメソッドG を呼び出している。
  • メソッドGはメソッドC を呼び出している。

といったような無限ループが起こり得る状態になっていても気づきづらくなっています。

(b) は私に言わせれば (a) よりもタチが悪いです。

それぞれ別クラスに書かれているために処理を追うのが面倒ですし、書かれている場所が違うだけで本質的に何も整理されていません。

1 の無限ループ問題が1と同じくらい発生しやすい状態です。

そこで依存によってクラスを分ける考え方が生まれます。

例えば

  • メソッドA はメソッドCからのみ呼び出される
  • メソッドB はメソッドCと何ら関係がない。 という場合は
  • メソッドAはメソッドCと同じクラスの private メソッドとする。
  • メソッドBはメソッドCと別クラスに分ける。

ということをします。

これにより、メソッドAの影響範囲はそのクラス内に矮小化されます。

この整理を進めることで、各メソッドの関係性が分かりやすくなり、プログラム全体としての見通しが立ちやすくなります。

似ている性質を持つ処理は同じクラスに、性質が似ていない処理は別クラスに

これは例えば

  • データを外部から入力するクラス
  • 入力したデータに不備がないかチェックするクラス
  • 入力したデータを処理するクラス
  • データを外部へ出力するクラス
  • 上に挙げた各クラスを適切な順序で呼び出す司令塔クラス

と言ったようなイメージです。

先ほど依存関係での整理について述べましたが、このように性質によってクラスを分けた場合、たいてい依存関係もきれいになります。

(依存関係による整理を先に書いたのは、こちらを先に書くと依存関係による整理を書きづらくなるから。基本的には性質によって分けるとよいです)

現場で培った経験やその現場の文化 (クラス粒度) もありますので、新人プログラマの人は困ったら先輩に相談すればいいと思います。

定数を置く箱 (これは無い場合もある)

定数クラスと呼ばれるものです。

定数は必要なクラスに private static final で宣言されるのが普通ですが、あえて同じ性質の定数を1か所にまとめて置くことで検索性を高めることが可能になります。

面白くないので割愛。

(まとまってないけど) まとめ

所詮クラス分けなんて整理術なんです。

小さなプログラムなら1クラスに全部書いてしまっても問題ありません。

しかしある程度以上大きなプログラムになると、1クラスに書いてられないので分類しているだけなのです。

ですのでクラス分けのこと考える際は、どのように分けるのが理解しやすい分類なのかを考えるのが一番有用だと思います。

行数なんて参考にするかどうかも怪しいような基準ですので、「1クラス50行」みたいなアホな話を信じないように。

長くなってしまったので、メソッド分割については次回書きます。