.NETのコレクション概要とImmutable Collectionsについて

先週の土曜日に、「プログラミング .NET Framework 第4版 」座談会でOverview of the .NET Collection Framework and Immutable Collectionsとして、コレクションフレームワークとImmutable Collectionsについて話してきました。

Overview of the .Net Collection Framework and Immutable Collections from Yoshifumi Kawai

案外コレクションについてまとまった話って、ない(or .NET 4.5からReadOnly系が入ってきて、話が更新されているもの)ので、資料として役に立つのではないかと思います。

Collection Framework

前半部分ですが、これのジューヨーなところはILinqable<T>、じゃなくて(スライド資料では出てないのでナンノコッチャですが)、ReadOnly系の取り扱いですね。MutableとReadOnlyが枝分かれしている理由とか対処方法とか、が伝えたかった点です。いやあ、コレクション作る時は両方実装しよう!とかしょうもないですねえ、shoganaiのですねぇ……。

IEnumerable<T>とIReadOnlyCollection<T>の差異は実体化されていない「可能性がある」かどうか。で、なのでメソッドの引数などで内部で実体化されてるのを前提にほげもげしたい場合は、IReadOnlyCollection<T>を受け取るほうが望ましいといえば望ましいのですが、汎用的にIEnumerableのままで……という場合は、以下のようなメソッドを用意しとくといいでしょう。

/// <summary>
/// sourceが遅延状態の場合、実体化して返し、既に実体化されている場合は何もせずそれ自身を返します。
/// </summary>
/// <param name="source">対象のシーケンス。</param>
/// <param name="nullToEmpty">trueの場合、sourceがnull時は空シーケンスを返します。falseの場合はArgumentNullExceptionを吐きます。</param>
public static IEnumerable<T> Materialize<T>(this IEnumerable<T> source, bool nullToEmpty = true)
{
    if (nullToEmpty && source == null)
    {
        return Enumerable.Empty<T>();
    }
    else
    {
        if (source == null) throw new ArgumentNullException("sourceがnullです");
    }

    if (source is ICollection<T>)
    {
        return source;
    }
    if (source is IReadOnlyCollection<T>)
    {
        return source;
    }

    return source.ToArray();
}

こんなのを作って、冒頭で呼べば、二度読みなどもOKに。

public static void Hoge<T>(IEnumerable<T> source)
{
    source = source.Materialize(); // ここで実体化する

    // あとは好きに書けばいいのではないでせうか
}

どうでしょ。また、二度読みなら列挙したらキャッシュして、再度読む時はそっから読んでくれればいいのに!というリクエストあるかと思います。それは一般的にはメモ化(Memoization)といいます。というわけで、シーケンスに実装してみましょう。

public static IEnumerable<T> Memoize<T>(this IEnumerable<T> source)
{
    if (source == null) throw new ArgumentNullException("sourceがnull");
    return new MemoizedEnumerable<T>(source);
}

class MemoizedEnumerable<T> : IEnumerable<T>, IDisposable
{
    readonly IEnumerable<T> source;
    readonly List<T> cache = new List<T>();
    bool cacheComplete = false;
    IEnumerator<T> enumerator;

    public MemoizedEnumerable(IEnumerable<T> source)
    {
        this.source = source;
    }

