SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

ScalaのOption[T]をC#で真似したら、ScalaのNothingがすげぇってなった話

Scalaへの理解を深めるために、

C#でOption相当のクラスを実装してみよう

と思い立ちました。

Optionそのものの定義

C#では、Scalaと違って共変と反変はinterfacedelegateにしか付けられないという制約があるので、

public interface IOption<out A>
{
    bool IsEmpty { get; }
    A Get();
}

という定義にして、共変制約を付けています。なお、先頭にIを付けないと命名の警告が出るので、IOptionという名前で妥協しています。

Optionの各種メソッド

そして、拡張メソッドで各種メソッドを生やします。

public static class Option
{
    public static IOption<A> None<A>()
    {
        return new None<A>();
    }

    public static IOption<A> Some<A>(A value)
    {
        return value == null ? throw new ArgumentNullException("") : new Some<A>(value);
    }

    public static IOption<A> Create<A>(A value)
    {
        return value == null ? None<A>() : new Some<A>(value);
    }

    public static B GetOrElse<A, B>(this IOption<A> self, Func<B> def) where A : B
    {
        return self.IsEmpty ? def() : self.Get();
    }

    public static IOption<B> Map<A, B>(this IOption<A> opt, Func<A, B> f)
    {
        return opt.IsEmpty ? None<B>() : new Some<B>(f(opt.Get()));
    }

    public static IOption<B> FlatMap<A, B>(this IOption<A> opt, Func<A, IOption<B>> f)
    {
        return opt.IsEmpty ? None<B>() : f(opt.Get());
    }

    public static void ForEach<A>(this IOption<A> opt, Action<A> f)
    {
        if (!opt.IsEmpty) { f(opt.Get()); }
    }
}

Optionの全メソッドは面倒だし、withFilter以外はすんなり実装できるので、省略しています*1

なお、SomeNoneもただのクラスになるのですが、利用先で毎回newするのは面倒なので、上記Optionクラスにショートカット用メソッドを用意しています。

SomeとNoneの定義

次に、Some*2Noneを定義します。IOption<T>を実装する形です。

internal sealed class Some<A> : IOption<A>
{
    private readonly A _v;
    public Some(A value) => _v = value;
    public bool IsEmpty => false;
    public A Get() { return _v; }
}

internal sealed class None<A> : IOption<A>
{
    public bool IsEmpty => true;
    public A Get() { throw new InvalidOperationException("object is not exist."); }
}

さて、Noneの定義がScalaの場合と違いますね。Noneに型制約がついてしまっています。

Scalaの実装

Scalaの場合、SomeNoneの定義は以下のようになっています。コメント等は除去しています。

final case class Some[+A](value: A) extends Option[A] {
  def isEmpty = false
  def get = value
}

case object None extends Option[Nothing] {
  def isEmpty = true
  def get = throw new NoSuchElementException("None.get")
}

Someに共変がついているのは良いとして、問題はNoneです。C#ではNone<A>と型制約を付けなければならないのに、Scalaでは不要です。

Scalaでなぜ型制約をつけなくてよいのか、そしてC#で型制約なしのNoneが実現できないか、かなり真剣に悩みました。

つぶやきました。すると、Scala界隈ですごく有名な@kmizuさんから返信いただきました。

このときは、

Nothing相当のbottom type

の意味が分かりませんでした。ScalaNothing = C#objectみたいな感じかな、みたいに考えていました。

そして一週間、どうにかしてC#ScalaのNoneを表現できないか、試行錯誤しました。case classだからじゃね?とかいろいろ考えましたが、どれも釈然とせず。

唐突に答えが分かる

この記事を書く直前です。

そもそもNothingってなんだよという疑問が、ようやく湧きました。遅い。頭が固い。

そして@kmizuさんの10年ぐらい前の記事に辿り着く。

kmizu.hatenablog.com

そして気づきます。Nothingは継承元じゃなくて継承先だわ、と。スーパータイプじゃなくてサブタイプだわ、と。

そもそもの勘違い

C#では、「全ての型の基底型」としてobjectが存在します。そして、値型の継承元としてValueTypeが存在します。あとはdelegateも型として扱われます*3。そして、すべてのクラスはこれらを継承元として派生します。

これに囚われていて、型システムは、基底型しか提供してこないみたいな先入観を持っていました。

そして、改めて公式ページを見ます。

docs.scala-lang.org

https://docs.scala-lang.org/resources/images/tour/unified-types-diagram.svg

気づきます。

NothingとNullの位置おかしくね?

と。暗黙的に全ての型のサブタイプになるとかできるんだ…って思いました。これすごくね?と。

例外はNothingを返す

というのは目から鱗です。たしかにそうすれば、例外を特殊扱いしなくて済みます。

以下のC#のソースは、例外を投げているゆえに戻り値は返さないように見えます。型システム上安全である、というより、「例外が投げられたから特殊扱いしました」みたいに見えます。

public A Get() { throw new InvalidOperationException("object is not exist."); }

以下のScalaのこのソースは、「例外はNothing。bottom typeだから戻り値としては正しい(だけど例外だから特殊処理しましょうね)」というように見えます。

def get = throw new NoSuchElementException("None.get")

似ていますが、これは意味が全然違います。ここまで考えてNothngが用意されているのを知って、本当に作った人は天才だと思いました。Scala美しい…すごい。

もしかしたらC#の例外も内部実装上はScalaと同じかもしれませんが、そこまで調べられませんでした。

自分の常識を疑うことも大事

初めてScalaを勉強したとき、型システムを見て、だいたいC#と同じかと思い、気にすることなく通り過ぎていました。

言語としては継承元だけ用意していると思い込んでいましたし、それで支障が出ることもありませんでした。

共通の継承先を用意するなんてことが実現できているなんて、考えもしませんでした。自分の常識では「そんなことはあり得ない」みたいに思っていましたし。

今回、ちゃんと型システムを見直す機会をたまたま作り、個人的には腑に落ちる形で結論が出せました。

自分の常識が実は常識じゃなかったり、またその逆もあるのだと、思い知った出来事でした。

(もはや余談)C#で実装したOptionを使う

サンプルです。というか適当な実装です。

var someOpt = Option.Some("hiro");

// flatmap
someOpt
    .FlatMap(s => DummyLogin(s))
    .Map(s => new User(s))
    .ForEach(user => Console.WriteLine($"Hello { user.Name }"));

結論を言うと、実装はできるし、便利には使えるものの、for式があるScalaには及ばないという感じでした。でも使えそうではあります。

非同期になったら途端に使いづらくなりそうです。まあ匿名async使えばいけるかな…?微妙かな?

*1:withFilterのC#実装は別のモナドを作らないと難しい。あと全く使いやすくならない

*2:厳密には、コンストラクタでnullチェックを行ったほうが良い

*3:eventは型じゃないので扱いづらい