C# ユニオン型: 差別的なユニオンがついに登場
かなりの期間 C# を書いてきた場合、「いくつかあるもののうちの 1 つ」となるものをモデル化しようとしたときに壁にぶつかった可能性が高くなります。おそらく、成功値またはエラーのいずれかを返すメソッドが必要でした。おそらく、クレジット カード、銀行振込、デジタル ウォレットを処理する支払いシステムを構築していたのでしょう。それぞれにまったく異なるデータが含まれていました。あるいは、F# や Rust を見て、「なぜ C# でそれができないの?」と思ったかもしれません。
待ち時間はもう終わりに近づいています。 差別的な共用体が C# に登場します。
これは長年にわたり最も要望の多かった言語機能の 1 つであり、コミュニティでの議論は 2017 年以前にまで遡ります。 C# 言語設計チームは、網羅的なパターン マッチングを使用して閉じた型の階層を定義するための union キーワードを導入する提案に取り組んでいます。この投稿では、識別共用体とは何か、それがそれほど重要である理由、これまでどのように偽装してきたのか、そして提案された構文が実際にどのようなものであるのかを、それぞれの実際のコード例とともに説明したいと思います。
本題に入る前に簡単な注意事項: この記事の執筆時点では、ユニオン型機能はまだ提案とプレビューの段階にあります。ここで説明する構文と動作は、公開されている最新の設計ドキュメントと C# 言語設計チームからのディスカッションに基づいています。最終リリースまでに状況が変わる可能性があります。何が確認されているのか、何がまだ議論中であるのかを明確にさせていただきます。
差別的な組合とは何ですか?
本質的に、識別共用体 (「タグ付き共用体」または「合計型」と呼ばれることもあります) は、可能な値の固定セットの 1 つを保持できる型であり、各バリアントは異なるデータを保持できます。 「識別された」部分は、ランタイムがそれがどのバリアントであるかを常に知っていることを意味します。それを識別するタグがあります。
これは enum のようなものですが、各メンバーが独自のデータ ペイロードを運ぶことができます。
他の言語を使用したことがある場合は、おそらくこの概念を以前に見たことがあるでしょう。
F# は初日から差別的な組合を持っていました:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of base: float * height: float
Rust は同じアイデアに enum を使用します。
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
TypeScript は、タグ付きユニオンを使用して同様のことを実現します。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
これらすべてのケースにおいて、コンパイラは考えられるすべてのバリアントを認識しており、それらをすべて処理するように強制できます。それが超能力です。徹底的なチェックです。新しいバリアントを追加すると、コンパイラは、それを処理するのを忘れた場所をすべて通知します。
C# にはこれを表現する最上級の方法がありませんでした。今まで。
今日の組合のシミュレーション方法
長年にわたり、C# コミュニティはいくつかの回避策を考案してきましたが、それぞれに独自のトレードオフがあります。最も一般的なアプローチについて説明します。
継承のある抽象レコード
最新の C# における最も慣用的な回避策は、シールされた派生型を持つ抽象レコードを使用することです。
public abstract record Shape
{
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double Base, double Height) : Shape;
private Shape() { } // Prevent external inheritance
}
これはかなりうまく機能します。不変性と値の同等性が得られ、パターン マッチングを使用できます。
double Area(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Rectangle r => r.Width * r.Height,
Shape.Triangle t => 0.5 * t.Base * t.Height,
_ => throw new InvalidOperationException("Unknown shape")
};
```しかし、重大な欠点もあります。コンパイラーは階層が閉じられていることを認識しないため、常に `_` 破棄アームが必要です。そうしないと警告が表示されます。新しいバリアントを追加した場合、コンパイラはそれを処理するのを忘れたすべての場所を通知しません。破棄はそれを黙って飲み込みます。それは完全に目的を裏切ります。
### OneOf ライブラリ
もう 1 つの一般的なアプローチは、[OneOf](https://github.com/mcintyre321/OneOf) NuGet パッケージです。
```csharp
public OneOf<Success<Order>, NotFound, ValidationError> ProcessOrder(OrderRequest request)
{
// ...
}
// Usage
var result = ProcessOrder(request);
result.Switch(
success => Console.WriteLine($"Order {success.Value.Id} processed"),
notFound => Console.WriteLine("Order not found"),
error => Console.WriteLine($"Validation failed: {error.Message}")
);
OneOf は、ジェネリック型パラメーターを通じてコンパイル時に徹底的なチェックを提供します。これは優れています。しかし、位置一致 (最初のタイプ、2 番目のタイプなど) に依存しており、汎用署名はすぐに扱いにくくなり、言語のパターン マッチングと統合されていません。これは賢いハックですが、それでもハックです。
手動列挙 + データ パターン
一部の開発者は、enum タグとデータ コンテナーを使用する古典的なルートを選択します。
public enum PaymentType { CreditCard, BankTransfer, DigitalWallet }
public class Payment
{
public PaymentType Type { get; init; }
public CreditCardInfo? CreditCard { get; init; }
public BankTransferInfo? BankTransfer { get; init; }
public DigitalWalletInfo? DigitalWallet { get; init; }
}
これは壊れやすいです。 Type を CreditCard に設定すること以外は、BankTransfer プロパティを設定することを妨げるものはありません。コンパイラは役に立たず、実行時エラーやあらゆる場所で null チェックが発生することになります。これは型モデリングに対する「文字列型指定」アプローチであり、拡張性がありません。
これらのアプローチはすべて、基本的な問題を共有しています。言語を操作するのではなく、言語と戦っている。コンパイラーは、可能性の閉じたセットについて推論することができないため、弁別共用体の最も貴重な特性である徹底的なチェックが失われます。
C# 提案: union キーワード
C# 言語チームの提案では、専用の union キーワードを導入しています。これは、名前付きメンバーの閉じたセットを定義し、それぞれがオプションでデータを保持します。提案されている基本的な構文は次のとおりです。
union Shape
{
Circle(double Radius),
Rectangle(double Width, double Height),
Triangle(double Base, double Height)
}
それだけです。すっきりしていて簡潔で、すぐに読めます。共用体の内部の各メンバーは、独自のデータを持つ個別のバリアントを定義します。コンパイラは、Shape がこれら 3 つのうちの 1 つにしかなり得ないことを知っています。
内部では、コンパイラーは密閉型階層を生成します。これは、抽象レコードを使用して手動で記述するものと似ていますが、コンパイラーは型の閉じた性質を完全に認識します。これは、コンパイラがパターン マッチングの網羅性を強制できることを意味し、これが主な利点です。
価値のみのメンバー
組合員はデータを持ち歩く必要がありません。データ保持メンバーと単純な値メンバーを混在させることができます。
union Option<T>
{
Some(T Value),
None
}
これは、関数型プログラマが C# で長年求めてきた古典的な Option/Maybe 型です。 None にはデータは含まれません。これは単なるタグです。
ジェネリックユニオン
上記の Option<T> の例からわかるように、共用体はジェネリックスをサポートしています。より複雑な例を次に示します。
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
これにより、予期される失敗ケースの例外に依存しない、エラー処理のスタイル全体が開かれます。これは、Rust および関数型言語で長年にわたって標準的に行われてきたことです。
メソッドとの結合
この提案では、他の型と同様に、共用体がメソッド、計算されたプロパティを持つこと、インターフェイスを実装することも許可されています。```csharp union Shape { Circle(double Radius), Rectangle(double Width, double Height), Triangle(double Base, double Height);
public double Area => this switch
{
Circle(var r) => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
Triangle(var b, var h) => 0.5 * b * h
};
public double Perimeter => this switch
{
Circle(var r) => 2 * Math.PI * r,
Rectangle(var w, var h) => 2 * (w + h),
Triangle(var b, var h) => b + h + Math.Sqrt(b * b + h * h)
};
}
`Area` および `Perimeter` 内の `switch` 式がデフォルトのアームを必要としないことに注目してください。コンパイラーは、共用体が完全であることを認識しています。バリアントは 3 つだけあり、3 つすべてが処理されます。後で 4 番目のバリアントを追加すると、コンパイラはそれを処理しないすべての `switch` にフラグを立てます。
## パターン マッチングの統合
C# ではパターン マッチングがバージョン 7.0 以降進化しており、共用体型はそのシステムの第一級市民となるように設計されています。
### 網羅的なスイッチ式
最も影響力のある機能は、徹底的なスイッチ チェックです。共用体を使用すると、コンパイラは考えられるすべてのケースを **認識**します。
```csharp
string Describe(Shape shape) => shape switch
{
Circle(var r) => $"A circle with radius {r}",
Rectangle(var w, var h) => $"A {w}x{h} rectangle",
Triangle(var b, var h) => $"A triangle with base {b} and height {h}"
};
廃棄アームはありません。 _ => throw new NotImplementedException() いいえ。大文字と小文字を忘れた場合、コンパイラは警告ではなくエラーを生成します。これは安全性の根本的な改善です。
ネストされたパターンマッチング
ユニオンは、ネストされたパターンで自然に構成されます。
union Expr
{
Literal(double Value),
Add(Expr Left, Expr Right),
Multiply(Expr Left, Expr Right),
Negate(Expr Inner)
}
double Evaluate(Expr expr) => expr switch
{
Literal(var v) => v,
Add(var left, var right) => Evaluate(left) + Evaluate(right),
Multiply(var left, var right) => Evaluate(left) * Evaluate(right),
Negate(var inner) => -Evaluate(inner)
};
この種の再帰的なデータ構造は、コンパイラ、インタプリタ、ルール エンジン、および数学的モデリングで非常に一般的です。現在の C# では、深いクラス階層と訪問者パターンが必要になります。共用体を使用すると、コードが大幅に単純になります。
ガード条項
共用体を使用したパターン マッチングは、ご想像どおり when ガードをサポートしています。
string Classify(Shape shape) => shape switch
{
Circle(var r) when r > 100 => "Large circle",
Circle(var r) when r > 10 => "Medium circle",
Circle(_) => "Small circle",
Rectangle(var w, var h) when w == h => "Square",
Rectangle _ => "Rectangle",
Triangle _ => "Triangle"
};
実用的な例
共用体型によってコードが劇的に改善される実際のシナリオをいくつか見てみましょう。
結果のパターン: 予期されるエラーの例外を置き換える
最新のアプリケーション開発で最も一般的なパターンの 1 つは、制御フローの例外を使用せずに、成功または失敗する可能性のある操作を表現することです。例外は、ネットワーク障害やメモリ不足状態など、例外的なものである必要があります。検証エラーまたは「見つからない」という結果は予期された結果であり、例外ではありません。
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
union OrderError
{
NotFound(Guid OrderId),
InsufficientStock(string ProductId, int Requested, int Available),
PaymentDeclined(string Reason),
ValidationFailed(IReadOnlyList<string> Errors)
}
Result<Order, OrderError> ProcessOrder(OrderRequest request)
{
if (!Validate(request, out var errors))
return new ValidationFailed(errors);
var product = catalog.Find(request.ProductId);
if (product is null)
return new NotFound(request.OrderId);
if (product.Stock < request.Quantity)
return new InsufficientStock(request.ProductId, request.Quantity, product.Stock);
var paymentResult = paymentGateway.Charge(request.Payment);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.Message);
var order = CreateOrder(request, product);
return new Ok(order);
}
その後、呼び出し元は考えられるすべての結果を処理する必要があります。
var result = ProcessOrder(request);
var response = result switch
{
Ok(var order) => Results.Created($"/orders/{order.Id}", order),
Error(NotFound(var id)) => Results.NotFound($"Order {id} not found"),
Error(InsufficientStock(var pid, var req, var avail)) =>
Results.Conflict($"Product {pid}: requested {req}, only {avail} available"),
Error(PaymentDeclined(var reason)) =>
Results.UnprocessableEntity($"Payment declined: {reason}"),
Error(ValidationFailed(var errors)) =>
Results.BadRequest(new { Errors = errors })
};
try-catch ブロック、忘れられた例外タイプ、実行時のサプライズはありません。すべての障害モードは型シグネチャに表示され、コンパイラによって強制されます。これは API の信頼性にとって大幅な改善です。
ドメイン モデリング: 支払いタイプ
これは、電子商取引システムでさまざまな支払い方法を処理する実際のドメイン モデリングの例です。
union PaymentMethod
{
CreditCard(string CardNumber, string Expiry, string Cvv),
BankTransfer(string Iban, string Bic),
DigitalWallet(string Provider, string Token),
CashOnDelivery
}
decimal CalculateProcessingFee(PaymentMethod method, decimal amount) => method switch
{
CreditCard _ => amount * 0.029m + 0.30m,
BankTransfer _ => 1.50m,
DigitalWallet(provider: "PayPal", _) => amount * 0.034m + 0.35m,
DigitalWallet _ => amount * 0.025m,
CashOnDelivery => 4.99m
};
string FormatForReceipt(PaymentMethod method) => method switch
{
CreditCard(var num, _, _) => $"Credit Card ending in {num[^4..]}",
BankTransfer(var iban, _) => $"Bank Transfer ({iban[..4]}****)",
DigitalWallet(var provider, _) => $"Digital Wallet ({provider})",
CashOnDelivery => "Cash on Delivery"
};
これを、5 つのファイルにまたがる 5 つの異なる実装を持つインターフェイスまたは抽象クラスがあり、その上に訪問者パターンが含まれる現在のアプローチと比較してください。ユニオンのアプローチでは、データ定義と操作をまとめて読み取り可能に保ち、徹底的にチェックします。
ステートマシン
ステート マシンは、注文処理、ワークフロー エンジン、接続管理、UI 状態など、ソフトウェアのあらゆる場所に存在します。 Union はそれらを明示的かつ安全なものにします。
union ConnectionState
{
Disconnected,
Connecting(string Host, int Port, DateTime StartedAt),
Connected(string Host, int Port, TcpClient Client),
Reconnecting(string Host, int Port, int Attempt, TimeSpan Delay),
Failed(string Host, Exception Error)
}
ConnectionState HandleEvent(ConnectionState state, ConnectionEvent evt) => (state, evt) switch
{
(Disconnected, Connect(var host, var port)) =>
new Connecting(host, port, DateTime.UtcNow),
(Connecting(var h, var p, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Connecting(var h, var p, _), ConnectionFailed(var ex)) =>
new Reconnecting(h, p, Attempt: 1, Delay: TimeSpan.FromSeconds(1)),
(Reconnecting(var h, var p, var attempt, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(var ex)) when attempt >= 5 =>
new Failed(h, ex),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(_)) =>
new Reconnecting(h, p, attempt + 1, TimeSpan.FromSeconds(Math.Pow(2, attempt))),
(Connected(_, _, var client), Disconnect) =>
new Disconnected,
_ => state // Ignore events that don't apply to current state
};
各状態は、その状態に関連するデータを正確に保持します。 Connecting 状態では Connecting が存在しないため、誤って [[TOK_39]]] にアクセスすることはできません。型システムはステート マシンの不変条件を強制します。
シリアル化と相互運用に関する考慮事項共用体型についてすぐに浮かぶ実際的な疑問の 1 つは、「どうやってそれらをシリアル化するのか?」というものです。 API を構築している場合、またはデータを保存している場合、正しく機能するには JSON シリアル化が必要です。
設計チームは、共用体型の組み込みの System.Text.Json サポートについて議論してきました。期待されるアプローチには、識別子プロパティを使用したシリアル化が含まれます。
{
"$type": "Circle",
"radius": 5.0
}
{
"$type": "Rectangle",
"width": 10.0,
"height": 20.0
}
これは、.NET 7 で JsonDerivedType 属性を使用して導入された既存のポリモーフィック シリアル化サポートと一致しています。デフォルトで型識別子としてバリアント名を使用し、共用体が System.Text.Json でそのまま動作することが期待されています。
Entity Framework Core の場合、おそらく階層ごとのテーブル (TPH) 継承マッピングが既に機能している方法と同様に、識別子列を使用して共用体値を格納するアプローチが考えられます。正確な EF Core 統合はまだ設計中ですが、閉じた型の階層を処理するためのインフラストラクチャはすでに存在しています。
ユニオンは内部で標準の IL クラス階層にコンパイルされるため、他の .NET 言語との相互運用がスムーズになることは注目に値します。 C# ユニオンを使用する F# コードは、それを標準の型階層として認識し、その逆も同様です。
試してみる方法
この記事の執筆時点では、ユニオン型は最新の .NET SDK プレビューのプレビュー機能として利用できます。提案された構文を試してみるには、次のことを行う必要があります。
- 最新の .NET プレビュー SDK をインストールします
- プロジェクト ファイルでプレビュー言語バージョンを有効にします。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
プレビュー機能は変更される可能性があることに注意してください。構文、動作、およびコンパイラ診断は、最終リリース前に大幅に進化する可能性があります。プレビュー言語機能に依存して製品コードを出荷しないでください。ただし、必ずそれらを試してフィードバックを提供してください。 C# チームは、csharplang リポジトリのディスカッションを積極的に監視しています。
提案の進捗状況を追跡したい場合は、注目すべき重要な場所は次のとおりです。
- 言語設計ディスカッションのための dotnet/csharplang リポジトリ
- コンパイラ実装の進捗状況のための dotnet/roslyn リポジトリ
- 公式発表に関する .NET ブログ
既存のアプローチとの比較
さまざまなアプローチがどのように積み重なるかを確認できるように、簡単な比較をまとめてみましょう。
| 特集 | 抽象的な記録 | OneOf<T1,T2> | 列挙型 + データ | ユニオンの種類 |
|---|---|---|---|---|
| 徹底チェック | ❌ いいえ | ✅ はい | ❌ いいえ | ✅ はい |
| パターンマッチング | ✅ はい | ❌限定 | ❌ マニュアル | ✅ ネイティブ |
| コンパイラによる強制的なクロージャ | ❌ いいえ | ✅ はい | ❌ いいえ | ✅ はい |
| バリアントごとのデータ | ✅ はい | ✅ はい | ⚠️壊れやすい | ✅ はい |
| 可読性 | ⚠️ 詳細 | ⚠️ 位置 | ❌ 悪い | ✅ 素晴らしい |
| 連載 | ✅ マニュアル | ⚠️コンプレックス | ✅ マニュアル | ✅ 内蔵 |
| 定型文 | ⚠️中程度 | ✅ 低い | ⚠️高い | ✅ ミニマル |
| 外部依存性なし | ✅ はい | ❌NuGet | ✅ はい | ✅ はい |
これが .NET エコシステムにとって何を意味するか
差別的結合の導入は、.NET エコシステム全体に波及します。私が期待しているものは次のとおりです。
ライブラリの設計が改善されます。 現在、「見つからない」ことを示すために null を返したり、検証の失敗に対して例外をスローしたりする API は、代わりに Result<T, E> タイプを返すことができるようになります。これにより、型シグネチャで障害モードが明確になります。ドキュメントやソース コードを読むのではなく、メソッド シグネチャを見ることで、何が問題になるのかがわかります。
ドメイン モデリングがより表現力豊かになります。 問題のドメインとコード表現の間のギャップが劇的に縮小します。ドメインの専門家が「支払いにはクレジット カード、銀行振込、または代金引換が可能です」と言う場合、それを継承階層に変換するのではなく、結合として直接モデル化できます。
C# 開発者が F# のアイデアにアクセスできるようになります。 多くの C# 開発者は、遠くから F# の型システムに憧れてきましたが、組織に F# を採用することができませんでした。ユニオン型は、F# の最も強力な機能の 1 つを C# にもたらします。これは、.NET エコシステム全体にとって有益です。
実行時エラーが減少します。 徹底的なチェックだけでも、カテゴリ全体のバグを防ぐことができます。新しいバリアントを共用体に追加するたびに、コンパイラはコードベース内の更新が必要なすべての場所を案内します。 switch の忘れ去られるケースや、運用環境でのみ表示されるデフォルトのブランチの NotImplementedException はもう必要ありません。
結論
差別的ユニオンは 10 年近くにわたって C# コミュニティの要望リストのトップにありましたが、それには十分な理由があります。これらは、型システムの根本的なギャップ、つまりコンパイラによって強制される安全性を備えた「いくつかのもののうちの 1 つ」となるデータをモデル化する機能を解決します。
提案された union キーワードは、C# のパターン マッチングと深く統合し、ジェネリックスと連携し、メソッドとインターフェイスをサポートし、実行時ではなくコンパイル時にバグを検出する徹底的なチェックを可能にする、クリーンで簡潔な構文をもたらします。
ドメイン モデルを構築している場合でも、明示的なエラー タイプを使用した API を設計している場合でも、ステート マシンを実装している場合でも、単に厄介な継承階層をより自然なものに置き換えようとしている場合でも、ユニオン型は C# の記述方法を変えることになります。
私たちはこれを長い間待っていました。構文は洗練されており、既存の言語機能との統合は思慮深く行われており、実際的な影響は .NET エコシステム全体にわたって感じられます。
プレビュー リリースに注目し、機能を試して、言語設計チームにフィードバックを提供してください。これは、一度使用すると、これなしでどうして生きていたのかと不思議に思うような機能の 1 つです。