    public IEnumerator<T> GetEnumerator()
    {
        if (enumerator == null) enumerator = source.GetEnumerator();
        return new Enumerator(this);
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    public void Dispose()
    {
        if (enumerator != null) enumerator.Dispose();
    }

    class Enumerator : IEnumerator<T>
    {
        readonly MemoizedEnumerable<T> enumerable;
        int index = 0;

        public Enumerator(MemoizedEnumerable<T> enumerable)
        {
            this.enumerable = enumerable;
        }

        public T Current { get; private set; }

        public void Dispose()
        {

        }

        object System.Collections.IEnumerator.Current
        {
            get { return Current; }
        }

        public bool MoveNext()
        {
            if (index < enumerable.cache.Count)
            {
                Current = enumerable.cache[index];
                index++;
                return true;
            }

            if (enumerable.cacheComplete) return false;

            if (enumerable.enumerator.MoveNext())
            {
                Current = enumerable.enumerator.Current;
                enumerable.cache.Add(Current);
                index++;
                return true;
            }

            enumerable.cacheComplete = true;
            enumerable.enumerator.Dispose();
            return false;
        }

        public void Reset()
        {
            throw new NotSupportedException("Resetは産廃");
        }
    }
}

こうしておけば、

// hoge:xが出力されるのは1回だけ
var seq = Enumerable.Range(1, 5)
    .Select(x =>
    {
        Console.WriteLine("hoge:" + x);
        return x;
    })
    .Memoize();

// なんど
foreach (var item in seq.Zip(seq, (x, y) => new { x, y }).Take(4))
{
    Console.WriteLine(item);
}

// ぐるぐるしても一度だけ
foreach (var item in seq.Zip(seq, (x, y) => new { x, y }))
{
    Console.WriteLine(item);
}

といった感じ。Materializeより合理的といえば合理的だし、そうでないといえばそうでない感じです。私はMaterializeのほうが好み。というのもMemoizeは完了していないEnumeratorを保持しなければいけない関係上、Disposeの扱いがビミョーなんですよ、そこが結構引っかかるので。

あと、IEnumerable<T>ですが、スレッドセーフではない。そう、IEnumerable<T>にはスレッドセーフの保証は実はない。というのを逆手に取ってる(まぁ、それはあんまりなので気になる人はlockかけたりしましょう)。ちなみにReadOnlyCollectionだってラップ元のシーケンスが変更されたらスレッドセーフじゃない。そして、スレッドセーフ性が完璧に保証されているのがImmutable Collections。という話につながったりつながらなかったり。

Immutable Collections

Immutable Collectionsは実装状況が.NET Framework Blogで随時触れられていて、リリース時のImmutable collections ready for prime timeを読めば、なんなのかっては分かるのではかと。その上で私が今回で割と酸っぱく言いたかったのは、ReadOnly「ではない」ってことです。そして結論はアリキタリに使い分けよう、という話でした。

セッション後の話とかTwitterで、バージョニングされたコレクションって捉えるといいんじゃないの?と意見頂いたのですが、なるほどしっくりきそうです。

スピーカー予定

今後ですが、大阪です!12/14、第3回 LINQ勉強会で発表する予定なので、関西圏の人は是非是非どうぞ。セッションタイトルは「An Internal of LINQ to Objects」を予定しています。これを聞けばLINQ to ObjectsのDeep Diveの部分は全部OK、といった内容にするつもりです。もう初心者向けってこともないので、完全に上級者がターゲットで。

asyncの落とし穴Part3, async voidを避けるべき100億の理由

だいぶ前から時間経ってしまいましたが、非同期の落とし穴シリーズPart3。ちなみにまだ沢山ネタはあるんだから!どこいっても非同期は死にますからね!

async void vs async Task

自分で書く場合は、必ずasync Taskで書くべき、というのは非同期のベストプラクティスで散々言われていることなのですけれど、理由としては、まず、voidだと、終了を待てないから。voidだと、その中の処理が軽かろうと重かろうと、終了を感知できない。例外が発生しても分からない。投げっぱなし。これがTaskになっていれば、awaitで終了待ちできる。例外を受け取ることができる。await Task.WhenAllで複数同時に走らせたのを待つことができる。はい、async Taskで書かない理由のほうがない。

んじゃあ何でasync voidが存在するかというと、イベントがvoidだから。はい。button_clickとか非同期対応させるにはvoidしかない。それだけです。なので、自分で書く時は必ずasync Taskで。async voidにするのはイベントだけ。これ絶対。

ASP.NET + async voidで死ぬ

それでもasync voidをうっかり使っちゃうとどうなるでしょう?終了を待てないだけとか、そんなんならいいんですよ、でも、ASP.NETでasync void使うと死にます。文字通りに死にます。アプリケーションが。じゃあ、ASP.NET MVCで試してみましょうか。WebForms?しらね。

public async void Sinu()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
}

public ActionResult Index()
{
    Sinu();

    return View();
}

死にました!警告一切ないのに!って、ああ、そうですね、async="true"にしないとですね、まぁそれはないのですけれど、はい、Task<ActionResult>を返しましょう。そうすればいいんでしょ?

public async void Sinu()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
}

public async Task<ActionResult> Index()
{
    Sinu();

    return View();
}

はい、死にました!非同期操作が保留中に非同期のモジュールとハンドラーが完了しちゃいましたか、しょーがないですね。しょーがない、のか……?

で、これの性質の悪いところは、メソッド呼び出しの中に一個でもasync voidがあると詰みます。

// こんなクソクラスがあるとして
public class KusoClass
{
    public async void Sinu()
    {
        await Task.Delay(TimeSpan.FromSeconds(1)); // 1じゃなく5ね。
    }
}

// この一見大丈夫そうなメソッドを
public async Task Suteki()
{
    // ここでは大丈夫
    await Task.Delay(TimeSpan.FromSeconds(1));

    // これを実行したことにより……
    new KusoClass().Sinu();

    // ここも実行されるし
    await Task.Delay(TimeSpan.FromSeconds(1));
}

// このアクションから呼び出してみると
public async Task<ActionResult> Index()
{
    // これを呼び出してちゃんと待機して
    await Suteki();

    // ここも実行されるのだけれど
    await Task.Delay(TimeSpan.FromSeconds(1));

    return View();
}

死にます。ただし、上で5秒待機を1秒待機に変えれば、動きます。なぜかというと、KusoClass.Sinuを実行のあとに2秒待機があってViewを返してるので、Viewを返すまでの間にKusoClass.Sinuの実行が完了するから。そう、View返すまでに完了してればセーフ。してなければ死亡。まあ、ようするに、死ぬってことですね結局やっぱり。何故かたまに死ぬ、とかいう状況に陥ると、むしろ検出しづらくて厄介極まりないので、死ぬなら潔く死ねって感じ。検出されずそのまま本番環境に投下されてしまったら……!あ、やった人がいるとかいないとかいるらしい気がしますが気のせい。

呼び出し階層の奥底にasync voidが眠ってたら死ぬとか、どーせいという話です。どーにもならんです。なので、共通ライブラリとか絶対async void使っちゃダメ。あるだけで死んでしまうのですから。

FireAndForget

さて、投げっぱなしの非同期メソッドを使いたい場合、どうすればいいんでしょう?

public async Task ToaruAsyncMethod()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    Debug.WriteLine("hoge");
}

public async Task<ActionResult> Index()
{
    // 待機することなく投げっぱなしにしたいのだけど警告が出る!
    ToaruAsyncMethod();

    return View();
}

あー、警告ウザす警告ウザす。その場合、しょうがなく変数で受けたりします。

public async Task<ActionResult> Index()
{
    // 警告抑制のため
    var _ = ToaruAsyncMethod();

    return View();
}

はたしてそれでいーのか。いやよくない。それに、やっぱこれだと例外発生した時に捉えられないですしね。TaskScheduler.UnobservedTaskExceptionに登録しておけば大丈夫ですけれど(&これは登録必須ですが!)。というわけで、以下の様なものを用意しましょう。

// こんな拡張メソッドを用意すると
public static class TaskEx
{
    // ロガーはここではNLogを使うとします
    private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();

    /// <summary>
    /// 投げっぱなしにする場合は、これを呼ぶことでコンパイラの警告の抑制と、例外発生時のロギングを行います。
    /// </summary>
    public static void FireAndForget(this Task task)
    {
        task.ContinueWith(x => 
        {
            logger.ErrorException("TaskUnhandled", x.Exception);
        }, TaskContinuationOptions.OnlyOnFaulted);
    }
}

// こんな投げっぱなしにしたい非同期メソッドを呼んでも
public async Task ToaruAsyncMethod()
{
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    Debug.WriteLine("hoge");
    throw new Exception();
}

public ActionResult Index()
{
    ToaruAsyncMethod().FireAndForget(); // こうすれば警告抑制&例外ロギングができる
            
    return View();
}

いいんじゃないでしょうか?

ところで、ToaruAsyncMethodに.ConfigureAwait(false)をつけてるのは理由があって、これつけないと死にます。理由は、覚えてますか?asyncの落とし穴Part2, SynchronizationContextの向こう側に書きましたが、リクエストが終了してHttpContextが消滅した状態でawaitが完了するとNullReferenceExceptionが発生するためです。

そして、これで発生するNullReferenceExceptionは、FireAndForget拡張メソッドを「通りません」。こうなると、例外が発生したことはUnobservedTaskExceptionでしか観測できないし、しかも、そうなるとスタックトレースも死んでいるため、どこで発生したのか全く分かりません。Oh...。

たとえFireAndForgetで警告が抑制できたとしても、非同期の投げっぱなしは細心の注意を払って、呼び出しているメソッド階層の奥底まで大丈夫であるという状態が確認できていて、ようやく使うことができるのです。うげぇぇ。それを考えると、ちゃんと警告してくれるのはありがたいね、って思うのでした。

まとめ

voidはまぢで死ぬ。投げっぱなしも基本死ぬ。

では何故、我々は非同期を多用するのか。それはハイパフォーマンスの実現には欠かせないからです。それだけじゃなく、asyncでしか実現できないイディオムも山のようにあるので。いや、こんなの全然マシですよ、大袈裟に書きましたがasync void使わないとか当たり前なのでそこ守れば大丈夫なんですよ(棒)。じゃあ何でasync voidなんてあるんだよとか言われると、イベント機構があるからしょうがないじゃん(ボソボソ)、とか悲しい顔にならざるを得ない。

というわけで、弊社は非同期でゴリゴリ地雷踏みたいエンジニアを大募集してます。ほんと。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive