はじめに
Union 型(共用体型)は、C# コミュニティでずっと要望され続けてきた機能のひとつです。初期の discriminated unions の提案から今日まで、何年もの設計と議論を経て、ついに C# 15 で正式に登場します。
Union 型を使うと、値を決まった型のどれかに限定できて、Union 値に対する switch 式で網羅性チェックが効くようになります。コンパイラがすべてのケース型を処理済みか確認してくれるので、多くの場合あの煩わしい _ のフォールバック分岐は要らなくなります。
この記事では、C# 15 での Union 型の設計と使い方を紹介していきます。
実際の問題から考えてみよう
ある関数が正常な結果を返すこともあれば、エラーを返すこともある。そんな場面を実装するとき、従来はラッパークラスを定義するのが一般的でした。
public class Result<T>
{
public T? Data { get; set; }
public Exception? Error { get; set; }
public bool IsSuccess => Error is null;
}
この書き方、問題は明らかですよね。Data と Error が型の上で同時に存在してしまうので、「成功時には必ず Data がある」「失敗時には必ず Error がある」ことをコンパイラは保証できません。正しさを型システムではなく"お約束"に頼っている状態です。
Union 型があれば、この問題はスッキリ解消されます。
Union 宣言
C# 15 では、新しい union キーワードが導入され、非常に簡潔な構文で Union 型を宣言できるようになりました。
public union Pet(Cat, Dog, Bird);
たったこれだけです!この一行で、値が Cat、Dog、Bird のいずれかである Pet という Union 型が宣言されます。
Union 宣言はコンパイラが内部的に構造体へ展開してくれます。値の格納には単一の object 参照が使われます。
// コンパイラが生成する等価コード
[Union] public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public Pet(Bird value) => Value = value;
public object? Value { get; }
}
つまり Union 宣言は、簡潔な構造体の宣言方法であり、コンパイラがボイラープレートをすべて肩代わりしてくれます。
もう少し実用的な例として、Union と既存の型を組み合わせて Option<T> を実装してみましょう。
public record class None();
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
Union にカスタムメソッドを追加することもできます。
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
IEnumerable<T> list => list,
T value => [value],
};
}
これも便利ですね!
ケース型は、ここまでの例に出てきたような具象型だけに限りません。提案では、インターフェース、型パラメータ、null 許容型、さらには他の Union もケース型にでき、ケース同士が重なっていても構いません。
ただし、Union 宣言自体はあえて制約を設けた設計になっています。メソッドなどは追加できますが、インスタンスフィールド、自動実装プロパティ、フィールド風イベントは宣言できません。自分で public な単一パラメータコンストラクタを定義することもできませんし、明示的に追加するコンストラクタは this(...) でコンパイラ生成のケース用コンストラクタに委譲しないといけません。
Union 変換
Union 型は、各ケース型からの暗黙的な変換をサポートしています。
Cat cat = new Cat("たま");
Pet pet = cat; // 暗黙の Union 変換、明示的なコンストラクタ呼び出しは不要
コンパイラはこれをコンストラクタ呼び出しに変換します。
// コンパイラが実際に生成するコード
Pet pet = new Pet(cat);
値を手動でラップする必要はなく、そのまま代入するだけです。もし既にユーザー定義の暗黙的変換演算子がある場合はそちらが優先されるので、既存のコードには影響しません。
ここでひとつ注意したいのは、Union 変換は暗黙変換だけだという点です。あるケース型への明示的変換があっても、Union 型全体への明示的変換まで自動的に生えるわけではありません。
Union マッチング
Union 型の一番強いところは、パターンマッチングとの組み合わせにあります。
Union 値に対してパターンマッチングを行うと、コンパイラが自動的に内部の値を"アンラップ"してくれます。
Pet pet = GetPet();
if (pet is Dog dog)
{
// dog はすでに Dog 型、そのまま使える
dog.Bark();
}
// switch 式
string description = pet switch
{
Dog dog => $"犬です:{dog.Name}",
Cat cat => $"猫です:{cat.Name}",
Bird bird => $"鳥です:{bird.Name}",
};
最後の分岐の後に _ フォールバックがないことに注目してください。これは Union 値に対する switch 式であり、Pet のケース型が Dog、Cat、Bird だと分かっているので、コンパイラはこの式を網羅的だと判断できます。
これは確かに非常に実用的な機能ですね。コードを簡潔にするだけでなく、コンパイル時に安全性を保証してくれます。もし後から Pet に新しいケース型 Fish を追加した場合、Fish を処理していないすべての switch 式でコンパイラ警告が出ます。見落としを防いでくれるわけです。
無条件の var と _ パターンの場合は、内部の値ではなく Union 値そのものにマッチします。
if (pet is var p) { ... } // p は Pet 型であり、object ではない
これはわざとそうなっています。var は通常マッチ対象の値に名前を付けるために使うので、object? まで型を落とすより Union 型を保持したほうが実用的です。
つまり、pet is Pet p は pet is var p と同じではありません。Pet p のような型パターンでは、Pet は外側の Union 値ではなくアンラップ後の中身に対して適用されるため、このパターンは通常は成功しません。
もうひとつ押さえておきたいのが null パターンです。Union が class の場合、result is null は Union オブジェクト自体が null のときだけでなく、中の Value が null のときにも成功します。U? のように struct ベースの Union を nullable で包んだ場合も同様で、外側の nullable に値がないか、中の Union の Value が null なら u is null が成功します。逆に、それ以外の Union マッチングは外側の値が存在するときだけ成功します。
Union の網羅性
先ほども触れましたが、この網羅性チェックが Union 型の一番大事なところです。
union Result(int, string, Exception);
string Describe(Result r) => r switch
{
int n => $"数値:{n}",
string s => $"文字列:{s}",
Exception e => $"エラー:{e.Message}",
// コンパイラが網羅的と判断、_ 分岐は不要
};
ただし、Union の値が null になりうる場合(たとえばケース型が null 許容型の場合)は、コンパイラが null のケースも処理するよう求めてきます。
Pet pet = GetNullableDog(); // pet.Value が null の可能性がある
var result = pet switch
{
Dog dog => "ワン",
Cat cat => "ニャー",
Bird bird => "ピヨ",
// 警告:null が処理されていない
};
Union パターンの手動実装
Union 宣言は便利ですが、Union の動作を手に入れる唯一の方法というわけではありません。次の条件を満たせば、既存の型に Union パターンを自分で実装できます。
- 型に
[Union]属性を付与する - 各ケース型に対応する単一パラメータのコンストラクタを提供する
-
object?型のValueプロパティを提供する
[Union]
public struct IntOrString
{
private readonly object _value;
public IntOrString(int value) => _value = value;
public IntOrString(string value) => _value = value;
public object? Value => _value;
}
既存の型を Union にしたいときや、格納方法を自分でコントロールしたいときに重宝します。
Union メンバープロバイダー(IUnionMembers)
ふだんはコンパイラが Union 型のコンストラクタからケース型を見つけてくれますが、公開コンストラクタを出したくない場合や、ファクトリメソッドで Union 値を作りたい場合もありますよね。そんなときは、Union 型の内部に IUnionMembers というインターフェースを宣言して、「メンバープロバイダー」として機能させることができます。
[Union]
public record class Result<T> : Result<T>.IUnionMembers
{
object? _value;
public interface IUnionMembers
{
public static Result<T> Create(T value) => new() { _value = value };
public static Result<T> Create(Exception value) => new() { _value = value };
public object? Value { get; }
}
object? IUnionMembers.Value => _value;
}
Union 型の中に IUnionMembers インターフェースがあると、コンパイラは Union 型本体のコンストラクタではなく、このインターフェースの Create ファクトリメソッドからケース型を見つけます。Value プロパティもインターフェース側で宣言します。
このパターンのメリットは:
- コンストラクタを隠して、ファクトリメソッド経由でのみインスタンスを作れる
-
classベースの Union に向いている(Union 宣言はデフォルトでstructを生成するため) - 内部の格納方法や初期化ロジックを自由にコントロールできる
暗黙的な変換は自動的にファクトリメソッド経由で行われます:
Result<string> result = "Hello";
// 以下と等価
Result<string> result = Result<string>.IUnionMembers.Create("Hello");
Non-boxing アクセスパターン
デフォルトの Union パターンは object? 型の Value プロパティで内部の値にアクセスするので、値型だとボックス化が発生します。パフォーマンスが気になる場合は、HasValue と TryGetValue メソッドも実装しておくと、パターンマッチング時にコンパイラが強く型付けされたアクセスパスを使ってくれます。
[Union]
public struct IntOrBool
{
private bool _isBool;
private int _value;
public IntOrBool(int value) => (_isBool, _value) = (false, value);
public IntOrBool(bool value) => (_isBool, _value) = (true, value ? 1 : 0);
public object Value => _isBool ? (object)(_value == 1) : _value;
// Non-boxing アクセスパターン
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value == 1;
return _isBool;
}
}
こうすると、パターンマッチング時に Value を経由せず TryGetValue を直接呼んでくれるので、ボックス化のオーバーヘッドを避けられます。
Result パターンの例
では、冒頭の問題に立ち返って、Union で型安全な Result<T> を実装してみましょう。
public union Result<T>(T, Exception);
たった一行です。使い方はこうなります。
Result<int> Divide(int a, int b)
{
if (b == 0) return new DivideByZeroException();
return a / b;
}
var result = Divide(10, 3);
var message = result switch
{
int value => $"結果は {value}",
Exception ex => $"エラー:{ex.Message}",
};
余計なラッパークラスも IsSuccess プロパティも不要で、型システムがすべてのケースの処理を保証してくれます。以前のやり方よりはるかにスッキリしていますね。
Union と型の階層構造
ここで押さえておきたいのは、C# の Union 型は「型の共用体」であって「タグ付き共用体」ではないという点です。伝統的な discriminated unions のように各分岐に独自の名前とデータを持たせたい場合は、record をケース型として組み合わせれば表現できます。
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
public record class Triangle(double Base, double Height);
public union Shape(Circle, Rectangle, Triangle);
double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 0.5 * t.Base * t.Height,
};
名前付きの分岐が要らなければ、既存の型をそのまま組み合わせるだけで十分です。どちらにも使いどころがあって、C# はその辺の柔軟性をしっかり確保してくれています。
もっと厳密に閉じた型の階層構造がほしい場合は、今後登場予定の closed hierarchies にも注目してみてください。Union 型と相性のいい機能です。
なぜ型消去ではダメなのか?
ここまで読んで「Union の値って結局ランタイムではただの object 参照でしょ? なら最初から object に消去して、コンパイル時のメタデータ(attribute など)で本来の型を覚えておけばいいんじゃない?」と思った方もいるかもしれません。dynamic やタプル名、null 許容注釈と同じ方式ですね。
実はこのアプローチ、C# の言語設計ワーキンググループで真剣に検討されています。型共用体の提案でも「Ad Hoc Union」というカテゴリがまさに消去ベースの設計でした。でも結局、C# 15 の実装方式にはなりませんでした。理由はいくつかあります。
ジェネリクスとの組み合わせで型安全が壊れる。 次のようなコードを考えてみてください:
public class MyCollection<T>
{
public bool TryAdd(object o)
{
if (o is T t)
{
// t を追加
return true;
}
return false;
}
}
MyCollection<(int or string)> でインスタンス化して、Union 型が object に消去されると、o is T は o is object になってしまいます。つまり何でも通ってしまう。型の安全性が完全に崩壊します。
消去しない方向にも問題がある。 消去を避けて ValueUnion<T1, T2> のようなラッパー型を使うと、今度は (string or bool) と (bool or string) がランタイムで別の型になってしまいます。Ad hoc union としてはこれは受け入れられません。同じ型の組み合わせなら交換可能であるべきだと誰もが思いますよね。ワーキンググループはランタイムレベルの対策も調査しましたが、「不完全でコストが大きすぎる」という結論でした。
実用面ではラッパー方式が勝った。 ワーキンググループは Trade Off Matrix を作って、3つの実現方法(クラス階層、object 参照(消去)、ラッパー型)を比較しています。最終的に選ばれたラッパー方式(Nominal Type Unions)は、後方互換性、ABI を壊さない進化、実装のカスタマイズ可能性、そして「近い将来出荷できる」という点で優位でした。消去方式は匿名構文や動的パターンマッチングでは優れていますが、安全に動かすにはランタイム側の大きな改修が必要で、すぐには実現できなかったわけです。
というわけで、最終的なデザインでは Union 宣言が構造体ラッパーを生成し、中に object? 参照で値を持ちます。「コンパイラが管理してくれるラッパー型」と考えればわかりやすいです。単なるメタデータの注釈ではなく、ランタイムに構造体が実在し、Value プロパティも実際にアクセスできます。だからリフレクション、シリアライゼーション、アセンブリ間呼び出しなど、コンパイル時の"見せかけ"だけでは破綻するシナリオでもきちんと動きます。
おわりに
Union 型の追加は、C# の型システムにとって大きな飛躍です。C# で「複数の型のどれか」を表現するときの長年のもどかしさが解消されて、規約やランタイムチェックに頼らなくても、コンパイラが型レベルで正しさを保証してくれます。
簡潔な union 宣言構文でほとんどの場面は数行でカバーできますし、柔軟な Union パターンを使えば内部実装を丸ごとカスタマイズすることもできます。「簡単なことは簡単に、複雑なことにも道がある」という設計は、まさに C# らしいですね。
C# 15 と .NET 11 の正式リリースが楽しみです~