C#でクラスを作ろう(13)/多重継承

このシリーズについて

C言語やC++言語などを学んではいるけどクラスをあまり作ったことが無い、という方を対象にしています。
このシリーズでは、C#でクラスを作るための基本的な構文を解説しています。C++やJavaなどと共通している概念も多いですが、サンプルコードは基本的にC#で解説します。ところどころ、C++特有の概念を解説することもあります。

これまで、単一の親クラスから継承する例を見てきました。では、複数の親クラスから継承ができるかというと、C#ではそのようなクラスの多重継承は認められていません。

しかし、C++では多重継承が認められています。ここではまず、C++の多重継承を見ていき、その問題点を明らかにしていきたいと思います。それにより、なぜC++で可能だった多重継承がC#で禁止になったかが見えてくるかと思います。

後半では、C#でも可能なインターフェースの多重継承について見ていきます。

スポンサーリンク

C++の多重継承

C++ではクラスの多重継承が可能です。

class Japanese
{
    //日本人を表わすクラス
    public:
        char* MyNumber;
};
class Student
{
    //小学生を表わすクラス
    public:
        char* SchoolName;
        int Grade;
};
 
class JapaneseStudent : public Japanese, public Student
{
    //日本人小学生を表わすクラス
};
 
//使い方
JapaneseStudent* js = new JapaneseStudent();
js->MyNumber = (char*)malloc(100);    //Japanese親クラスのメンバにアクセス可能
js->SchoolName = (char*)malloc(100);  //Student親クラスのメンバにアクセス可能
Japanese* jp = js;  //アップキャスト可能
Student* st = js;   //アップキャスト可能

JapaneseStudentクラスは、JapaneseクラスからもStudentクラスからも継承したクラスになっています。

コロン「:」の後に続く継承の記述が「public Japanese」や「public Student」のように「public」が付いていますが、これは公開継承と呼ばれるものです。C#では継承は全て公開継承なのでこのような区別はありません。本件の多重継承とは関係ありませんので、C++ではこう書く、という程度に思っておいてください。

一見便利に思える多重継承ですが、これを多用すると、俗に菱形継承(ダイヤモンド継承)と呼ばれる複雑な継承関係が出来上がってしまいます。

菱形継承(ダイヤモンド継承)

例えば、JapaneseクラスもStudentクラスも、元は人物を表わすクラスです。つまり、いずれもそのさらに親クラスとして、Personクラスを持つような状況が考えられます。

class Person
{
    //人物を表わすクラス
    public:
        char* Name;
};
 
class Japanese : public Person
{
    //日本人を表わすクラス
    //中略...
};
class Student : public Person
{
    //小学生を表わすクラス
    //中略...
};
 
class JapaneseStudent : public Japanese, public Student
{
    //日本人小学生を表わすクラス
};

これを図で表わすと、下図のような継承関係になっています。

菱形継承

継承関係を表わす矢印が菱形(ダイヤモンド)になっていることから、菱形継承(ダイヤモンド継承)と呼ばれています。

ここで、以下の例を見てください。

JapaneseStudent* js = new JapaneseStudent();
js->Name = (char*)malloc(100);  //※エラー!

JapaneseStudent型から親の親クラスにあたるPersonクラスのメンバ「Name」にアクセスしようとしていますが、このNameはJapanese側のNameなのか、それともStudent側のNameなのか、区別が付きません。

菱形継承をすると、同じ親クラス(Person)が複数の経路を辿って継承されてくることになります。その経路の数だけPersonクラスのコピーを持つことになるので、「js->Name」という指定だけではどの経路から継承されてきた「Name」なのかが特定できないわけです。

これを特定するためには、以下のいずれかの方法を使います。

JapaneseStudent* js = new JapaneseStudent();
//方法1:経路を指定するためにアップキャストする
Japanese* jp = js;
jp->Name = (char*)malloc(100);
//方法2:スコープ解決演算子を使って経路を特定する
js->Japanese::Name = (char*)malloc(100);

このような方法により、「どっち側のPersonクラスのメンバ"Name"か」を特定することができ、コンパイルエラーを防ぐことができます。

しかし、そもそも「親の親」にあたるPersonクラスの状態を2つ持つこと自体が正常な状態とは言えません。複数の経路を辿ったとしても、JapaneseStudentが「Personである」という「is-a」の関係は唯一のはずです。そこでC++では、仮想継承という方法でこれを解消します。

仮想継承

class Person
{
    //人物を表わすクラス
    public:
        char* Name;
};
 
