インターフェイスのデフォルト実装

Ver. 8.0

C# 8.0 (.NET Core 3.0)で、インターフェイスの制限が緩和されました。 以下のようになります。

これら指して「インターフェイスのデフォルト実装」(default implementations of interfaces)と呼びます。 (1番目の「インターフェイスが関数メンバーの実装を持てる」というのを主目的に検討されたもので、 言葉の意味だけからすると、狭義にはこの1番目の機能こそが「デフォルト実装」です。 ただ、これのついでに実装されたものなので2番目、3番目には具体的な名前がついていません。)

機能面で言うと、クラス(特に抽象クラス)との差は「フィールドを持てない代わりに多重継承できる」というくらいに縮まりました。 フィールドは、多重継承、特に、ひし形継承との相性が悪く、複雑度のわりにメリットが少ないのでこれ以上の制限は緩和されません。

ただ、いくつかクラスとは挙動が違います。

  • アクセシビリティ未指定のときなど、既定の挙動が違う
  • 派生インターフェイスでのオーバーライドは明示的実装が必須
  • デフォルト実装を持っているメンバーは、派生クラス・派生インターフェイスからは直接呼べない(親へのキャストが必要)

これらはインターフェイス特有の問題というよりは、 既存機能・既存コードへの影響を最小限にとどめるためや、 .NET Core 側の修正が簡単な範囲に収めるための挙動です。

Java 由来で、「インターフェイスのデフォルト メソッド」(default interface method、略して DIM)と呼ばれたりもします。

ランタイム側の修正

インターフェイスのデフォルト実装は C# コンパイラー上のトリックだけでは実装できず、 .NET ランタイム側の対応が必要な機能です。 C# 8.0 以降を使っていても、ターゲットとなるランタイム(TargetFramework)が古いと使えません。 .NET Core 3.0 (かそれと同世代)以降のランタイムである必要があります。 .NET Framework 側では対応予定はない(.NET Core 3.0 と同世代な .NET Framework 4.8 でも未対応)です。

詳しくは以前書いたブログ「RuntimeFeature クラス」で説明しています。

導入の動機

この制限緩和には、以下のような動機ががあります。

  • 既存のインターフェイスにメンバーを追加しても破壊的変更にならない
  • 同様の機能を持っている Android (Java (8以降))や iOS (Swift)との相互運用
  • トレイト的にも使える

メンバー追加による破壊的変更

最大の動機は1番目の「破壊的変更にならない」という部分です。 抽象メンバーは派生クラスでの実装が必須で、実装しなければコンパイル エラーを起こします。 その結果、「後から追加したら派生クラスがコンパイル エラーを起こす」という状態になります。

interface I
{
    void X();
 
    // 後から追加したものとする
    void Y();
}
 
class C : I
{
    // X は実装してある
    public void X() { }
 
    // C が I を実装するコードを書いたころには Y がなかったので OK。
    // Y を追加したことでコンパイル エラーに。
}

この問題を回避するには、これまでは抽象クラスを使うしかありませんでした。 抽象クラスは抽象クラスで、多重継承ができないという別の制限があるので完全な回避策にはなりません。

(あるいは、語尾にExとか2とか3とかが付いた新しいインターフェイスを作ったり、 ユーザーに破壊的変更を受け入れてもらうという手もありますが、 どちらもかなり最終手段です。)

そこで、C# 8.0 ではインターフェイスも実装を持てるようにしました。 Java 8 の同様の機能も同じ動機に基づいています。 機能名が「デフォルト実装」(default = de(脱) + fault(不備))なのもこのためです。 「本来なくてはならない実装がない」という状態(fault)に対して既定動作を与えることで、エラーを回避します。

「規約だけを定める」というクリーンさを犠牲にしてでも、このメリットは大きいです。

この観点で言うと、インターフェイスのデフォルト実装はライブラリ作者のための機能になります。 特に、広く使われているライブラリほど破壊的変更はできないものなので、 一番恩恵を受けるのはcorefx (.NET Core の標準ライブラリ部分)チームだったりします。

(小さい規模だと、自分たちで作ったインターフェイスを自分たちで使うということが多くなりますし、 その場合は別に破壊的変更が気になること自体あまりありません。)

トレイト用途

