Design Notes の一斉アップロード祭りがらみ、今日でやっと最後。 先月28日に1つ追加の Note もあります

とりあえずこれまでの分:

で、今日は最後に、C# 8.0 辺りで入りそうな機能の話になります。

Caller Expression Attribute

Caller Info 属性に1種類追加。 M(2 * x, y * y, Sin(z)); みたいなメソッド呼び出しをした時に、2 * x, y * y, Sin(z) の部分をそのまま受け取るというデバッグ用の機能です。 主な用途は Assert 系の API。

で、既存の Assert API がこの機能を活用しようと思ったら、 Equal(T expected, T actual) みたいなメソッドを Equal(T expected, T actual, [CallerExpression]string expression = null) みたいなシグネチャに変える必要があります。

こいつの呼び出し側は、元のまま Assert.Equal(x, y); で呼べばいいんですが…

  • 既存の Equal(T expected, T actual) を残すと、こっちの方が優先度が高いのでこっちしか呼ばれない
  • 既存の Equal(T expected, T actual) を消すと、ソースコード互換はあるけどバイナリ互換はない(一応、破壊的変更)
    • ソースコードの再コンパイルなしで、Assert ライブラリの DLL だけ更新すると実行できなくなる

という問題が。 XUnit は昔この手の引数追加での破壊的変更を1度やっているらしく、まあ、許容できると言えばできそう。

null 許容参照型

このブログでもたびたび紹介していますが、参照型も T (null なし)とT? (null あり)の区別がつくようになるやつ。

待望の機能ですし、すでにプロトタイプ実装も始まっていますが、やっぱ細々と検討事項が出てきます。

明示的なキャストの扱い

(T)x とか (T?)x とか、キャストをどう扱うかというのも案外自明ではないみたい。 特に、既存コードからの移行の都合と、新たに書き始めるのであれば追求したい「正しさ」との間のバランスで悩むとか。

  • 既存コードには (string)null とかで「型指定ありの null」とかは結構書くけども
    • これは自動的にstring?扱いすべきか(既存コード優先)
    • 警告すべきか(正しさ優先)

M((string)x)M((string?)x) みたいなキャストは、ジェネリック メソッドの呼び分けでははっきりとキャストが意味を持ってる。 例えばT M<T>(T x) に対して、 var y = M((string)x) とすると ystring (nullなし)になるし、 var y = M((string?)x) とすると ystring? (nullあり)になる。

一方で、Mが旧時代のコード(TT? の区別をしない時代のコードでは、T から null が帰ってくることがあるし、 コンパイラーもそのつもりで null チェックをする)だった場合、 M((string)x) はどう解釈すべきか。 x が新時代(T?あり)コードなら警告でいいけども、 x も旧時代コードならこのstringはどう扱うべきか。

警告の種類

古いコードをアップグレードするとき、一気にはコードを修正できないので #pragma warning などを使って警告を無視したいことがあり得る。 この「警告オフ」作業を煩雑にしないために、null チェック絡みの警告の種類はまとめられる限りまとめたい。

実際、「null リテラルを T に代入」と、「T? 型変数の値を T に代入」は統合したみたいです。

型推論

T? 型の変数であっても、null チェックがあったらそれ以降は「null ではない」という扱いになります (コンパイラーがそういう風に判断して警告の有無が決まる)。

例えば、以下のような判定になったりします。

string? ns = ...
if (ns is null) return; // null だったらここから下にはいかない
var s = ns; // なので、s は「非 null」

ここで問題なのは、じゃあ、この s は「string?だけどnullチェック済み」という判定なのか、 「string型として推論されてる」という状態なのか。 ジェネリック メソッドに s を渡した時のオーバーロード解決とかにも絡んでくる。

var?

var s1 ="Hello"; とか書いたとき(string扱い?)、s1 に後から null を代入したい場面ではどうするべきか。

var s2 = (string?)"Hello"; は、「varと書くと非nullとして推論」として認めないようにするか、string?として推論すべきか。

varと書くと非null」の対として、var? s3 = "Hello"; みたいな書き方を用意する?

