HN 日本語サマリー

← 一覧へ戻る
プログラミング

ユニオン型とオプション型 (2025)

Union Types and Option Types (2025) (tibleiz.net)

47 pointsby g0xA52A2A21 コメント

要約

この記事は、プログラミング言語におけるユニオン型とオプション型の概念、実装、およびトレードオフについて解説しています。ユニオン型は値が複数の型を取りうることを可能にし、オプション型(T|Nothingの特殊ケース)は値が存在しない可能性を扱います。著者は、Copper言語での実装経験を共有し、より明確で安全なコードのための複雑さと潜在的な利点を強調しています。

全文翻訳

ユニオン型とオプション型

2025-03-16

過去数年間、私は低水準言語(Copper)と高水準言語でユニオン型の実験を行ってきました。非常に興味深い結果が得られましたが、現在はその簡易版、つまりオプション型のみを実験しています。

ユニオン型とは何か?

ユニオン型とは、値が保持できる複数の代替型のリストです。例えば:

func f(x: Int | String)
    ...
end

パラメータ x は、文字列または整数のいずれかになります。

一部の言語では、ユニオンのすべての型に共通する操作を直接実行できます。例えば、Int と String 型に print メソッドがある場合、x.print のように実行できます。しかし、基本的なケースでは、値を使用する前にまず実際の型を判別する必要があります:

func f(x: Int | String)
    match x
    case Int -> i
        // i を整数として何か処理を行う
    case String -> s
        // s を文字列として何か処理を行う
    end
end

Copperへのユニオン型の追加

私の独自の「より良いC」言語であるCopperで、ユニオン型の実験を行いました。一般的なケース T1|T2|...|Tn をタグで処理したいと考えましたが、同時に Pointer|Nothing という特殊なケースを、追加のタグなしで、NULL ポインタ値を使用して判別することで高速に処理し、外部ライブラリと容易に統合できるようにしたいとも思いました。

制約

この機能は十分に定義されておらず、効率的なコンパイルのために多くの制限を追加しました。

ネストしたユニオンの禁止

(T1|T2)|T3 は許可されません。これにより、ユニオン型の値がペアであるというコンパイラの実装が簡素化されます。

[画像: Option type value]

値の部分には再帰的にタグを含めることができません。

順序の重要性

T1|T2 は T2|T1 とは異なります。この制限により、一般的なパターンに対して非常に便利なショートカットが可能になります:

var file =
    match File.open(filename)
    case Error -> err
        return err
    else -> f
        f
    end

これは構文糖衣を使用して書き換えることができます:

var file = File.open(filename)...

Error 型を言語に組み込む必要はありません。例では、ユニオンの最初の型である Error|File を選択するだけです。

ユニオン間の互換性のないこと

T1|T2 は T1|T2|T3 と互換性がありません。

これには、各型にグローバルな一意のタグを割り当てるか、式を互換性のあるユニオンに昇格させる際にタグのマッピングを実装する必要がありました。

このような制限があれば、各部分に簡単にタグを割り当てることができます:

  • 最初の型には 0、
  • 2番目の型には 1、
  • ...
結果

この機能は非常に役立ちましたが、Copperのようなミニマルな低水準言語にとってコンパイラに多くの複雑さを与えすぎました。最も厄介だったのは、特殊な最適化ケース Pointer|Nothing の処理でした。

私の主な用途は主に2つのケースでした:

  • T|Nothing
  • T|Error

したがって、おそらく T|Nothing だけを処理すれば十分であり、エラーについては、エラーと値の両方を返す方法に戻るでしょう。

オプション型とは何か?

オプション型はユニオン型の特殊なケース T|Nothing です。これは値を持たない可能性がある式を表します。

オプション型 T? は、T の任意の値と特別な nil 値を持つことができます。

[画像: T?]

  • T の値は T? です。
  • nil は T? です。

Copperへのオプション型の追加

実装は、元の型で値を予約することによって行われます。各型は独自の nil 値を定義できます。

ポインタの場合、選択は明白で、nil値はアドレス 0 になりますが、これは言語にハードコードされているわけではなく、他のアドレスも使用できます。

他の型については、状況によります。32ビット整数内の24ビットRGBカラーは、例えば 0x8000_0000 を予約できます。符号付き整数インデックスデータ型は、通常、無効または見つからない値を示すため、-1 を使用できます。