トレイト的な用途としては、フィールドを持てないなどの制限があるので、恩恵は限定的です。 C# の場合には拡張メソッドでも似たようなことができるので、特に恩恵は少なめです。

「拡張メソッドでもできなくはないけども、virtual な実装方法を取りたい」みたいな場合に使います。

よく例に上がるのが LINQ to Object の Count メソッドです。 IEnumerable<T>(System.Collections.Generic名前空間) に対する Count(含まれている要素数を数える)は、汎用的に書くなら以下のように書くしかありません。

static int Count<T>(IEnumerable<T> source)
{
    var count = 0;
    foreach (var _ in source) ++count;
    return count;
}

ただ、配列やList<T>など、元々長さを持っている型であれば、この foreach は全くの無駄で、できれば元々フィールドとして持っている長さを直接返したいです。 そのため、実際の Count の実装には is 演算子による分岐が挟まっています。 この分岐をするくらいなら、拡張メソッドではなく、インターフェイスのデフォルト実装としてトレイト的に実装する方が素直(virtual なので ICollection 側でオーバーライドできる)です。

実装を持つ関数メンバー

ということで、インターフェイスが実装を持てるようになりました。

using System;
 
interface I
{
    void X();
 
    // 後から追加しても、デフォルト実装を持っているので平気
    void Y() { }
}
 
class A : I
{
    // X だけ実装していればとりあえず大丈夫
    public void X() { }
}
 
class B : I
{
    public void X() { }
 
    // Y も実装。I 越しでもちゃんとこれが呼ばれる。
    public void Y() => Console.WriteLine("B");
}
 
class Program
{
    static void Main() => M(new B());
    static void M(I i) => i.Y();
}
B

ただし、以下の制限は残っています。

主目的(新規メンバー追加での破壊的変更の回避)のためにはインスタンス メンバーだけ実装を持てればいいわけですが、ついでにいろいろと緩和されています。

静的メンバー

静的メンバーも持てるようになりました。 インスタンス メンバーと違って、静的コンストラクターや静的フィールドは持てます。 定数や、入れ子の型も持てます。

using System;
 
interface I
{
    static I() { }
    static int _field;
    static int Method() => ++_field;
    const int Constant = 1;
    class Inner { }
}
 
class Program
{
    static void Main()
    {
        Console.WriteLine(I.Method());
        I.Inner inner;
    }
}

次節で説明する通り、アクセシビリティは特に指定しなければ public です。 明示すれば protectedprivate にすることもできます。

アクセシビリティ

C# 7.3 までは、インターフェイスのメンバーは常に publicvirtual でした。 C# 8.0 からは、明示的に指定することでクラスと同じく、protected などのアクセシビリティを指定できます。

interface I
{
    // 未指定の挙動は今まで通り、public virtual。
    void Public()
    {
        Private();
    }
 
    // 明示することでそれ以外のアクセシビリティを指定できるように。
    // protected なら派生クラス・派生インターフェイスから、
    // private なら自分自身からのみ呼び出し可能。
    protected void Protected() { }
    private void Private() { }
}
 
interface IDerived : I
{
    void M()
    {
        Public();
        Protected();
        // Private(); はダメ
    }
}

ちなみに、省略時の挙動は今まで通り public virtual です。 クラスの場合の省略時は private なので、クラスとは挙動が異なります。

また、後述しますがprotected なメンバーにアクセスできるのは派生インターフェイスからだけです。 クラスの場合、派生(実装)しているクラスであっても protected メンバーは見えません。

既定で仮想

アクセシビリティを明示して protectedinternal などを付けても、protected virtualinternal virtual の意味になります。 仮想呼び出しになる方が既定動作です。 これも、クラスとは既定動作が違います。 C# のクラスは何も指定しなければ仮想関数にはなりません。

private か、あるいは明示的に sealed を指定した時だけ、非仮想になります。

interface I
{
    // 未指定の挙動は今まで通り、public virtual。
    void Public() { }
 
    // これも実際には protected virtual。
    protected void Protected() { }
 
    // private メンバーは派生側から呼ばれないので virtual である必要がない。
    private void Private() { }
 
    // sealed を明示すれば virtual ではなくせる。
    sealed void Sealed() { }
}

ちなみに、派生インターフェイスで基底インターフェイスの virtual なメンバーに sealed を付けることはできません。 一度 virtual になったものはずっと virtual です。