class Japanese : virtual public Person
{
    //日本人を表わすクラス
    //中略...
};
class Student : virtual public Person
{
    //小学生を表わすクラス
    //中略...
};
 
class JapaneseStudent : public Japanese, public Student
{
    //日本人小学生を表わすクラス
};

菱形継承の中間の層にあたるJapaneseクラスとStudentクラスを宣言する際に、親のPersonクラスからの継承を「virtual (public) Person」とすることで、この継承は仮想継承となります。

仮想継承

Personクラスを仮想継承しておくと、それらJapaneseクラスやStudentクラスをさらに継承した孫クラス等でPersonクラスの継承関係が重複してしまったときに、自動的に1つにまとめられるようになります。1つにまとめられたPersonクラスはもはや唯一のPersonクラスなので、

JapaneseStudent* js = new JapaneseStudent();
js->Name = (char*)malloc(100);  //Personが仮想継承なら、これでOK

このような記法も可能になります。

多重継承の問題点

多重継承を許すと、菱形継承のような複雑な継承関係が現れてきてしまいます。前述の仮想継承によってある程度複雑さは回避できますが、「is-a」の関係が網の目のように張り巡らされた状況はあまり好ましくはありません。

また、これはキーワード上の問題なのですが、仮想関数を表わす「virutal」と仮想継承を表わす「virtual」に同じキーワードが使われています。この2つは全く違う概念なのですが、あちこちに「virtual」と書かれていると初学者にとっては混乱の元となります。

多重継承にはこのような問題点があったため、C#(やJava)では多重継承が廃止になりました。

複数のクラスから機能を継承したいとき、それをよく検討してみると、「is-a」ではなく「has-a」の関係になっていることが多いです。つまり、親クラスに相当する機能をメンバに持たせるで解説したとおり、包含関係によって記述できることが多々あります。

はじめから多重継承を無いものとして考えても、十分クラス設計はできるはずです。

インターフェースの多重継承

ここからはC#の話に戻ります。

C#ではクラスの多重継承はできませんが、インターフェースの多重継承は可能です。

interface IA
{
    void MethodA();
}
interface IB
{
    void MethodB();
}
class C
{
}
 
//2つのインターフェースから継承
class D : IA, IB
{
    public void MethodA()
    {
        //IA.MethodAの暗黙的な実装
    }
    public void MethodB()
    {
        //IB.MethodBの暗黙的な実装
    }
}
 
//1つのクラスと1つのインターフェースから継承
class E : C, IA
{
    public void MethodA()
    {
        //IA.MethodAの暗黙的な実装
    }
}

クラスDのように2つのインターフェースIA,IBから継承することができます。また、クラスEのように1つのクラスと1つのインターフェースから継承することもできます。

2つのインターフェースでたまたま同じ名前のメソッドがあった場合、暗黙的な実装では、両方のインターフェースメソッドを実装したことになります。明示的な実装では、どちらのインターフェースメソッドを実装するかを指定することができます。

interface IA
{
    void SomeMethodImp();
    void SomeMethodExp();
}
interface IB
{
    void SomeMethodImp();
    void SomeMethodExp();
}
class D : IA, IB
{
    public void SomeMethodImp()
    {
        //暗黙的な実装
        Console.WriteLine("This is SomeMethodImp");
    }
    void IA.SomeMethodExp()
    {
        //明示的な実装
        Console.WriteLine("This is IA.SomeMethodExp");
    }
    void IB.SomeMethodExp()
    {
        //明示的な実装
        Console.WriteLine("This is IB.SomeMethodExp");
    }
}
 
//挙動
D d = new D();
d.SomeMethodImp();  //出力:This is SomeMethodImp
((IA)d).SomeMethodImp();  //出力:This is SomeMethodImp
((IB)d).SomeMethodImp();  //出力:This is SomeMethodImp
d.SomeMethodExp();  //※エラー!
((IA)d).SomeMethodExp();  //出力:This is IA.SomeMethodExp
((IB)d).SomeMethodExp();  //出力:This is IB.SomeMethodExp

暗黙的に実装されたSomeMethodImpは、IA.SomeMethodImpの実装でもあり、IB.SomeMethodImpの実装でもあり、さらにクラスDのpublicなメソッドの1つでもあります。従って、どのように呼び出しても、出力は"This is SomeMethodImp"となります。

明示的に実装されたSomeMethodExpは、インターフェースIAに対する実装とインターフェースIBに対する実装を別々のものとしています。明示的な実装はクラスD自身のpublicメソッドではないので、「d.SomeMethodExp();」のような呼び出しはエラーになります。SomeMethodExpは常にインターフェースIAかインターフェースIBにアップキャストして使用され、どちらの実装が実行されるかはアップキャスト先によって変わります。

