iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

クラス継承とどう付き合えばよいかわからなくなったので、Goを書いてみた(Composition over inheritance)

はじめに

iimonでエンジニアをしています。腰丸です。
突然ですが、Go言語がクラス継承をサポートしていないことをご存知でしょうか?(結構有名かもしれません)
言語として、意図的にクラス継承をサポートしていないって大丈夫なのか?という疑問もありますが、確かにクラスの継承ってネックになることが多い気もしています。
ちょうど良い機会なので、Goを使用してクラス継承を使用せずに、クラス継承で行ってきたような実装を実現する方法と、クラス継承の良し悪しについて考えてみました!

私がクラス継承に関して思うところ

  • 微妙だと思っているところ
    • 抽象化の粒度がよくわからない(is-aとか範囲広すぎない?)
    • 深いネストがあると見るのしんどい(ヨーヨー問題)
    • 影響範囲わからなくて怖い
    • SOLIDとかの原則を守るのが難しい(仮に現時点で問題なくても将来的に守れるのかがよくわからない)
  • 良いなと思うところ
    • 実装が楽(これはやっぱり圧倒的なメリット)
    • オブジェクト間の関係がわかりやすい
    • ポリモーフィズムが使えて楽なこともある

諸先輩方の意見

クラス継承はなくてもいいのか?

  • やっと本題ですが、クラス継承という概念が存在しないGo言語は、代わりにどういう考えや仕組みを提供してくれているのかということを調べてみました。重要な考えとして、 Go言語には、「継承より合成(Composition over inheritance)」という明確な方針があります。実際のコードをもとに見てみましょう。
type Animal struct {
    Name string
}

func (a *Animal) Speak() {
    fmt.Println(a.Name, "Animalのインスタンスの方の鳴き声")
}

func (a *Animal) Sleep() {
    fmt.Println(a.Name, "sleep")
}

func (a *Animal) SpeakAndSleep() {
    a.Speak()
    a.Sleep()
}

type Cat struct {
    Animal // Animal 構造体の埋め込み
}

type Bird struct {
    Animal // Animal 構造体の埋め込み
    CanFly bool
}

func (b *Bird) Fly() {
    if b.CanFly {
        fmt.Println(b.Name, "is flying")
    } else {
        fmt.Println(b.Name, "can't fly")
    }
}

func (b *Bird) Speak() {
    fmt.Println(b.Name, "鳥のインスタンスの方の鳴き声")
}

func main() {

    bard := &Bird{
        Animal: Animal{Name: "Sparrow"},
        CanFly: true,
    }

    cat := &Cat{
        Animal: Animal{Name: "Cat"},
    }

    bard.Speak()
    bard.SpeakAndSleep()
    bard.Fly()
    cat.Speak()
    cat.SpeakAndSleep()
}
Sparrow 鳥のインスタンスの方の鳴き声
Sparrow Animalのインスタンスの方の鳴き声
Sparrow sleep
Sparrow is flying
Cat Animalのインスタンスの方の鳴き声
  • このコードを見てみると、CatとBirdはAnimalを埋め込んでいることがわかります。この部分が、CatとBirdがAnimalを継承ではなく、合成しているということになるようです。
type Cat struct {
    Animal // Animal 構造体の埋め込み
}

type Bird struct {
    Animal // Animal 構造体の埋め込み
    CanFly bool
}

良いなと思ったところ

  1. 合成されたクラスで元のコードをオーバライドしても、合成元のクラスのメソッドに影響がでにくい
func (b *Bird) Speak() {
    fmt.Println(b.Name, "鳥のインスタンスの方の鳴き声")
}
  • こうやってオーバーライドしてるけど、合成元のAnimalのSpeakメソッドには影響がない
func (a *Animal) SpeakAndSleep() {
    a.Speak()
    a.Sleep()
}
  • 合成先のクラスでSpeak()がオーバーライドされてても、SpeakAndSleep()内のa.Speak()は、合成元のAnimalのSpeakメソッドを呼び出している部分の出力
Sparrow Animalのインスタンスの方の鳴き声
Sparrow sleep
  • つまり、具象クラスを継承して、メソッドのオーバーライドすることによって、親クラス側のメソッドに影響がでるみたいな実装が避けられる。

  • 継承のネストを避けた拡張がしやすい

たとえば、フクロウ構造体や、ペンギン構造体のような、Bardよりも具体的な構造体(クラス)を作りたいとなったときに、何も考えずにjsでそのまま実装すると、おそらく下記のようなネストの構造ができやすいと思います。

class Animal
class Bird extends Animal
class Penguin extends Bird

今回のGoのコードの場合は、Bardのみの振る舞いを切り出して、下記のような構造体を作ることで、機能の拡張ができるのではと思っています。

type Penguin struct {
    Animal
    Bird
}

こんなにうまくいかないとしても、個別の事象によって共通でしようしたい機能ができたときに、その機能を切り出して合成することで、継承のネストを避けた実装がしやすいのではないかと思っています。

最終的なコード

