アマゾンバナーリンク

ディスプレイ広告

スポンサーリンク

C#メソッドシグネチャだけでわかる安全な型の定義を理解する

こんにちは!ジェイです。みなさんはプログラミングをしていて、なんとなく必要な引数をただ渡したりしてませんか?今日は、そんな時に起き得る落とし穴について解説します。

この記事を読むとメソッドを定義する時により安全な形にする方法がわかります。

記事内広告

リストを引数にとる場合

例えばこんなコードがあったとします。

List<Entity> RemoveObject(List<Entity> entities)
{
    var list = new List<Entity>();
    // 省略(500行ぐらいある)
}

これを見て一見何も問題はなさそうに見えます。さらになんとなく不要な物を削除してくれるような処理をしてくれるというのは予想できます。

しかし、ここでの問題点は、引数で渡したリストが直接変更されるのか、変更されないのか どうかはすべてのソースを見ないとわからないという事です。これではとても効率が悪いですよね。

IReadOnlyCollection

次にこんなコードがあったとします。

List<Entity> RemoveObject(IReadOnlyCollection<Entity> entities)
{
    var list = new List<Entity>();
    // 省略(500行ぐらいある)
}

引数の方がIReadOnlyCollection<Entity>に変わっていますが、これはリードオンリーなコレクションと言う意味です。

先程と違うところは、呼び出し側を見ただけで、引数にリストを渡してもメソッド内で変更(Add,Remove)されることはない ということです。

これは大きなメリットで、わざわざメソッドの実装を全部読まなくてもリストが変わらない事が担保されています。なので、リストを安心して渡せます。

このように引数の意味を考えることはとても重要です。

次にこんな例があったとします。

戻り値なしの場合

void RemoveUnnecessary(List<Entity> items)
{
    var list = new List<Entity>();
    // 省略(500行ぐらいある)
}

この場合は戻り値がないので、引数で渡したリストが変更されそうなことは明らかです。

ここまできて、今までの流れで一番重要な事は、メソッドシグネチャ(引数と戻り値の型定義のこと)を見るだけで、メソッドがどのような振る舞いをするのかがわかるようにプログラムを書くということです。

初心者の頃は、あまり意識できないので、無理にとは言いません。ですが慣れてきたら、ぜひこういう部分にも意識して、プログラムを修正しやすいように書くべきです。

はっきりいうとメソッドの実装を読まないとわからないような書き方は三流で、工数や時間を浪費することになります。

List<T>を持つクラス

class MyQueryResult {
    // クエリ結果
    public List<Entity> Items { get; }

    // コンストラクタ
    public MyQueryResult()
    {
        // 省略
    }

    // 他にもメソッドがいっぱいある

}

これはなかなか恐ろしいクラスになっていて、今まで話した悪い部分が全部詰まってます。

Itemsの型がList<Entity>なので、AddやRemoveなどの変更ができてしまいます。こんな状態では、クエリーの改ざんが起きる可能性があり、予期しないバグを引き起こす可能性があります。

コーディングルールでItemを変更するなというルールがあったとしても、人間はミスをするものなので、変更されてしまう可能性は十分にあります。

なので、そもそも変更できないようにしておくのが、ベストな選択肢です。

こんな感じです。

IReadOnlyList

class MyQueryResult {
    // クエリ結果
    public IReadOnlyList<Entity> Items { get; }   // 変更不可

    // コンストラクタ
    public MyQueryResult()
    {
        // 省略
    }

    // 他にもメソッドがいっぱいある

}

Itemの型がIReadOnlyList<Entity>に変わっているので、AddやRemoveされることはありません。これだけでも相当な安心感があるはずです。

ちなみにコンストラクタ側もIReadOnlyList<Entity>にかえてしまうのも良いアイデアです。

複雑なリストを引数にとる例

次にこんな実装例があったとします。

List<Entity> MergeAndUnique(List<Entity> source1, List<Entity> source2)
{
    // 省略(3000行ぐらいある)
}

このメソッドは名前からして、source1,source2を混ぜて、重複を除去するという機能かということが推測されます。
ここまで来たら問題は、明確でsource1,source2が変更されない保証がないということです。

ではこれならどうでしょうか。

