今日もC# 8.0の新機能の話で、今日のはすでに Visual Studio 2019 Preview 1に入っているやつです。

Ranges and Indicesと呼ばれていて、配列などに対して、 a[^i]で「後ろからi番目」とか、 a[i..j]で「i番目からj番目の範囲」とかを取り出せるようにする機能です。

正確にいうと、^iとかi..jとかの部分がC#の新機能で、 これらはそれぞれIndex型、Range型になります。 IndexRangeを受け取るインデクサーやメソッドはライブラリ側の機能です。 (ただし、配列だけは言語レベルで処理している模様。)

背景1: 統一ルールが欲しい

一旦先ほどの説明は忘れてまっさらな状態で、 例えば「3..5」と言われると何を思い浮かべるでしょう。 文脈次第だとは思いますが、以下のようなものがあり得ます。

  • 3, 4, 5 (5も含む)
  • 3, 4 (5は含まない)
  • 3, 4, 5, 6, 7 (3から初めて5つ)

どれがいいかは用途次第で、実際、どれもあり得ます。 例えば、.NET でも、以下のようなメソッドがあります。

var r = new Random();
var a = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
 
var x = r.Next(3, 5); // 3, 4 (5を含まない)
var s = a.AsSpan(3, 5); // 3, 4, 5, 6, 7 (3から始めて5つ)

ちょっとでもわかりやすくしたければ、以下のように名前付き引数にすべきかもしれません。

var x = r.Next(minValue: 3, maxValue: 5); // 「5つ」でないことは明確なものの、5を含むかどうかわからず
var s = a.AsSpan(start: 3, length: 5); // これなら割とわかりやすく「3から始めて5つ」

Random.Nextの例のように、名前が「max」だけで、「含むかどうか」がわからないAPIも多いです。 この区別のために、Parallel.Forなんかは引数名がfromInclusivetoExclusiveとかになっていたりします。 しかし、どんどん名前が長くなって書きづらい上に、 所詮は命名規約なので規約が守られない場合だってあり得ます。

さらにいうと、多次元データになるともっとしんどくなります。

var m = new[,]
{
    { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 10, 11, 12 },
};
 
// (x, y) が (1, 2) ~ (3, 4) の範囲?
// x が 1~2、y が 3~4 の範囲?
// 2, 4 は含む?含まない?
var n = m.Slice(1, 2, 3, 4);

ということで、範囲を表す専用の文法が欲しいという話になります。

背景2: インデックス用途

その両端を含むか含まないか問題ですが、どちらがいいかは正直用途によります。

例えば、x in 1..3 みたいに「x がその範囲に入るかどうか」(マッチング用途)の場合、 大体は「3も含む」の方にしたいという要望が多いです。 一方で、x[1..3]みたいに「xの1番から3番の要素」(インデックス用途)の場合、 「3を含まない」にした方が都合がよかったりします。 インデックス用途における「含まない」の利点は以下のようなもの。

  • 実装上、パフォーマンス的に有利
    • 長さを length = maxExclusive - minInclusive で計算できる(+1が要らない)
    • ループが for (var i = minInclusive; i < maxExclusive; i++) になる(<= だと int.MaxValueに対する特別扱いが必要)
  • i..iが空(0要素)範囲になる。「含む」の方だと空範囲がi..i-1になってちょっとキモい

C# 8.0で導入される範囲構文は、後者のインデックス用途を狙ったもので、「末尾は含まない」の方になります。

ちなみに、「範囲に入るかどうか」の方は別途パターン マッチングの一種(range pattern)として提供される可能性はあるんですが、 おそらく別の文法(x in 1 to 3みたいな)になりそうです。

一方、インデックス用途に絞ったことで、 「配列の末尾からi番目」を表したいという別の要望も出てきます。 そこで、^演算子を導入して、^iで「末尾からi番目」を表すことになりました。

文法

ということで、C# 8.0で導入されるのは以下のような文法です。

  • ^i 演算子で「末尾からi番目」を表す Index型を作る
    • 正確には「Length - i」を表す。^0Length番目なので、array[^0]は OutOfRange。
  • i..j 演算子で、「i番目からj番目」を表すRange型を作る
    • 開始の方(i)は含む、末尾の方(j)は含まない
    • 両端は省略可能。i..なら「iから末尾」、..jなら「先頭からj」、..なら「配列全体」
    • Indexを受け付ける。^3..なら「末尾から3要素」

ちなみに、RangeIndexはいずれもSystem名前空間の構造体です。

例えば以下のように書けます。

var data = new[] { 0, 1, 2, 3, 4, 5 };
 
// 1~2要素目。2 は exclusive。なので、表示されるのは 1 だけ。
Write(data[1..2]);
 
// 先頭から1~末尾から1。 1, 2, 3, 4
Write(data[1..^1]);
 
// 先頭~末尾から1。 0, 1, 2, 3, 4
Write(data[..^1]);
 
// 先頭から1~末尾。 1, 2, 3, 4, 5
Write(data[1..]);
 
// 全体。0, 1, 2, 3, 4, 5
Write(data[..]);

範囲構文

内部実装

実装としては以下のようになります。

  • ^inew Index(i, true)になる(第2引数のtrueが「末尾から」の意味)
  • 整数から Index へは暗黙の型変換がある
  • i..jRange.Create(i, j)になる
  • i..Range.FromStart(i)になる
  • ..jRange.ToEnd(j)になる
  • ..Range.All()になる
var r1 = Range.Create(1, 2);                  // 1..2
var r2 = Range.Create(1, new Index(1, true)); // 1..^1
var r3 = Range.ToEnd(new Index(1, true));     // ..^1
var r4 = Range.FromStart(1);                  // 1..
var r5 = Range.All();                         // ..

ちなみに、RangeIndexはそれぞれ、

  • Indexintを1つだけ持つ構造体
    • .NET の配列は負のインデックスを想定していないので、負の数を使って「末尾から」を表現
  • RangeIndexを2つ持つ構造体

になっています。

また、構文上は、^の方は単なる単項演算子、 ..の方は専用の構文(オペランドを省略可能というのが特殊なので、単なる2項演算子扱いにはできない)だそうです。

Rangeを受け付けるインデクサー

配列に対して a[i..j] と書いた時の挙動はちょっとまだもめているみたいです。 要は以下のどちらにすべきか。

  • 配列からは配列で「subarray」を返すべきではないか
    • 新しい配列のアロケーションとコピーが発生
  • アロケーションを避けるために Span<T> で返すべきではないか

Visual Studio 2019 Preview 1 での実装は前者になっていて、 new T[]Array.Copyが生成されます。 パフォーマンスを気にするならa.AsSpan()[i..j]と書く必要があります。