1 ポイント 投稿者 GN⁺ 2024-08-09 | 1件のコメント | WhatsAppで共有
  • C# に type unions(discriminated unions)を導入し、1つの変数やパラメーターが制限された複数の型のうち1つだけを保持できるよう表現する提案であり、現在は Proposed 段階で、prototype・implementation・specification は Not Started の状態である
  • 既存の継承階層や object ベースの実装では、閉じた型集合、無関係な型の組み合わせ、ラッパーなしの値格納、割り当て回避を同時に満たすことが難しく、4つの union カテゴリーに分かれる
  • 提案では union classunion struct、ad hoc union、custom union を区別しており、各方式は宣言方法・割り当ての有無・既存型の再利用可否において異なる制約を持つ
  • switch とパターンマッチングでは、すべてのメンバー型を処理すれば default が不要な 網羅性 を提供するが、union struct では default が宣言メンバーに対応しない可能性があるため、警告と明示的な default メンバー指定が必要になる
  • ad hoc union は (A or B or C) 構文で既存型を束ね、erasure とランタイム検査で実装されるが、値型の boxing、ref type 不可、真の runtime overloading 不可といった制約がある

提案の目的と動機

  • Type unions for C# は C# に type unions、すなわち discriminated unions を導入する提案である
    • 文書の状態は Proposed
    • Prototype、Implementation、Specification はすべて Not Started と表示されている
  • ソフトウェア開発では、1つの変数に毎回同じ種類ではなく、制限された関連型のいずれか1つを入れる必要がある場面がある
    • 例としては CustomerSupplier のように、一部のプロパティだけを共有しつつ、違いに応じて似た処理を行う必要がある場合である
  • 共通の抽象メソッドやインターフェースで実装を各型に分散する方法は、その型がその処理のために存在しているか、その処理が型の本質的な一部である場合に適している
    • 型がより広い目的を持つ場合、そのようなメソッドを追加することが望ましくないこともある
  • 継承で Contact のような共通の基底型を作ることもできるが、次のような場合には難しいか不適切である
    • 型定義を所有していない場合
    • 似たような状況が多く、継承だけでは1つしか解決できない場合
    • 特定の処理要件をデータ定義に漏らしたくない場合
  • object を使っても動作はするが、正しい値だけが入るという保証はドキュメントやコメントで管理しなければならない
    • ラッパー階層やカスタム集約型で保護することもできるが、状況ごとの型集合が多いと時間がかかり煩雑になる
  • C# が 制限された複数の型のうち1つ を同じ場所に格納することを宣言でき、変数の保護を言語側が担うようにすることが目標である

4つの union カテゴリー

  • すべてのユースケースを1つの実装で満たすのは難しいため、4つのカテゴリーに分かれる
  • Standard - union classes

    • union とメンバーを一緒に定義でき、メンバーを独立したクラスのように使う意図がある場合に用いる
    • クラスの割り当てが問題にならないケースを対象とする
    • 例:
      • プロトコル、シリアライズ、データ転送型
      • UI データモデル(XAML)
      • syntax tree
      • 頻繁には変化しないステートマシン状態
      • その他の多相データモデル
      • フィールドやプロパティのように union 形で長く保持される値
  • Specialized - union structs

    • 割り当てを避ける必要がある、または特殊な型の使用が必要で、その代わりに一定の制約を受け入れられる場合に用いる
    • 例:
      • 連続配列に割り当てられる値
      • メモリブロック上にマッピングされる値(interop)
      • 頻繁に変化するステートマシン状態
      • 引数や戻り値のように union 形で短時間だけ保持される値
      • 特殊用途の可能性があるライブラリ型
  • Ad Hoc - ad hoc unions

    • 既に存在し、互いに無関係である可能性のある型で union を構成しなければならない場合に用いる
    • 同じメンバー型で宣言された union 同士は相互に交換可能であるべきだ
  • Custom unions

    • 他のカテゴリーにうまく当てはまらない場合のための方式である
    • 例:
      • 容易に再定義できない既存の型や階層
      • カスタムのストレージレイアウト
      • カスタムの API 形態と動作

Standard - union classes

  • union class は、すべてのメンバー型を1つの self-contained 宣言の中に置く named type union である
  • 宣言は enum に似ているが、各メンバーが1つ以上の状態変数として状態を持てる型である点が異なる
union U
{
    A(int x, string y);
    B(int z);
    C;
}
  • 各メンバーには名前と状態変数の一覧だけを指定できる
  • 生成はメンバー型を割り当てることで行う
U u = new A(10, "ten");
  • 生成されたメンバーの型は A であり、変数 u に割り当てられる際に U に変換される
  • 分解は 型テスト とパターンマッチングで行う
if (u is A a) { ... }

if (u is A(var x, var y)) { ... }

if (u is A { y: var y }) { ... }
  • union class は exhaustive と見なされる
    • switch expression や statement で全メンバー型を処理すれば default case は不要である
var x = u switch {
    A a => a.x,
    B b => b.z,
    C c => 0
    };
  • null は標準の nullable 表記で含められる
U? u = null;
  • 実装は abstract record class と nested derived record class で表現される
[Closed]
abstract record U
{
    public record A(int x, string y) : U;
    public record B(int z) : U;
    public record C : U { public static C Singleton = new C(); };
}
  • Closed attribute は、基底型モジュールの外でサブタイプが宣言されない 閉じた階層 であることを言語が理解できるようにする

Specialized - union structs

  • union struct も、すべてのメンバー型を1つの self-contained 宣言の中に置く named type union である
    • union とメンバー型の両方が struct であり、heap allocation なしで使用できる
  • 宣言は union class に似ているが、struct キーワードを追加する
union struct U
{
    A(int x, string y);
    B(int z);
    C;
}
  • 生成、分解、網羅的 switch、nullable 表記は union class と似ている
U u = new A(10, "ten");

if (u is A a) { ... }

U? u = null;
  • union struct は未割り当て、または default が割り当てられて undefined state になることがある
    • この状態は宣言されたどのメンバー型にも対応しない
    • 網羅性に依存した switch でランタイム例外が発生する可能性がある
U u = default;

var x = u switch
{
    A a => a.x,
    B b => b.z,
    C c => 0
}
  • コンパイラーは struct union に default が割り当てられると警告を生成する
// warning: default not a valid state
U u = default;
  • 警告を避けるには、union struct に default 状態を宣言して特定のメンバー型と結び付けられる
union struct U
{
    A(int x, string y);
    B(int z);
    C = default;
}
  • 実装は、ネストされた record struct のメンバー型と、メンバー型と aggregate union struct の間で変換する API を持つ struct として表現される
    • 内部レイアウトは、可能なメンバー型のデータを効率よく格納できるようにコンパイラが選択する
    • 速度とサイズの間のトレードオフもコンパイラが選択する
[Union]
struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);
    public record struct C { public static C Singleton = default; };

    public static implicit operator U(A value) {...};
    public static implicit operator U(B value) {...};
    public static implicit operator U(C value) {...};

    public static explicit operator A(U union) {...};
    public static explicit operator B(U union) {...};
    public static explicit operator C(U union) {...};

    public bool TryGetA(out A value) {...};
    public bool TryGetB(out B value) {...};
    public bool TryGetC(out C value) {...};

    public enum UnionKind { A = 1, B = 2, C = 3 };
    public UnionKind Kind => {...};
}
  • Union 属性は、その型が union struct であることを識別する
  • default 状態を持つ union struct は、対応する UnionKind を 0 として宣言する
  • union struct の全生成 API は、まだドキュメントに記載されていない

union struct の型テスト、boxing、reflection

  • 既知の union struct に対して型テストを行うと、struct 自体の型を調べるのではなく、union struct API を呼び出す
u is A a
  • 上の式は次のように変換される
u.TryGetA(out var a)
  • switch expression も KindTryGetX 呼び出しを使う形に変換される
u.Kind switch {
   U.UnionKind.A when u.TryGetA(out var a) => a.x,
   U.UnionKind.B when u.TryGetB(out var b) => b.z,
   U.UnionKind.C when u.TryGetC(out var c) => 0,
   _ => throw ...;
}
  • boxed union struct は、メンバー型の値が boxing されたものではなく、union struct 自体が boxing されたものになる
    • union struct の主なユースケースは boxing の回避だが、ときには boxing が必要になることもある
  • union struct 型とメンバーは相互に関連していることがわかっているため、boxed union struct をメンバー型として型テストし、unbox できる
U u = ...;
object value = u;

if (value is A a) {...}
  • 上のコードは次のように変換される
if (value is A a || (value is U u && u.TryGetA(out a))) {...}
  • 逆に、boxed member type も union struct としてテストして unbox できる
A a = ...;
object value = a;

if (value is U u) {...}
  • 型テストの両側が union struct と関連していることを静的に判断できない場合、型テストは失敗する
bool IsType(object value) => value is T;

U u = new A(...);

if (IsType(u)) {...}
  • reflection を使う際、boxed member type A を boxed union struct U に変換する必要がある場合がある
  • struct union 機能は、実行時に boxed union struct と boxed member type の間で変換するユーティリティメソッドを提供する
public static class TypeUnion
{
    public bool TryConvert(Type unionType, object value, out object? boxedUnion);
    public bool TryConvert(object value, out TUnion union);
    public object? GetValue(object? boxedUnion);
}
  • union class と ad hoc union は、reflection で使う形としてすでに適切なので変換は不要

Ref union structs

  • ref 修飾子が付いた union struct は、状態変数として refs や ref structs を含められる
ref union struct U
{
    A(ref int x);
    B(ReadOnlySpan y);
    C;
}
  • この場合、union の実装と、ref struct 値を持つメンバー型は ref struct に変換される
ref struct U
{
    public ref struct A { public ref int x; public A(ref int x) {...}; }
    public ref struct B { public ReadOnlySpan y; public B(ReadOnlySpan y) {...} }
    public record struct C { public static C Singleton = default; }
    ...
}
  • C# に ref record struct type が追加されれば、影響を受けるメンバー型も引き続き record struct のまま維持できる

Ad Hoc - ad hoc unions

  • ad hoc union は、別の場所で宣言された型で構成される anonymous union
  • 構文は括弧と or pattern syntax を使う
(A or B or C)
  • 共通名で参照するには、file または global using alias を使う
global using U = (A or B or C);
  • 生成は、union メンバー型のいずれかのインスタンスを ad hoc union 型変数に代入して行う
record A(int x, string y);
record B(int z);
record C() { public static C Singleton = new C(); };

(A or B or C) u = new A(10, "ten");
  • 分解は型テストとパターンマッチングで行う
if (u is A a) {...}

if (u is A(var x, var y)) { ... }
  • ad hoc union も exhaustive とみなされ、すべてのメンバー型を処理していれば default case は不要
  • null は nullable 表記で含められる
(A or B)? x = null;
  • 同じメンバー型を持つ ad hoc union は、順序に関係なくコンパイラが同じ型として理解する
(A or B) x = new A(10, "ten");
(B or A) y = x;

ad hoc union の代入、交換可能性、推論

  • 同じ型、または subset の ad hoc union は、ランタイム検査なしで superset ad hoc union に代入できる
(A or B) x = new A(10, "ten");
(A or B or C) y = x;
  • superset ad hoc union を subset ad hoc union に代入するには、明示的な coercion とランタイム検査が必要
(A or B or C) x = new A(10, "ten");
var y = (A or B)x;
  • source union のすべてのメンバー型が、target union の少なくとも 1 つ以上のメンバーと同一または subtype であれば、ランタイム検査なしで implicit coercion が可能
(Chihuahua or Siamese) pet = ...;
(Cat or Dog) animal = pet;
  • そうでなくても、source メンバー型の1つ以上が target メンバー型の1つの subtype であれば、明示的 coercion とランタイム検査が可能