List<Entity> MergeAndUnique(IEnumerable<Entity> source1, IEnumerable<Entity> source2)
{
    // 省略(3000行ぐらいある)
}

引数の型がIEnumerable<Entity>に変わっています。これはforeachで繰り返しをするためのイテレーターを表す型で、AddやRemoveで変更することはできません。

これで無事引数に渡したリストが変更されることはない事が保証されました。

コレクション型の種類

引数の型の変更ができるかできないかを言及してきましたが、どのようなコレクションがあって、どのような特性があるのか見ていきましょう。

コレクション型の継承関係図

追加削除できない追加削除できる
遅延評価あり(要素未確定)IEnumerable<T>
要素数確定IReadOnlyColliection<T>ICollection<T>
順序の概念ありIReadOnlyList<T>IList<T>
具象クラスReadOnlyCollection<T>
int[]ような普通の一次元配列
List<T>

それぞれの型の特徴

IEnumerableは遅延評価

IEnumerable<T>はすべてのコレクション型の最上位に位置する、単純に列挙可能な(foreachで回すことが可能な)型を表します。

メソッド内で1回だけforeachしたり、1つの LINQ 式で完結してしまう場合は、引数の型をIEnumerable<T>にすれば十分です。

ただ、IEnumerable<T>を使うときは遅延評価に留意しましょう。
やろうと思えば ICollection<T>などでも遅延評価を実装することは出来ますが、IEnumerable<T>の場合は言語仕様により遅延評価(yield returnなど)がサポートされているため、暗黙的に遅延評価を考慮すべき型であると認識すべきです。

メソッド引数としてList<T> を書きたくなったら、まず IEnumerable<T> 型に出来ないか検討してみましょう。検討するには、 IEnumerable<T> を引数の型に採用すべきでないケースを知っておく必要があります。具体的には、以下のようなケースです。

  • メソッド内でCount()で要素数を取得している→要素数を知りたいなら、要素数が確定しているIReadOnlyCollctionにすべき
  • メソッド内でforeachを2回以上行っている→ループの度にイテレータが再評価されて、無駄が多くなるため、IReadOnlyCollectionを使うべき
  • ToArray()やToList()などで結局内部で配列やリストにして使っている→配列やリストが必要なら、素直に引数の型をそれにして、IEnumerableから配列やリストへの変換が必要なら呼び出し元で変換してもらうべき

ICollectionとIListの違い

Collection<T>は要素の集合を表し、要素数は確定しているが順序関係を持たないです。
イメージとしては、空間に要素がバラバラに入っていて、foreachで取り出すときにどの順番で取り出されるか不定、みたいな感じです。

IList<T>は可変長配列で、順序を維持した集合であり、Insert()メソッドで位置を指定して要素追加したりできます。イメージとしては、順番に要素同士が連結されていて、foreachで取り出すと必ず順序通りに取り出される、みたいな感じです。

IListとListの違い

IList<T>はList<T>とよりも非常にシンプルな実装になっていて、メソッドは、インデクサとIndexOf()にInsert()RemoveAt()の3つだけです。

なのでList<T>で定義されているSort()やReverse()などの順序を変更するメソッドはIList<T>では使えません。
これらのメソッドを利用したい場合は、素直に引数List<T>にして、変更されることを明示的にすべきです。

同様Contains()やFindXXなどの探索系メソッドも使えませんが、これらはコレクションの追加削除をするものではないので、引数の型は ReadOnly 系にしておいて、 内部でToList()して便利メソッドを使う、というような配慮がにするのがベストです。

ListはIListを実装しています。
引数の型としてListではなくIListを使うことは、間口を広げることにつながります。Listだと、渡せるのはList かそれを継承したサブクラスだけになりますが、IListだとそれを実装したあらゆる型を受け付けることが出来きます。

戻り値としては、例えばメソッド内部でリストを生成して返すような処理になっているのなら、素直にList<T>を返すようにしましょう。IList<T>だけでなく、戻り値をインターフェース型にするようなシーンはあまりないです。

IReadOnlyCollection と ReadOnlyCollection の違い

まずこの2つは名前空間が違い
System.Collections.Generic.IReadOnlyCollection<T>と
System.Collections.ObjectModel.ReadOnlyCollection<T>です。

