Build Insiderオピニオン:岩永信之(15)

Build Insiderオピニオン:岩永信之(15)

インターフェースを拡張する2つの手段 ― C#への「インターフェースのデフォルト実装」の導入(後編)

2017年4月17日

破壊的な影響を他に及ぼすことなくインターフェースの機能を拡張するには、デフォルト実装に加えて拡張メソッドも使用できる。今回はこれら2つの方法がなぜ必要なのか、それぞれが得意としている分野について詳しく見る。

岩永 信之
  • このエントリーをはてなブックマークに追加

デフォルト実装と拡張メソッド

 C#以外の言語が持つ「インターフェースのデフォルト実装」に類似の機能、例えばJavaのデフォルトメソッドは、「既存のインターフェースに対して、利用側のコードを壊さずに、インターフェース機能を拡張するもの」と説明される。C#で「拡張」というと、すでに拡張メソッドという機能があって、同じ目的のものがすであるのにどうしてデフォルト実装が必要なのかと思うかもしれない。

 これら2つの機能は、確かに同じ目的に使える部分もあるが、それぞれにしかできないことも存在している。ひとくちに「インターフェースの機能拡張」といっても、実のところ、拡張の仕方には以下のような2種類のものがある。

  • インターフェースの作者以外の人が、インターフェースとも、インターフェースを実装するクラスとも別のライブラリで機能を追加したい
  • インターフェースの作者自身が、新バージョンを作るに当たって、インターフェースを実装する既存のコードを壊さないように機能を追加したい

 拡張メソッドは前者向け、デフォルト実装は後者向けの機能である。

 その他にも細かな違いはあるが、この「それぞれにしかできないこと」の価値は高くて、両方の機能が必要とされる。これが、今、C#にデフォルト実装を導入しようとしている動機となる。

 これらの「それぞれにしかできないこと」に関して、もう少し詳しく見てみよう。

拡張メソッドの利点

 まずはC#利用者にとってなじみの深い拡張メソッドから見て行こう。拡張メソッドは、簡単にいえば、Enumerable.Select(source, x => x * x)というような静的メソッド呼び出しを、source.Select(x => x * x)というように、インスタンスメソッドと同じ書き方ができるようにする単純な機能である。

 非常に単純なため、妥協的で低機能と勘違いするかもしれないが、案外この実装方法が有利な点は多い。以下のような点は、デフォルト実装ではできず、拡張メソッドでだけできるものである。

  • 第三者拡張: インターフェース作者とも、インターフェースを実装するクラスの作者とも違う第三者が機能追加できる
  • 特殊化: ジェネリックなインターフェースに対して、特定の特殊化(IEnumerable<int>だけなど)に対してだけ実装を与えられる
  • 選択性: 使わない選択(要らないときはusingしなければIntelliSenseを汚さない)

 これら3つの点について少し考えてみよう。

第三者拡張

 インターフェース自体に手を入れてメンバーを追加するのは、インターフェースの作者自身が行う必要がある。これは結構大きなデメリットになる。何か欲しい機能があったときに、作者に提案を送って実装してもらい、それがリリースされるのを待つのは現実的ではない。

 例として、LINQ的なちょっとした便利機能を紹介しよう。リスト12は、よくある「インデックス付きのforeach」を拡張メソッドでやっている例である。

C#
using System.Collections.Generic;
using static System.Console;

public static class TupleExtensions
{
  public static IEnumerable<(T item, int index)> Indexed<T>(this IEnumerable<T> source)
  {
    var i = 0;
    foreach (var x in source)
    {
      yield return (x, i);
      ++i;
    }
  }
}

