C#でも構文を最大限悪用してDSLを定義したい

はじめに

ディレクトリのような階層構造をC#で簡潔に表現できないかと構文を最大限悪用して試行錯誤した結果です。

作例

var result = await "C:\\aaa" with
{
    _ = new _
    {
        await "bbb" with
        {
            _ = new _
            {
                "ddd"
            }
        },
        await "ccc" with
        {
            _ = new _
            {
                "eee",
                "fff"
            }
        }
    }
};

Show(result);

上記のような何となく木構造にも見えなくもないコードを書くと以下のような結果を得られます。

C:\aaa
C:\aaa\bbb
C:\aaa\bbb\ddd
C:\aaa\ccc
C:\aaa\ccc\eee
C:\aaa\ccc\fff

それにしてもstringawaitしたりwith式を呼び出し始めたりして意味わかんないですよね。 ここからはどうやってこのコードを合法としているのかを解説します。

await出来るstring

知っている人にとっては常識かもしれませんが、async/await構文はTaskTask<T>専用ではありません。 一定の条件を満たすクラスや構造体であれば何でもawait出来ます。

  • インスタンスメソッドや拡張メソッドでGetAwaiterを実装している
  • GetAwaiterから返される型が以下の要件を満たしてる
    • public bool IsCompleted { get; }を実装している
    • public ??? GetResult()を実装している
    • INotifyCompletion経由でpublic void OnCompleted(Action continuation)を実装している

というわけで、まずは拡張メソッド経由でstringGetAwaiterを生やします。

internal static class StringExtension
{
    internal static StringPathAwaiter GetAwaiter(this string @this)
    {
        return new StringPathAwaiter(@this);
    }
}

そうしたらStringPathAwaiterを上記の条件を満たすように実装します。

internal class StringPathAwaiter : INotifyCompletion
{
    private readonly string _path;

    internal StringPathAwaiter(string path)
    {
        _path = path;
    }

    public bool IsCompleted { get { return false; } }

    public void OnCompleted(Action continuation)
    {
        continuation();
    }

    public Path GetResult()
    {
        return new Path(_path);
    }
}

こうすることでstringawaitすることで最終的にStringPathAwaiter経由でPathクラスを生成しています。

謎のコレクションクラス_

それぞれのPathには複数の子Pathを持たせたいので、先にコレクションクラスをでっちあげます。

C#ではIEnumerableを実装して1つの引数を持つメソッドAddが実装されていればコレクション初期化子を使用することができます。

ufcpp.net

この辺を悪用して極限まで目立たないクラス名のコレクションクラスをでっちあげます。

internal class _ : IEnumerable<Path>
{
    private List<Path> _backing = new List<Path>();

    public void Add(Path path)
    {
        _backing.Add(path);
    }

    public IEnumerator<Path> GetEnumerator()
    {
        return _backing.GetEnumerator();
    }

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

    public static implicit operator List<Path>(_ x)
    {
        return x._backing;
    }
}

Pathレコードの実装

C#ではレコード型として実装すればwith式で指定されたプロパティを変更できます。*1

また、withの優先順位はawaitよりも低いので、見た目が悪くなるかっこが不要になりよりスタイリッシュさを演出できます。

Pathを格納するプロパティ名を_にして、コード上で最大限目立たなくします。

また、stringからの暗黙の型変換を定義することで、型推論が使える場面ではawaitを経由しないで直接変換出来るようにします。

internal record Path
{
    public string Name { get; }

    internal Path(string value)
    {
        Name = value;
    }

    public override string ToString()
    {
        return Name;
    }

    public static Path operator /(Path x, string y)
    {
        return new Path(IO::Path.Combine(x.Name, y));
    }

    public IEnumerable<Path> _ { set; get; } = Enumerable.Empty<Path>();

    public static implicit operator Path(string path)
    {
        return new Path(path);
    }
}

まとめ

上記のようなコードを書けば、まともに生きていればお目にかかることのないC#を書くことができます。

プロダクションコードに投入すればいろんな意味で楽しいと思うで、ぜひとも真似してみてください。

C#でDSLをでっち上げるアレ · GitHub

*1:正確には変更しているのではなく、プロパティを書き換えた新しいインスタンスを生成しているのですが、別にここはそういうのを解説している記事ではないので厳密ではない説明でお茶を濁します。