interface IDerived : I
{
    // 基底側で virtual なものを派生側で sealed に変えることはできない。
    // コンパイル エラーになる。
    sealed void I.Protected() { }
}

(多重継承があり得るインターフェイスでは、ある経路で sealed を付けてオーバーライドを禁止しても、別のある経路では sealed が付いていないなど、不整合があるため認められません。)

多重継承

クラスとの最大の差は多重継承ができる点です。

デフォルト実装があっても、 フィールドさえ持たなければ多重継承の実装はそれほど難しいものではないので、 パフォーマンスなどへの悪影響はありません。 (参考: 「インターフェースのデフォルト実装」の導入(前編))

ただ、「別経路で同じメソッドに別実装が与えられている」という場合があって、 そこでの呼び分けが問題になることがあります。

例えば以下のようなコードでは、どの実装を使いたいのか不明瞭なので、コンパイル エラーを起こします。

using System;
 
interface IA
{
    void M() => Console.WriteLine("A.M");
}
 
interface IB : IA
{
    void IA.M() => Console.WriteLine("B.M");
}
 
interface IC : IA
{
    void IA.M() => Console.WriteLine("C.M");
}
 
// IB にも IC にも M の実装があって、どちらを使いたいのか不明瞭(コンパイル エラー)。
class C : IB, IC
{
}

ちなみに、「コンパイルするときには IB にしか M の実装がなかったからコンパイルできたけど、後から ICM を追加した状態のライブラリに差し替えた」というような状況もあり得て、この場合は実行時エラーになります。 AmbiguousImplementationException(System.Runtime 名前空間)が throw されます。

もちろん、自分自身が実装を持てばそれが優先されるので、この「不明瞭」エラーは起きません。

class C : IB, IC
{
    // これなら IB.M でも IC.M でもなく、この M が呼ばれるので明瞭
    public void M() => Console.WriteLine("new implementation");
}

「どうしても IB.M を呼びたい」というように、特定の実装を明示的に呼び出したい場合もあるかと思います。 そういうときのために、base キーワードに特定の型を指定できる機能も追加される予定です。 base(T) というように書きます。

class C : IB, IC
{
    // これなら IB.M を明示的に呼べる。
    public void M() => base(IB).M();
}

元々 C# 8.0 に入る予定で一時的には実装されていましたが、 最終的には 8.0 から外れて、9.0 で取り組みなおすことになりました。

ちなみに、C# 9.0 (予定)以降であれば、この書き方はクラスに対しても使えます (参考: 「base(T) アクセス」)。

その他の制限

