Movatterモバイル変換


[0]ホーム

URL:


Logo

目次

概要

Ver. 8.0

C# 8.0 で、配列などに対して以下のような書き方をできるようになります。

  • a[^i] で「後ろからi番目の要素」を参照
  • a[i..j] で「i番目からj番目の範囲」を参照

例えば、以下のような書き方で配列の前後1要素ずつを削ったものを得ることができます。

using System;classProgram{staticvoidMain()    {vara =new[] { 1, 2, 3, 4, 5 };// 前後1要素ずつ削ったものvarmiddle =a[1..^1];// 2, 3, 4 が表示されるforeach (varxinmiddle)        {Console.WriteLine(x);        }    }}

ちなみに、i..j は「iは含んでjは含まない」という範囲になります。for (var x = i; x < j; ++x) のイメージ。

より細かく言うと、以下のような機能の組み合わせになります。

  • ^i で「後ろからi番目」を表すIndex 型の値を得る
  • i..j で「i番目からj番目」を表すRange 型の値を得る
  • 所定の条件を満たす型に対してIndex/Range を渡すと、所定のパターンに展開する

いくつかのプログラミング言語で似たような構文があり、多くの場合は range (範囲)構文と呼ばれます。C# 8.0 で導入されたものは配列などのインデックス用途に特化していて、Index型とRange型からなるので、index/range (インデックス/範囲)構文と言ったりもします。

背景

Span

C# 7.2 で、Span<T> 構造体が導入されました。配列や文字列中の一定範囲を抜き出して効率的に読み書きするための型です。(単純な機能なのでもっと昔からあってもよさそうなものですが、ガベージ コレクションがあっても安全かつ高速に動くようにするのが意外と大変で、C# 7.2 まで導入が見送られていました。)

配列のインデクサー」というブログで書いたことがあるんですが、Span<T> 構造体は特別な最適化の対象になっていて、非常に高速です。例えば以下の2つのメソッドでは、Span<T> を使ったSum2 の方が高速です。

// i番目からj番目までの和。[MethodImpl(MethodImplOptions.NoInlining)]staticintSum1(int[]array,inti,intj){varsum = 0;for (intx =i;x <j;x++)sum +=array[x];returnsum;}// Sum1 と同じ処理を Span を使って書く。// Sum1 よりこっちの方が速い。[MethodImpl(MethodImplOptions.NoInlining)]staticintSum2(int[]array,inti,intj){varsum = 0;foreach (varxinarray.AsSpan()[i..j])sum +=x;returnsum;}

範囲のルール統一

配列の一定範囲を抜き出すという処理は、array.AsSpan(x, y) というように、単なるメソッド呼び出しでもできます。ただ、ここで問題となるのは、引数の意味がメソッドによってぶれている点です。xy にそれぞれ3、5を渡した場合、どういう意味になるでしょう。例えば、以下のようなパターンが考えられます。

  • (1) 3, 4, 5 (3から5まで、3も5も含む)
  • (2) 3, 4 (3から5まで、5は含まない)
  • (3) 3, 4, 5, 6, 7 (3から5要素)

実際、 .NET の標準ライブラリ中でもぶれています。例えば、Parallel.ForRandom.Next は (2) の意味ですが、SubstringAsSpanは (3) の意味です。

using System;using System.Threading.Tasks;classProgram{staticvoidMain()    {// この2つは 1から3 (3は含まない) = 1, 2の意味Parallel.For(1, 3,i => { });varv =newRandom().Next(1, 3);// この2つは 1から3要素 = 1, 2, 3 の意味varspan =new[] { 1, 2, 3, 4, 5 }.AsSpan(1, 3);varsubstr ="abcde".Substring(1, 3);    }}

名前付き引数を使えば、多少混乱を予防することはできます。

Parallel.For(fromInclusive: 1,toExclusive: 3,i => { });varv =newRandom().Next(minValue: 1,maxValue: 3);varspan =new[] { 1, 2, 3, 4, 5 }.AsSpan(start: 1,length: 3);varsubstr ="abcde".Substring(startIndex: 1,length: 3);

ただ、名前付き引数を使っても以下の問題があります。

  • コードがとにかく長くなる
  • Random.Next のように「含むか含まないか」を明示していないやつがいる
  • あくまで実装者の良心頼みになっている
  • 多次元データだとmatrix[1, 3, 1, 3] みたいにさらにわかりにくい

そこで、範囲を表す専用の構文が欲しいわけです。構文になっていれば意味がぶれることがなくなります。C# では、i..j で「i番目からj番目(j は含まない)」となる構文を採用しました。

インデックス用途

i..j と書いたとき、j を含むかどうかは難しい問題です。実際、あるプログラミング言語では j を含みますし、別のある言語では含みません。..=..< などで含む・含まないを選ぶようになっている言語もありますが、.. だけを書く構文もあったりして、その.. の意味は言語ごとにまちまちです。

用途次第でもあります。「この範囲に入っているかどうかを判定」みたいな用途(要するにパターン マッチング)だと、末尾も含んでくれている方がわかりやすいです。一方で、SpanSubstringのように、配列や文字列から一定範囲を抜き出す用途(インデックス用途)では、末尾を含まない方が使いやすかったりします。

インデックス用途での「末尾を含まない」には以下のようなメリットがあります。

  • j - i だけで長さを計算できる
  • ループで使いやすい
    • ループではfor (int x = i; x < j; ++x) というように< で条件判定することが多い
  • i..i (先頭と末尾が同じ)が不正にならない(単に長さ0の範囲になる)
    • 逆に「j を含む」を採用する場合、長さ0の範囲はi..(i-1) と書く必要がある

C# のi..j で「j は含まない」の方を採用したのは、明確にインデックス用途を意図したものです。

Index

配列や文字列からの一定範囲の抜き出しではよく「末尾から i 番目」という場所を取りたいことがあります。C# 8.0 では、そのために単項^ 演算子を使います。

vari = ^1;// Length - 1 の場所varvalue = 1;varj = ^value;// 変数に対しても ^ を使える

単項^ 演算子はオペランドにint (かint に暗黙に変換できる型)しか受け付けません。また、戻り値はIndex 構造体(System 名前空間)になります。Index は、以下のようなプロパティ・メソッドを持つ構造体です。

publicreadonlystructIndex{publicIndex(intvalue,boolfromEnd =false);publicbool IsFromEnd {get; }publicint Value {get; }publicintGetOffset(intlength);publicstaticimplicitoperatorIndex(intvalue)}

^inew Index(i, true) に展開されます(第2引数のtrue が「末尾から」の意味です)。int からの暗黙的な変換もあって、それは素直に「先頭から i 番目」の意味になります。

補足: インデックスは0以上の整数

C# では、配列のインデックスは0以上(非負)という前提があります。なので、Index 構造体も以下のような作りになっています。

  • コンストラクターに負の整数を渡すとIndexOutOfRange 例外が発生する
    • ^-1 みたいな書き方は文法的には認められるものの、実行時に例外発生
  • 内部的にはint 1つだけ持っていて、負の数を「末尾から」の意味で使っている
    • 構造体のサイズはint と同じ4バイト

Range

C# 8.0 で.. という新しい構文が追加されました。

varr1 = 1..^1;varr2 = 1..;varr3 = ..^1;varr4 = ..;vari = 1;varj = ^1;varr =i..j;

他の2項演算子と違って、i....j.. というようにオペランドを省略できます。オペランドはIndex 型か、(int を含む)Index 型に暗黙的に変換できる型である必要があります。戻り値はRange 型(System 名前空間)になります。Range は、以下のようなプロパティ・メソッドを持つ構造体です。

publicreadonlystructRange{publicRange(Indexstart,Indexend);publicIndex Start {get; }publicIndex End {get; }public (int Offset,int Length)GetOffsetAndLength(intlength);}

左オペランドの省略時は先頭から、右オペランドの省略時は末尾までの意味になります。すなわち、i..i..^0 と、..j0..j と、..0..^0 と同じ意味です。また、i..jnew Range(i, j) に展開されます。

名前通り、Start が開始位置で、End が末尾位置です。コンストラクターの引数は、第1、第2引数がそれぞれStartEnd と対応しています。これまでの説明通り、Start は「含む」、Endは「含まない」という扱いです。

この辺りは言葉で説明してもわかりにくいと思うので、以下の図を参考にしてください。

Index/Range の意味

i..^j で、先頭からi要素、末尾からj要素を削った範囲になります。

ちなみに、演算子の優先順位は結構高いです。2項演算(乗除算含む)やswitchよりも上になります。

_ =2 * 3..4;// 2 * (3..4) の意味。そんな掛け算はできないのでコンパイル エラーに。_ = 2..3switch// 2..3 という Range が switch 式の引数になる{Range_ => 4,};_ = (1 + 2)..(3 + 4);// 足し算とかを優先したければ () 必須

Index/Range とインデクサー

Index/Range型に対するインデクサーは、以下で説明するように、一定のパターンでint に対するインデクサーやSliceメソッドに展開されます。

(当初予定では、^iからIndex型を、i..jからRange型を作るところまでだけが C# コンパイラーの仕事で、それを使ったインデクサーは使う側(配列やList<T>などのコレクションの側)の仕事にする予定でした。それだとあらゆるコレクションに対して1個1個インデクサーのオーバーロードを追加する作業が大変なのと、最適化が掛けにくいという理由で、現状のパターン ベースな方式に変更されました。)

Index型のi に対するインデクサーa[i] は基本的に以下のように展開されます。

intoffset =i.GetOffset(a.Length);a[offset];

また、Range 型のr に対するインデクサーa[r] は基本的に以下のように展開されます。

varoffset1 =r.Start.GetOffset(a.Length);varoffset2 =r.End.GetOffset(a.Length);a.Slice(offset1,offset2 -offset1);

a の型によって多少バリエーションがあります。C# のコレクションは長さをLength で取るものとCount で取るものの両方あるので、そのどちらにも対応しています。Length がなくてCount がある場合それを使います(Length があるならそっちが優先)。

intoffset =i.GetOffset(a.Count);a[offset];
varoffset1 =r.Start.GetOffset(a.Count);varoffset2 =r.End.GetOffset(a.Count);a.Slice(offset1,offset2 -offset1);

また、Range 型インデクサーには、配列と文字列の場合だけ特別扱いがあります。Slice メソッドではなく、それぞれGetSubArraySubstring メソッドが呼ばれます(GetSubArrayRuntimeHelpersクラス(System.Runtime.CompilerServices 名前空間)の静的メソッド)。

コピーの回避

配列と文字列に対するRange型インデクサーa[i..j] (展開結果的にはGetSubArraySubstring)は、それぞれ配列、文字列を返します。この際、新しい配列・文字列を確保してコピーするコストが発生します。

vararray =new[] { 1, 2, 3, 4, 5 };varstr ="abcde";for (inti = 0;i < 100;i++){// こういう書き方をすると、ループのたびに new int[], new string が発生。// だいぶ重たい。varsubarray =array[1..^1];varsubstr =str[1..^1];}

これらはそれなりに重たい処理なので、パフォーマンスにシビアな状況での利用には注意が必要です。

コピーを発生させたくない場合、Span<T>を経由します。要するに、AsSpan()AsMemory() を挟めばコピーを回避できます。

vararray =new[] { 1, 2, 3, 4, 5 };varstr ="abcde";for (inti = 0;i < 100;i++){// 以下の書き方をすれば Span<int>/ReadOnlySpan<char> の Slice が呼ばれるようになる。// これならコピーは発生せず、軽い。varsubarray =array.AsSpan()[1..^1];varsubstr =str.AsSpan()[1..^1];}
サンプル

「一定範囲を抜き出す」という処理は、テキスト処理でよく使います。

例として、書式が決まっているテキストの中から一部分を取り出してみましょう。今回は「1行1項目で、: 区切りでキーと値が並んでいる」というような書式を考えます。この書式のテキストの中からキーだけを取り出すようなコードを以下のように書けます。

using System;using System.Collections.Generic;classProgram{staticvoidMain()    {vartestData =@"longitude: 139.8803943latitude: 35.6328964postal code: 279-0031";foreach (varkeyinGetKeys(testData))        {Console.WriteLine(key);        }    }// 行頭から : までの間の文字列だけを抜き出すstaticIEnumerable<ReadOnlyMemory<char>>GetKeys(stringcontent)    {varstart = 0;for (inti = 0;i <content.Length;i++)        {varc =content[i];if (c ==':')            {yieldreturncontent.AsMemory()[start..i];            }elseif (c =='\n')            {start =i + 1;            }        }    }}
longitudelatitudepostal code

例なのでシンプルな書式にしましたが、もうちょっと実用的な、例えば JSON 形式からのキーの取り出しなども、こういうコードの延長線上になります。

更新履歴

更新:言語バージョンの指定

[C#]

ファイル ベース実行

[C#]

C# 14.0 の新機能

[C#]

更新:[雑記] オーバーロード解決

[C#]

型の分割定義 (partial)

[C#]

ブログ

C# 14 の破壊的変更点(First-class Span)

ファイナライザー

文字列リテラルを data セクションに UTF-8 で書き込む案

nameof(T<>)

First-class な Span 型


誤字等を見つけた場合や、ご意見・ご要望がございましたら、GitHub の Issues まで気兼ねなくご連絡ください。

[8]ページ先頭

©2009-2025 Movatter.jp