C# にユニオン型を導入する: .NET 11 の最新追加機能を徹底解説

何年もの間、F#、Rust、TypeScript などの関数型言語から来た開発者は C# を見て、複数の異なる型のいずれかの値を表す第一級の方法が欠けていることに疑問を抱いていました。これまでの一般的な回避策は、脆弱な基底クラス、object キャスト、または OneOf のようなサードパーティライブラリを使用することでした。

.NET 11 プレビュー(および C# 15)により、言語はついに union キーワードを導入しました。この追加により、開発者は「これ または あれ」のシナリオを型レベルで直接モデル化でき、型安全性が大幅に向上し、「Result パターン」に伴うボイラープレートが削減されます。

C# 15 におけるユニオン型の理解

本質的に、ユニオン型は単一の変数が事前に定義された複数の型のうちのいずれかを保持できるようにします。関数型言語では何十年も前から使用されていますが、C# の実装は タグ付きユニオン(別名 判別ユニオン または 合併型)に焦点を当てています。これらはラッパーとして機能し、値が許可された型のちょうど一つであることを保証します。

union キーワードの実例

複数のオペレーティングシステムをサポートし、それぞれが異なるデータ要件を持つシナリオを考えてみましょう。従来は基底クラスや enum タグを使用していたかもしれませんが、C# 15 ではこれを簡潔に定義できます。

public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);

// Define the union type
public union SupportedOS(Windows, Linux, MacOS);

インスタンスの作成は直感的で、明示的な構築と暗黙的な変換の両方をサポートします。

// Implicit conversion
SupportedOS os = new MacOS("Tahoe", 25);

完全網羅的パターンマッチング

ユニオンの真の力は switch 式と組み合わせたときに発揮されます。コンパイラはユニオンの境界を理解できるようになり、完全網羅性 を強制できます。可能な型の一つを処理し忘れると、コンパイラは警告(CS8509)を出し、特に必要でない限りデフォルトの破棄(_)ケースは不要になります。

string GetDescription(SupportedOS os) => os switch
{
    Windows windows => $"Windows {windows.Version}",
    Linux linux => $"{linux.Distro} {linux.Version}",
    MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // No discard case required!

内部実装とボクシング

パフォーマンスへの影響を理解するために、コンパイラが union キーワードをどのように実装しているかを見ると有益です。デフォルトでは、union は新しい IUnion インターフェイスを実装し、[Union] 属性が付与された struct として生成されます。

デフォルト実装

標準的に生成されるコードは概ね以下のようになります:

[Union]
public struct SupportedOS : IUnion
{
    public object? Value { get; }
    public SupportedOS(Windows value) => this.Value = (object) value;
    public SupportedOS(Linux value) => this.Value = (object) value;
    public SupportedOS(MacOS value) => this.Value = (object) value;
}

内部の値が object として保存されるため、値型(intbool など)はヒープ上にボックス化されます。多くのアプリケーションではこのオーバーヘッドは無視できる程度ですが、ハイパフォーマンスな「ホットパス」では大きなボトルネックになる可能性があります。

カスタムユニオンでボクシングを回避する

パフォーマンスが重要なコードでは、.NET 11 フレームワークは TryGetValue パターンを実装することでデフォルトのボクシング動作を回避できます。ユニオンの各メンバーに対して特定の TryGetValue メソッドを提供すると、コンパイラは switch 式の際にボックス化された Value プロパティにアクセスする代わりにこれらのメソッドを使用します。

[Union]
public struct IntOrBool : IUnion
{
    private readonly bool _isBool;
    private readonly int _value;

    public bool TryGetValue(out int value)
    {
        value = _value;
        return !_isBool;
    }

    public bool TryGetValue(out bool value)
    {
        value = _isBool && _value is 1;
        return _isBool;
    }
    
    public object Value => _isBool ? _value is 1 : _value;
}

コミュニティの視点とトレードオフ

ユニオン型の導入は開発者コミュニティで大きな議論を呼び起こしました。概ね歓迎されているものの、いくつかの争点が浮上しています:

  • 「F# の影響」: 多くの観測者は、C# が時間とともに実質的に F# から機能を吸収していることに気付いています。あるコメント者は次のように述べています:

    "F# は何十年も前からこれを持っており、C# は基本的に C スタイルの構文で徐々に F# になりつつあるだけだ。"

  • タグ付きユニオン vs. タグなしユニオン: 用語に関して重要な技術的区別が指摘されました。C# は タグ付き ユニオン(代数データ型)を実装しており、これは TypeScript にある タグなし ユニオン(ラッパーコンストラクタなしで変数が A | B 型になれる)とは異なります。
  • パフォーマンスへの懸念: 一部の開発者は、ボクシングがデフォルト動作であることに失望し、言語はデフォルトで値型のヒープ割り当てを回避するために TryGetValue パターンを内部実装すべきだと主張しています。

今後の展望

ユニオン型は、C# におけるより完全な網羅性を目指す広範な取り組みの出発点に過ぎません。言語ロードマップには以下のような関連提案が含まれています:

  1. クローズド列挙型: すべてのケースを網羅できるため、switch 式でキャッチオールの破棄ケースが不要になる列挙型。
  2. クローズド階層: クラスを closed とマークして外部からの派生を禁止し、クラス階層の完全網羅マッチングを可能にする機能。
  3. ユニオンメンバープロバイダー: ユニオン本体とは別の型でユニオンメンバーを定義できる仕組み。

これらの機能を統合することで、C# は関数型言語の安全性と表現力に近づきつつ、汎用的でオブジェクト指向の言語としての柔軟性を保ち続けています。

Sources