子どもの頃,話が「抽象的すぎる」とよく言われていて,あまりよくない意味に捉えていたときもありました。しかし,よいプログラムを書く際に「抽象化」はとても大切なことの一つです。抽象度を上げたり,下げたり(具体化したり)を自在にできる考え方を身に付けることは,システム開発のみならず,様々な場面で有効です。
今回はプログラミングにおける抽象化についてです。継承やインターフェイスなどのオブジェクト指向言語の機能をはじめて覚えたら,何かと使いたくなってしまうものです。ですが,練習用のプログラムならまだしも実践でやたらめったら使ってしまうと大変なことになりかねません。なぜその機能があるのか理解して,適切に使いこなしましょう。
適用可能範囲
ある要求(シグネチャ)に対して,適用できるものと適用できないものを考えてみます。まずは,シンプルな関数を見てみましょう。
public int Double(int x) => x * 2;
- 数値を2倍にして返す
この時の要求は int 型の値が1つです。文字列を渡すことはできません。また,整数でなければなりませんし,閾値もあります。1
これは数値や文字列だけでなくクラスや構造体であっても同じです。
public class VectorUtil
{
public double Distance(Vector2 a, Vector2 b) =>
Sqrt(Pow(a.X - b.X, 2) + Pow(a.Y - b.Y, 2));
}
public class Vector2
{
public double X { get; }
public double Y { get; }
public Vector2(double x, double y) { /* ... */ }
}
- 2点間の距離を返す
Vector2
は double 型のプロパティ X
, Y
を持ち,2次元ベクトルを表現します。Distance
関数は,これを2つ受け取って2点間の距離を計算します。この場合も Vector2
クラスのインスタンスしか適用することができません。引数の型によって受け取ることができるデータの条件を限定しているわけですね。
しかし,実際に関数が必要としているのは「X
, Y
(を持っているオブジェクト)」というだけです。同じ意味を持つのであれば,別のクラスのオブジェクトであってもよさそうなものです。これには,インターフェイス(言語の機能における 2)を使うことで解決できます。
インターフェイスとは,実装はさておき「属性(プロパティ)および振る舞い(メソッド)はこうなっていますよ」という定義だけを外部に宣言する仕組みです。また,そのインターフェイスを引数や戻り値に取るということは,「ある実装」ではなく「ある宣言」を受け取る,あるいは返却するという意味になります。
Distance
の引数を IVector2
に変更し,Vector2
に IVector2
を実装させます。これで,Vector2
のインスタンスを渡したときの挙動は先ほどと同じになります。
public class VectorUtil
{
public double Distance(IVector2 a, IVector2 b) =>
Sqrt(Pow(a.X - b.X, 2) + Pow(a.Y - b.Y, 2));
}
public interface IVector2
{
int X { get; }
int Y { get; }
}
public class Vector2 : IVector2
{
public int X { get; }
public int Y { get; }
public Vector2(int x, int y) { /* ... */ }
}
ここで,地図上のある点を表す Pin
クラスがあったとき,IVector2
を実装させることによって,このインスタンスを渡しても同じように結果を得ることができるようになります。
public class Pin : IVector2
{
public int Id { get; }
public string Name { get; }
public int X { get; }
public int Y { get; }
public Pin(int id, string name, int x, int y) { /* ... */ }
}
Pin
クラスは X
, Y
以外のプロパティも持っていますが,Distance
関数では使いません。実際,引数は IVector2
になっているので Distance
関数の中から Id
や Name
にはアクセスすることはできません。約束事(シグネチャ)は,「X
, Y
(を持つオブジェクト)」なので,適用する側はそれさえ守れば何を渡してもよいですし,逆に適用される側はそれ以外の要素に言及してはいけない(アクセスしてはいけない)ということです。
ただし,「X
, Y
を持っている同じ型のオブジェクト」に限定したい場合は次のようにします。
public static double Distance<T>(T a, T b) where T : IVector2 => ...
こうした場合は,Vector2
と Pin
の組み合わせを渡すことはできなくなります。
また,さらに Id
と Name
を持つオブジェクトに対して処理を行いたい別の関数があったとします。新しいインターフェイス IAnyItem
を追加して,Pin
クラスに実装させてみましょう。この場合,既に対象の要素は持っていたので実装は変えることなく,特定の性質についての定義だけを追加することができます。これで,Pin
を適用することができますし,IAnyItem
を実装した別のクラスのインスタンスを適用することもできるようになります。
Distance
から見ると,IVector2
を実装しているクラスであれば何でも受け入れることができます。また,Pin
から見ると IVector2
を要求している関数にも IAnyItem
を要求している関数にも適用することができます。インターフェイスを使うことで柔軟なプログラミングが可能になります。
このように,同一のシグネチャ(呼び出し方)によって複数の型を受け取れる性質を「ポリモーフィズム(Polymorphism)」といいます。多態性,多相性などとも訳されます。3 これは,振る舞い(メソッド)だけでなく,属性(プロパティ)についても同じです。とくに,オブジェクト指向ではプロパティもカプセル化されるという点で実質的にメソッドと変わりがありません。
I/F における抽象化とは
“適用可能な範囲をコントロールする”
ことです。
言語による違い
インターフェイスの機能は言語によって実装が異なります。
C# や Java のような静的型付け言語では,その型が何者であるのかがコンパイル時に明らかになっていなければなりません。どのような属性や振る舞いを持つのかという特性だけでなく,どのような継承関係なのかも必要です。型には “血統書” が求められます。インターフェイスの実装もその一部ですので,明示的に実装する必要があります。※ どこまで記述する必要があるかどうかは,言語の型推論能力などにも依ります。
Ruby や Python などの動的型付け言語では,実行時に型を判断します。インターフェイスという機能はありません。インターフェイスに限らず,受け入れる側が引数の型を強制する手段がありません。“血統書” は必要ではなく,そのオブジェクトの属性や振る舞いの有無だけが重要です。「ガーガー」鳴くなら,生物のアヒルでも機械のアヒルでもいいわけです。いわゆる「ダック・タイピング(duck typing)」です。ある意味,全面的に多態性を受け入れている(それを表現するためのコーディングも不要)ともいえますが,型の不整合は実行時にならないと分からないため,型の安全性という面では懸念があります。
継承とインターフェイス
オブジェクト指向には「継承」という概念があります。多態性を表現するために,(クラスの)継承を用いることもできますが,これは適切ではありません。継承とインターフェイスでは動機が異なります。
(クラスの)継承というのは,サブクラス(子クラス)がスーパークラス(親クラス)からその特性(属性や振る舞い)を受け継ぐことです。受け継いだ特性は継承元のクラスと同じように利用できます。
継承は,主に**「汎化(一般化)」と「再利用」**を目的としています。
- 子 -> 親:汎化
- 親 -> 子:再利用
複数のクラスで同じ意味のプロパティや同じ実装のメソッドがあった場合,それぞれに記述してしまうと冗長的コードになってしまいます。同じ意味のコードが散在していると,機能の修正や変更があったとき,すべての箇所に同じように適用しなければならず,手間なだけでなく,漏れる危険もあります。同じコードは重複して書かずに,1か所にまとめておきたいところです。※ ソースのコピペは厳禁です。
1人が負うべき責務は1つであると同時に,1つの責務を担当する人は1人であるべきです。
というわけで,1か所にまとめるためにそれぞれのクラスから共通する特徴を抽出しようというのが**「汎化」**です。共通する部分をスーパークラスにまとめておき,継承することで各サブクラスで利用できるようにします。
さらに,同じ共通機能を必要とする別のクラスを追加したい場合には,新たにサブクラスとして追加することでその機能を利用することができます。既存のコードを直接変更しないので元のクラスを参照しているコードへの影響がなく安全に機能追加ができます。このように,既存のコード(資産)を**「再利用」**して,変更や拡張を簡単にできるようにしているわけです。
継承は特徴を引き継ぐため,スーパークラスを引数に取る関数に対してサブクラスを渡すことができます。インターフェイスと同じような使い方ができます。
しかし,この場合は少し意味が違います。クラスの継承というのは,I/F ではなく「実装」を共通利用するのが目的です。一方,多態性というのは,同一の I/F で異なる実装に対応することです。このように,これらは基本的な動機が異なります。
また,多重継承の問題があるため,前述の Pin
クラスのように複数のインターフェイスを実装させるということを継承で行うのは難しいという面もあります。C# や Java では多重継承を禁止しており,インターフェイスはその代替としての役割も存在理由のひとつです。
多態性を表現するには,
“継承ではなくインターフェイスを使う”
ようにしましょう。
処理の再利用
そもそもクラスの継承には問題があるため,基本的に利用するべきではありません。
継承を利用した場合,クラス同士の結合度が高まり,柔軟性が低下します。サブクラスは直接的にスーパークラスに依存してしまうため簡単には変更できません。しかし,汎化および再利用は継承を用いずとも可能です。
単純にある機能を共通利用したいだけであれば「継承」ではなく「使用」にしましょう。
// 共通ロジック
public class CommonModel
{
public void CommonProc() =>
Console.WriteLine("(*・з・*)");
}
// 継承の場合
public class Successor : CommonModel
{
}
// 使用の場合
public class Director
{
private CommonModel Model { get; }
public Director() => Model = new CommonModel();
public void CommonProc() => Model.CommonProc();
}
共通利用したいロジックを持つクラス CommonModel
をどのように扱うか。
Successor
は,CommonModel
を「継承」しています。コードは簡単ですね。そのまま CommonProc
が呼び出せます。
Director
は,CommonModel
を「使用」4 しています。コードは増えましたが,CommonModel
のインスタンスを操作しているだけです。とくに難しいことはありませんね。こちらの場合であれば,別の使用するクラスを追加することもできます。
継承は手軽で安全に機能を追加できますが,多重継承できないなどの問題があります。抽象クラスの継承を使った有効なテクニックもありますが,多くの場合,モジュールを切り分け,組み合わせるだけでも十分に効果があります。まずは,こちらの方をマスターしましょう。
依存性の注入(DI : Dependency injection)
さらに DI というテクニックを使うと柔軟性が向上します。
先ほどの例では,CommonModel
のインスタンスを自身で生成しており,該当クラス(の実装)への依存が確実なため,依存度という点では継承と変わりません。しかし,依存するオブジェクトを外部から受け取ることで依存度を下げられます。受け取る型をインターフェイスにすることで,クラス自体を変更することなしに,状況に応じてその振る舞いを変更することができます。オブジェクト指向において最も大事なことの1つである「実装ではなく抽象に依存しろ」ということにもつながります。5
public interface ICommonModel
{
void CommonProc();
}
public class Flunky
{
private ICommonModel Model { get; }
public Flunky(ICommonModel model) => Model = model;
public void CommonProc() => Model.CommonProc();
}
DI を用いてインスタンスの生成時に依存するオブジェクトを選択できるようにしておくと,単体テストでモックに差し替えたテストが容易に行えるというメリットもあります。
まとめ
- 多態性の表現には「インターフェイス」を使う
- コードの冗長性はなるべく減らして一元管理する
- 基本的にクラスの「継承」は使わず,DI する
参考図書
-
C# の
int
の範囲は -2,147,483,648 ~ 2,147,483,647 。ちなみにこの実装だとint.MinValue
およびint.MaxValue
の半分より大きな値を入れるとオーバーフローしてしまいますね。
このように,要求(シグネチャ)というのは
“その関数に適用できる対象の範囲を限定する”
という意味でもあります。 ↩ -
この記事では,言語機能については “インターフェイス”,広義の意味のものについては “I/F” と表記します。 ↩
-
広い意味では,オーバーロードや多重ディスパッチなども含まれるようです。
ポリモーフィズム - Wikipedia ↩ -
この場合は「委譲」でもあります。
委譲 - Wikipedia ↩ -
DDD や クリーンアーキテクチャ の実装でもこのテクニックは利用されます。
依存性逆転の原則 - Wikipedia ↩