この実装には多くの利点があります:

  • ネイティブC APIと非常によく統合されます。
  • 効率的です:値にタグを付ける必要がありません。
  • 柔軟性があります:予約するには1つの値で十分です。

しかし、いくつかの欠点があります:

  • nil のために予約できる値を持たない型があるかもしれません。
  • T?? は不可能です。
オプション型の洗練

オプション型の主なアイデアは、それが nil 値でないことを確認する前に、メイン型に洗練できないことです。

これを行う簡単な方法を見つけました:

expr as id

これは、型 T? の expr がnilであるかどうかをテストするブール式です。式がnilでない場合、値は型 T のローカル不変変数 id にコピーされます。

トリックは、変数が直接利用可能ではなく、アクセス可能な隠しスコープで作成されることです。これは3つの特別なケースで可能です:

  • if の内部ブロック
  • while の内部ブロック
  • and の右側の式
if expr as x
    // x はここで利用可能
end

while expr as x
    // x はここで利用可能
end

var isSingleton = list.head as first and first.next == nil


if list.head as first and first.next == nil // first は 'and' の後に利用可能
    // first はここでも利用可能
end
早いリターンの許可

as 演算子は十分ですが、アローアンチパターンにつながる可能性があります:

if expr1 as v1
    if expr2 as v2
        if expr3 as v3
            // 何か処理を行う
        else
            return err3
        end
    else
        return err2
    end
else
    return err1
end

これは早期リターンで解決できますが、式がnilの場合にブロックを評価する新しい構文 or else なしでは不可能です:

var v1 = expr1 or else
    return err1
end

var v2 = expr2 or else
    return err2
end

var v3 = expr3 or else
    return err3
end

// 何か処理を行う

ブロックは return、break、または pass で終了する必要があります。

チェックなしのオプション型の洗練

理論的にはこれは美しいです:値を確認せずにポインタを逆参照することはできません。そうしないとコンパイラがエラーを報告します。残念ながら、実際のコードでは、値が存在することはわかっているがコンパイラにはわからない状況が多くあります。例えば:

  • 要素数が等しい2つの連結リストの反復処理。
  • 2つの異なるフェーズを持つオブジェクト:初期化フェーズと処理フェーズ。最初のフェーズでは一部のプロパティはオプションですが、2番目のフェーズではそれらのプロパティは定義されていることが保証されます。

これらは確かにさまざまな方法で解決できます:

  • 値が定義されているとわかっていても、アクセスする前にテストする。
  • アルゴリズムを書き直し、データモデルを変更し、データを非オプション型の新しい構造にコピーする。

しかし、これらは非効率的でコードを混乱させる可能性があります(なぜここで値がテストされているのか、この時点で定義されているはずではないのか?)。または、新しいクラスやコードを追加することでコードをより複雑にします。

型のリファインメントを強制し、コードの特定の部分で値が定義されていると仮定しても安全な理由を読者にコメントで残す方が、シンプルで簡単です。

これのためにCopperに何も追加しませんでした。この言語にはすでに、検証なしで式の型を変更できる cast 演算子があります。しかし、デバッグ時に実行時チェックを実行する便利な notNil メソッドを標準ライブラリに追加しました:

var value = maybeValue.notNil // 何をやっているかわかっている
オプション型の拡張

オプション型はデフォルトでは非常に少ないメソッドしか持ちません:

  • 値と別の値の比較、
  • 型変換

しかし、nilである可能性がある場合でも、型に対してメソッドを書きたい場合があります。一般的な例は次のとおりです:

if obj as x
    x.delete
end

defopt コマンドを使用すると、オプション型に対してメソッドを定義できます:

defopt delete // delete は T? に適用される
    if self as x
        // 'self' は T? 型です
        // 'x' は T 型です
        // 実際の delete を呼び出す
        x.delete
    end
end

そして、次のように実行できます:

obj.delete

nilチェッカー付きオプション型

問題

前の解決策は非常によく機能しますが、ソースコードを読むためだけに知っておく必要がある複数の概念を導入します。

さらに、一部の単純なケースを非常に面倒にすることがあります。例えば、この一般的な遅延作成パターン:

if self.findDialog == nil
    self.findDialog = self.createFindDialog
end
self.findDialog.open

は次のように変わります:

var dialog: FindDialog
if self.findDialog as d
    dialog = d
else
    dialog = self.createFindDialog
    self.findDialog = dialog
end
dialog.open