ReadOnlyCollection<T>はReadOnlyのListだと理解すれば大丈夫です。

対して、IReadOnlyCollection<T>には、要素の追加削除などのメソッドが定義されてなくて、IEnumerable+Countとだけの最小の構成になってます。

もう一方のIReaOnlyList<T>はIReadOnlyCollection<T>+インデクサで、固定長の一次元配列のようなイメージです。なので、IReadOnlyCollection<T>型の引数には配列を渡すことができます。

// このメソッドに渡せる型には、どんなものがある? List<int>?
void Func(IReadOnlyCollection<int> arg) { }

// 呼び出し元
var array = new int[] { 1, 2 };
Func(array);  // 実は配列も渡せる

したがって、メソッド引数として使う際には、ReadOnlyCollectionではなく、IReadOnlyCollection<T>かIReadOnlyList<T>を使うべきです。

型の使い分け方

それぞれの型がわかったので、実際にメソッドの引数や戻り値に何を使えばいいか考えてみます。

メソッドの引数の型

メソッド引数は、可能な限り最大に間口を広げるべきです。
つまり、出来る限り上位のインターフェース(継承関係図で上の方にあるもの)で受けます。

  • 要素の追加削除をしないならList<T> よりもIReadOnlyList<T>にして、順序関係が関係ないならIReadonlyCollection<T>で、要素数を知る必要がないなら、IEnumerable<T>を使う
  • 要素の追加削除をするなら、基本はList<T>になるがIList<T>やICollection<T>でも受けられないか考えてみる

基本的には、IReadonlyCollection<T> で書ける場合が多いので、まずこの型で置き換えできないか検討してみましょう。

メソッド戻り値の型

戻り値は引数の逆で、出来る限り下位のインターフェース(継承関係図で下の方にあるもの)を返すようにします。

メソッド内部でリストを生成して返すのなら、素直にList<T> を戻り値にすればよいです。無理にIEnumerable<T>にする必要はありません。

理由は簡単で、より具体的なインタフェースで返す方が、呼び出し元での利便性が高まるからです。
例えば以下のようなケース。

IEnumerable<T> Reorder<T>(IReadOnlyCollection<T> list)
{
  var result = new List<T>();
  // 省略
  return result;
}

// 呼び出し元はこうなっている
var newList = new List<T>(Reorder(list));  // いちいち新しく List を作っている
newList.Add();  // じゃないと要素を追加できない

もしReorder()の戻り値がList<T> なら、それをそのまま使うことができます。もちろん、元のコードのままnew List() で新しく作ってもちゃんと動くので、互換性が失われることもなく、利便性だけが高まります。

なので以下のようにもできます。

List<T> Reorder<T>(IReadOnlyCollection<T> list)
{
  var result = new List<T>();
  // 省略
  return result;
}

// 呼び出し元はこう書ける
var newList = Reorder(list);  // 新しく List を作る必要がなくなった
newList.Add();

いちいち新しいリストを生成しなくても済むので、メモリ効率もよくなります。

ジェネリックの共変性

もしList<T>のTを上位の型に暗黙変換したい時には、IReadOnlyList<T> を使うと変換できます。

List<object> Func1()
{
    // コンパイルエラー。 Func1().Add(0) というように違う型を追加出来るため、おかしくなる
    return new List<string>();
}

IReadOnlyList<object> Func2()
{
    // これはオッケー。戻り値は ReadOnly なので、Add() されることはない
    return new List<string>();
}

LINQ 式の結果をそのまま返したいとか、遅延評価を行う場合はIEnumerable<T>を戻り値にしないといけません。

ちなみに配列はメソッドの引数に使うシーンはあまりないです。IReadOnlyList<T> を使えば完全互換になるためです。

まとめ

普段はあまり意識しないList<T>について、深堀りしてみました。

これにより、今回一番伝えたかったメソッドシグネチャ(引数と戻り値の型定義のこと)を見るだけで、メソッドがどのような振る舞いをするのかがわかるようにプログラムを書くことができるようになります。

可読性の高いバグの少ないプログラムを書く、手助けになれば幸いです。

アイコンは茄子さんからお借りしました。

+4

:)