CodeGym /コース /C# SELF /ポリモーフィズムと抽象化の問題点

ポリモーフィズムと抽象化の問題点

C# SELF
レベル 25, レッスン 3
使用可能

1. ポリモーフィズムはいつも魔法じゃない

最初の例だけ見ると、C#のポリモーフィズムって「継承して、overrideして、全部ベース型で呼べばOK!」みたいな超合理的な仕組みに見えるよね。でも実際は細かい罠があるんだ。ちょっと詳しく見てみよう。

メソッドの隠蔽とnewキーワード

例えば、ベースクラスと派生クラスがあって、どっちにも同じ名前のメソッドがあるけど、overrideキーワードがない場合。派生クラスで同じ名前のメソッドを新しく定義しても、overrideがなければ、それはベースのメソッドを「隠す」だけで「オーバーライド」じゃないんだ。コンパイラは親切に警告して、newを明示しろって言ってくるよ。

class Animal
{
    public void Speak()
    {
        Console.WriteLine("動物が音を出す。");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("ニャー!");
    }
}

// 使い方
Animal animal = new Cat();
animal.Speak(); // 出力: "動物が音を出す。"

おおっ!Cat型のオブジェクトをAnimal型の変数に入れても、呼ばれるのはベースクラスの元のメソッドなんだ。なぜ?それはvirtual宣言されてないから!これが罠その1。ポリモーフィズムを使いたいなら、virtualとoverrideを忘れずに。newは本当に「隠したい」時だけ使おう(でもそんなケースはほとんどないし、理由がちゃんとある時だけね)。

コンストラクタ呼び出しとポリモーフィズム

もう一つの意外なポイント:コンストラクタはvirtualじゃない。ベースクラスでコンストラクタを定義して、派生クラスで自分のコンストラクタを作っても、ポリモーフィックにはならない。例を見てみよう:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Animalのコンストラクタ");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Catのコンストラクタ");
    }
}

// 使い方
Animal animal = new Cat();
// 出力:
// Animalのコンストラクタ
// Catのコンストラクタ

でも、もしベースクラスのコンストラクタからvirtual/abstractメソッドを呼ぶと、派生クラスの初期化前にvirtualメソッドが呼ばれちゃうことがある!だから、コンストラクタでvirtual/abstractメソッドを呼ぶのはやめよう。

overrideでカプセル化が壊れる問題

virtualメソッドは便利だけど、ベースクラスで「このメソッドはこう動くはず!」って思ってたのに、派生クラスでoverrideされてロジックが壊れると、思わぬバグになることも。

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("動物が食べる。");
    }

    public void Live()
    {
        Eat(); // どんなoverrideでも呼ばれる!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("猫が魚を食べる。");
    }
}

Animal a = new Cat();
a.Live(); // 出力: "猫が魚を食べる。"

もしベースクラスAnimalのEat()が「動物が食べる。」を出してたのに、派生クラスで危ないことを追加しちゃうと、クラス全体の動作が壊れるかも。これがリスコフの置換原則違反(Liskov Substitution Principle, LSP)ってやつ。設計するときは、派生クラスの動作がベースクラスの論理に合ってるか、ちゃんと考えよう。

キャストと型変換の罠

ポリモーフィズムを使うと、いろんなオブジェクトを一つの「箱」(例えばAnimal型のリスト)に入れられる。犬も猫もAnimalを継承してるからね。でも、もし特定の機能を呼びたくなったら…?

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

型チェックを忘れて無理やりキャストすると、InvalidCastExceptionみたいなエラーが出るよ。こういうのが多いと、型チェックだらけになってコードが複雑になるし、設計を見直した方がいいサインかも。

2. 抽象化の問題点

抽象化は、オブジェクトの使い方をシンプルにしたり、内部状態へのアクセスを制限したりするのに便利。でも、ここにも落とし穴がある!

抽象化しすぎ(Over-Abstraction)

初心者(だけじゃなくてベテランも)ありがちなのが、「正しいOOP」にこだわりすぎて、ベースクラスやインターフェース、抽象レイヤーを何重にも重ねちゃうこと。結果、作者本人でも仕組みが分からなくなる…。

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("ニャー!");
    }
}

中間の抽象クラスが何も追加してないなら、なんで必要?抽象化のための抽象化は、メンテも難しくなるし、アーキテクチャもごちゃごちゃになるだけ。