コードが冗長になり、追加のローカル変数が2つ必要になります。LLVMバックエンドにとっては問題ありません。すべてを結合して不要な分岐を排除する最適化を行うためですが、コードの理解は難しくなります。マクロは少し役立ちます:

var dialog = self.findDialog.ifNil do
    var d = self.createFindDialog
    self.findDialog = dialog
    pass d
end
dialog.open

よりコンパクトですが、ifNil マクロが何をするかを知る必要があります。

これらすべてを回避することはできないでしょうか?

解決策

代替案は、ローカル変数がコードの特定の時点でnilになり得ないことを推測することです。明示的な型リファインメントの代わりに、コンパイラはテストに基づいてリファインメントを行います。

この解決策は、TypeScriptやKotlinが行っていることに似ています。

例を挙げます:

if v1 <> nil
    f(v1) // OK
end
if v1 == nil
    ...
else
    f(v1) // OK
end
if v1 == nil
    ...
    return
end
f(v1) // OK

全体として、これはうまく機能し、非常に侵襲的ではありません。読者は、T? がオプションの T を表すこと以外に特別なことを知る必要なしにコードを理解できます。

しかし、悪魔は細部に宿ります:

ループでの2パス分析が必要です。変数はループの開始時には有効かもしれませんが、後で変更される可能性があり、2回目の分析が必要です:

if v1 <> nil
    while cond
        f(v1) // 最初のイテレーションではOK
        v1 = ... // ここで v1 は再び nil になる可能性がある
    end
end

ジェネリックとはうまく機能しません。テストにもかかわらず、変数の型は変わりません。理由は、nil分析が関数のビルド後(マルチパスのため)に行われるためです。これにより予期しないエラーが発生し、多くの明示的な変換が必要になります:

if v1 <> nil
    // v1 はまだ T? 型なので、f がジェネリックの場合、パラメータは
    // オプションになり、関数は v1 が nil でないことを前提としません。
    f(v1)
end

そして最後に、これは一種のブラックマジックです:プログラムの有効性はnilチェッカーの能力に依存します。例えば、コンパイラは次のケースを処理しますか?

(1)

while v1 == nil
    ... // breakなし
end
f(v1)

(2)

if v1 == nil or v2 == nil
    return
end
f(v1)
f(v2)

(3)

v2 = v1
if v1 <> nil
    f(v1)
    f(v2)
end

回答:

  • (1) はい
  • (2) はい
  • (3) いいえ

しかし、将来変更される可能性があります。

そして、コンパイラへの5%のパフォーマンスインパクトについては言及しません ;-)。

このアプローチには多くの利点がありますが、私は諦めて前の解決策に戻りました。

それだけの価値はあるか?

オプション型は、ユニオン型よりもシンプルですが、多くの新しい概念を導入し、言語をより複雑にする追加の知識を必要とします。

長年にわたって設計してきたミニマルでありながら十分に完全な言語を、この機能で壊す準備はできていますか?それが明らかにする少数のバグはそれだけの価値があるでしょうか?

良い点:

  • オプション型は、ソースコードを型注釈で過度に汚染することなく、意図をより明確にします。
  • パラメータ、戻り値、または属性がnilになる可能性があるかどうかをコードやドキュメントで検索する必要がなくなります。
  • nil参照の偶発的な逆参照を防ぎます。
  • 静的型付け言語によく適合します:型は、値に対して許可される操作を読者とコンパイラに伝えます。

コードをより読みやすく、エラーを起こしにくくするのに役立ついくつかの型注釈は受け入れられますが、コンパイラは控えめなレビュー担当者であり、バグを見つけるのを助けてくれる友人であってほしいです。尋問官や検閲官のように、アルゴリズムを難解な方法で書き直すことを強制するのではなく。

Porting the compiler(言語は自己ホスト型です)またはClade(私の独自のテキストエディタ)はそれほど難しくなく、いくつかの潜在的および実際のバグを示しました。

本当の挑戦は、私の実験的な逆コンパイラの移植でした:多くの複雑なアルゴリズムとデータモデル。アルゴリズムは、終了すること、およびnil値がないことを保証するために、いくつかのプロパティ(例:制御フローグラフはエントリノードから到達可能なすべてのノードを持つ)に依存しています。これはコンパイラでは検証できないことです。

当初、オプション型の利点は非常に低いと考えており、コード内のドキュメントとテストはほぼ同等でしたが、役立つ可能性があることを認めなければなりません。おそらく、10億ドル(の誤り)を捨てる時が来たのかもしれません。