最新C# 7言語概説
C# 7.0で知っておくべき10の新機能(後編)
Visual Studio 2017およびVisual Studio Codeで利用可能になったC#言語の新バージョン「7.0」の新機能を、公開されている議論を基に解説。前編として「パフォーマンス向上」と「コード記述の単純化」に関連する6つの新機能を説明する。
前編では「データ中心設計」に関連する4つの新機能を説明した。後編である今回は、その続きとして「パフォーマンス向上」と「コード記述の単純化」に関連する6つの新機能を説明する。
本稿ではC# 7.0で追加される機能を10個に分け、さらに「データ中心設計」「パフォーマンス改善」「コードの書きやすさの向上」の3つに分類して紹介する。
【C# 7.0新機能の一覧】
- データ中心設計:
1outパラメーター付き引数での変数宣言(Out Var)
2パターンマッチング(Pattern matching)
3タプル(Tuples)
4分解(Deconstruction) - パフォーマンス改善:
5ローカル関数(Local Functions)
6参照返り値と参照ローカル変数(Ref Returns and Ref Locals)
7asyncメソッドの返り値型の一般化(Generalized async return types) - コードの書きやすさの向上:
8リテラル表記の向上(Literal improvements)
9式形式メンバーの追加(More expression bodied members)
10スロー式(Throw Expressions)
パフォーマンス向上(Performance)
5ローカル関数(Local Functions)
ローカル関数は関数の中で、その関数でしか利用できない関数を内部に定義できる機能である。C# 6.0以前にもラムダ式に代表される匿名関数の機能があったが、再帰が書きづらいことやイテレーターが記述できないなどの制約があった。ローカル関数は匿名関数に比べると、宣言ステートメントとしてしか記述できないためメソッド呼び出しの引数などでは利用できないが、通常のメソッド定義と同様に再帰やイテレーターが記述できるようになる。また、後述するがパフォーマンス面で匿名関数を上回るケースもある。
まず、リスト27に再帰するサンプルコードを載せた。匿名関数の場合は「最初に宣言して代入する」という手間が必要だったが、ローカル関数での再帰は通常の関数のように記述できる。ローカル関数もラムダ式同様、暗黙的に型宣言するとコンパイルエラーとなる。また、ローカル関数同士で再帰的な呼び出しも可能であるが、無限に再帰する場合、実行時エラーとなるのは通常の関数と同じである。
void RecursiveProc(int i)
{
// 匿名関数で再帰する場合は最初に宣言しないといけない
Func<int, int> f2 = null;
f2 = n => (n >= 1) ? (n * f2(n - 1)) : 1;
var res = f2(i);
WriteLine(res);
// ローカル関数は通常の関数同様再帰を記述できる
int f(int x) => x >= 1 ? x * f(x - 1) : 1;
// ローカル関数を暗黙的に型宣言するとコンパイルエラー
var f3(int x) => x >= 1 ? x * f(x - 1) : 1;
res = f(i);
WriteLine(res);
}
// ローカル関数内でお互いの関数を呼び出すコードは記述できる
// が、無限に再帰する場合は実行時エラーとなる
void RecursiveLocalFunction()
{
void Add(int x, int y)
{
WriteLine($"{x} + {y} = {x + y}");
Multiply(x, y);
}
void Multiply(int x, int y)
{
WriteLine($"{x} * {y} = {x * y}");
Add(30, 10);
}
Add(2, 3);
}
|
リスト28はローカル関数でイテレーターを記述するサンプルである。イテレーターを返すメソッドで引数のnullチェックを行いたい場合などにローカル関数を使うとシンプルに記述できる。
// LINQのSelectメソッドで同一のことができるが、サンプルとして記述
IEnumerable<string> Apply(IEnumerable<int> source, Func<int, string> converter)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (converter == null) throw new ArgumentNullException(nameof(converter));
IEnumerable<string> Inner()
{
foreach (var x in source)
yield return converter(x);
}
return Inner();
}
|
リスト29は非同期メソッドの結果をキャッシュする場合にローカル関数を使うコード例だ。
Task<int> cache;
Task<int> GetAsync()
{
async Task<int> inner()
{
await Task.Delay(3000);
return 1;
}
cache = cache ?? inner();
return cache;
}
|
ローカル関数では、スコープ内にある変数を参照・代入でき、これをキャプチャと呼んでいる。ただし、キャプチャした変数は、ローカル関数の実行時には確実に初期化されていないと、コンパイルエラーとなる。そのためgoto
文と組み合わせると難解なコードにもなる。変数宣言はコードを上から読んで最初に出てくる場所で行う必要があるが、実際の初期化はコード実行順で最初に参照される場所より前で行われなければならない(リスト30)。なお、この変数宣言と初期化の位置が異なるケースは、ローカル関数に限らずC# 6.0以前も同じである。
void CaptureVariable()
{
int x;
// 初期化されていないローカル変数をキャプチャできる
void AddTo(int y) => x = x + y;
// xが初期化される前にローカル関数を呼び出すとコンパイルエラー
var res0 = AddTo(3);
x = 5;
AddTo(3);
WriteLine(x); // 8
}
// 3 5 の順に出力される
void Goto()
{
goto Assign;
Before:
goto Read;
Declare:
int x = 5;
goto Read;
Assign:
WriteLine(x); // 初期化されていない扱いなのでコンパイルエラー
x = 3;
goto Before;
Read:
WriteLine(x);
if (x == 3) goto Declare;
}
|
このようにローカル関数はコードの記述しやすさの向上にもつながっているが、一部のケースでは匿名関数よりもパフォーマンスの向上が期待できる。それはローカル関数を匿名関数にキャストしておらず、かつ変数のキャプチャもしていない場合、内部では値型で記述したコードとなるためGC(ガベージコレクション)の影響を受けずに高速化が期待できるからだ。パフォーマンスを強く求められる処理では、変数のキャプチャをしないように検討することも必要だろう。
ローカル関数自身を関数内で再帰的に呼び出すことも可能だ。リスト31にその例を載せた。ローカル関数でも再帰呼び出しはスタックを消費する。ローカル関数に限った例ではないが、階乗の計算を再帰呼び出しではなくループで記述した例も併せて載せておく。
//ローカル関数でも再帰呼び出しはスタックを消費する
//このコードは下のループと同じになるように意図的にローカル変数をキャプチャしている
BigInteger GetFactorialUsingRecursive(int number)
{
BigInteger result = 1;
void Multiply(int x)
{
if (x > 1)
{
result *= x;
Multiply(x - 1);
}
}
Multiply(number);
return result;
}
BigInteger GetFactorialUsingLocal(int number)
{
BigInteger result = number;
while (number > 1)
{
Multiply(number - 1);
number--;
}
void Multiply(int x) => result *= x;
return result;
}
|
※正式リリース版の改訂時にコード内容とこれに関する説明を修正しました。
6参照返り値と参照ローカル変数(Ref Returns and Ref Locals)
C#では返り値が値型の場合、代入時に値の複製が発生する。そのため、データ構造的に大きい値型を返すことはパフォーマンス上、懸念される場面がある。C# 6.0以前でも引数の参照渡しはできていたが、C# 7.0で返り値とローカル変数の参照渡しが可能になった。リスト32にそのサンプルを載せた。
メソッドの返り値の型、メソッド内でのreturn
、メソッド呼び出し、参照渡しの返り値を受け取る変数宣言の全てに、ref
キーワードを付ける必要がある。このことには「参照渡しという機能は副作用が大きいため、その副作用を認識しやすくする」という意味も含められている。
void RefUsage()
{
int[] array = { 1, 15, -39, 0, 7, 14, -12 };
ref int place = ref Find(7, array); // 値が7の配列要素の参照を取得
place = 9; // 参照先の配列要素の値を書き換える
WriteLine(array[4]); // 5番目の要素の値が「7」から「9」に書き換わっている
}
ref int Find(int number, int[] numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
if (numbers[i] == number)
{
return ref numbers[i]; // 配列の値ではなく参照を返す
}
}
throw new IndexOutOfRangeException($"{nameof(number)} not found");
}
|
リスト33にはローカル変数でも参照渡しが使える例を載せた。多段に参照渡しを繰り返した場合でも、渡した参照をたどって同じ参照先を見ている。
void RefLocals()
{
var a = 100;
ref int b = ref a; // bとaの参照は同じ
var c = b; // cにbのその時の値「100」を代入
ref var d = ref b; // dとbの参照は同じ。aとも同じ参照
++b; // bに1を足すので、a/b/dとも101
++a; // さらにaに1を足すので、a/b /dとも102
++c; // cに1を足して、cだけ101
WriteLine($"{a},{b},{c},{d}"); // 102,102,101,102
}
|
参照渡しの引数はコンパイラーが追跡して参照渡ししても安全なものしか渡せないようになっている。リスト34のサンプルコードを載せたが、通常の引数、定数リテラル、ローカル変数は参照渡しにできない。何段かになって渡されている場合でも、コンパイラーが大本を追跡するようになっている。
ref int M1(ref int i) => ref i;
ref int M2(int i) => ref i; // 通常の引数を参照渡しにするとコンパイルエラー
ref int M3() => ref 1; // 定数リテラルを参照渡しにするとコンパイルエラー
ref int M4()
{
var x = int.Parse(Console.ReadLine());
return ref x; // ローカル変数を参照渡しにするとコンパイルエラー
}
ref int M5()
{
var x = 1;
ref var y = ref M1(ref x);
ref var z = ref M1(ref y);
return ref z; // 多段参照の大本にあるxがローカル変数の値として初期化されているためコンパイルエラー
return ref new int[] { 1, 2, 3 }[0]; // オブジェクトの参照は返せる
}
|
7asyncメソッドの返り値型の一般化(Generalized async return types)
- ※プレビュー時点では
async
メソッドの返り値としてValueTask
を使う場合、NuGetからSystem.Threading.Tasks.Extensionsをプロジェクトに追加する必要があった。正式版では追加することなく利用可能である。
C# 5.0で導入されたasync
キーワードによる非同期メソッドは、返り値にvoid
/Task
/Task<T>
のいずれかしか指定できなかった。C# 7.0では、一定の規約に従った(ユーザーによる作成を含む)Task的な(task-like)型を指定できるようになった。しかし、実際にユーザーがこの規約に従ってクラスを作成するのは注意事項が多く、ユースケースとしてはかなり少ないと思われる。一般的なユースケースとしては、新たに導入されるValueTask
クラスを非同期メソッドの返り値として利用することの方が圧倒的に多いだろう。本記事でも新たに非同期メソッドの返り値として定義できるTask的な型を定義する方法は割愛し(※これについてはこちらの記事を参照されたい)、ValueTask
について説明する。
ValueTask
は名前の通り、「値型として扱えるTask
」的な型である。実装としては、待機している処理が完了したときに得られる値、もしくは完了前であればTask
インスタンスのどちらかを保持する構造体となっている。また、C# 6.0以前でTask
もしくはTask<T>
を使っていたところを、そのままValueTask
もしくはValueTask<T>
に置き換えることができる。
これがパフォーマンス上のメリットとなるのがリスト35のようなコードだ。async
キーワードの付いた非同期メソッドではあるが、ほとんどの場合(=「100」以外の値の場合)、同期的に処理を返す。返り値がTask
の場合、同期的に処理を行う場合でもTask
クラスをインスタンス化しないといけないためGCのコストがかかるが、ValueTask
は構造体であるためValueTask
を生成してもGCのコストはかからない。
internal void Run()
{
async Task Inner()
{
var res = await SearchAsync(100);
WriteLine(res);
res = await SearchAsync(1);
WriteLine(res);
}
Inner().GetAwaiter().GetResult();
}
async ValueTask<int> SearchAsync(int a)
{
if (a != 100)
return 0;
await Task.Delay(1000);
return 1;
}
|
コード記述の単純化(Code Simplification)
8リテラル表記の向上(Literal improvements)
バイナリーリテラル(Binary Literals)
C# 6.0以前は、整数リテラルの表記は「先頭に何も付けない場合の10進表記」と「0x
もしくは0X
を先頭に付ける16進表記」の2通りが用意されていた。C# 7.0からは、リスト36のように「0b
もしくは0B
を付けることで2進表記」も可能になった。2進表記でも、末尾にu
もしくはU
を付けることによりuint
型もしくはulong
型を、l
もしくはL
を付けることでlong
型もしくはulong
型を、ul
もしくはUL
/uL
/Ul
などを付けることでulong
型を明示する方法が利用できる。
void Run()
{
int a1 = 10;
int a2 = 0b01011;
int a3 = 0B101011100;
uint a4 = 0b101011100u;
long a5 = 0b101011100L;
ulong a6 = 0b101011100uL;
Console.WriteLine($"{a1} {a2} {a3} {a4} {a5} {a6}");
}
|
桁区切り(Digit Separators)
整数リテラルおよび浮動小数点リテラルで桁数が多くなった場合の可読性を上げるために、桁区切りに_
を利用できる。リスト37のように_
は、「先頭」「末尾」「小数点の直前直後」以外の任意の桁に挿入でき、連続して挿入することも可能である。
int i1 = 0b1000_0110_1110;
long i2 = 0xdead_beaf;
var i3 = 123_456_789.987_654;// 123456789.987654
var i4 = 1___2__3_____4; // 1234
//var i5 = _123; // 先頭に使うとコンパイルエラー
//var i6 = 123_.4; // 小数点の直前に使うとコンパイルエラー
//var i7 = 123._4; // 小数点の直後に使うとコンパイルエラー
//var i8 = 123_; // 末尾に使うとコンパイルエラー
WriteLine(i1);
WriteLine(i2);
WriteLine(i3);
WriteLine(i4);
|
9式形式メンバーの追加(More expression bodied members)
C# 6.0で式形式によるメンバーの定義(=ラムダ式の形式でクラスメンバーの本体を記述できる機能)が導入されたが、C# 7.0ではリスト38のようにC# 6.0で使えた場所(メソッドおよびプロパティのgetterのみ)に加えて、コンストラクター、デストラクター、プロパティとインデクサー(getterおよびsetter)、イベント(addおよびremove)でも使えるようになった。
class Example
{
private static int counter = 0;
private string name;
private IDictionary<string, string> dictionary = new Dictionary<string, string>();
public Example() => ++counter; // コンストラクター
~Example() => --counter; // デストラクター
public string Name
{
get => name; // getter
set => name = value; // setter
}
public string this[string key]
{
get => dictionary[key]; // インデクサーのgetter
set => dictionary[key] = value; // インデクサーのsetter
}
public event Action E
{
add => ++counter;
remove => --counter;
}
}
|
10スロー式(Throw Expressions)
例外のスローは、C# 6.0まではステートメントとしてのみ記述できた。その一方、式形式で記述できるメンバーの導入など、式が活用できる場面が増えてきたため、「例外のスローも式として記述したい」という要望が出ていた。そこでC# 7.0では、限定したケースであるが式として例外のスローが書けるようになった。
リスト39に示しているが大きく3つある。
- ラムダ式や式形式のメンバーなど
=>
の後ろ - 条件演算子
? :
の第2、第3オペランド(被演算子) - null合体演算子
??
の第2オペランド
class Person2
{
public string Name { get; }
public Person2(string name) => Name = name ?? throw new ArgumentNullException(name);
public string GetFirstName()
{
var parts = Name.Split(' ');
return (parts.Length > 0) ? parts[0] : throw new InvalidOperationException("No name!");
}
public string GetFirstName2()
{
var parts = Name.Split(' ');
return (parts.Length == 0) ? throw new InvalidOperationException("No name!") : parts[0];
}
public string GetLastName() => throw new NotImplementedException();
public void BadUsage()
{
var a = null ?? throw new NotImplementedException();
var b = false ? throw new NotImplementedException() : null;
var c = false ? throw new NotImplementedException() : throw new NotImplementedException();
}
}
|
スロー式自体は型を持たず、かつ任意の型に暗黙的に変換可能であるため、nullと並べたりスロー式自体を並べたりした場合は、返す型が定まらない。そのため、BadUsage
メソッドのような実装はコンパイルエラーとなる。
■
以上、10の項目がC# 7.0で導入予定の機能である。Roslynが導入されたC# 6.0と比べるとかなり意欲的な機能追加であり、その機能追加と実装の様子がパブリックなGitHubを通じて公開された最初のバージョンとなる。また、パターンマッチングなど一部の機能は次バージョン以降のより大きな機能のリリースにつながる機能の一部となっている。今後も継続的に新機能のリリースが行われ、その過程が公開され、かつ以前の言語機能との互換性を保ちながら進んでいるというのがC#の大きな特徴の一つとなっている。
1. C# 6.0で知っておくべき12の新機能
Visual Studio 2015正式版のリリースで利用可能になったC#言語の最新バージョン「6.0」の新機能を解説する。CTP 5→正式版に合わせて改訂。
2. C# 7.0で知っておくべき10の新機能(前編)
Visual Studio 2017およびVisual Studio Codeで利用可能になったC#言語の新バージョン「7.0」の新機能を、公開されている議論を基に解説。前編として「データ中心設計」に関連する4つの新機能を説明する。