class Program
{
  static void Main()
  {
    var items = new[] { "a", "b", "c" };

    foreach (var (item, index) in items.Indexed())
    {
      WriteLine($"{index}番目の要素は{item}");
    }
  }
}
リスト12: インデックス付きのforeachを拡張メソッドで実現

 C# 7.0でタプル分解構文が使えるようになったので、リスト12ではさっそくこれを使っている(NuGetからSystem.ValueTupleパッケージをインストールする必要がある)。こういう新機能を活用したライブラリは、C#がリリースされてそれが世に広まってから、その次の世代でようやく標準で提供されたりする。そして、標準提供されるのを待っていたら、いつそれを使えるようになるかは分からないものである。これに対して、拡張メソッドであれば、必要なときに自分自身でメンバーを追加できる。

 ちなみに、このコードは実のところ手抜きである。名前はこれでいいのかという検討もしていないし、例外処理もさぼっている。自分向けに書いたコードだからこそ許されている面もある。標準ライブラリとしてIEnumerable<T>自体にメンバーを追加するとなると、こういう手抜きはできないだろう。程よく手抜きができるのも、第三者が自由に機能追加できる拡張メソッドのいい点だろう。

選択性

 インターフェースを使いたい全ての人が、そのインターフェースの拡張機能まで使いたいとは限らない。例えば、IEnumerable<T>は使いたいが、LINQ to Object(=System.Linq.Enumerableクラス中の拡張メソッド)は不要という場面も多いだろう。インターフェースと、それに対する拡張機能は関心の範囲が異なることが多い。

 関心もないものが常に付属してくると正直迷惑だろう。大は小を兼ねる的に「あっても困らないだろう」と思うかもしれないが、使い勝手は確実に落ちる。無意味に機能を増やすと、本当に必要とするものを探しにくくなるのはそれなりのストレスである。

 例えば、現在のLINQ to Objectであれば、using System.Linq;ディレクティブの有無によって拡張メソッドを使うか使わないかを選択できる。図15に示すように、明示的にusingディレクティブを書いて使うことを選択したときにだけ、拡張メソッドがIntelliSenseの補完候補に現れる。必要なときに必要なだけ検索候補を得られるのも拡張メソッドのメリットである。

図15: 拡張メソッドを使うか使わないかの選択例
図15: 拡張メソッドを使うか使わないかの選択例
特殊化

 C#のジェネリクスでは、型の特殊化は認めていない。要するに、あるジェネリックな型X<T>があるとき、X<int>のような型引数が特定の型に特殊化された場合にだけ、実装を別のものにするようなことはできない。インターフェースにデフォルト実装が入っても、この制限は引き継がれる。

 例えば、IEnumerable<T>の要素の和を計算するような場合を考えよう。LINQのSumメソッドがまさにその例だが、ジェネリックな型引数Tに対して加算演算子を使えないため、一般には和の計算ができない。intdoubleなど、加算演算子を持っている型に対してだけ実装がある。インターフェースの特殊化はできないので、このような実装を行うには拡張メソッドが必要になる(拡張メソッドであれば、リスト13の要領で実装できる)。

C#
public static class Enumerable
{
  // 一般には実装できない
  public static T Sum<T>(this IEnumerable<T> source)
  {
    T sum = default(T);
    foreach (var x in source)
    {
      sum += x; // ここでコンパイルエラー
    }
    return sum;
  }

  // intなどに限ればSumを実装できる
  public static int Sum(this IEnumerable<int> source)
  {
    int sum = 0;
    foreach (var x in source)
    {
      sum += x;
    }
    return sum;
  }
}
リスト13: ジェネリックインターフェースに対する特殊化実装の例

デフォルト実装の利点

 当然、拡張メソッドにはできなくて、デフォルト実装でならばできることがある。

  • インターフェース自身の変更: インターフェースに後からメンバーを足しても破壊的変更にならないようにできる
  • 仮想メンバー: 汎用実装を既定で与えておいて、クラスごとに最適な実装に変更できる