今のままだと、Animalという共通のふるまいに対するポリモーフィズムができていないので、下記のように、Animalをインターフェースにして、Animalを埋め込んだ構造体がAnimalのインターフェースを満たすようにしてみました。 Bard構造体固有のメソッドを実行するためにtypeを確認し、ポリモーフィズムを使用しつつ、安全にBard構造体固有のメソッドを実行できるようにしました。

package main

import "fmt"

type AnimalInterface interface {
    Speak()
    Sleep()
    SpeakAndSleep()
}

type Animal struct {
    Name string
}

func (a *Animal) Speak() {
    fmt.Println(a.Name, "Animalのインスタンスの方の鳴き声")
}

func (a *Animal) Sleep() {
    fmt.Println(a.Name, "sleep")
}

func (a *Animal) SpeakAndSleep() {
    a.Speak()
    a.Sleep()
}

type Cat struct {
    Animal
}

type Bird struct {
    Animal
    CanFly bool
}

func (b *Bird) Fly() {
    if b.CanFly {
        fmt.Println(b.Name, "is flying")
    } else {
        fmt.Println(b.Name, "can't fly")
    }
}

func (b *Bird) Speak() {
    fmt.Println(b.Name, "鳥のインスタンスの方の鳴き声")
}

func main() {
    var animal AnimalInterface
    
    animal = &Bird{
        Animal: Animal{Name: "Sparrow"},
        CanFly: true,
    }

    cat := &Cat{
        Animal: Animal{Name: "Cat"},
    }

    animal.Speak()
    animal.SpeakAndSleep()
    // typeを確認して、ポリモーフィズムを使用しつつ、安全にBard構造体固有のメソッドも実行できるようにする
    switch v := animal.(type) {
        case *Bird:
            v.Fly()
        default:
    }
    cat.Speak()
}

TypeScriptで同じようなコードを書いてみた

  • せっかくなので、TypeScriptで同じような実装を書いてみました。Goの合成のようなクラス継承を前提としないサポートがないため、ちょっと助長になりますが一応こんな感じで書けました。
interface AnimalInterface {
    speak(): void;
    sleep(): void;
    speakAndSleep(): void;
  }
  
  class Animal {
    name: string;
  
    constructor(name: string) {
      this.name = name;
    }
  
    speak(): void {
      console.log(`${this.name} Animalのインスタンスの方の鳴き声`);
    }
  
    sleep(): void {
      console.log(`${this.name} sleep`);
    }
  }
  
  class Bird implements AnimalInterface {
    private animal: Animal;
    canFly: boolean;
  
    constructor(name: string, canFly: boolean) {
      this.animal = new Animal(name);
      this.canFly = canFly;
    }
  
    speak(): void {
      console.log(`${this.animal.name} 鳥のインスタンスの方の鳴き声`);
    }
  
    sleep(): void {
      this.animal.sleep();
    }
  
    speakAndSleep(): void {
      // Animalのspeakメソッドを呼び出す
      this.animal.speak();
      this.sleep();
    }
  
    fly(): void {
      if (this.canFly) {
        console.log(`${this.animal.name} is flying`);
      } else {
        console.log(`${this.animal.name} can't fly`);
      }
    }
  }

    class cat implements AnimalInterface {
        private animal: Animal;
        canFly: boolean;
      
        constructor(name: string, canFly: boolean) {
          this.animal = new Animal(name);
          this.canFly = canFly;
        }
      
        speak(): void {
          this.animal.speak();
        }
      
        sleep(): void {
          this.animal.sleep();
        }
      
        speakAndSleep(): void {
          this.animal.speak();
        }

      }
  
  function main() {
    let animal: AnimalInterface = new Bird("Sparrow", true);
  
    animal.speak();
    animal.speakAndSleep();
  
    // Birdクラスのflyメソッドを呼び出す
    if (animal instanceof Bird) {
        animal.fly();
      } else {
      }
  }
  
  main();

まとめ

  • Goの考え方的には、is-aの関係でコードを作っていくよりも、has-aの関係でコードを作っていくほうが良いというのがベースにあって、 例えば、

    • 鳥は動物である(is-a) -> 鳥というクラスに動物を継承する
      というように親子関係に着目して、具体的な実装を追加していくよりも
    • 鳥は鳴き声を上げる、眠る、飛べたり飛べなかったりする -> 鳥というクラスに、こういった鳥としての振る舞いを一定共通化しながら、具体的な実装を追加していく
      というように、具象化されるクラスの振る舞い(has-a)に着目して機能を拡張するのが安全というのが、Goの主張(継承より合成という考え方)なのかなと思いました。
  • 今回のGoのコードを書いてみて、合成というアプローチでクラス継承を避けるという考え方は、結構良い気がしています。 継承を使うことに不安がでたり、将来的な安全性が担保できなさそうなときは、合成(委譲)といったアプローチで対応してみるのも良いのではないかと思っています。 (Goのフレームワークをちょっと勉強してみようかな)

  • 結局のところjsなどのクラス継承が基本機能としてある言語でGoのような思想を持ち出すのが良いのかがわかりませんでした。 クラス継承は使い所を間違えなければ、便利で良いものだとも思いますし、なによりコーディングが楽なのは間違いないので、 ちょっとだけ慎重になりつつも、やっぱり今後もクラス継承は使うことにはなるかなと思います。