この記事では、ポリモーフィズムの種類、C#でポリモーフィズムの概念はどのように扱われているかについて解説します。
注意
・「多相」を「ポリモーフィズム」に置き換えています
・私の考えが強く反映されているので注意してください
・指摘はコメントでお願いします
なぜこの記事を書こうと思ったのか
・前回の記事だけでは内容が不十分であるから
・ポリモーフィズムを実現する手段や使い分けについて自分なりにまとめたかったから
使用言語
C# 10.0を使用します。
今回わざわざ言語とバージョンを書いたのは、ポリモーフィズムの記事というよりはC#をつかって実装してみよう!というノリの記事になると思ったからです。
記事の構成
概要 → 利点 → 実現する手段 → (実現する手段の考察) → まとめ
という流れで書きます。(括弧の中身は省略する場合があります)
【復習】そもそも「ポリモーフィズム」とは何か。何ができるか。
・メインルーチンを共通化する仕組みである
・サブセット(部分集合)をあたかもスーパーセットのように扱うことができるようになる
(この説明は大雑把すぎて不適切かもしれない)
取りあえず今回はこの二つを前提にして手段について調べていきます。
「ポリモーフィズム」の分類
まず、実現する方法を考えるにはポリモーフィズムの概念について知る必要があります。
主にこの三つです。(実際はもっとあるらしい)
①サブタイピング
②アドホックポリモーフィズム
③パラメトリックポリモーフィズム
他にもローポリモーフィズム、ポリタイピズムという概念があるらしいです。
オブジェクト指向で一般に言われる「ポリモーフィズム」は「サブタイピング」の概念です。その他はオブジェクト指向とは少し離れた話になるので、先にサブタイピングについて掘り下げていきます。
①「サブタイピング」について
「サブタイピング」は『犬は、動物である。』のように、『<サブタイプ>は、<スーパータイプ>である。』の関係を司る概念です。オブジェクト指向ではサブタイプをサブクラス、スーパータイプをスーパークラスという形で表現できます。動的なポリモーフィズムです。
スーパータイプはサブタイプで代入ないし代替することが可能です。この性質を「代入可能性」と言います。
リスコフの置換原則(LSP)はサブタイピングの代入可能性をコンセプトにした原則です。
ただし、継承関係があるからといって『スーパータイプ=スーパークラス、サブタイプ=サブクラス』が成立するわけではないので言葉の使い分けに気を付ける必要があります。
「サブタイピング」の利点
サブタイピングの利点は、複数種類の型を一つの型のように扱うことができることだと思います。
具体的には、アップキャストされても問題なく動作する処理を実装できます。スーパークラスの配列に
サブクラスのオブジェクトを入れたり、引数にスーパークラス型を指定されているメソッドの引数に
サブクラスのオブジェクトを入れても問題なく動作します。
これらを駆使すると、
サブクラスが増えても、サブクラスを利用する側の修正箇所がなくなるという恩恵を得ることができます。
次はC#でサブタイピングを実現する手段について考えていきます。
クラス継承とオーバーライドを用いる方法
鳴くメソッドのMakeSounds
仮想メソッドを実装したMammal
クラスを定義します。そしてMammal
クラスのサブクラスであるCat
クラス、Dog
クラスを実装します。サブクラスではMakeSounds
メソッドをオーバーライドし、それぞれ別の振る舞いをさせます。
public class Mammal
{
public virtual void MakeSounds() => Console.WriteLine("何の動物か知らないけど鳴いたよ");
}
public class Cat : Mammal
{
public override void MakeSounds() => Console.WriteLine("ぬお~~ん");
}
public class Dog : Mammal
{
public override void MakeSounds() => Console.WriteLine("うおんうおん");
}
class Program
{
public static void Main()
{
var mammals = new List<Mammal>()
{
new Cat(),
new Dog()
};
foreach(var mammal in mammals)
{
mammal.MakeSounds();
}
}
}
/*出力結果*/
/*ぬお~~ん*/
/*うおんうおん*/
このコードではCat
、Dog
型のオブジェクトをあたかもMammal
型のように扱っています。『Cat
型、Dog
型 は Mammal
型』という関係が成立しているという事です。
このコードではMammal
クラスでもMakeSounds
メソッドを実装していますが、哺乳類全般をターゲットにしているスーパークラスで具体的な実装をする必要はありません。そこで次の方法を使います。
「抽象基底クラス」の継承とオーバーライドを用いる方法
Mammal
クラスをMammalBase
クラスに改名し、次のように書き換えます
public abstract class MammalBase
{
public abstract void MakeSounds();
}
public class Cat : MammalBase
{
public override void MakeSounds() => Console.WriteLine("ぬお~~ん");
}
public class Dog : MammalBase
{
public override void MakeSounds() => Console.WriteLine("うおんうおん");
}
class Program
{
public static void Main()
{
var mammals = new List<MammalBase>()
{
new Cat(),
new Dog()
};
foreach(var mammal in mammals)
{
mammal.MakeSounds();
}
}
}
Mammal
クラスのMakeSounds
メソッドを宣言だけしています。このようなメソッドを抽象メソッドといい、抽象メソッドのあるクラスを抽象基底クラスといいます。抽象基底クラスを継承しているクラスを具象クラスといいます。
「インターフェイス」を実装する方法
MammalBase
クラスをIMammal
インターフェイスにして、次のように書き換えます。
interface IMammal
{
void MakeSounds();
}
public class Cat : IMammal
{
public void MakeSounds() => Console.WriteLine("ぬお~~ん");
}
public class Dog : IMammal
{
public void MakeSounds() => Console.WriteLine("うおんうおん");
}
class Program
{
public static void Main()
{
var mammals = new List<IMammal>()
{
new Cat(),
new Dog()
};
foreach(var mammal in mammals)
{
mammal.MakeSounds();
}
}
}
インターフェイスを用いても抽象基底クラスと同じような事が出来ます。
ではどのように使い分ければ良いのでしょうか。
「抽象基底クラス」と「インターフェイス」の使い分け方
まず、使い分けをするうえで二つの観点があると思います。
1.「抽象基底クラス」、「インターフェイス」が使われる理由や目的に着目する
2.機能に着目する
今回はこの二つの観点から使い分けを考えていきます。
『理由や目的』に着目して考える
いろいろな人の説明を見て『インターフェイスは継承先でできることを宣言することが目的』であり『抽象基底クラスは一つ一つの型の大きな枠組みやひな形を作ることが目的』だと解釈しました。インターフェイスはインターフェイスを実装したクラスを安全に使えるようにする、抽象基底クラスは派生クラスの構造の大枠を作って派生クラスを実装しやすくするという役割が大きいと思います。
要するに、利用する側が対象であるのか実装する側が対象であるかという違いだと思います。
『機能』に着目して考える
使い分けについて考えるだけなので細かい言語の仕様は省きます。
結論から言うと、インターフェイスの方が柔軟性があるので基本的にインターフェイスを使えば問題なさそうです。インターフェイスは複数実装できますが、抽象基底クラスは一個だけしか継承できないというのが理由として大きいです。C#8.0
以降インターフェイスもメソッドやプロパティの実装を持てるようになったので抽象基底クラスとは機能的な違いが減ってきています。
補足情報:C#8.0からデフォルトインターフェイスメソッドを実装できるようになったのでインターフェイスをトレイトのように扱うことができるようになりました。
では「抽象基底クラス」はいらない子なのか
そんなことは無いと思います。抽象基底クラスもインターフェイスも派生クラスを抽象化することができますが、抽象化する範囲が異なると考えました。
この図は電化製品を例にしたものです。インターフェイスで「ボタン」、「スピーカー」など多くの種類の電化製品で使える部品を作っています。そしてこれらを実装した抽象基底クラス、今回は「音楽プレイヤー」と「動画プレイヤー」を作ります。そして抽象基底クラスを継承した派生クラスが、それぞれの製品になるイメージです。(高い音楽プレイヤー、酷い音楽プレイヤーなど)
ここで抽象基底クラスとインターフェイスでは抽象化する範囲と抽象化した範囲に対する規模で違いが表れています。
インターフェイスで抽象化している範囲は広いですが、電化製品に対する規模は小さくなっています。例えば、電化製品インターフェイスのように抽象化した対象すべてをカバーする規模の大きなインターフェイスを作ってしまうと「冷蔵庫」でもスピーカーに関する振る舞いを実装しないといけなくなります。これはインターフェイス分離原則に違反しています。インターフェイスは複数実装できるので使いたいインターフェイスを自由に組み合わせて実装できることはとても魅力的です。
一方で抽象基底クラスは抽象化する範囲は狭いですが、抽象化する対象の必要なものはすべて定義するので抽象化した範囲に対しての規模が大きくなります。今回の例だと「音楽プレイヤー」、「動画プレイヤー」のように電化製品の中では限られた部分を抽象化していますが、抽象化した対象の共通している処理はすべて定義することができます。そして共通していない部分を含めて派生クラスで実装して一つ一つの製品になります。
このように抽象基底クラスとインターフェイスを組み合わせて使うことができる場合があります。
また、使い分けるというよりはどちらも使いこなせるようにするという考え方をする方が表現の幅は広がりそうです。
「サブタイピング」を実現できたからといって油断してはいけない
Cat
、Dog
クラスに固有の特技(メソッド)を実装することになりました。そしてProgram
クラスには引数に与えられた動物に、鳴いた後に特技をさせるという処理をするメソッドを実装します。
interface IMammal
{
void MakeSounds();
}
public class Cat : IMammal
{
public void MakeSounds() => Console.WriteLine("ぬお~~ん");
public void Sleep() => Console.WriteLine("グースカ");
}
public class Dog : IMammal
{
public void MakeSounds() => Console.WriteLine("うおんうおん");
public void Paw() => Console.WriteLine("右手を差し出す");
public void OtherPaw() => Console.WriteLine("左手を差し出す");
}
class Program
{
public static void Main()
{
var cat = new Cat();
var dog = new Dog();
ShowSpecialSkill(cat);
ShowSpecialSkill(dog);
}
private static void ShowSpecialSkill(IMammal mammal)
{
mammal.MakeSounds();
if (mammal is Dog)
{
var dog = mammal as Dog;
dog.Paw();
dog.OtherPaw();
}
else if (mammal is Cat)
{
var cat = mammal as Cat;
cat.Sleep();
}
}
}
ShowSpecialSkill
メソッドをみると、mammal
に鳴かせてからmammal
をダウンキャストして猫、犬の特技をさせています。
問題はダウンキャストする処理にあります。
このメソッドは動物を鳴かせた後に特技をさせるという役割がありますが、動物の種類が増えるたびにelse if
が量産されていきます。サブタイピングの利点を殺すコードの完成です。そもそも、鳴いてから特技をするのは各動物なので、各動物のクラスにメソッドを実装すべきです。
public class Dog : IMammal
{
public void MakeSounds() => Console.WriteLine("うおんうおん");
public void Paw() => Console.WriteLine("右手を差し出す");
public void OtherPaw() => Console.WriteLine("左手を差し出す");
public void ShowSpecialSkill()
{
MakeSounds();
Paw();
OtherPaw();
}
}
class Program
{
public static void Main()
{
var dog = new Dog();
dog.ShowSpecialSkill();
}
}
「サブタイピング」のまとめ
【概要】
・サブタイピングは『<サブタイプ>は、<スーパータイプ>である。』の関係を司る概念
・スーパータイプをサブタイプで代入ないし代替できる性質を「代入可能性」という
・スーパータイプ=スーパークラス、サブタイプ=サブクラスの関係は
常に成り立たないので言葉に注意する必要がある
・動的なポリモーフィズム
・サブタイピングの利点はサブクラスの数が増えてもサブクラスを利用する側の修正をなくすこと
【実現方法】
・継承してメソッドをオーバーライドする
・抽象基底クラスを継承してメソッドをオーバーライドする
・インターフェイスでメソッドを定義して派生クラスで実装する
【実現方法の考察】
・抽象基底クラスとインターフェイスで実現できることはほぼ同じだが、役割や目的は異なる
・機能的にはインターフェイスの方が適している場面が多い
⇒だいたいはインターフェースを使えば問題ない
・インターフェイスと抽象クラスを両方つかうと良い場合がある
・サブタイピングの利点を上手く活かせないと感じた場合、コードの設計を見直したほうが良い
②「アドホックポリモーフィズム」について
「アドホックポリモーフィズム」は、同名であっても複数の実体を持ち、それぞれ別の振る舞いをする性質です。その性質を持つメソッドは、メソッドを利用する側からは実体が一つのメソッドに見えますが、実際は複数の実体を持ちます。静的なポリモーフィズムです。
普段使っている演算子に注目してみる。
string str = "Hello" + " World!!";
int num = 1 + 1;
文字列の連結にも+演算子、加算にも+演算子を使っていることがわかります。
また、デリゲートの組み合わせにも+演算子を使います。
##名前の『意味』について考えて分かる事
演算子の例に挙げた+演算子ですが、『足す』と言い換えることができます。『整数を足して下さい。』、『文字列を足してください。』という言い回しでも一応伝わります。引数にbool
型の変数もstring
型の変数も入るConsole.WriteLine
メソッドの役割を簡単に説明しても『画面に文字を表示する』という説明で落ち着きます。
つまり、具体的な処理の中身に注目せずに同じ処理だと言えるものにアドホックポリモーフィズムは適用できると言えます。
アドホックポリモーフィズムの利点は身近過ぎて気づきにくい
普段は無意識的に文字列の連結も数値の加算も+演算子を使い、コンソールアプリケーションで画面に変数の中身を表示したいときはConsole.WriteLine
メソッドを使っています。使うたびに『うっひょぉおアドホックポリモーフィズムを感じるぜよ~』とか思ってる変態は居ても90%程度だと思います。
利点はここに隠されています。
大雑把に見て同じ処理と言えるのに(加算、連結は足すと言い換えられるなど)、わざわざ処理内容の詳細を見て使い分けていたら面倒くさいです。もし+演算子で数値の加算しかできなくて『文字列の連結は+++
演算子を使って、デリゲートの組み合わせは++++
演算子をつかって…』なんてやっていたら慣れるまでが大変です。アドホックポリモーフィズムはプログラミングを人間の感覚に近づけることができる性質といえると思います。
C#ではどのようにアドホックポリモーフィズムを実現できるのでしょうか。
##オーバーロードを利用する方法と例
C#ではアドホックポリモーフィズムをメソッドのオーバーロードで実現できます。
class Program
{
public static void Main()
{
Console.WriteLine(Add(1,1));
Console.WriteLine(Add("Hello"," World!!"));
}
private static int Add(int x,int y) => x + y;
private static string Add(string x,string y) => x + y;
/*出力結果*/
/*2*/
/*Hello World!!*/
}
このAddメソッドは二つの実体を持っています。
また、メソッド名の意味に着目すると、『Add』は、『整数の加算』、『文字列の連結』の二つの意味を持っています。
みんな大好きConsole.WriteLine
メソッドもオーバーロードされていて複数の実体を持っています(17個もあります)。引数にbool
型変数を入れてもstring
型変数を入れても画面に出力してくれます。
コンストラクタもオーバーロードできる
struct Color
{
private int _r;
private int _g;
private int _b;
private float _a;
/*R G B Aプロパティは省略*/
public Color(int red,int green,int blue)
{
/*実際に使う場合はRGBの引数が0~255、Aの引数が0.0~1.0かのチェックが必要だけど省略*/
this._r = red;
this._g = green;
this._b = blue;
this._a = 1.0f;
}
public Color(int red,int green,int blue,float alpha)
{
this._r = red;
this._g = green;
this._b = blue;
this._a = alpha;
}
}
どちらも『初期化』の処理ですが、『RGB』の値を初期化するのと『RGBA』の値を初期化するのでは意味が異なります。(アルファ値を意識しないでColor構造体を扱うことができるようになります。)
「アドホックポリモーフィズム」のまとめ
【概要】
・アドホックポリモーフィズムは、同名だが複数の実体を持ち、それぞれ別の振る舞いをする性質のこと
・アドホックポリモーフィズムの利点は具体的な処理にあわせて使い分ける必要がなくなること
・静的なポリモーフィズム
【実現方法】
・メソッドをオーバーロードする
・コンストラクタをオーバーロードする
【実現方法の考察】
オーバーロードは、具体的な処理を考えないで処理が同じといえるメソッドで使うと良い
③「パラメトリックポリモーフィズム」について
「パラメトリックポリモーフィズム」は値の静的な型安全性を確保しつつ一様に値を扱うことができる性質のことです。
似た概念に「ポリタイピズム」というものがあるらしいです。静的なポリモーフィズムです。
(調べたけど違いがいまいちわからなかった)
「パラメトリックポリモーフィズム」の利点
パラメトリックポリモーフィズムの利点というよりはジェネリックプログラミングの利点という感じになってしまいました。
型に囚われない処理を作ることができる
一様に値を扱うことが出来るのですべての型に対応した処理を作ることができるので、型に関係ない振る舞いをする汎用性の高い処理を簡潔に書くことができます。その性質をもつクラスの事をジェネリッククラス、メソッドをジェネリックメソッドといいます。ちなみにジェネリックインターフェイス、デリゲートも作れます。
List<T>
クラスのようなコンテナクラスには最適だと思います。
正直、汎用性の高い処理はほとんど標準ライブラリに入っているので、作る場面より使う場面の方が多そうです。
パフォーマンスが良い場合がある
値型を扱うとき、ArrayList
クラスを使うよりList<T>
クラスの方が良いです。(ソートなど)
何故ならArrayList
クラスでは値型がobject型にキャスト(ボックス化)されてから格納されるからです。このようにパフォーマンスにパラメトリックポリモーフィズムの利点が現れることもあります。
「アドホックポリモーフィズム」との違い
アドホックポリモーフィズムを活用した場合メソッドの引数によって別の振る舞いをしますが、パラメトリックポリモーフィズムでは引数の型がどの型でも同じ振る舞いをします。そのため、パラメトリックポリモーフィズムを活用する場合、型固有の動作は使うことができません。その対価として汎用性が与えられます。
次はジェネリックな処理を作る方法について調べます。
ジェネリッククラスを利用する
試しにList<T>
をランダムソートするジェネリッククラスを作ってみます。
class RandomSorter<T>
{
private IReadOnlyList<T> _list;
public RandomSorter(IReadOnlyList<T> list)
{
_list = list;
}
public List<T> GetSortedList()
{
var sortedList = _list.OrderBy(x => Guid.NewGuid()).ToList();
return sortedList;
}
}
ジェネリッククラスの本体はこのようになります。たったこれだけですべての型に対応した処理が書けました。刺さるところでは凄まじいパワーを発揮します。
ジェネリックメソッドを利用する
ソートする機能しかないならジェネリックメソッドという形にしたほうが良さそうです。
public static List<T> GetRandomSortedList<T>(IReadOnlyList<T> list)
{
var sortedList = list.OrderBy(x => Guid.NewGuid()).ToList();
return sortedList;
}
引数がIReadOnlyList
型、戻り値がList<T>
型のジェネリックメソッドです。
List<int> list = new List<int>()
{
1, 2, 3, 4, 5, 6, 7, 8, 9,
};
GetRandomSortedList(list);
使うときは型引数を省略できますが、ジェネリックメソッドもオーバーロードできるので同名の
非ジェネリックメソッドが存在したらそちらが呼び出されるので注意が必要です。
##ジェネリックデリゲートを利用する
このようにするとジェネリックメソッドをデリゲートに代入できます。
delegate void SayDelegate<T>(T arg);
public static void Main()
{
SayDelegate<int> sayInt = Say;
sayInt(145432);
}
public static void Say<T>(T arg){}
型に制約を掛けることができる
上の例ではすべての型に対応した処理になっていますが、型引数に制約を掛けることができます。
class GenericClass<T> where T : struct {}
このようにwhere T : ○ ○
で制約を追加できます。上の例ではTが値型でなければならないという制約が書かれています。
class GenericClass<T> where T : List<T>, IComparable<T>
また、制約は複数適用でき、制約自体をジェネリック型にすることもできます。
制約の一覧ついては公式ドキュメントをご覧ください
「パラメトリックポリモーフィズム」のまとめ
【概要】
・パラメトリックポリモーフィズムは値の型安全性を確保しつつ値を一様に扱える性質
・型に囚われない処理が作れる=汎用性の高い部品を作れる
・ジェネリックを活用するとパフォーマンスが良くなる場合がある
・型固有の処理は書けない
・静的なポリモーフィズム
【実現方法】
・ジェネリッククラス、メソッド、デリゲートを作る
・型引数に制約を掛けることができる
感想
前回はオブジェクト指向におけるポリモーフィズムのみ扱っていたので広い部分をカバーする概念という事に
気づけませんでしたが、今回オーバーロードやジェネリックプログラミングの仕組みも
ポリモーフィズムの概念を含んでいるという事を学べてよかったと思います。
ポリモーフィズムはどれも『抽象化』に関わってくる概念ですが、下手に使うと逆に複雑になりそうなので、
次は抽象化するテクニックや悪い例を学びたいと思います。
『○○は××するもの』という説明を見るだけではどのように使うか実感がわかないので、
全体的に難しい内容だと感じました。特に概念の話は抽象的すぎて難しいと感じました。
まだ理解できていない事があるので今後も更新していきたいです。