🐷

オブジェクト指向で最強のクラス考えてみた

2021/11/15に公開

バリエーションにおいて汎用性の高いクラス構成と方針

バリエーションにおいて汎用性の高いクラス構成を考えてみました。最強と言いつつ、interfaceと「継承よりも委譲」を組み合わせただけです。あれこれってBridgeパターンでは。。 継承しか使ったことのない人へのヒントになればと思います。意見・指摘歓迎です。

ポイントは以下を分離して考えることです。

  • データ構造
  • 振る舞い
  • アルゴリズム

データ構造とは、データの集合がもつ値です。レコードです。
振る舞いとは、メソッド名、関数名のことです。インプット(引数)とアウトプット(返り値)の型も定められていることが多いです。
アルゴリズムとは、振る舞いの実際の内部実現です。

データ構造と振る舞いをあわせてデータ型が決まります(int型 129054等の数字 四則演算)。

例えば、sortというものは、振る舞いです。内部のアルゴリズムは複数選択できます。

オブジェクト指向においては上記3つを

  • クラスフィールド値
  • クラスメソッド
  • クラスメソッドの実装

に対応させます。

そして方針は以下の通りです。

  • 継承は利用しない。
  • 共通の振る舞いはinterfaceで強要する。
  • 共通のアルゴリズムは専用のクラスを作成し委譲する。
  • 共通のデータ構造は冗長だがすべてに記述する。
  • 共通でない振る舞いは、単にそのクラスに振る舞いを追加する。
  • 共通でないアルゴリズムは、他のクラスのようにアルゴリズム専用クラスには委譲せずに、直接記述する。
  • 共通でないデータ構造は、単にそのクラスに新しくフィールドを追加する。

継承を利用しない理由としては、継承はデータ構造と振る舞いとアルゴリズムを引き継ぐからです(アルゴリズムの上書きは可能)。バリエーションのなかにはデータ構造が異なるものも現れます。するとその差分を吸収するために、ベースとなるクラスが肥大化し、かつ子クラスに必要としないフィールド値が大量発生していきます。つまり、継承は共通でないデータ構造に弱いのです。

サンプル

以上を基づいてサンプルクラスを作って見ました。ここから必要に応じて機能を追加したり減らしたりします。

base
// 共通の振る舞い(定義)
interface Common {
  doSomething: () => void;
}

// 共通のアルゴリズム
class HogeAlgorithm {
  do(){}
}

class Apple implements Common {
  private algorithm: HogeAlgorithm;
  private color: string; // 共通のデータ構造
  
  constructor(){
    this.hogeAlgorithm = new HogeAlgorithm();
    this.color = 'red';
  }
  
  // 共通の振る舞い(実装)
  doSomething() {
    // 共通のアルゴリズムに委譲
    this.hogeAlgorithm.do();
  }
}

class Banana implements Common {
  private algorithm: HogeAlgorithm;
  private color: string; // 共通のデータ構造
  
  // 共通のアルゴリズムを注入
  constructor(){
    this.hogeAlgorithm = new HogeAlgorithm();
    this.color = 'yellow';
  }
  
  // 共通の振る舞い(実装)
  doSomething() {
    // 共通のアルゴリズムに委譲
    this.hogeAlgorithm.do();
  }
}

以降は、共通でないデータ構造を持つものを合わせていくと上のクラスがどのように変化していくかを見ていきます。

説明のためにもう少しシチュエーションに具体性をもたせます。

博物館で展示品が自らを紹介するような近未来的なシステムがあるとします。地球上のモノはほぼ絶滅し、りんごとバナナを展示しています。

step1
interface Exhibit {
  explainMe: () => string;
}

class FruitExplainAlgorithm {
  explain(color: string){
    return `I'm a sweat fruilt!. My color is ${color}!`;
  }
}

class Apple implements Exhibit {
  private explainAlgorithm: FruitExplainAlgorithm;
  private color: string;
  
  constructor(){
    this.explainAlgorithm = new FruitExplainAlgorithm();
    this.color = 'red';
  }
  
  explainMe(): string {
    return this.explainAlgorithm.explain(this.color);
  }
}