Records

C# 6.0の頃から案だけはあるものの、気が付けば C# 7.X でも入らず 8.0 に伸びてる Record 型。 class Record(int X, int Y); とか書くと、プロパティ XY とか ==Deconstruct を自動で実装してくれるというやつです。

先月の Microsoft MVP Global Summit でやっぱ MVP からさんざん突っ込みが入ったそうで。それに対する回答:

  • 重要性はわかっているけども、現実主義的に行きたい(要するに、課題も多くて完成には及ばず)
  • Discriminated Unionsと併せて取り組みたい。別機能ではあるものの、Records と一緒に取り組むとより良くなる
  • いくつか「用途が狭い」と判定された提案は「コード生成でやってくれ」と言ってきてるが、Records に関して言うとコード生成の領分ではない。ちゃんと言語機能として取り組むべき

Ranges

28日に追加分でも Ranges に関する記述あり。

プロトタイプ提供するにあたって

Ranges 構文はあくまで Range 型を作るという機能であって、Span<T> span = array[1..3]; みたいな書き方をするためには、配列やListRange型を受け付けるインデクサーを持っていないといけない。

で、この機能のプロトタイプを提供するにあたって、こういう「Range型を受け付けるインデクサー」も同時にプロトタイプ提供したい。 特に、配列とかstringとかの型でこれを使いたいわけだけど、そのために .NET Core ランタイム側で対応作業をしてもらう必要があるとなると、 プロジェクト間の依存が大きすぎる。

Span<T> this[Range range] インデクサーを被せるだけのラッパー構造体を作ってそれをプロトタイプ提供すべき?

それか、一時的に「拡張インデクサー」を提供してしまう? (拡張インデクサーは、Shapesって機能の一部として提供したけども、 こいつは 8.0 よりもさらに先で検討されてる。Ranges の方が先に実装される。なので、本来 Ranges と同時期には提供しないはずの機能を「一時的に」「仮実装」で提供。)

今のところ「一時的な拡張インデクサー」の提供を考えてる。

どこまで提供するか

結局どこまでやるか。

  • m..n で「m から n まで」みたいな開始・終了インデックス型だけを提供
  • 「m から len 要素」みたいなやつや、「配列の末尾から n 要素目」みたいなのを考えて、Index型と^演算子みたいなのも提供

前者なら実装は楽だけどできることの制限が強い。 まずはこの「実装が楽な部分」だけを実装してみて、ユーザーの反応を見てみるという手もある。 でも、ちょっとこの制限はきついと思っていて、たぶん高機能な後者の方の需要はある。

switch 式の網羅性

switch 式」では網羅性(exhaustiveness)のチェックをしたいという話がある。 例えば bool なら(変なことをしなければ) truefalse の2値しか持っていないわけだから、 x switch { case true: a; case false: b; } みたいな式は「すべての値を網羅している」と言えるはず。

これをコンパイラーがチェック(網羅されていれば「default 句なし」を認める)すべき? チェックするとして、(いくつか、コンパイラーの静的チェックに漏れるケースがあり得るけど、その時のために)実行時チェックもする?

今のところ、網羅性をチェックして、網羅できていなければ警告にする。 静的チェックに漏れた場合の実行時チェックも入れて、例外を投げるようにすると思う。

nested stackalloc

現状、stackallocは非同期メソッド内では使えないという制限があります。 まあ、await をまたいで使うことは原理的にできない機能ではあります。

async Task M()
{
    Span<int> span = stackalloc int[10];

    await Task.Delay(1);

    span[0] = 1; // これは本当に不正。原理的に無理
}

でも、await さえまたがなければ、例えば以下のような書き方であれば安全に使えるはずです。

async Task M()
{
    {
        Span<int> span = stackalloc int[10];
        span[0] = 1;
    }

    // ブロックでくくったので、span がここに漏れることはない
    await Task.Delay(1);
    // ここから下で使えなければ span は安全
}

この、「ブロックで囲った(nested) stackalloc」を認めたいという感じになっています。