💡

C#でエラー処理を実装するときにEither型を作ったら良い感じになった

2022/05/30に公開

要約

  • 処理に失敗したことをメソッドの呼び出し元に通知するのに例外を使いたくなかった
  • とはいえTupleでエラーオブジェクトを返すのもなんか取り回しが悪い
  • そこで「2つの型のどちらかが設定されている」を保証するEither型を実装したらめちゃくちゃ書きやすくなった

背景

おそらくC#において「メソッドの処理が失敗した」ことを呼び出し元に通知する一番メジャーな方法は例外オブジェクトを投げる方法だと思います(標準ライブラリの多くがそうやってエラーを通知しています)。

しかし例外を投げるように実装すると、例外は分岐が見えにくく追いかけるのが面倒だし、呼び出し側でtry~catchをいちいち書くのが面倒だし、キャッチが漏れた例外がそのまま上に突き抜けたりとか、色々な問題を孕んでいます。
また、メソッドが例外を投げることがメソッドのシグネチャで表現されないので、あるメソッドを呼び出したときに例外をキャッチする処理を書くべきかどうかが厳密な根拠で判断できないし、キャッチ漏れがあったとしてそれを静的にチェックできない、みたいなのもあります。
というような「例外機構の是非」については色んな人が議論しているのでここでは深く追いかけません。ともかく「例外を使いたくない場面がある」ということを押さえておいてください。

では例外を使わずにどうやってエラーを通知するかですが、まず真っ先に思いつくのは、メソッドの戻り値から「正常な処理結果」と「エラー情報」を多値返却(Tuple)する方法です。

// 通常はresultに値が設定されるが、処理に失敗したらerrorにエラー情報が設定される
(Something result, ErrorCode error) r = GetSomething();

なんとなく良さそうですが、この場合は「resulterrorが排他関係にあること」を型システムの制約として保証できないというのが気になります。

  • 極端なケースだと、「正常終了でもresultとしてnullを返すパターン」があると一気にややこしくなります
  • resulterrorの両方に値が設定されていたら呼び出し元はそれをどう判断すべきか?
  • Tupleで返す場合、毎回(result, null)とか(null, error)みたいに返さない方の引数も記述しないといけなくて面倒くさい

ところでC#以外の他の言語に目を向けてみると、たとえばScalaにはまさにEitherという名前の型があるようです。

Scala入門備忘録_エラーを表現するデータ型

処理の実行結果を、成功/失敗どちらであっても値として保持する型。

たぶんこれが求めていたやつだと思います。良さそうですね。これをC#で作りましょう。

実装してみる

最初は自力で頑張ってなんとかしようとしてたんですが、ググったらまさにそのとおりのコードがでてきたのでそのまま採用しました。

Validation with Either data type in C# | Mikhail Shilkov

ほぼそのままパク……再実装したのがこちら。

/// <summary>
/// 2つの異なる型のどちらかの値が入っていることを表現する
/// </summary>
/// <typeparam name="TL">型1</typeparam>
/// <typeparam name="TR">型2</typeparam>
public struct Either<TL, TR> : IEquatable<Either<TL, TR>>
{
    private readonly TR _Right;
    private readonly TL _Left;
    private readonly bool _IsRight;

    /// <summary>
    /// <see cref="TL"/>型の値をラップしてインスタンスを作成する
    /// </summary>
    /// <param name="left"></param>
    public Either(TL left)
    {
        this._Left = left;
        this._Right = default;
        this._IsRight = false;
    }

    /// <summary>
    /// <see cref="TR"/>型の値をラップしてインスタンスを作成する
    /// </summary>
    /// <param name="right"></param>
    public Either(TR right)
    {
        this._Left = default;
        this._Right = right;
        this._IsRight = true;
    }

    /// <summary>
    /// "is"演算子と同じ機能
    /// </summary>
    /// <returns>キャスト可能であればtrue</returns>
    public bool Is<T>()
    {
        return this.Is<T>(out _);
    }

    /// <summary>
    /// "is"演算子と同じ機能
    /// </summary>
    /// <returns>キャスト可能であればtrue</returns>
    public bool Is<T>(out T value)
    {
        if (this._IsRight)
        {
            if (this._Right is T rValue)
            {
                value = rValue;
                return true;
            }
            value = default;
            return false;
        }
        else
        {
            if (this._Left is T lValue)
            {
                value = lValue;
                return true;
            }
            value = default;
            return false;
        }
    }

