11
2

More than 3 years have passed since last update.

安全なunion、SumTypeの紹介と使い方

Last updated at Posted at 2019-12-05

はじめに

注意
2021/06/05 にリリースされた dmd v2.097.0 にて sumtypestd.sumtype として標準ライブラリに取り込まれました。
今後はライブラリとしての参照を追加する必要はありません。

記事の大部分はそのまま残しておきますが、最新版のコンパイラを利用していれば import std.sumtype として利用してください。

当面はライブラリのほうも同じように保守されるようなので、そのまま使うことも可能です。


今回は、個人的に最近よく使うようになってきた sumtype というライブラリの紹介と使い方メモです。

@nogc@safe などにも対応し、シンプルに便利でメタプログラミングの面白さが溢れるライブラリだと思いますので、ぜひ使ってみてください!

概要

できることは至ってシンプルで、「『指定した型のうちいずれか』を表す型」を安全に構築し、扱えるようにします。

安全に扱うための match 関数が特徴で、他の言語で言うところの match 式と似たようなことができます。

下手な説明より見た方が早い説があるので、ちょっと補足しながらコードを眺めたいと思います。

dubなどの構築は済んでいる前提で、ざっくりプロジェクトを初期化するところからです。

プロジェクト準備
dub init sandbox-sumtype -n
cd sandbox-sumtype
dub add sumtype // 最新版のコンパイラを使っていれば、このステップは不要です

と、これでプロジェクトはできるので後はお好みのエディタで開いてコードを書いていきましょう。

まずはよくある例ということで、以下のようなサンプルです。

  • 矩形と円形を定義、それのいずれかが入るShape型を定義
  • Shape型の引数を受け取って面積を計算するarea関数を定義
    • matchを使って分岐を掛けたうえで面積を計算する
利用例
import std.sumtype; // 最新版のコンパイラを利用している場合
import sumtype;     // 旧版のコンパイラを利用している場合

struct Rectangle
{
    float width;
    float height;
}

struct Circle
{
    float radius;
}

// 矩形 または 円 を表す抽象的な「図形」というレベルの型を定義します
alias Shape = SumType!(Rectangle, Circle);

// 図形の面積を計算します
float area(Shape shape)
{
    import std.math : PI;

    return shape.match!(
        (Rectangle rect) => rect.width * rect.height,
        (Circle circle) => PI * circle.radius ^^ 2
    );
}

void main()
{
    import std.stdio;

    Shape shape = Rectangle(10, 5);
    shape.area().writeln();

    shape = Circle(3);
    shape.area().writeln();
}
出力
50
28.2743

ライブラリ機能

提供される機能は基本3つしかないのですぐに覚えられます。

SumType

可変長のテンプレート引数を指定して、「そのいずれかである型」を生成するためのテンプレートです。

サンプルの通り、 alias S = SumType!(string, int); といった使い方をします。
また可変長の引数を持ったテンプレートですので、扱う型はお好きなだけ指定して大丈夫です!(フラグ)

なお、あんまりハマらないと思いますが、使う上での注意点として「引数に同じ型は含められない」というのがあります。

最小限の例を見れば分かるのですが、以下のような状況です。

NG
alias S = SumType!(string, string);
エラーメッセージ
onlineapp.d(6,11): Error: template instance `sumtype.SumType!(string, string)` does not match template declaration `SumType(TypeArgs...)`
  with `TypeArgs = (string, string)`
  must satisfy the following constraint:
`       allDistinct!TypeArgs`
dmd failed with exit code 1.

TypeArgs = (string, string) です。 allDistinct!TypeArgs を満たす必要があります。」という感じでコンパイルエラーになります。
(なんか最近エラーメッセージが綺麗になりました?)

allDistinct なので、「すべての型が異なることを期待している」ということですね。

もしメタメタなプログラミングをしていてパラメーターの重複が怪しいときは、 std.metaNoDuplicates というテンプレートがあるので、これを使って重複を除去しておくといい感じだと思います。

これに加えて std.variantThis という型を使って自分の型を使う再帰した型が作れるのですが、ちょっと複雑で長くなるのとあまり使い慣れていないので割愛します。

match

matchSumType で生成した型を受け取るテンプレート関数です。

扱う型毎にハンドラーを指定して final switch で分岐するような関数です。
ハンドリングされない型があるとコンパイルエラーになるため、処理漏れがないのが良いところです。

alias S = SumType!(string, int);

