先月書いた通り、C# 9.0 がらみはほぼ確定(バグ修正レベルの変更しかしない状態)になっています。

(そういえばライブ配信はやったもののブログ化していなかった話題として、.NET 5.0 の RC 1 到達というのもあります。 RC (リリース候補)が付くと、もう大きな変更はできません。 あと、.NET Conf のページに「.NET Conf 2020 は11月10日開始」、「.NET 5 launch」の文字が入ったので、.NET 5.0 のリリース日も決まりました。アメリカ西海岸時間で11月10日なので、日本だと11月11日に朝起きたらリリースされているくらいのタイミング。)

そうなると、今デザイン作業が行われているのはすでにその次、C# 10.0 の話になります。

ということでここ2週間ほどの C# の Language Design Meeting はC# 10.0 がらみが議題になります。

10/8 に1件、10/7 日付の議事録の話を追記:

一番大きな話題は C# 9.0 で導入されるレコードに関するものです。 C# 9.0 時点では仕様を詰め切れていなくて「9.0 リリース後に再検討」となっていた項目がいくつかあって、 この2週ほどのミーティングではまさにその再検討な話が結構な割合を占めています。

ちょっと長くなりそうなので、今日はこのレコード関連の話だけを書こうかと思います。

他に、構造体(特に ref フィールド、ref 構造体の改善など)の話題とか、細かいトリアージ作業とかもあったりするんですが、この辺りは後日改めて。

前提知識: C# 9.0 レコード型

レコード型は、以下のように record キーワードを使って宣言する新しい型で、

record Point(int X, int Y);

内部的には以下のようなクラスの生成になります。

class Point
{
    public int X { get; init; }
    public int Y { get; init; }
    public Point(int X, int Y) // X, Y プロパティに代入
    public void Deconstruct(out int X, out int Y) // X, Y プロパティから値取得
    public override bool Equals(object? obj) // X, Y の値の比較
    public override int GetHashCode() // X, Y からハッシュ値生成
    public override string ToString() // Point { X = ... } の書式で文字列化
    public Point Clone() // shallow コピー (実際には通常の C# から参照できない名前で生成)
}

いくつかのとらえ方がありますが、以下のようなものとして説明されます。

  • プレーンなデータを簡潔に書けるようにするための型
  • 匿名型 (new { X = 1, Y = 2 } みたいなやつ)の名前付き版
  • value semantics (値による比較やコピー生成)を持つクラス
    • C# の場合は構造体が元から value semantics 的な挙動を持っているので、「レコードは構造体的な性質を持つ参照型」ともいえる

ちなみに、この手の型は immutable にしないとまずかったりします。 わかりやすくまずいのは例えば以下のような場合。 ハッシュ値が変わってしまうことで HashSetDictionary の挙動を壊します。

using System;
using System.Collections.Generic;
 
var p = new Point { X = 1, Y = 2 };
 
// HashSet (ハッシュ値で等値比較してる)にインスタンスを渡す
HashSet<Point> set = new();
set.Add(p);
 
// その後、値を書き換え
p.X = 3;
 
// ハッシュ値が変わってしまってるので判定が狂う
Console.WriteLine(set.Contains(p)); // false
 
// Remove もできなくなる
set.Remove(p);
Console.WriteLine(set.Count); // Remove できてないので 1 が返る
 
class Point
{
    public int X { get; set; }
    public int Y { get; set; }
    public bool Equals(Point other) => (X, Y) == (other.X, other.Y);
    public override bool Equals(object? obj) => obj is Point other && Equals(other);
    public override int GetHashCode() => X ^ Y;
}

レコード型から生成されるクラスの例に init というキーワードが入っていますが、 これも C# 9.0 の新機能で、プロパティがオブジェクト初期化子までは書き換え可能、その後は書き換え不能になるという機能です。 既存のプロパティと比べて、

  • set 可能プロパティ(int X { get; set; } みたいなの): どこでも書き換えできる
  • get-only プロパティ (int X { get; } みたいなの): コンストラクター内でだけ書き換えできる
  • init プロパティ (int X { get; init; } みたいなの): コンストラクター内とオブジェクト初期化子でだけ書き換えできる
var p = new Point
{
    Settable = 1, // OK
    GetOnly = 1,  // ✖
    Init = 1,     // OK
};
 
p.Settable = 1; // OK
p.GetOnly = 1; // ✖
p.Init = 1; // ✖
 
class Point
{
    public int Settable { get; set; }
    public int GetOnly { get; }
    public int Init { get; init; }
 
    public Point()
    {
        Settable = 1; // OK
        GetOnly = 1;  // OK
        Init = 1;     // OK
    }
}

というものです。 C# の場合、初期化子が C# 3.0 からの後付けなせいでちょっと使いにくかったんですが、その改善案になります。

immutable なデータを書き換えて使いたい場合、 shallow コピーを作ってからそのコピーの方を書き換えるというのが推奨される方式になります。 これに関しても C# 9.0 で「with 式」という新しい文法が追加されていて、 以下のような書き方でコピー& init プロパティの書き換えができます。

using System;
 
var p1 = new Point(1, 2);
var p2 = p1 with { X = 3 };
 
Console.WriteLine(p1); // Point { X = 1, Y = 2 } (元のまま)
Console.WriteLine(p2); // Point { X = 3, Y = 2 } (新インスタンスで X が書き換わってる)
 
record Point(int X, int Y);

C# 10.0 に持ち越されたレコード関連議題

いくつか、レコードにはいくつか議題が残っていて、「10.0 で改めて検討」となっているものがあります。

  • レコードは参照型である
    • 値型版 (仮称 record struct)をどうするか
  • 既存の構造体との兼ね合い
    • 「record struct」を新設すべきなのか、既存の構造体に手を入れるべきなのか
    • プロパティ生成とかはしないとしても、既存の構造体の時点で with 式を使える条件はそろってるはず
  • プライマリ コンストラクター
    • 通常のクラスにも class Point(int X, int Y) みたいな書き方を認めたい ‐ その場合、単にコンストラクターの簡易記法であってプロパティなどのコンパイラー生成はしない

以下、 9/3010/510/7での検討事項。

構造体の等値比較

レコードでは「クラスに対して Equals メソッドをコンパイラー生成する」という仕組みで「値比較」を実現しています。

構造体の場合、object.Equals(object) の中で、.NET ランタイムが「値比較」に相当する処理を行っています。 なので、挙動としてはレコードと構造体の Equals はどちらも同じ「値比較」なんですが、 現状の構造体の Equals はちょっとパフォーマンスが悪いです。 これは、Equals(object)を介しているせいでボックス化が起こるのと、.NET ランタイム内での処理がリフレクション的になっているからです。

そこで、レコードと同じく構造体に対してもコンパイラー生成で「値比較」の Equals メソッドを生成すべきかどうかというのが議題になっていました。

これに関しては以下のような結論。

  • コンパイラー生成の Equals は作らない方がいい
    • コンパイラー生成してしまうとコンパイル結果のバイナリ サイズが膨らむ
    • 既存の構造体に対して Equals 生成すると既存コードを壊す ‐ かといって、新しい「record struct (仮)」だけが高パフォーマンスみたいな状態になると、既存の構造体が忌むべきものになってしまう(それは望まない)
  • 既存の構造体と record struct (仮)は明確に別
    • プライマリ コンストラクターからのプロパティ生成、型付きの == 演算子・Equalsメソッド生成、IEquatable<T> 実装するのは record struct (仮)だけ
  • .NET ランタイムのレベルで構造体の Equals(object) を最適化すべき

構造体に対する with 式

構造体は元から shallow コピーを持っている(単に代入するだけでコピー発生。.NET の中間言語的にも dup 命令ってのを持ってて、1命令でコピーになる)ので、with 式を使える条件を満たしています。

また、C# 9.0 で入るレコード(クラスで生成されるやつ)は、現状、コピーのカスタマイズ性がない(通常の C# からは参照できない($Clone<>みたいな)名前でコピー メソッドが生成されていて、手書きでの上書きができないようにしてある)状態です。 これは「将来改めて検討する」ということになっていて、とりあえず、カスタマイズ性がある状態からない状態には戻せないけれど、できないものをできるようにすることは簡単だからいったん「ない」仕様にしてあります。

これに対して、C# 10.0 では以下のような方針(決定ではない)で進めていきたいようです。

  • すべての構造体は with 式利用可能にする
  • ただ、既存の構造体は with 時のコピーのカスタマイズ性は提供しない
    • デフォルト動作の「dup 命令でコピー」を常に使う
  • record struct (仮) の場合は、レコード (9.0 で入るクラスのやつ)と合わせて再検討することになるけども… ‐ record struct (仮)のコピーのカスタマイズは認めない方がよさそう
    • でないと、ジェネリック型引数で where T : struct なものの挙動がおかしくなりそう

プライマリ コストラクター

C# 9.0 のレコードでは、プライマリ コンストラクターの引数(record Point(int X, int Y)XY)から public な init プロパティ(public int X { get; init; } とか)が生成されます。

C# 10.0 で検討している record struct (仮) でも同様であるべきかという話があります。

  • record (参照型のレコード) と record struct (値型のレコード)という見方をすると、 同じ挙動であった方がいい
  • 「record は名前付きの匿名型」に対して、「タプルの名前付き版」が欲しいという話もあって、record struct (仮) をその位置に据えたいという見方もある
    • この場合、タプルのメンバーは public フィールドになっているので、record struct (仮)は public フィールドを生成した方が合う
  • immutable でないと問題を起こすのは参照型だけ
    • 値型の場合は代入で常にコピーが作られるので前述の HashSet みたいな問題を起こさない