イケてない継承階層

例えば、ある動作を継承階層の一番上に置いたけど、実は全員に合わない場合どうなる?

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("猫は飛べないよ!");
    }
}

派生クラスで例外を投げるダミーメソッドを書くか、インターフェースが現実を反映してないのを我慢するしかない。これが典型的な「ダメな継承階層」アンチパターン。こういう時は、メソッドをIFlyableみたいな別インターフェースに分けよう。

アドバイス:「全部入り」な抽象化はやめよう。

抽象クラスとAPI変更の問題

抽象クラスがプロダクションに入って、そこから継承され始めると、変更が超リスキーになる。新しい抽象メソッドを追加すると、全部の派生クラスで実装しないとコンパイルできなくなる。これがライブラリや公開APIのメンテを難しくする理由。

こういう時のために、デフォルト実装付きインターフェース(Default Interface Methods、レクチャー116参照)がある。これなら既存コードをすぐ直さなくてもインターフェースを拡張できるよ。

抽象化でカプセル化が壊れる問題

クラスを抽象化するとき、派生クラスからアクセスできるようにprotectedでメンバーを公開しがち。でもこれ、内部ロジックが漏れやすくなる。結果、派生クラスがベースクラスのデータや操作に手を出して、内部の一貫性が壊れることも…。

3. 実際によくあるミスのシナリオ

「こんなの宿題だけでしょ?」って思うかもだけど、実際の現場でもベテランがよくやらかす例を紹介するね。

virtual宣言し忘れたメソッドの例

例えば、ログアプリ(Day24参照)を拡張してるとしよう。ベースのロガーがあって…

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // ファイルに書き込む
        Console.WriteLine("ファイルへ: " + message);
    }
}

// 使い方:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // 期待: "ファイルへ: Hello!"、現実: "Hello!"

FileLoggerの人は「メソッドをオーバーライドした!」と思ってたけど、overrideを付け忘れたし、ベースのメソッドもvirtualじゃなかった。結果、呼ばれるのはベースのバージョン。

おすすめ:オーバーライドするメソッドは必ずベースでvirtual、派生でoverrideを付けよう。

イケてない抽象化の例:「柔軟」な動物たち

動物ネタ続き!IFlyableインターフェースを作って、全動物にFlyを実装させるのをやめよう:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("鳥が飛ぶ!");
}

class Cat
{
    // 猫はIFlyableを実装しない
}

これで「飛べるやつ」だけを扱う関数が書ける:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("この動物は飛べないよ。");
    }
}

こうすれば、ダミーの抽象メソッドでアーキテクチャを壊さずに済む。

「ガチガチ」な抽象クラスの拡張性の問題

例えば、こんな抽象クラスでライブラリを公開したとする:

public abstract class Creature
{
    public abstract void DoAction();
}

ユーザーがこのクラスを継承して自分のクラスを作り始めた。1年後、APIを拡張したくなってこうしたら…

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // 新しいメソッド!
}

今度は全部のユーザークラスがコンパイルできなくなる。新しい抽象メソッドは全部実装必須だからね。だから、抽象クラスの設計は慎重に。できればデフォルト実装付きインターフェースを使おう。

4. よくある罠を避けるコツ

アプリは体操選手みたいに柔軟に。でも足を折らないようにね!

  • 継承の使いすぎ注意:コンポジション(オブジェクトの組み合わせ)で済むならそっちを使おう。
  • 本当にオーバーライドが必要な時だけメソッドをvirtualにしよう。
  • 意味のない抽象クラスや「将来のため」の継承階層は作らない。
  • メソッドをオーバーライドするときは、ベースクラスのルール(不変条件)を壊さないように。
  • 公開済みのベースクラスやインターフェースに新しい抽象メソッドを追加しない。
  • プログラムのパーツ同士をゆるくつなぐにはインターフェースを使おう。
  • API拡張にはDefault Interface Methodsを使おう。
2
タスク
C# SELF, レベル 25, レッスン 3
ロック未解除
`new` キーワードを使ったメソッドの隠蔽
`new` キーワードを使ったメソッドの隠蔽
2
タスク
C# SELF, レベル 25, レッスン 3
ロック未解除
抽象クラスと専門化の原則の利用
抽象クラスと専門化の原則の利用
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION