去年の3月にブログに書いたものの続報。

C# でも限定的に破壊的変更を許していこうかという話だったわけですが、 ちょっと具体化しました。

ある機能を実現するにあたって破壊的変更の原則と進め方についての話をしています。

破壊的変更の候補

C# 13 で導入したい field アクセス(自動プロパティのバッキングフィールドにアクセスするための field キーワード)と、 これまでに破壊的変更を避けるためにちょっと変な設計になっている var (型推論変数宣言)、_ (discard)が検討の対象になっています。

破壊的変更を認める基準

  1. あくまで控えめな破壊的変更で、エンドユーザーに明確なメリットがある
  2. 破壊的変更を踏むようなコードは割かしレア
  3. 破壊的変更を起こす予定のコードはどういう理由でどこが問題で、どう直せばいいかが明確に示せる
  4. 破壊的変更を避けられるよう、完全に自動で、簡単で、堅牢で、局所的な code-fix が提供できる

field の場合

field アクセスは以下のような話。

class こういうのを
{
    private int _x;
    public int X
    {
        get => _x; set => _x = int.Min(value, 0);
    }
}

class こう書きたい // C# 13 候補
{
    public int X
    {
        get => field; set => field = int.Min(value, 0);
    }
}

class こういうコードで困る
{
    private int field;
    // ↑「このフィールドがないときだけ field をキーワード扱いする」みたいなことすると使い勝手が悪くなる。

    public int X
    {
        get => field; set => field = int.Min(value, 0);
    }
}

これは以下のように、前述の基準を満たします。

  1. field アクセスが欲しいという要望は多い。「field フィールドがないときだけ」とやると構文が複雑になるし、使い勝手も悪くなる
  2. field という名前のフィールド」はなくはないだろうけども多くはないし、問題になるのはプロパティのアクセサー内だけ
  3. field が将来キーワードになる」(から使うな)という明確な説明ができる
  4. 型名や this を付けて A.field とか this.field と書くように変えればいい

var の場合

C# 3.0 の頃からある varですが、 有名な話、「class var { } とかいう型をどこかに書いておけば、型推論の var を阻害できる」という問題があります。

// 普通は型推論の var になるはず。
var x = 1;

// が、こういうことをすると var x の意味が変わってしまう。
class var
{
    public static implicit operator var(int _) => 0;
}

嫌がらせでしかないんですが、 昔は「型推論とか怖いから嫌がらせしてやれ」と言っちゃう人が実際いたとか…

今でも「型推論は嫌」ということをもう人はいるとは思いますが、 その場合も今はソースコード分析の設定を変えて警告なりエラーにできるようになっているので「class var { }」みたいな変なことをする必要はありません。

なので、もう今となってはこれも破壊的変更してでも「var は常に型推論」にしてしまっていいのではないかという話になります。

これについての前述の基準:

  1. class var { }みたいなものは実用的じゃない。その割に var を常にキーワード扱いできないのは構文ハイライトとかで結構困る
  2. 嫌がらせ以外で「class var { }」を書く人もいない
  3. var という名前の型は作るな」と説明できる
  4. もしどうしても「var 型」を作りたければ @var と書けばいい

_ の場合

C# 7 で discard が導入されたわけですが、 これも「_ を普通に変数として使っていないときに限り、_ が discard の意味になる」という挙動になっています。

void m1(int i, string s)
{
    // これはいずれも discard。
    (_, string _) = (i, s);
    int.TryParse(s, out _);
}

void m2(int i, string s)
{
    var _ = i; // これがあるせいで…

    (_, string _) = (i, s); // ここの1個目の _ は変数。
    int.TryParse(s, out _); // ここの _ は変数。
}

void m3(int i, string s)
{
    var _ = i;
    var _ = s; // これは「同じ名前の変数がすでにある」エラー。

    (_, string _) = (i, s);
    int.TryParse(s, out _);
}

これについての前述の基準:

  1. 今のままだといつ _ が discard になるかわかりにくすぎる
  2. 元々 _ を変数・引数として使っていた人も、「値を特に読まない」(なのでほんとは discard にしたい)という意味でこの名前を使うことが多い
  3. _ が常に discard の意味になる」と説明できる
  4. @_ と書けば「_ という名前の変数」を書ける

破壊的変更の影響を軽減

破壊的変更に対応しやすくするため、 C# N に対応したコンパイラーを使ったとき、 まだ C# N - 1 以下だった場合に警告と code-fix を提供したいとのこと。

現在、LangVersion を明示しなかった場合、 .NET SDK が「TargetFramework に応じた言語バージョンを自動選択する」という挙動になっています。

なので、例えば以下のような流れで比較的安全にバージョンアップができます。

  • .NET 9 SDK をインストールすると C# 13 対応コンパイラーになる
  • この時点で既存のプロジェクトは net8.0 とかがターゲットになっているはずで、C# 12 が選ばれる
  • 「C# 13 対応コンパイラーで C# 12 を利用」状態なので、field に関する警告が出る
  • 警告を直してから net9.0 ターゲットに上げると安全にバージョンアップができる

ただ、この手の警告の追加自体が破壊的変更 (警告は必ず取る方針であったり、なんなら WarnAsError オプションでエラーにできる)なので、 年に1回のメジャーバージョンアップ時以外には警告追加しないとのこと。

C# のバージョンアップを予定していない人向けに警告抑止の手段の提供や、 もしかしたら「先送りはするけどいつかバージョンアップしたい」人向けに「30日だけ警告を止める」みたいな手段を提供するのがいいかもしれないという話も出ています。

LangVersion latest, preview

LangVersion latest にすると、 常に C# コンパイラーが対応している最新の C# バージョンになります。 こうなると先ほどの「C# N 対応コンパイラーで C# N - 1」という状態が起きなくなるので、 「言語バージョンアップ前に破壊的変更を修正」ということができません。 なので、latest は今後非推奨にして、使っていたら警告を出すことを検討しているそうです。

一方で、LangVersion preview はわかってて使っている人柱向けですし、 プレビュー提供している言語機能は普通にリリースまでに破壊的変更がかかることもあって、 元から破壊的変更は覚悟の上で使っているはずです。 なので、preview に対しては特に問題視はしないそうです。