    /// <summary>
    /// 型に応じた処理を適用する
    /// </summary>
    /// <param name="left">このインスタンスが<see cref="TL"/>だった場合に実行する処理</param>
    /// <param name="right">このインスタンスが<see cref="TR"/>だった場合に実行する処理</param>
    public void Match(Action<TL> left, Action<TR> right)
    {
        if (left == null)
        {
            throw new ArgumentNullException(nameof(left));
        }

        if (right == null)
        {
            throw new ArgumentNullException(nameof(right));
        }

        if (this._IsRight)
        {
            right(this._Right);
        }
        else
        {
            left(this._Left);
        }
    }

    /// <summary>
    /// 型に応じた処理を適用する
    /// </summary>
    /// <param name="left">このインスタンスが<see cref="TL"/>だった場合に実行する処理</param>
    /// <param name="right">このインスタンスが<see cref="TR"/>だった場合に実行する処理</param>
    public T Match<T>(Func<TL, T> left, Func<TR, T> right)
    {
        if (left == null)
        {
            throw new ArgumentNullException(nameof(left));
        }

        if (right == null)
        {
            throw new ArgumentNullException(nameof(right));
        }

        return this._IsRight ? right(this._Right) : left(this._Left);
    }

    #region キャスト

    /// <summary>
    /// <see cref="TR"/>型からキャストする
    /// </summary>
    public static implicit operator Either<TL, TR>(TR v)
    {
        return new Either<TL, TR>(v);
    }

    /// <summary>
    /// <see cref="TL"/>型からキャストする
    /// </summary>
    public static implicit operator Either<TL, TR>(TL v)
    {
        return new Either<TL, TR>(v);
    }

    /// <summary>
    /// <see cref="TR"/>型にキャストする
    /// </summary>
    /// <exception cref="InvalidCastException">このインスタンスは<see cref="TR"/>型ではない</exception>
    public static explicit operator TR(Either<TL, TR> v)
    {
        if (v._IsRight)
        {
            return v._Right;
        }

        throw new InvalidCastException();
    }

    /// <summary>
    /// <see cref="TL"/>型にキャストする
    /// </summary>
    /// <exception cref="InvalidCastException">このインスタンスは<see cref="TL"/>型ではない</exception>
    public static explicit operator TL(Either<TL, TR> v)
    {
        if (!v._IsRight)
        {
            return v._Left;
        }

        throw new InvalidCastException();
    }

    #endregion キャスト

    #region 等価演算

    public override bool Equals(object obj)
    {
        if (!(obj is Either<TL, TR> other))
        {
            return false;
        }

        return this.Equals(other);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            var hashCode = EqualityComparer<TR>.Default.GetHashCode(this._Right);
            hashCode = (hashCode * 397) ^ EqualityComparer<TL>.Default.GetHashCode(this._Left);
            hashCode = (hashCode * 397) ^ this._IsRight.GetHashCode();

            return hashCode;
        }
    }

    public static bool operator ==(Either<TL, TR> left, Either<TL, TR> right)
    {
        return left.Equals(right);
    }

    public static bool operator !=(Either<TL, TR> left, Either<TL, TR> right)
    {
        return !(left == right);
    }

    /// <summary>現在のオブジェクトが、同じ型の別のオブジェクトと等しいかどうかを示します。</summary>
    /// <param name="other">このオブジェクトと比較するオブジェクト。</param>
    /// <returns>現在のオブジェクトが <paramref name="other" /> パラメーターと等しい場合は <see langword="true" />、それ以外の場合は <see langword="false" /> です。</returns>
    public bool Equals(Either<TL, TR> other)
    {
        // IDEによる自動実装
        return EqualityComparer<TR>.Default.Equals(this._Right, other._Right) 
               && EqualityComparer<TL>.Default.Equals(this._Left, other._Left) 
               && this._IsRight == other._IsRight;
    }

    #endregion 等価演算
}

ポイントはキャストのブロックで、TL型またはTR型からEither<TL, TR>型に直接キャストできるようにしています。

こんな感じで使います。

// 成功したら処理結果(int)、失敗したらエラー情報(string)
Either<int, string> Add10(string a)
{
    if (int.TryParse(a, out var val))
    {
        return val + 10;
    }
    
    return "数字に変換できません!";
}

var result = Add10(Console.ReadLine());
result.Match(
	val => Console.WriteLine("成功:" + val),
	error => Console.WriteLine("失敗:" + error));

使ってみた感想

  • 呼び出し元にエラーが伝搬していくような実装をするときにいちいちtry~catchを書かなくてよくなったのでコードがすっきりした
  • 「正常終了だろうがエラーだろうが同じ処理」を書きやすくなった
  • 暗黙のキャストで書きやすい
  • 型安全なので安心😊

Discussion