🐱

【Goで理解するオブジェクト指向設計 #2】 ─ 汎化~「良い抽象」こそが最強の武器である~

に公開

汎化(Generalization)とは?

まず、汎化の意味を考えてみてください。

汎化とは、「コードの再利用」のことではありません。

それは、複数の具象的なモノ(Dog, Cat)から、共通の概念や振る舞いを見出し、それに 「意味づけ」 をすることです。

『「犬 is-a ペットである」「猫 is-a ペットである」という共通の 「is-a」関係 を見出すこと』

これが、前回の記事で見たポリモーフィズム(多態性)の基盤となります。

このように、汎化とはis-a関係の発見を通した「概念の一般化(抽象化)」を実現することが真の目的です。

汎化がもたらす「最大のメリット」とは?

では、この「汎化」という思考が、私たちに何をもたらすのでしょうか?

ここで、前回の記事の多態性のセクションの実装を思い出してください。

https://zenn.dev/hashidev/articles/ada492742d55ae

switch文の実装

OwnerがDogやCatの 「具象」 の詳細(typeフィールド)に依存していました。これは密結合であり、具象が増えるたびにOwnerが修正を余儀なくされる「不安定」な設計でした。

ポリモーフィズムの実装

OwnerはPetという 「抽象」 にのみ依存していました。
まさに、このswitch文を消滅させたプロセスこそが、「汎化」の力です。
私たちが「DogもCatも『ペット』である」という共通の is-a関係を見抜き、「Petインターフェース」という「意味づけ(抽象化)」を行った行為。あれこそが汎化なのです。

その結果、OwnerはDogやCatという「具象」の詳細に依存するのをやめ、「Pet」という「抽象」にのみ依存できるようになりました。

そして、この「抽象(Pet)」こそが、システムがどんなに拡張されても変わることのない 「安定した抽象」 なのです。

この「安定した抽象」を定義し、依存の方向をコントロール可能にすること。これこそが汎化がもたらす最大のパワーです。

では、その「良い抽象(あるいは悪い抽象)」とは一体何なのでしょうか?

どうすれば「良い抽象」を見つけられるか?

汎化の力を正しく使うためには、「良い抽象」を見つける必要があります。

しかし、多くの開発者がここで罠に陥ります。

「万能すぎる抽象」の誘惑(悪い抽象)

PetShop(ペットショップ)で売るCat(猫)をモデル化する際に、こう考えてしまうかもしれません。

「世の中にはWildCat(野良猫)もいる。だから、Catは必ずしもPet(ペット)ではないな…」

そして、「動物(Animal)」という、より万能で「完璧な」抽象概念を作り出そうとします。

真実:コンテキスト(文脈)こそが全て

しかし、そのシステムは「ペットショップ」という文脈(コンテキスト)で動いていますその文脈において、「野良猫」という概念はノイズ(無関係な情報)でしかありません。

システム化対象の問題を逸脱して万能な抽象を考えた時点で、そのシステムは機能不全に陥ります

汎化を継承で実装する際の「最大の罠」

前のセクションで、「汎化(is-a関係)」が「良い抽象」を生み出し、システムを安定させるというメリットを解説しました。

この「汎化」を実現するために、PHPやJavaといった多くのオブジェクト指向言語では「継承」(abstract classなど)を利用するのが一般的です。

しかし、この「継承」という機能そのものに、カプセル化を破壊し、密結合を生み出す「最大の罠」が潜んでいます。

汎化とコードの再利用を「ごちゃ混ぜ」にするな

PHPのような言語が持つ「継承」の罠とは、言語の仕様として、目的が全く異なる2つのことをごちゃ混ぜにできてしまう点にあります。

  1. 汎化(is-a:抽象的な「契約」を定義すること。(例:abstract public function speak()
  2. コードの再利用:共通の「実装」を提供すること。(例:public function eat() { ... }

例えば、Petという抽象クラスをPHPで書くと、この「ごちゃ混ぜ」が簡単に発生します。

<?php
// "Pet"という抽象クラス
abstract class Pet {
    // ① 汎化(抽象)の側面
    // 子クラスに「鳴く」という振る舞いを強制する
    abstract public function speak(): string;

    // ② 実装の再利用の側面
    // ★★★罠★★★
    // 「食べる」という共通の実装を、親クラス(抽象)が
    // 持ってしまっている。
    public function eat(): string {
        return "is eating standard food.";
    }
}

このコードでは、「抽象」であるはずのPetクラスが、「食べる」という 具体的な「実装」 を持ってしまっています。

これがなぜ最悪の密結合を引き起こすのか?

Owner(飼い主)がPet(抽象)に依存しているつもりでも、Petが持つeat()メソッド(実装)にも間接的に依存してしまうからです。

もしeat()の共通実装を変更したら、そのeat()を使っていたDogCat、そしてOwnerにまで影響が及ぶ可能性があります。


Go言語が選んだ「賢い分離」─ インターフェースとコンポジション

Goは、この継承の危険性を避けるため、古典的な継承が持っていた2つの役割を、言語レベルで強制的に分離しています。

1. 汎化(is-a) → interface が担当

Goのinterface純粋な「抽象」 です。メソッドのシグネチャ(契約)しか定義できません。

PHPのabstract classのように、実装や状態(フィールド)を持つことができないため、「ごちゃ混ぜ」の発生のしようがないのです。

(Goのコード例:pet/pet.go)

package pet

// 「汎化」だけを担当する、純粋な「契約」
type Pet interface {
    Speak() string
    Eat()
}

2. コードの再利用(has-a) → コンポジション(埋め込み) が担当

一方で、純粋に「コードの再利用」がしたい時のために、Goはコンポジション(埋め込み)を用意しています。

これは「is-a(の一種だ)」ではなく、「has-a(を持っている)」という、より疎結合な関係性です。

(Goのコード例:behavior/common.go)

package behavior

// 「食べる」という「実装」だけを担当する「部品」
// Petインターフェースのことは何も知らない
type CommonEater struct {
    Name string
}

func (e *CommonEater) Eat() {
    fmt.Printf("%s is eating standard food.\n", e.Name)
}

Goによる「疎結合」な解決

この2つの道具(interfacecomposition)を組み合わせることで、PHPで密結合していた問題を、Goは以下のようにクリーンに解決します。

(Goのコード例:pets/dog.go)

package pets

import "example.com/pet"
import "example.com/behavior"

// Dogストラクト
type Dog struct {
    behavior.CommonEater // ② コードの再利用(has-a)
}

// ① 汎化(is-a)
// pet.Petインターフェースの「契約」を実装する
func (d *Dog) Speak() string {
    return "Woof!"
}

// Eat()の契約も、埋め込んだ部品が満たしてくれる

Ownerpet.Petインターフェースを使う時、OwnerPetという純粋な「抽象」にのみ依存します。

DogCatが、そのEatメソッドをどう実装しているか(CommonEaterを部品として使っているか、独自に実装しているか)を、Ownerは一切知る必要がありません。

Goは、言語の設計によって「汎化」と「実装の再利用」をごちゃ混ぜにすることを防ぎ、開発者に疎結合な設計を強制します。これこそが、Goが選んだ「賢い分離」なのです。

Discussion