S data = "text";
data.match!(
    (string s) { ... },
    (int n) { ... },
);

また、一部のみ処理して他は特に気にしない場合、「引数の型」を省略したラムダ式でハンドラーを指定することで一律処理できます。

alias S = SumType!(string, byte, short, int);

S data = 100;

auto text = data.match!(
    (string s) => "text: " ~ s,
    _ => "some numbers",
);

writeln(text); // some numbers

これは要するに何にでもマッチするラムダ式なので switch文における case default のようなものです。
「得に使わない値」ということでアンダーバーを指定しておく慣例があるみたいです。

tryMatch

match と違い、ハンドラーで処理されない型が来ると例外を発生させるバージョンです。
こちらはハンドラーの種類が不足していても大丈夫で、スクリプト的に使う場合は楽そうですね。

alias S = SumType!(string, int);

S data = 10;

// 実行時にMatchExceptionが発生
data.tryMatch!(
    (string s) { writeln("string: ", s); },
);

簡単な仕組み(タグ付きunionとは)

上記の例を何も考えずにやろうとすると、 union などを使って以下のようになると思います。

import std;

struct Rectangle
{
    float width;
    float height;
}

struct Circle
{
    float radius;
}

enum ShapeType
{
    Rectangle,
    Circle,
}

struct Shape
{
    ShapeType tag;
    union
    {
        Rectangle rect;
        Circle circle;
    }

    void opAssign(Rectangle rect)
    {
        this.tag = ShapeType.Rectangle;
        this.rect = rect;
    }
    // その他省略
}

void main()
{
    Shape shape;
    shape = Rectangle(10, 5);
    // shape = Circle(4);

    final switch (shape.tag)
    {
    case ShapeType.Rectangle:
        writefln!"Rectangle: %f x %f"(shape.rect.width, shape.rect.height);
        break;
    case ShapeType.Circle:
        writeln("Circle: radius = %f", shape.circle);
        break;
    }
}

Shape 構造体の定義を見ればわかる通り、 union に加え、どのフィールドを使っているか表す tag を持った構造体なので「Tagged Union (タグ付きunion)」と言われます。

これはしっかり final switch を使っているので良いですが、そういうことをしなくても .circle にアクセスできたり、 .tag だけ書き換えたりできるので危険ですよね。

要は「タグとunionのフィールドが独立している」というのが問題で、SumType テンプレートはテンプレート引数の型を使い、メタプログラミングでより安全に使える型を自動的に作り出します!

作者のとあるブログ記事

ある日作者がC++の std::visit 、Cのunion、Dのstd.variant および sumtype で書いたコードをアセンブリレベルで比較した記事を投稿しました。

最近よく見るラノベ風に意訳すると、「本気でやったわけではないのだが、気づいたら std::visit を破っていた」という感じのタイトルです。

Cの union が最も効率の良い結果を生成しており、C++がそれにどこまで近いのか、Dで書いたものがどれくらい近いのか、というのを整理されています。

要旨は「C++ほど頑張らなくてもC並に最適化された結果が得られ、しかもC++より効率が良い」という点なのですが、
作者は「sumtype は最初からこれを気にして作ったものではなく、書けそうだったから書いてみただけで、実験したらこの結果だった」という点を強調しています。

比較の過程で生成されるアセンブリに対する細かい話もありますが、最終的に「これぞゼロコスト抽象化ではないだろうか」という感じで記事は締めくくられています。

補足

@nogcpure などへの対応で色々ありますが、確かにソースを見てみても愚直にstatic foreachなどの機能を使って必要なコンストラクタや関数オーバーロードを生成するのがメインとなっています。

また猛烈なunittestの山を見るに、ほぼ気合で書いている状況が連想されますね…

単体テストは人を強くする…!

所感・まとめ

当然ライブラリはとても便利に使っているのですが、プログラミング言語が溢れ代数的データ型などが組み込みの言語も珍しくないご時世ですので、それに倣った便利機能が使えるのはとても良いですね。

また、「簡単に作れそうだと感じた」「実際に作れた」「Cとほぼ同じ結果」という点は注目に値しそうです。
そして本当にC++等で同じようなものが作れないのか?作るのが難しいのか?というのも気になるところです。

D言語を主に使う身としては、何が簡単そうだと感じさせるのか、具体的なところを今後掘り下げていきたいですね!

メタプログラミングの良さも再確認できましたし、使っていて非常に面白いライブラリの1つです!

というわけで、これを機にぜひ使ってみてください!

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2