そして検討の結果、現状、record struct (仮)に関しては以下のような方向性になりそうみたいです。

  • デフォルトで public で mutable なプロパティ(public int X { get; set;} みたいなの)を生成する
  • 手書きでカスタマイズ可能なので必要であれば `public int X { get; init; } を自分で足してもらう
  • あるいは構造体の場合元からreadonly structがあるので、immutable にしたければ readonly record struct Point(int X, int Y) みたいに書いてもらう
  • C# 9.0 時点のレコードには「プロパティかフィールドか」のカスタマイズ権はない(プロパティでないとダメ)ので、フィールドでも上書きできるように変更する

record struct

10/8 追記:

record struct (仮) と、(仮) を付けて書いていたのは、具体的な文法をどうするかが決まっていなかったからです。 10/5 のものまでは、原文でも theoretical (理論上の)とか hypothetical (仮説上の)という前置き付きで record structs と言っていました。

10/7 で具体的な文法が検討されて、以下のような方向性になりました。

  • record struct (構造体に record 修飾)と struct record (レコードに struct 修飾)だと前者の方を選ぶメンバーが多かった
  • となると、record 修飾子と考える方が自然
  • record class も認めたい
  • record は、そのうちよく使う方の record class の短縮形だという風に考えたい
  • なので、record struct という文法を足すと同時に、record class も認める

data メンバー

プライマリ コンストラクター (record Point(int X, int Y) みたいなの)からのプロパティ生成は、 常にコンストラクター生成がセットで、コンストラクターでの初期化が前提になります。

必然的に以下のような書き方になって、コンストラクター呼び出しには引数順序に意味があるので、 これを「位置によるレコード」(positional record)と呼んだりします。

var p = new Point(1, 2);

これに対して、init プロパティだけを書いて、

record Point
{
    public int X { get; init; }
    public int Y { get; init; }
}

オブジェクト初期化子を前提にした書き方をすることもできます。 こちらはプロパティ名指定が必須で、逆に順序には意味がなくなるので、「名前によるレコード」(nominal record)と呼んだりします。

var p = new Point { X = 1, Y = 2 };

これはこれで便利なんですが、レコード型の「プレーンなデータを簡潔に書けるようにする」という目的からすると、 public int X { get; init; }という書き方はちょっと煩雑過ぎます。

そこで提案されているのが data メンバーで、以下のようなコードから public int X { get; init; } をコンパイラー生成したいというものです。

record Point
{
    data int X;
    data int Y;
}

この案自体はちょっと前からあって、単純に案が出たのがギリギリ過ぎて C# 9.0 には入れなかったという状態です。

ここで改めて record struct (仮)が議題になるんですが、

  • プライマリ コンストラクターからのプロパティ生成の仕方が違うけど、data メンバーの場合はどうするべきか
    • C# 9.0 の参照型レコードは immutable (get; init;)
    • record struct (仮) は mutable (get; set;)

という問題があります。

ここはまだだいぶ悩んでいるようで、以下の3案が全部まだ候補だそうです。

  • positional に合わせるべきで、値型の場合は mutable (get; set;)、参照型の場合は immutable (get; init;)
  • data の挙動は一致しているべきで、値型だろうと参照型だろうと immutable (get; init;)
  • data メンバーという提案自体をあきらめる

10/8 追記:

10/7 では「public int X { get; init; }data int X に縮める」という案が本当に有効かどうか、具体的なシナリオを検討したみたいです。

  • discriminated union を考えるとき、例1みたいなのには魅力を感じるけども、例2みたいなのはいまいちで、だったら data メンバーはそんなに「求めていたもの」じゃない
// 例1: discriminated union (仮) として単一行メンバーなら書きたいモチベーションになる
record Union
{
    A;
    B(int X);
}
// 例2: data メンバーを使って書く場合複数行に。これは魅力的か?
record Union
{
    A;
    B
    {
        data int X;
    }
}
  • required property を考えるとき、data メンバーが required (オブジェクト初期化子で値を渡すことが必須)かどうかを変更できる追加のキーワードが必要(なので、記述が短くならないか、もしくは、data とは別のさらに追加のキーワードが必要)
  • 通常のクラスにもプライマリ コンストラクターを定義できるように当たって、class X(int X, data int Y) みたいな書き方で、「X は単なるパラメーター、Y はレコードと同じくパラメーターからのプロパティなどの生成を行う」みたいなキーワードにしたいという案もある

この辺りが悩ましくて、C# 10.0 のタイミングでは data メンバーはやめておこうという雰囲気みたいです。 discriminated union、required property、通常クラスのプライマリ コンストラクターの3つはいずれも C# 10.0 で検討している機能で、それが具体的に決まった後でないと、data キーワードの有効性がどうなるかわからないということで、C# 10.0 の後に再検討した方がいいだろうという感じ。