Amosapientiam

https://yuchiki.github.io/

C#で型安全なBuilderパターン

Builderパターン とは、オブジェクト生成に用いられるデザインパターンです。必要な引数のみをメソッドチェーンで渡してオブジェクトを生成し、コンストラクタが多くの引数を取り、また省略可能な引数が多いときに有効とされています。

今までにさまざまな言語で型安全なBuilderの書き方が紹介されてきており、つい昨日もuhyo氏によりTypeScriptで型安全なBuilderパターンを書く記事が公開されました。

本記事ではC#で型安全なビルダーを構築します。

ソースコードこちら

断り書き

  • C#には名前付き引数や省略可能引数などがありますが、今回は先行記事たちとレギュレーションを近づけるために使っていません。
  • JavaでBuilderパターンが有効な場面であるからといって、C#でもBuilderパターンが有効だとは限りません。ですので、本記事はあくまで「あえてBuilderパターンを適用してみた場合」の話です。

本題

対象クラス

以下のクラスのビルダーが作りたいとします。

internal class Person {
    private readonly int    _id;
    private readonly string _name;
    private readonly int?   _age;

    public Person(int id, string name, int? age) => (_id, _name, _age) = (id, name, age);

    public void Show() => Console.WriteLine(
        _age == null
            ? $"id = {_id}, name = {_name}"
            : $"id = {_id}, name = {_name}, age = {_age}");
}

IDと名前は必須で指定する必要があり、年齢は指定しなくてもかまいません。

型安全でないビルダー

まずは型安全でないビルダーを書いてみます。

internal class SimpleBuilder {
    private readonly int    _id;
    private readonly string _name;
    private readonly int?   _age;

    private SimpleBuilder(int id, string name, int? age) => (_id, _name, _age) = (id, name, age);

    public static SimpleBuilder Create() => new SimpleBuilder(default(int), default(string), default(int?));

    public SimpleBuilder Id(int      id)   => new SimpleBuilder(id,  _name, _age);
    public SimpleBuilder Name(string name) => new SimpleBuilder(_id, name,  _age);
    public SimpleBuilder Age(int     age)  => new SimpleBuilder(_id, _name, age);

    public Person Build() => new Person(_id, _name, _age);
}

使い方

SimpleBuilder.Create().Id(1).Name("Riku").Age(17).Build() などすれば国民番号1番で17歳のRikuさんが生成されます。正しくビルダーを使用すれば正しく人間が生成されます。

しかし、残念ながらこのビルダーは以下のような不正な使用を弾くことができません。

SimpleBuilder.Create().Id(2).Build() // 名前が指定されていない 
SimpleBuilder.Create().Id(3).Name("Kimi").Name("Maria").Build() // 名前が二回指定されている

このビルダーはどうすれば安全になるでしょうか? 実行時に検査して例外を吐くように変更するのもひとつの手ですが、ここではレギュレーション従うことにします。すなわち、このビルダーを静的に正しさを保証するビルダー、すなわち型安全なビルダーに改造します。

型安全なビルダー

全体像は以下のとおりです。

static class Builder {
    public static Builder<None, None, None> Create() =>
        new Builder<None, None, None>(default(int), default(string), default(int?));

    public static Builder<Some, TName, TAge> Id<TName, TAge>(this Builder<None, TName, TAge> builder, int id)
        where TName : IOpt where TAge : IOpt =>
        new Builder<Some, TName, TAge>(id, builder.Name, builder.Age);

    public static Builder<TId, Some, TAge> Name<TId, TAge>(this Builder<TId, None, TAge> builder, string name)
        where TId : IOpt where TAge : IOpt =>
        new Builder<TId, Some, TAge>(builder.Id, name, builder.Age);

    public static Builder<TId, TName, Some> Age<TId, TName>(this Builder<TId, TName, None> builder, int age)
        where TId : IOpt where TName : IOpt =>
        new Builder<TId, TName, Some>(builder.Id, builder.Name, age);

    public static Person Build<TAge>(this Builder<Some, Some, TAge> builder) where TAge : IOpt =>
        new Person(builder.Id, builder.Name, builder.Age);
}

internal class Builder<TId, TName, TAge> where TId : IOpt where TName : IOpt where TAge : IOpt {
    internal readonly int    Id;
    internal readonly string Name;
    internal readonly int?   Age;

    internal Builder(int id, string name, int? age) => (Id, Name, Age) = (id, name, age);
}

internal interface IOpt {}

internal abstract class None : IOpt {}

internal abstract class Some : IOpt {}

変更点を順を追って説明します。

型をフラグとして使う

まず、各項目がすでに指定されているかどうかを型で管理する必要があるので、それを表すフラグとしてIOptインターフェースを用意しました。未指定の場合はNone, 既指定の場合はSomeと使い分けます。

internal interface IOpt {}

internal abstract class None : IOpt {}

internal abstract class Some : IOpt {}

項目の状態管理

Builderは3つの項目の指定の有無を管理するためにIOptを継承する型変数を3つ保持します。

internal class Builder<TId, TName, TAge> where TId : IOpt where TName : IOpt where TAge : IOpt 

フラグ更新

Id関数は、Builder<None, -, ->型からBuilder<Some,-,->型への関数となっています。 idがまだ未指定のときのみId関数を呼ぶことができ、一度呼ぶとIdが指定されたフラグが立ちます。 TId以外の型パラメータは変更されません。

public static Builder<Some, TName, TAge> Id<TName, TAge>(this Builder<None, TName, TAge> builder, int id)
        where TName : IOpt where TAge : IOpt =>
        new Builder<Some, TName, TAge>(id, builder.Name, builder.Age);

Name関数、Age関数も同様です。

Build可能条件

Build関数はBuilder<Some,Some, ->型から Person型への関数になっています。 idとnameがすでに指定されたBuilderでなければBuild関数を呼ぶことはできません。 ageに関しては未指定/既指定を問うていません。

public static Person Build<TAge>(this Builder<Some, Some, TAge> builder) where TAge : IOpt =>
        new Person(builder.Id, builder.Name, builder.Age);
}

使い方

以下のような呼び出しはコンパイルが通ります。

Builder.Create().Id(4).Name("Aki").Build()
Builder.Create().Name("Anna").Id(5).Build()
Builder.Create().Name("Mika").Age(24).Id(6).Build()

以下のような呼び出しはコンパイル時に型エラーで弾くことができます。

Builder.Create().Id(7).Name("Masa").Name("Jarkko").Build()
Builder.Create().Id(8).Build();

項目指定の間違いをコンパイル時の段階で弾いてくれる型安全なビルダーができあがりました。

関連リンク

型安全なBuilderパターン

Scalaで型安全Builderパターン - tototoshi の日記

RustのちょっとやりすぎなBuilderパターン | κeenのHappy Hacκing Blog

  • 幽霊型で状態をタグ付けする手法が本記事と同じです。

TypeScriptで型安全なBuilderパターン

  • TypeScriptの精緻な型システムを用いて問題が解決されています。

C#で幽霊型

C# で Phantom Type もどき - present

  • 幽霊型を使ってクラスの状態管理をする方法がとてもわかりやすく例示されています。

C#で型安全なFluent Interface

C# Fluent Interface with Type Safe Polymorphic Chain Calls | 10101010101001

  • 本記事で扱っているものとは少し違ったBuilderパターンの例が紹介されています。

感想

どうしてもボイラープレートが多くなってしまって幸せじゃないですね...