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を使おう。
GO TO FULL VERSION