class Banana implements Exhibit {
  private explainAlgorithm: FruitExplainAlgorithm;
  private color: string;
  
  constructor(){
    this.explainAlgorithm = new FruitExplainAlgorithm();
    this.color = 'yellow';
  }
  
  explainMe(): string {
    return this.explainAlgorithm.explain(this.color);
  }
}

コードに具体性が出てきました。共通の振る舞い共通のアルゴリズムを表現しています。ここで、新しく服を展示することになりました。ただし服はもとの色がわからないほどボロボロだったので、素材だけ情報としてのせることにしました。また、服は試着もできるようにしました。

step1(追記)
...中略

class Clothes implements Exhibit {
  private explainAlgorithm: FruitExplainAlgorithm;
  private material: string; // 共通でないデータ構造
  
  constructor(){
    this.explainAlgorithm = new FruitExplainAlgorithm();
    this.material = 'wool';
  }
  
  // 共通の振る舞い
  explainMe(): string {
    // おっとこのアルゴリズムはフルーツ専用だった!!
    // 変更する必要がある。
    return this.explainAlgorithm.explain(this.color);
  }
  
  // 共通でない振る舞い
  tryOn() {
    // 試着アルゴリズム
  }
}

服はフルーツではないので、FruitExplainAlgorithm は使えません。そこで服用の場合は直接アルゴリズムを書きます。

step1(追記修正)
...中略

class Clothes implements Exhibit {
  private material: string; // 共通でないデータ構造
  
  constructor(){
    this.material = 'wool';
  }
  
  // 共通の振る舞い
  explainMe(): string {
    // 共通でないアルゴリズム
    return `I'm a piece of clothes!. My material is ${material}!`;
  }
  
  // 共通でない振る舞い
  tryOn() {
    // 試着アルゴリズム
  }
}

とりあえずここまでで、なんとなく方針がつかめたのではないでしょうか?

ここからの派生を考えてみたいと思います。

派生1: 新しいアルゴリズムを定義する(服の種類が増える)

服の種類が増えることで、Clothesクラスが分解され、かつアルゴリズムが共通であれば、ClothesExplainAlgorithm というものが作成されるかもしれません。

hasei1
...中略

class ClothesAlgorithm {  
  explain(material: string){
    return `I'm a piece of clothes!. My material is ${material}!`;
  }
  
  tryOn() {
    // 実装 
  }
}

class Sweater implements Exhibit {
  private algorithm: ClothesAlgorithm;
  private material: string;
  
  constructor(){
    this.algorithm = new ClothesAlgorithm();
    this.material = 'wool';
  }
  
  explainMe(): string {
    return algorithm.explain(this.material);
  }
 
  tryOn() {
    this.algorithm.tryOn();
  }
}

class OnePiece implements Exhibit {
  private explainAlgorithm: ClothesExplainAlgorithm;
  private material: string; // 共通でないデータ構造
  
  constructor(){
    this.material = 'wool';
  }
  
  // 共通の振る舞い
  explainMe(): string {
    return algorithm.explain(this.material);
  }
  
  // 共通でない振る舞い
  tryOn() {
    this.algorithm.tryOn();
  }
}

派生2: アルゴリズムを注入する

最初のベースに戻るのですが、コンストラクタからアルゴリズムを注入するのもありだと思います。例えばAppleに対してインスタンス化のときに、それぞれ異なるアルゴリズムを定義したくなるかもしれません。アルゴリズム自体にバリエーションをつければ、Strategyパターンになります。

hasei2
class Apple implements Common {
  private algorithm: HogeAlgorithm;
  private color: string; // 共通のデータ構造
  
  constructor(algorithm: HogeAlgorithm){
    this.algorithm = algorithm;
    this.color = 'red';
  }
  
  // 共通の振る舞い(実装)
  doSomething() {
    // 共通のアルゴリズムに委譲
    this.hogeAlgorithm.do();
  }
}

派生3: interfaceを消す

共通の振る舞いを持たないのであれば最初からinterfaceを定義しない。振る舞いが違っていて、アルゴリズムが共通のとき、それは本当にたまたまなのか、それとも実は振る舞いが共通だったというオチな気がする。あまり派生3は使わなさそう。

Discussion