(Cat or Chihuahua) mostlyCats = ...;
(Dog or Siamese) mostlyDogs = (Dog or Siamese)mostlyCats;
  • ad hoc union でない型も、代入可能性の判定では単一型の ad hoc union のように見なせる
    • この規則は実装されたインターフェイスにも適用される
  • generalized coercions も定義される
    • ある型から union のメンバー型の1つへ implicit coercion があれば、その型の値を union 型へ implicit coercion できる
    • union のすべてのメンバー型がある型へ implicit coercion 可能であれば、union 値をその型へ implicit coercion できる
    • union のメンバー型の1つがある型へ coercion 可能であれば、union 値をその型へ explicit coercion できる
    • source union のすべてのメンバー型が target union のメンバーの1つへ implicit coercion 可能であれば、union 間の implicit coercion が可能
    • source union のメンバーの1つ以上が target union のメンバーの1つへ explicit coercion 可能であれば、union 間の explicit coercion が可能
  • 複数の coercion が可能な場合にどの coercion を選ぶかの規則は、まだ必要
  • この assignability 関係は subtyping 関係ではない
    • ある ad hoc union は別の ad hoc union の subtype ではない
  • 同じメンバー型を持つ ad hoc union は、generics と array elements を通じて交換可能
(T1 or T2)[] F(T1 v1, T2 v2) => new (T1 or T2)[] { v1, v2 };

(Dog or Cat)[] pets = F(rufus, petunia);
  • generic type arguments として使われた ad hoc union は、関連する2つの union のすべてのメンバー型が対応するメンバーと subtype 関係を持つとき、covariance と contravariance に使用できる
    • 具体的な規則の代わりに「Have Mads write this part」という note が残っている
  • ad hoc union はパターンマッチングで or pattern と似たように動作し、変数宣言も併せて持てる
if (u is Dog or Cat) { ... }

if (u is (Dog or Cat)) { ... }

if (u is (Dog or Cat) pet) {...}
  • ad hoc union 変数に代入すると、値型の boxing が発生することがある
  • conditional expression と switch expression は、構成 expression から ad hoc union の result type を推論できる
Dog rufus = ...;
Cat petunia = ...;
Bird polly = ...;

var u =
      x == 1 ? rufus
    : x == 2 ? petunia
    : polly;
  • lambda expression の戻り値型も、lambda body の戻り値型から作られた ad hoc union として推論できる
    • この場合も値型の boxing が発生することがある

ad hoc union の実装と制限

  • ad hoc union は erasure とランタイム検査で実装される
(A or B) ab = new A(10, "ten");
  • 上のコードは次のように変換される
object ab = new A(10, "ten");
  • 静的に正しさが分からない assignment にはランタイム検査が必要
    • コンパイラは、モジュールで使われた固有の ad hoc union ごとに custom method を生成する
object value = ...;
var ab = (A or B)value;
  • 変換例は次のとおり
object value = ...;
object ab = (value);

object (object? value) =>
    value is A or B ? value : throw ...;
  • method entry では parameters は検査されない
  • metadata には custom attributes を使って ad hoc union 型をエンコードする
void M((A or B) x);
  • 変換例は次のとおり
void M([AdHocUnion([typeof(A), typeof(B)])] object x);
  • attribute の詳細はまだ指定されていない
  • すべての ad hoc union は同じ型に erase されるため、ad hoc union parameter を持つメソッドの true runtime overloading は不可能
public void Wash((Cat or Dog) pet) { ... }
public void Wash((Compact or Sedan) car) { ... }
  • overloading はまだ議論が開かれている領域

Custom unions

  • union class や union struct 構文では指定できない動作が必要な場合、自分で custom class や struct を宣言し、C# がそれを custom union type として認識できるようにできる
  • class hierarchy で union を実装した場合、Closed attribute を付けると union class と同じ exhaustiveness の動作を得られる
[Closed]
public class U { ... }
public class A(int x, string y) : U { ... }
public class B(int z) : U { ... }
  • specialized storage rules を持つ struct wrapper で union を実装した場合、Union attribute を付けて union pattern に従う API を提供すれば、union struct と機能的に同等になる
[Union]
public struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);

    public bool TryGetA(out var A a) { ... }
    public bool TryGetB(out var B b) { ... }
}
  • union が member type を含まない、または別の API pattern を使う場合は、コンパイラが期待する API を extensions で提供できる
  • union struct API pattern 全体はまだ指定されていない
  • ad hoc union は、個々の member type の動作を変更する以外では動作を custom できない

Common unions: Option と Result

  • Option は他言語の同名または同目的の型に似た struct union
    • 値が存在する場合と存在しない場合があることを表す
public union struct Option
{
    Some(TValue value);
    None = default;
}
  • 使用例は次のとおり
Option x = new Some("text");
Option y = None;