インターフェース自身の変更

 前編(第13回)でも話した通り、インターフェースにデフォルト実装を認める最大の動機は、インターフェースにメンバーを追加しても破壊的変更を起こさないようにすることにある。

 インターフェースに対するメンバー追加が破壊的な変更となる問題について、一番悩まされているのは.NETの標準ライブラリである。標準ライブラリはそのプログラミング言語で最も広く使われるライブラリであり、当然、破壊的な変更は避けなければならない。

 .NETの開発チームは初期の段階からインターフェースの問題を認識していたため、.NETの標準ライブラリはインターフェースを避ける傾向にある。インターフェースを定義しておいてほしいような場面でも、代わりに抽象クラスが使われがちだ。

 しかし、やはり.NETチームも、以下のような2つの意味で現状をつらいと思っているようである。

  • インターフェースに後からメンバーを足せないことで、別のインターフェースを追加する必要があるなど、回りくどい実装になるのがつらい
  • 抽象クラスでは、多重継承ができなくてつらい

 かつてはインターフェースを避けていたような箇所でも、もうあきらめてインターフェースを使う場面も見られる。例えば以下のコードを見てほしい。

 これは標準ライブラリではなくC#コンパイラーのソースコードだが、そこには以下のようなコメントが添えられている。

This interface is reserved for implementation by its associated APIs. We reserve the right to change it in the future.
このインターフェースは実装上、関連するAPIによって差し押さえられている。われわれ(=C#コンパイラーチーム)は、これを将来的に変更する権利を予約する。

 これは、インターフェースに対して、破壊的変更になることが分かっている「メンバーの追加」を将来的に行うという予告である。つまるところ、このインターフェースを第三者が実装することは推奨していないし、それで破壊的変更になっても責任は取らないと言っている。コメントで注釈している辺り、言語機能の敗北である。

 こうしたことからも、インターフェースへのメンバー追加ができるようになるデフォルト実装が強く求められていることがよく分かる。

仮想呼び出し

 拡張メソッドで困るのは、仮想呼び出しができないことである。ある特定の派生型でだけ特別な処理をするということが難しい。

 例えば、EnumerableクラスのLastOrDefaultメソッドの実装はリスト14のようになっている。IList<T>のようにインデクサーが使えるものに対してMoveNextでの列挙をするのは完全に無駄なので、特別な分岐が存在する。

C#
public static TSource LastOrDefault<TSource>(this IEnumerable<TSource> source)
{
  // 前略
  IList<TSource> list = source as IList<TSource>;
  if (list != null)
  {
    int count = list.Count;
    if (count > 0) return list[count - 1];
  }
  else
  {
    // 略。最後の1要素までMoveNextするような実装
  }
  return default(TSource);
}
リスト14: LastOrDefaultの実装(抜粋)

 このような実装には2点ほど難点がある。

  • IList<T>に対してしかこの最適化は働かない。例えば、IReadOnlyList<T>ではこの最適化が掛かっておらず、MoveNextが呼ばれる効率の悪いコードが実行される
  • as演算子を使った分岐が不格好

 これに対して、拡張メソッドが持つ「第三者が機能追加できる」「関心が分離される」という利点が失われるため一長一短だが、インターフェースのデフォルト実装機能を使えばリスト15のような書き方ができる。

C#
public interface IEnumerable<T> : IEnumerable
{
  T LastOrDefault<T>(this IEnumerable<T> source)
  {
    // 略。最後の1要素までMoveNextするような実装
  }
}

public interface IReadOnlyList<out T> : IReadOnlyCollection<T>
{
  T this[int index] { get; }

  override T LastOrDefault<T>(this IEnumerable<T> source)
  {
    if (Count > 0) return list[Count - 1];
    else return default(T);
  }
}
リスト15: デフォルト実装を使ったLastOrDefaultの実装例

 他のインターフェースでも、デフォルトの実装よりも効率的な実装が可能であれば、それぞれでoverrideすればよい。

 場合によっては、ほとんどデフォルトの実装しか使わないメソッドもあるだろう。中には歴史的経緯でそうせざるを得なかったものもある。例えば、ジェネリックなIEnumerable<T>インターフェース(System.Collections.Generic名前空間)は、もともとあったIEnumerableインターフェース(System.Collections名前空間)を継承しているが、IEnumerableの方のGetEnumeratorはほぼ全てのクラスでリスト16に示すような実装になるだろう。

C#
class MyList<T> : IEnumerable<T>
{
  public IEnumerator<T> GetEnumerator() { ... }

  // ほとんどの実装で、ジェネリックな方のGetEnumeratorを呼ぶだけ
  IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
リスト16: IEnumerable.GetEnumeratorの実装例

 ほぼ全てがリスト16と同じ実装になるのであれば、リスト17に示すように、インターフェース側にデフォルト実装を持たせるべきだろう。

C#
public interface IEnumerable<T> : IEnumerable
{
  IEnumerator<T> GetEnumerator()
  IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

class MyList<T> : IEnumerable<T>
{
  public IEnumerator<T> GetEnumerator() { ... }

  // IEnumerable.GetEnumerator()の方はクラス側での実装不要
}
リスト17: IEnumerable.GetEnumeratorにデフォルト実装を与える例

まとめ

 デフォルト実装はインターフェースの「拡張」に使われるものである。C#で拡張というとすでに拡張メソッドがあるわけだが、デフォルト実装と拡張メソッドではそれぞれにしかできないことがある。

 拡張メソッドには、第三者による拡張ができる、拡張機能の独立性・選択性がある、特殊化ができるなどの利点がある。その一方で、インターフェース自身へのメンバー追加や、仮想呼び出しについてはデフォルト実装でしかできない。

 このそれぞれにしかできない部分には大きな価値がある。すでに拡張メソッドがあるC#にもデフォルト実装が必要とされているし、デフォルト実装が導入されたからといって拡張メソッドの立ち位置が変わることはない。

岩永 信之(いわなが のぶゆき)

岩永 信之(いわなが のぶゆき)

 

 

 ++C++; の中の人。C# 1.0がプレビュー版だった頃からC#によるプログラミング入門を公開していて、C#とともに今年で15年になる。最近の自己紹介は「C#でググれ」。

 

 

 

 

※以下では、本稿の前後を合わせて5回分(第13回~第17回)のみ表示しています。
 連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。

13. インターフェースを「契約」として見たときの問題点 ― C#への「インターフェースのデフォルト実装」の導入(前編)

C#におけるインターフェースとは、ある型が持つべきメソッドを示す「契約」であり、実装は持てない。だが、このことが大きな問題となりつつある。今回から全3回に分けて、C#がこの問題にどう対処しようとしているかを見ていく。

14. デフォルト実装の導入がもたらす影響 ― C#への「インターフェースのデフォルト実装」の導入(中編)

前回は一般論としてのインターフェースとその課題を見た。今回はC#にインターフェースのデフォルト実装を導入すると、どのようなコードが書けるようになるのか、導入するために必要な修正点などについて見ていく。

15. 【現在、表示中】≫ インターフェースを拡張する2つの手段 ― C#への「インターフェースのデフォルト実装」の導入(後編)

破壊的な影響を他に及ぼすことなくインターフェースの機能を拡張するには、デフォルト実装に加えて拡張メソッドも使用できる。今回はこれら2つの方法がなぜ必要なのか、それぞれが得意としている分野について詳しく見る。

16. C# 7のタプルが一般的なガイドラインに沿わずに書き換え可能な構造体である背景

C# 7.0で登場した新しいタプル(ValueTuple構造体)は、複数の値をひとまとめにして扱うのに便利なデータ構造だが、その実装は一般的な構造体のガイドラインに従っていない。なぜそうなっているのか、技術的背景を追う。

17. C# 7.1: 半年でのマイナーリリース

C# 7で始まったリリースサイクルの短期化に伴って、つい先日C# 7.1がリリースされた。そこに含まれる新機能、C# 7.2/7.3に含まれる予定の機能、そこから見えてくるものについて考えてみよう。

サイトからのお知らせ

Twitterでつぶやこう!