以下のような菱形継承の場合でも、混乱が起きることはありません。

interface IX
{
    void SomeMethod();
}
interface IA : IX
{
}
interface IB : IX
{
}
class D : IA, IB
{
    public void SomeMethod()
    {
        Console.WriteLine("This is SomeMethod");
    }
}

インターフェースというのは何も実体を持たない「規約」だけの集まりです。上記のように菱形継承によってインターフェースIXが複数の経路を辿って継承されてきたとしても、そこに何か実体があるわけではありません。IXのメソッドSomeMethodは、クラスDによって実装されることによって初めて実体を持ちます。従って、実体は常に1つです。

つまり、クラスの菱形継承のように「経路の途中で実体が2つになった」というようなことは元から起こりえないわけです。ある意味、インターフェースの継承は常に、C++で言うところの仮想継承であるとも言えます。

C#8.0におけるインターフェースの既定の実装

C#8.0でのインターフェースの制限緩和で紹介したとおり、C#の言語バージョン「C#8.0」では、インターフェースに既定の実装を持たせることができます。その他にもいくつかの制限緩和がなされており、インターフェースは抽象クラスとほぼ同等のものとなっています。

例えば、以下のようにインターフェースの実装を省略することができます。

interface IX
{
    public void SomeMethod()
    {
        Console.WriteLine("既定の実装");
    }
}
 
class SomeClass : IX
{
    //インターフェースIXを継承するが、何も実装しない
}
 
//使用例
SomeClass obj = new SomeClass();
IX x = (IX)obj;
x.SomeMethod();    //出力:"既定の実装"

インターフェースにメソッドSomeMethodを定義すると同時に、既定の実装を記述することができます。「このメソッドを定義するけど、大体デフォルトでこんな動作になることをあらかじめ決めておくよ。気に入らなかったら独自に実装してね」というときに使える手法です。

また、既存のインターフェースにメソッドを追加したときに、何もしない既定の実装「{}」を定めておくと、そのインターフェースを既に実装しているクラス全てに対して修正が必要になるというようなこともありません。

interface IX
{
    void FirstMethod();
    void SecondMethod() {}  //後から追加したメソッド。既定の空の実装を持っておく
}
class SomeClass : IX
{
    public void FirstMethod()
    {
        Console.WriteLine("FirstMethodの実装");
    }
    //IX.SecondMethodが後から追加されても、規定の空の実装を持っているので
    //SomeClassに修正の必要が無い
}

このように、C#8.0におけるインターフェースの既定の実装は、使いようによっては便利です。しかし、この手法を使うと、C++で問題となった菱形継承の問題が再浮上してしまいます。

interface IParent
{
    void SomeMethod();
}
public interface IChild1 : IParent
{
    //ここで既定の実装を定める
    void IParent.SomeMethod() { Console.WriteLine("Child1"); }
}
public interface IChild2 : IParent
{
    //ここで既定の実装を定める
    void IParent.SomeMethod() { Console.WriteLine("Child2"); }
}
 
public class GrandChild : IChild1, IChild2
{
    //※エラー!
    //省略されたSomeMethodの実装は、
    //IChild1のものを使うかIChild2のものを使うかが特定できない
}

親の親にあたるインターフェースIParentのメソッドSomeMethodに関して、子のインターフェースにあたるインターフェースIChild1とインターフェースIChild2がそれぞれ既定の実装を定めています。では、IChild1とIChild2の両方を継承したGrandChildクラスでのIParent.SomeMethodメソッドの実装は一体どれが採用されるべきでしょうか?

これを特定する方法はありません。つまり、コンパイルエラーになります。このエラーを解消するには、GrandChildクラスで新たにSomeMethodを実装する必要があります。

さて。

とりあえずC#8.0のインターフェースの既定の実装と、菱形継承の場合の挙動を解説しました。が、率直な感想はどうでしょう? 複雑すぎてわけわからん、と感じた方も多いのではないでしょうか。

これらのテクニックが有用な場面もあるといえばあるのですが、複雑になることを避けるという観点からすると、あまり使うべきではないと思います。

プログラミングとは、よりわかりやすく、よりミスが少なく、よりメンテナンスしやすくするべきものだと僕は考えます。あまりに複雑すぎるテクニックを使いすぎたとき、それが自己満足に陥ってしまっていないか、振り返ってみることも大切です。

プログラミングにおいては、簡単に書けるなら簡単に書く、ということもまた必要な技術の1つだと僕は思います。

スポンサーリンク