if (x is Some(var value)) {...}

var v = x is Some(var value) ? value : 0;
  • Option 型は完全には指定されていない
  • Result も他言語の同名または同目的の型に似た struct union
    • 関数で成功結果またはエラーを返すのに使う
public union struct Result
{
    Success(TValue value);
    Failure(TError error);
}
  • 使用例は次のとおり
Result x = Success("hurray!");
Result y = Failure("boo");

switch (x)
{
    case Success(var value): ...;
    case Failure(var error): ...;
}
  • Result 型も完全には定義されていない

関連提案

  • この提案には、存在すると仮定されている提案、または今後提案される機能が含まれる
  • Closed Hierarchies

    • abstract base type に Closed attribute を適用すると、宣言モジュール内のすべての subtype を閉じた subtype 集合として宣言する
    • 宣言モジュール外で subtype を宣言するとコンパイラ エラーになる
    • closed hierarchy はコンパイラによって網羅的に扱われ、すべての subtype を switch で処理すれば default case は不要になる
  • Singleton values

    • static Singleton property を持つ singleton 型は、non-type context でその property に暗黙的にアクセスして値のように使える
var x = U.C.Singleton;
  • 上のコードは次のように書ける
var x = U.C;
  • Nested Member Shorthand

    • 束縛されていない名前は、target type の static member または nested type に束縛できる
Color color = Color.Red;
  • 上のコードは次のように書ける
Color color = Red;
U u = new U.A(10, "ten");
  • 上のコードは次のように書ける
U u = new A(10, "ten");

Q&A に明記された設計判断と制約

  • union class は、nested record hierarchy を直接簡単に宣言できるなら必須ではないが、簡潔な構文と struct modifier ひとつで union struct に切り替えやすい点が利点
  • union struct は割り当てが少なく、より多くの種類の型を使えるが、あらゆる場合でより適しているわけではない
    • 独自の allocation がなくても、必ずしも高速とは限らない
    • stack footprint が大きく、assignment、pass、return 時に通常はコピーされる
    • anonymous ad hoc union には容易に置き換えられず、相性がよくない
    • boxed 状態や generic type parameter として静的に表現される場合、type tests、casts、pattern matching に問題がある
  • union struct は内部的には tagged union でありつつ type union でもある
    • 内部では tag の役割をする enum property を公開し、コンパイラ生成コードを高速化できる
    • 言語表面では、type tests、casts、pattern matching のような馴染みある方法で扱えるよう type union として見える
  • コンパイラは、union struct member type を union struct 変数へ直接代入する場合、member type の生成を省略する最適化を行える
  • union を直接変数へ分解する場合、union struct の状態変数を member type にコピーする処理も省略される最適化が期待される
  • union struct は union class のように member type の実際の base type ではない
    • struct は実際の継承を許可しない
    • 論理的には自動変換を通じて base type のように動作するが、その関係は type system と runtime 全体には拡張されない
  • ad hoc union は現時点では名前付きで直接宣言できない
    • 長い union の繰り返しを避けたい、または説明用の名前が必要なら global using alias を使う必要がある
  • ad hoc union は値型を boxing する
    • boxing を避ける必要があるなら union struct を使うべき
  • ad hoc union は ref types を含められない
    • ref types が必要なら union struct を使うべき
  • ad hoc union が object に erase される理由は、現在開発者が主に使っている object ベースの解決策を、compile-time type safety と generated validation checks によって改善する形だから
  • ad hoc union の共通 property や method には、各 type case を処理せずに直接アクセスすることはできない
    • 個別の型への変換に成功した後にのみ値へアクセスできる
  • F# の union types はこの仕様の union class と union struct に対応するが、この提案では member を tag state や associated state variables ではなく、言語の型として扱う
    • ad hoc union は TypeScript の type unions に近い
  • Option は、null と nullable reference types で同じ目的を達成できるなら不要かもしれない
    • 一部の開発者は、C# の nullable types より強い強制力を求めて option type を好む
  • C# は現在、OptionResult について F# で可能な monadic behaviors を言語に含めていない
  • Result の多くのユースケースは C# の exception handling で解決できる
    • ただし、ランタイムでエラーが予想され、しかも頻繁に発生する場合には、exception handling を避けて呼び出し側にエラーを明示的に扱わせたいことがある
  • OptionResult に類似した型はサードパーティ ライブラリにすでに存在するが、複数の開発者が、ライブラリ間の interoperability のためにランタイムに標準化された型を含めてほしいと要望している