主に既存の(C# 7.3 以前の)コードを壊さないようにするためですが、 その他にもいくつか制限が掛かっています。 派生クラスと派生インターフェイスで挙動が変わったりもするので注意が必要です。

まず、派生インターフェイスでは、オーバーライドは常に明示的実装が必要です。

interface I
{
    void M() { }
}
 
interface IDerived : I
{
    // オーバーライドには明示的実装が必須。
    void I.M() { }
 
    // 単に M と書くと、別物になる。
    // 「別物で基底の M を隠したければ new 修飾を付けろ」と警告が出る。
    void M() { }
}

class C : I
{
    // クラスの場合は別にそんな制限はなくて、public な同名のメソッドを書けば I.M として使える。
    public void M() { }
}

基底インターフェイスのメンバーの呼び出しは、 派生側もインターフェイスの場合にはクラス → クラスの時と同じような感覚です。 普通に呼べるし、proteted なものに触れます。

一方、派生側がクラスの場合、デフォルト実装しかない(自分自身はオーバーライドしていない)時にはそのメンバーを直接呼べません。 また、protected なものには触れません。

interface I
{
    void Abstract();
    void Default() { }
 
    protected void Protected() { }
}
 
interface IDerived : I
{
    void M()
    {
        // クラス → クラスの派生と同じ感覚。
        // public, protected メソッドを呼べるし、デフォルト実装の有無も関係なく呼べる。
        Abstract();
        Default();
        Protected();
    }
}
 
class C : I
{
    // デフォルト実装がないものは実装が必須
    public void Abstract() { }
 
    public void M()
    {
        // これは、自身も実装を持っているので呼べる。
        Abstract();
 
        // これはコンパイル エラーになる。
        Default();
 
        // 呼びたければ1段キャストが必要。
        ((I)this).Default();
 
        // protected なものは呼べない。コンパイル エラーに。
        ((I)this).Protected();
    }
}

演習問題

問題1

多態性問題 1Shape クラスをインターフェース化せよ。

TriangleShape 関係の例題は一応、これで完成形。

余力があれば、楕円、長方形、平行四辺形、(任意の頂点の)多角形等、さまざまな図形クラスを作成せよ。 また、これらの図形の面積と周の比を計算するプログラムを作成せよ。

解答例1

三角形、円に加え、多角形を実装した物を示します。

using System;

/// <summary>
/// 2次元の点をあらわす構造体
/// </summary>
struct Point
{
  double x; // x 座標
  double y; // y 座標

  #region 初期化

  /// <summary>
  /// 座標値 (x, y) を与えて初期化。
  /// </summary>
  /// <param name="x">x 座標値</param>
  /// <param name="y">y 座標値</param>
  public Point(double x, double y)
  {
    this.x = x;
    this.y = y;
  }

  #endregion
  #region プロパティ

  /// <summary>
  /// x 座標。
  /// </summary>
  public double X
  {
    get { return this.x; }
    set { this.x = value; }
  }

  /// <summary>
  /// y 座標。
  /// </summary>
  public double Y
  {
    get { return this.y; }
    set { this.y = value; }
  }

  #endregion
  #region 演算子

  /// <summary>
  /// ベクトル和
  /// </summary>
  /// <param name="a">点A</param>
  /// <param name="b">点B</param>
  /// <returns>和</returns>
  public static Point operator +(Point a, Point b)
  {
    return new Point(a.x + b.x, a.y + b.y);
  }

  /// <summary>
  /// ベクトル差
  /// </summary>
  /// <param name="a">点A</param>
  /// <param name="b">点B</param>
  /// <returns>和</returns>
  public static Point operator -(Point a, Point b)
  {
    return new Point(a.x - b.x, a.y - b.y);
  }

  #endregion

  /// <summary>
  /// A-B 間の距離を求める。
  /// </summary>
  /// <param name="a">点A</param>
  /// <param name="b">点B</param>
  /// <returns>距離AB</returns>
  public static double GetDistance(Point a, Point b)
  {
    double x = a.x - b.x;
    double y = a.y - b.y;
    return Math.Sqrt(x * x + y * y);
  }

  public override string ToString()
  {
    return "(" + x + ", " + y + ")";
  }
}

/// <summary>
/// 2次元空間上の図形を表すクラス。
/// 三角形や円等の共通の抽象基底クラス。
/// </summary>
interface Shape
{
  double GetArea();
  double GetPerimeter();
}

/// <summary>
/// 2次元空間上の円をあらわすクラス
/// </summary>
class Circle : Shape
{
  Point center;
  double radius;

  #region 初期化

  /// <summary>
  /// 半径を指定して初期化。
  /// </summary>
  /// <param name="r">半径。</param>
  public Circle(Point center, double r)
  {
    this.center = center;
    this.radius = r;
  }

  #endregion
  #region プロパティ

  /// <summary>
  /// 円の中心。
  /// </summary>
  public Point Center
  {
    get { return this.center; }
    set { this.center = value; }
  }

  /// <summary>
  /// 円の半径。
  /// </summary>
  public double Radius
  {
    get { return this.radius; }
    set { this.radius = value; }
  }

  #endregion
  #region 面積・周

  /// <summary>
  /// 円の面積を求める。
  /// </summary>
  /// <returns>面積</returns>
  public double GetArea()
  {
    return Math.PI * this.radius * this.radius;
  }

  /// <summary>
  /// 円の周の長さを求める。
  /// </summary>
  /// <returns>周</returns>
  public double GetPerimeter()
  {
    return 2 * Math.PI * this.radius;
  }

  #endregion

  public override string ToString()
  {
    return string.Format(
      "Circle (c = {0}, r = {1})",
      this.center, this.radius);
  }
}

/// <summary>
/// 2次元空間上の三角形をあらわすクラス
/// </summary>
class Triangle : Shape
{
  Point a;
  Point b;
  Point c;

  #region 初期化

  /// <summary>
  /// 3つの頂点の座標を与えて初期化。
  /// </summary>
  /// <param name="a">頂点A</param>
  /// <param name="b">頂点B</param>
  /// <param name="c">頂点C</param>
  public Triangle(Point a, Point b, Point c)
  {
    this.a = a;
    this.b = b;
    this.c = c;
  }

  #endregion
  #region プロパティ

  /// <summary>
  /// 頂点A。
  /// </summary>
  public Point A
  {
    get { return a; }
    set { a = value; }
  }

  /// <summary>
  /// 頂点B。
  /// </summary>
  public Point B
  {
    get { return b; }
    set { b = value; }
  }

  /// <summary>
  /// 頂点C。
  /// </summary>
  public Point C
  {
    get { return c; }
    set { c = value; }
  }

  #endregion
  #region 面積・周

  /// <summary>
  /// 三角形の面積を求める。
  /// </summary>
  /// <returns>面積</returns>
  public double GetArea()
  {
    Point ab = b - a;
    Point ac = c - a;
    return 0.5 * Math.Abs(ab.X * ac.Y - ac.X * ab.Y);
  }

  /// <summary>
  /// 三角形の周の長さを求める。
  /// </summary>
  /// <returns>周</returns>
  public double GetPerimeter()
  {
    double l = Point.GetDistance(this.a, this.b);
    l += Point.GetDistance(this.a, this.c);
    l += Point.GetDistance(this.b, this.c);
    return l;
  }

  #endregion

  public override string ToString()
  {
    return string.Format(
      "Circle (a = {0}, b = {1}, c = {2})",
      this.a, this.b, this.c);
  }
}

/// <summary>
/// 自由多角形をあらわすクラス
/// </summary>
class Polygon : Shape
{
  Point[] verteces; // 頂点

  #region 初期化

  /// <summary>
  /// 座標を与えて初期化。
  /// </summary>
  /// <param name="verteces">頂点の座標の入った配列</param>
  public Polygon(params Point[] verteces)
  {
    this.verteces = verteces;
  }

  #endregion
  #region プロパティ

  /// <summary>
  /// 頂点の集合。
  /// </summary>
  public Point[] Verteces
  {
    get { return this.verteces; }
    set { this.verteces = value; }
  }

  #endregion
  #region 面積・周

  /// <summary>
  /// 三角形の面積を求める。
  /// </summary>
  /// <returns>面積</returns>
  public double GetArea()
  {
    double area = 0;
    Point p = this.verteces[this.verteces.Length - 1];
    for (int i = 0; i < this.verteces.Length; ++i)
    {
      Point q = this.verteces[i];
      area += p.X * q.Y - q.X * p.Y;
      p = q;
    }
    return 0.5 * Math.Abs(area);
  }

  /// <summary>
  /// 三角形の周の長さを求める。
  /// </summary>
  /// <returns>周</returns>
  public double GetPerimeter()
  {
    double perimeter = 0;
    Point p = this.verteces[this.verteces.Length - 1];
    for (int i = 0; i < this.verteces.Length; ++i)
    {
      Point q = this.verteces[i];
      perimeter += Point.GetDistance(p, q);
      p = q;
    }
    return perimeter;
  }

  #endregion

  public override string ToString()
  {
    System.Text.StringBuilder sb = new System.Text.StringBuilder();
    sb.AppendFormat("Polygon ({0}", this.verteces[0]);
    for (int i = 1; i < this.verteces.Length; ++i)
    {
      sb.AppendFormat(", {0}", this.verteces[i]);
    }
    sb.Append(")");

    return sb.ToString();
  }
}

/// <summary>
/// Class1 の概要の説明です。
/// </summary>
class Class1
{
  static void Main()
  {
    Triangle t = new Triangle(
      new Point(0, 0),
      new Point(3, 4),
      new Point(4, 3));

    Circle c = new Circle(
      new Point(0, 0), 3);

    Polygon p1 = new Polygon(
      new Point(0, 0),
      new Point(3, 4),
      new Point(4, 3));

    Polygon p2 = new Polygon(
      new Point(0, 0),
      new Point(0, 2),
      new Point(2, 2),
      new Point(2, 0));

    Show(t);
    Show(c);
    Show(p1);
    Show(p2);
  }

  static void Show(Shape f)
  {
    Console.Write("図形 {0}\n", f);
    Console.Write("面積/周 = {0}\n", f.GetArea() / f.GetPerimeter());
  }
}

更新履歴

ブログ