1件のコメント

 
GN⁺ 2024-08-09
Hacker Newsのコメント
  • F#で判別共用体を何年か使ってきたので、C#にももう当然あるものだと思っていた。
    みんなが好きな機能というわけではないだろうが、型付き言語ではどんな形であれ**代数的データ型(ADT)**のない言語に戻るのは本当につらい。今はJavaを使っていて概ね問題ないが、F#なら3行で済むことをラッパークラスで回避しなければならない時はかなり煩わしい

    • F#は一度慣れると戻るのが本当に難しい。Microsoftがもっときちんと支援して後押ししてくれたらと思う。
      純粋関数のような制約に縛られずに関数型の利点を多く取り込んだ絶妙なバランスがあり、開発しやすい。その一方でC#をとてもゆっくりF#のように変えていっていて、それを見るのも不思議な感じだ
    • 良いニュースとしては、最近のJavaでもsealed interfaceとrecordによって比較的少ないボイラープレートで実現できることだ。
      あくまでJava基準では、だが
    • 2020年にF#と.NETをやめた時点では、CLRには継承しかなく、F#のenumも実際には継承で実装されていた。
      たとえばSomeNoneOptionクラスの派生クラスで、F#アセンブリを逆コンパイラで見ると確認できた。今も同じかは分からないが、このC#提案はA or B構文の匿名enumまで含んでおり、継承の上のシンタックスシュガーだけで作るのは難しそうだ。なので、これを動かすにはCLRがenumを第一級でサポートする必要がありそうだ
    • ちなみにJavaにはバージョン16からタグ付きユニオン型がある。
      パターンマッチングもある
    • Javaはすでに数年前からADTを持っているので、「3行で済むことをラッパークラスで回避する」というのは少し変に聞こえる。
      Records https://en.wikipedia.org/wiki/Java_version_history#Java_16
      Sealed classes https://en.wikipedia.org/wiki/Java_version_history#Java_17
      そしてほぼ1年前からpattern matchingもある https://en.wikipedia.org/wiki/Java_version_history#Java_21
      ただし、F#で開発する楽しさには大いに同意する
  • この提案には本当に期待している。C#の長所を語る時に、いつも弁解するように付け加えなければならなかった最大の欠落機能だった。
    これ以外にC#に欠けている大きな言語機能は思い浮かばない。これが入っても、HNでは相変わらずC#を10年前と同じ言語のように扱う様子が見られそうで、それも楽しみだ

    • ちゃんとした型エイリアスもあればうれしいが、最近のC#コーディングは本当に楽しい
    • 良くも悪くも、C#の設計目標は可能な限りあらゆる機能を、それも複数のバリエーションで入れることのように見える。
      そのため誰にでも合わせられ、強い主張を持つ言語のように人を遠ざけはしないが、最初に学ぶのは少し難しくなるかもしれない
  • なぜこれをtype unionsと呼ぶのか説明してもらえるだろうか。こういう名前は初めて聞いた。
    ALGOL68のような型同士のunionではなく、ML系言語のtagged unionのように見える。C#開発者が既存の用語の代わりに別の名前を作って使う例なのかとも思う。SelectManyIEnumerableのように

    • 「tagged」は内部に型を区別するタグがあるという実装の詳細のように聞こえる。
      おそらく「union」の前に「type」を付けて、型に関するものだと明確にしたかったのだろう。文書上の構文自体は単にunionのようだ。FAQにはこうある。
      Q: Why are there no tagged unions?
      A: Union structs are both tagged unions and type unions. Under the hood, a union struct is a tagged union, even exposing an enum property that is the tag to enable faster compiler generated code, but in the language it is presented as a type union to allow you to interact with it in familiar ways, like type tests, casts and pattern matching.
    • この提案が複数種類のunionを扱っているからだ。
      コンパイラがビルド時に検証する参照型の閉じた階層、閉じた階層のように振る舞うようコンパイラが処理する値型のタグ付きユニオン、任意の実装を持ちながら同じコンパイラ機構に接続できるユーザー定義型、そして既存型のad-hoc unionまで含まれる
  • red/blue/white/black pill のような色のメタファーは全部外しているとしても、網羅的パターンマッチングのある union は、一度その良さを知ると、ない状態では最もつらい言語機能の一つだと思う。
    expression problem の含意を完全に理解したと感じたことはないが、今の仮説はこうだ。従来の多相性で拡張ポイントを提供するやり方は、私の知らない将来のクライアントがコードを拡張するときに向いていて、網羅的パターンマッチングのある union は、私やチームが所有するコードによりよく合う。たいてい、そのコードを外部から拡張したいというよりは、ビジネスドメインの理解の変化に合わせて中核データ構造を更新し、その後で命令型コードがもはやドメイン構造と一致しなくなった箇所を、できるだけコンパイラエラーとして受け取りたい。

    • その違いを理解するためにこの記事を書いた。
      https://deliberate-software.com/christmas-f-number-polymorph...
    • expression problem の核心は、オブジェクト指向と関数型でコード拡張性を得る方法の対比として説明できると思う。
      オブジェクト指向のインターフェース/継承の方式では、基底クラスやインターフェースに新しい型バリアントを追加するのは簡単だが、新しい機能を追加するには既存のすべての型に実装しなければならないので難しい。関数型の 判別共用体 の方式では、新しい関数を作って union に対してマッチさせれば、コンパイラがすべてのケース処理を保証するので新機能の追加は簡単だが、新しい型バリアントを追加するとコードベース全体の網羅的パターンマッチングを更新しなければならないので難しい。Kotlin は両方の方式をかなりうまくサポートしているので、例として適している。
  • 用語が少しずれていないか? TypeScript には union types があるはず。
    ただ、これは F# や Haskell で見ていた discriminated union のように見える。判別共用体は、名前付きの case constructor がある点が違いだと思う。

    • TypeScript には “union types” があり、この提案はそれを ad hoc unions と呼んでいるようだ。
      “type union” は少なくとも私が聞いたことのある正式用語ではない。C# 側で sum type を説明するために作った表現のように見える。
    • C# から TypeScript の type union に移ると、いつも違和感があった。
      これをここまで先送りしてきたのには、それなりの理由があったのかもしれない。対象ユーザーを見極める必要があるからだ。時間がたてば慣れはするが、A|B|undefined が多すぎると読むのがしんどい。さらに、もう一つ受け取って三つ以上の組み合わせを雑に返すような怠慢も生まれがちで、呼び出しスタックを上がるほど混乱が増していく。
      C# がこれを 制約された形 で扱わせようとしている点は気に入っている。ただ、この機能を求める側に説得力のある論理があるなら聞いてみたい。
  • 長く C# を使ってきたが、この提案では何かを見落としている気がする。ユースケースがあまり明確に見えないので、現実的な例を挙げてもらえるだろうか?
    提案書の例は、空のインターフェースを宣言し、いくつかの record クラスにそれを「実装」させれば実現できそうに見える。そうした場合に何を失うのかがよくわからない。

    • そのような階層は、処理時にすべてのケースを扱ったかどうかわからないという意味で 開いた階層 だ。
      誰でも、たとえ私のコードの外であっても、そのインターフェースの新しい実装を作れる。また、選択肢どうしが共通の表面をまったく共有しないこともあり得るので、すべての型の「インターフェース」が空になってしまい、オブジェクト指向ではやや不自然だ。内部的には結局型階層だが、拡張が閉じていて、コードが case を使うときに漏れた case があれば コンパイル時エラー になる、という点が手動実装との本質的な違いだ。
    • 最も現実的な例は AST やデータプロトコルだ。
      JSON を例にすると、JsonValue クラス一つにデータを詰め込む代わりに、文字列、ブール値、数値、union 型の配列、文字列キーとそのような union 型の値を持つマップのいずれかである union 型として作れる。Rust の Result のような結果型も実装できるので、API が値またはエラーを返すと定義できる。提案書でジェネリクスを扱っていたかどうかは見ていない。関数型プログラミングの世界では主に algebraic data type と呼ばれていて、こういう型モデリングに慣れると、サポートしていない言語では本当に恋しくなる。
      [1] https://en.m.wikipedia.org/wiki/Algebraic_data_type
    • このライブラリは F# スタイルの 判別共用体 を実装している。
      [0] https://github.com/mcintyre321/OneOf
      外側の階層で DTO やエラーなどをひとまとめにした Result 型を返すようにするときに使った。結果に対してパターンマッチできるのでエラー処理がしやすくなり、境界の関数シグネチャを変えずに、時間とともにモデルを進化させられる。
    • この5年以上、空のインターフェースで F# の判別共用体をまねてきたが、とてもうまく動いていた。
      「switch ですべての case を処理していなかった」という問題はごくまれで、およそ5万行に1回くらいしか起きず、たいてい最初のスモークテスト実行後に修正できた。だから大きな問題ではなかった。.NET チームがいまだに判別共用体を追加していない理由も、インターフェースで十分効果的にまねできるからだと思う。
    • 単純な例は、Ok(T value) または Error(string message) のどちらかになりうる Result だ。
      値を取り出すには両方のケースを switch しなければならないので、その場でエラーケースを処理することが強制される。
  • covariance / contravariance の項目の下に “Note: Have Mads write this part.” と書いてあって笑った :)

    • ここでいう Mads は、C# の主任設計者である Mads Torgersen のことだ。
  • 「union struct の内部レイアウトは、可能なメンバー型内のデータを効率的に格納できるよう、コンパイラが速度とサイズのトレードオフを選択する」という記述がある。
    FieldOffset で C# の union 黒魔術をやりすぎて痛い目を見た立場からすると、ここには残念な問題がある。ポインタ / ref 値と値型を aliasing するのは UB だ。つまり u64object の struct union では、それぞれ別個のフィールドが必要になり、8 バイト無駄になる。ryujit / GC がこれを理解するよう更新されない限りそうなる

    • UB ではなく 不正。ECMA 335, II.10.7 によれば、このようにフィールドを重ねること自体はできるが、オブジェクト参照が占めるオフセットは、組み込み値型や他のオブジェクト参照の一部が占めるオフセットと重なってはならない。
      .NET 8.0 では、FieldOffset(0)UInt64 FooObject Bar を一緒に置いたコードはコンパイルされるが、ロード時に System.TypeLoadException を投げる。メッセージは、offset 0 の object field が non-object field と不正に整列しているか重なっている、という内容。興味深いことに、AOT コンパイラはこのメソッドが常に throw すると警告するが、C# コンパイラは何の警告も出さない
  • C# がより良いオブジェクト指向言語になる代わりに、ますます醜い F# になろうとしているようで残念だ。
    たとえば 多重ディスパッチ の構文は、なぜ今でもこんなに無骨なのか。擬似 OO が世界を支配し、人々がそれに反発したので、本当にオブジェクト指向をうまくやろうとするより、「波括弧のある少し関数型な言語」になるほうが存在感を保ちやすかったのだろう、というのは分かるが

    • 逆側からも同じ不満がある。主流言語は昔から広範な可変状態と副作用を持っていて、今はそこに ラムダ が追加されたにすぎない。
      ではオブジェクト指向側では何が欠けているのか。C# や他の言語が「オブジェクト指向をうまくやっている」状態になるには何が必要なのだろうか?
    • ここでいう「より良いオブジェクト指向」とは何を意味するのか。
      どんな概念や機能を念頭に置いているのか気になる
  • 現在は private コンストラクタを持つネストした record と、NuGet パッケージ https://github.com/shuebner/ClosedTypeHierarchyDiagnosticSup... を組み合わせて、switch 型で _ case が不要であることを保証している。
    本質的にはこの提案の「Union Classes」を 糖衣構文を取り除いた バージョンで、すでにかなりうまく動作している。それでも NuGet パッケージが不要になり、構文糖衣も手に入るならうれしいので、この提案は気に入っている

    • record 型は private コンストラクタがあっても閉じていない点に注意が必要。
      コンパイラがコピー操作のための protected コンストラクタを生成し、そのコンストラクタは継承できる。これを防ぐには、自分で protected コンストラクタを定義し、有効な case でなければランタイム例外を投げるようにする必要がある