C# 7 早わかりリファレンス(後編)
C# タプル/ローカル関数/ラムダ式/非同期処理/例外処理 最速再入門【7.0対応】
C# 7.0主要文法がコンパクトにまとまったリファレンス(全3回)。後編では、タプルと分解(デコンストラクション)/ローカル関数/構造体/継承とインターフェース/列挙型/イテレーター/例外処理/リソースの解放/ラムダ式/非同期処理(async/await)を説明する。
前編ではC#言語の文法の中でも最もベーシックな「型」「変数」「演算子」「ステートメント(文)」といった基本機能を、中編ではオブジェクト指向言語であるC#の要となる「クラス」関連の機能をできるだけコンパクトに説明した。今回後編では、それ以外の残りの言語仕様や機能として、「タプルと分解(デコンストラクション)」「ローカル関数」「構造体」「継承とインターフェース」「列挙型」「イテレーター」「例外処理」「リソースの解放」「ラムダ式」「非同期処理(async/await)」について説明する。前編から後編までの内容で、C#の言語機能を一通り確認したことになる。
タプルと分解(デコンストラクション)
タプル
C# 6.0以前では、メソッドの返り値に複数のオブジェクトの集合を返したいが独立したクラスとして適切な名前が思いつかない場合、独立したメソッドとせずに匿名オブジェクトとして扱うか、Tuple<T1, T2, ……>
を利用するかのどちらかの方法を取っていた。しかし、匿名オブジェクトはメソッドをまたげないこと、Tuple
はプロパティ名が無意味なItem1
、Item2
、……で分かりづらくなることが問題だった。この問題に対し、C# 7.0でタプル型が導入された。実体はValueTuple
クラス(System名前空間)である。また「タプル」という表現がC# 6.0以前のTuple
と紛らわしいため、NuGetからダウンロードしてVisual Studioなどから参照できるcorefx(.NET Coreライブラリ)などのドキュメントコメントでは、C# 6.0以前のTupleを「組」と表現している。
なお執筆時点で筆者が調べた環境では、C# 7.0で導入されたタプルを利用するためには、.NET Framework 4.7(=Windows 10 Creators Updateに含まれている)以外の環境(.NET Framework 4.6以前および.NET Core 1.0と1.1)では、「System.ValueTuple」パッケージをNuGetから追加しておく必要がある。
using System;
using System.Linq;
class Program
{
static void Main(string[] args)
{
// 文字列を全て小文字にしたものと全て大文字にしたものを取得する
var texts = new[] { "aaA", "bBb", "cCC" };
// 匿名オブジェクトを利用
foreach (var text in texts.Select(x => new { lower = x.ToLower(), upper = x.ToUpper() }))
{
Console.WriteLine(text);
}
// Tuple(組)を利用
foreach (var text in texts.Select(x => new Tuple<string, string>(x.ToLower(), x.ToUpper())))
{
Console.WriteLine(text);
}
// C# 7.0のタプルを利用
foreach (var text in texts.Select(Convert))
{
Console.WriteLine($"lower={text.lower}, upper={text.upper}");
}
var str1 = "aaAA";
// タプルリテラルで宣言
var t1 = (str1.ToLower(), str1.ToUpper()); // 要素名は任意なのでつけなくてもよい
Console.WriteLine($"Item1={t1.Item1}"); // つけない場合はC#6.0以前のタプルと同様のItemNという名前で参照
var t2 = (lower: str1.ToLower(), upper: str1.ToUpper());
// new形式はコンパイルエラー
//var t3 = new (int, int)(0, 1);
var t4 = (lower: str1.ToLower(), str1.ToUpper()); // 一部のみ要素名を省略することも可
Console.WriteLine($"{t4.lower}, {t4.Item2}");
// 同じ要素名で重複するとコンパイルエラー
//var t5 = (lower: text.ToLower(), lower: text.ToLower());
}
static (string lower, string upper) Convert(string text)
{
return (lower: text.ToLower(), upper: text.ToUpper());
}
}
|
C# 7.0で導入されたタプルは、匿名オブジェクトと同じように要素名に任意の名前をつけられるうえに、型であるため、返り値の型に指定することができる。またタプルリテラルという記述で宣言することもできる。
型として利用する場合、要素名の命名は任意であり、各要素の型が一致していれば要素の名前が異なっていても代入可能である。また、要素名をつけずに利用することもでき、その場合はC# 6.0以前のタプル(組)と同じItemN
(Nは要素のインデックス)という名前で参照できる。
分解(デコンストラクション)
C# 7.0ではタプルが導入されたことに加えて、タプルを要素ごとに分けて受け取る分解(Deconstruction)も導入された。これによりタプルの固まりをそのまま受け取るのではなく、あらかじめ変数に分けた状態で受け取ることができる。
using System;
class Program
{
static void Main(string[] args)
{
var tuple = (name: "Tom", age: 34);
string name; int age;
(name, age) = tuple; // 宣言済みの変数(=式)に分解して代入する
Console.WriteLine(age);
int x, y;
(x, y) = new Ex12B_2_Destruction_Point(3, 5); // Deconstructメソッド(後述)を持つインスタンスを分解する
Console.WriteLine($"{x} {y}");
// 分解して新しく宣言した変数に代入する
(var newName, var newAge) = tuple;
(var myX, var myY) = new Ex12B_2_Destruction_Point(3, 5);
// 式と変数宣言の併用はコンパイルエラー
//(x, var y2) = new Point(3, 4);
// Out Varと同じく_で読み捨てることができる
(var x1, var _) = new Ex12B_2_Destruction_Point(4, -3);
Console.WriteLine($"{x1}");
// var _ は変数宣言扱いなので変数への代入と併用するとコンパイルエラー
//(x1, var _) = new Point(4, -3);
}
}
class Ex12B_2_Destruction_Point
{
public int X { get; }
public int Y { get; private set; }
public Ex12B_2_Destruction_Point(int x, int y) { X = x; Y = y; }
public void Deconstruct(out int x, out int y)
{
x = X;
y = Y;
}
}
|
分解を受け取る側は、あらかじめ宣言済みの変数(=式)に代入する場合と、その場で宣言した変数に代入する場合の2通りがある。両者を混在させることはできない。その場で宣言する場合は_
を使って読み捨てることもできる。分解される側は、ValueTuple
型のオブジェクトに加えて、デコンストラクトメソッドを持つオブジェクトに分解できる。デコンストラクトメソッドは、public void
なDeconstructという名前であり、分解される変数を引数に記述する。
ローカル関数
ローカル関数の定義
C# 7.0からローカル関数が追加された。ローカル関数は、関数の中でしか利用できない関数を関数内部に定義できる機能である。C# 6.0まではラムダ式に代表される匿名関数の機能があったが、再帰が書きづらいことやイテレーターが記述できないなどの制約があった。ローカル関数は匿名関数に比べると、宣言ステートメントとしてしか記述できないためメソッド呼び出しの引数などでは利用できないが、通常のメソッド定義と同様に再帰やイテレーターが記述できるようになる。
using System;
class Program
{
static void Main(string[] args)
{
// 匿名関数で再帰する場合は最初に宣言しないといけない
Func<int, int> f2 = null;
f2 = n => (n >= 1) ? (n * f2(n - 1)) : 1;
var res = f2(3);
Console.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(3);
Console.WriteLine(res);
// ローカル関数内でお互いの関数を呼び出すコードは記述できる
// が、無限に再帰する場合は実行時エラーとなる
void Add(int x, int y)
{
Console.WriteLine($"{x} + {y} = {x + y}");
Multiply(x, y);
}
void Multiply(int x, int y)
{
Console.WriteLine($"{x} * {y} = {x * y}");
Add(30, 10);
}
Add(2, 3);
}
}
|
匿名関数の場合は「最初に宣言して代入する」という手間が必要だったが、ローカル関数での再帰は通常の関数のように記述できる。ローカル関数もラムダ式と同様に、暗黙的に型宣言するとコンパイルエラーとなる。また、ローカル関数同士で再帰的な呼び出しも可能であるが、無限に再帰する場合、実行時エラーとなるのは通常の関数と同じである。ローカル関数の利用例として、イテレーターメソッドでのnullチェックと非同期メソッドの結果のキャッシュを別項目に記載しているので参考にしてほしい。
構造体
構造体の宣言
class Program
{
static void Main(string[] args)
{
var p1 = new Point(10);
// 参照ではなく、コピーした値がp2に割り当てられる
var p2 = p1;
// p1のプロパティを更新してもp2には影響がない
p1.X = 1;
// p1.X=1
// p2.X=10
// 構造体の初期値はnullにはなり得ない
var points = new Point[10];
// point[0] != null
// point[0].X == 0 // 各フィールドはデフォルト値で初期化される
// [参考]一方、クラスの初期値はnullである
var uris = new System.Uri[10];
// uris[0] == null
}
struct Point
{
private int x;
public int X
{
get { return x; }
set { x = value; }
}
public Point(int x)
{
this.x = x;
}
}
}
|
構造体は、クラスによく似た構造だが、値型でありヒープ割り当てを必要としない。そのため、値を持つ小規模なデータ構造に適している。
構造体は値型であるため、構造体を参照する変数を別の変数に割り当てると、参照ではなくコピーした値が代入される。サンプルコードでは、変数p1
をp2
に代入しているが、その後p1
のプロパティを変更してもp2
は変更されない。
また、値型であるため、構造体自身の初期値はnullではなく、構造体の各フィールドはそれぞれのデフォルト値で初期化した値となる。そのため構造体を要素とする配列を初期化した時点で、配列の各要素には構造体の初期値が代入されている。
継承とインターフェース
継承と仮想、override、new
using System;
class Program
{
static void Main(string[] args)
{
var my = new MyClass1();
my.Test2(); // MyClass1.Test2
var my2 = new MyClass2();
my2.Test2(); // 1
}
}
public class MyBase
{
public int Value { get; set; } = 0;
public void Test1()
{
Console.WriteLine("MyBase.Test1");
}
public virtual void Test2()
{
Console.WriteLine("MyBase.Test2");
}
}
public class MyClass1 : MyBase
{
public new int Value { get; set; } = 1;
public override void Test2()
{
Console.WriteLine("MyClass1.Test2");
}
}
public class MyClass2 : MyClass1
{
public override void Test2()
{
Console.WriteLine(Value);
}
}
|
オブジェクト指向の特徴の一つであるクラスの継承は、クラス名の後に:
で基底クラスを宣言することで記述できる。C#ではvirtual
を省略したメソッドは、非仮想メソッドであり、継承したクラスでオーバーライドすることはできない。そのため、オーバーライドを許可するメソッドは基底クラスの側でvirtual
修飾子を付けてオーバーライドを許可する必要がある。オーバーライドする側のメソッドはoverride
修飾子を付けるが、さらに継承先のクラスでオーバーライドすることもできる。
また、継承したクラスで基底クラスのメソッドやプロパティと同じ名前のメンバーを宣言することも可能だ。このとき、基底クラスのメンバーは隠ぺいされてしまうため、継承したクラスではメソッドやプロパティなどのメンバーにnew
修飾子を付けて隠ぺいしていることを明示できる。
抽象クラスとシールクラス、シールメソッド
using System;
class Program
{
static void Main(string[] args)
{
var my = new MyClass();
my.Test1(); // MyAbstractClass.Test1
my.Test2(); // MyClass.Test2
my.Test3(); // MyClass.Test3
var my2 = new MyClass2();
my2.Test1(); // MyAbstractClass.Test1
my2.Test2(); // MyClass2.Test2
my2.Test3(); // MyClass.Test3
}
}
public abstract class MyAbstractClass
{
public void Test1()
{
Console.WriteLine("MyAbstractClass.Test1");
}
public abstract void Test2();
public abstract void Test3();
}
public class MyClass : MyAbstractClass
{
public override void Test2()
{
Console.WriteLine("MyClass.Test2");
}
public sealed override void Test3()
{
Console.WriteLine("MyClass.Test3");
}
}
public sealed class MyClass2 : MyClass
{
public override void Test2()
{
Console.WriteLine("MyClass2.Test2");
}
}
|
抽象クラスは直接インスタンス化できないクラスで、通常のメソッド定義に加え抽象メソッドや抽象プロパティをメンバーに持つことができる。これは継承先のクラスが持つ共通の振る舞いをあらかじめ定義しておくときに役立つ。抽象メソッドを継承したクラスでオーバーライドするときはoverride
修飾子を付ける。
抽象クラスからの継承に限らず、定義したクラスを継承することを禁止する場合、sealed
修飾子を付けてシールクラスにすることができる。また、メソッド単位でオーバーライドを禁止する場合も、sealed
修飾子を付けてシールメソッドにすることができる。
インターフェースと明示的実装
using System;
class Program
{
static void Main(string[] args)
{
var s1 = new Surface();
s1.Name = "a";
s1.Paint(); // Paint
((ISurface)s1).Paint(); // Paint
((IPaintable)s1).Paint(); // Paint
var s2 = new Surface2();
s2.Paint(); // Paint
((ISurface)s2).Paint(); // Paint
((IPaintable)s2).Paint(); // IPaintable.Paint
}
}
interface IPaintable
{
void Paint();
}
interface ISurface
{
string Name { get; set; }
void Paint();
}
public class Surface : ISurface, IPaintable
{
public string Name { get; set; }
public void Paint()
{
Console.WriteLine("Paint");
}
}
public class Surface2 : ISurface, IPaintable
{
public string Name { get; set; }
public void Paint()
{
Console.WriteLine("Paint");
}
void IPaintable.Paint()
{
Console.WriteLine("IPaintable.Paint");
}
}
|
インターフェースはメソッドやプロパティのコントラクト(=実装すべき規約)を定義でき、インターフェースを実装するクラスや構造体は、必ずそのコントラクトに従って実装する必要がある。C#では継承するクラスは1つのみだが、インターフェースは複数実装できる。
インターフェースを複数実装する場合、異なるインターフェースが同じコントラクトを持っていることがある(サンプルコードではIPaintable
/ISurface
インターフェースの両方にvoid Paint()
メソッドがある)。この場合、特に指定せずに実装すると両者のインターフェースは同じ実装メソッドを呼び出す。しかし、それぞれのインターフェースで異なる実装を行いたい場合は、明示的に実装可能だ。サンプルコードのSurface2
クラスのIPaintable.Paint
メソッドがその例で、IPaintable
インターフェースにキャストして呼び出すと、明示的実装が呼び出されることになる。
列挙型
列挙型の宣言
class Program
{
static void Main(string[] args)
{
var c1 = Color.Red;
var c2 = Color.Blue;
// 定義されていない値をキャスト可能だが、非推奨
var t3 = (LongType)23;
}
}
public enum Color
{
Red,
Blue,
Orange
}
public enum LongType : long
{
Solid = int.MaxValue + 1L,
Soft = 0, // 必ず0を定義することが推奨される
Hard = Solid
}
|
列挙型(=列挙体)は、定数のリストを名前付きで管理するための構造である。例えばリスト15-1のColor
列挙型の各値には名前を付けているが、その実体はint
型もしくはlong
型の数値である。数値を明示的に指定しない場合は、宣言された順に0から1つずつ増えた値が割り当てられる。明示的に指定する場合、0が割り当てられる列挙型の値を必ず定義することが推奨されている。
列挙型は数値であるため、数値を列挙型にキャストでき、実際には定義されていない値であってもキャスト可能である。しかし、これは予期しない実行時エラーを起こしかねないので推奨されていない。
Flags属性
using System;
class Program
{
static void Main(string[] args)
{
var colors = Color.Red | Color.Blue;
colors.HasFlag(Color.Red); // True
var f1 = (colors == Color.Red); // False
colors.HasFlag(Color.Yellow); // False
}
}
[Flags]
public enum Color
{
Red,
Blue,
Yellow
}
|
列挙型の値を利用する際に、複数の値を持っている状態を表現したい場合がある。その場合は、列挙型の宣言にFlags
属性を付けることでビットフラグを表現できる。複数の値を持つ値は|
演算子で記述でき、複数とり得る値の中で指定した列挙型の値が含まれているかどうかは、HasFlag
メソッドで検査できる。
イテレーター
イテレーターの宣言とyield
using System;
using System.Collections;
using System.Collections.Generic;
class Program
{
static void Main(string[] args)
{
// a b c z
var samples = MyCollection.GetSamples();
// 1, 9, 25, 49, 81
foreach (var element in new MyCollection())
{
Console.WriteLine(element);
}
}
}
public class MyCollection : IEnumerable<int>
{
public static IEnumerable<string> GetSamples()
{
yield return "a";
yield return "b";
yield return "c";
yield return "z";
}
public IEnumerator<int> GetEnumerator()
{
for (int i = 0; i < 10; i++)
{
if (i % 2 == 0)
continue;
yield return i * i;
}
}
// IEnumerableインターフェースのGetEnumerator()メソッドを実装
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
|
返り値がIEnumerable
型およびそのジェネリスク型のIEnumerable<T>
である場合、yield return
キーワードで反復表現を記述できる。yield return
するたびにIEnumerable
型オブジェクトの要素1つを返すことができ、メソッドが完了した時点で要素の列挙が終わったことになる。
また、クラスがIEnumerable
インターフェースもしくはそのジェネリクス型のインターフェースを実装する場合の、GetEnumerator
メソッドの実装にもyield return
を実装できる。
ローカル関数を使ったイテレーターでのnullチェック
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main(string[] args)
{
IEnumerable<int> input = null;
var old = input.ApplyOld(x => x.ToString()); // ここではまだnullチェックされない
try
{
// 列挙が実行されて初めてApplyOld内の処理が実行され、nullチェックされる
var array = old.ToArray();
}
catch (ArgumentNullException)
{
Console.WriteLine("NullCheck");
}
try
{
// イテレーターを返すローカル関数の場合は、メソッドを呼び出した時点でnullチェックが実行される
var newApply = input.Apply(x => x.ToString());
}
catch (ArgumentNullException)
{
Console.WriteLine("NullCheck");
}
}
}
// LINQのSelectメソッドで同一のことができるが、サンプルとして記述
static class Extensions
{
public static IEnumerable<string> ApplyOld(this IEnumerable<int> source, Func<int, string> converter)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (converter == null) throw new ArgumentNullException(nameof(converter));
foreach (var x in source)
yield return converter(x);
}
public static IEnumerable<string> Apply(this 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();
}
}
|
イテレーターの引数でnull
チェックを行いたい場合、イテレーターを返すメソッドの最初でnull
チェックを行うと、例外が直感的でない場所でスローされることがある。これは、イテレーターを返すメソッドの処理が、メソッドを呼び出した場所では必ずしも実行されず、ToArray
など実際にイテレーターの列挙を実行する場所で行われるためである。C# 7.0で導入されたローカル関数を利用し、イテレーターを返す部分をローカル関数で実装すると、null
チェックがメソッド呼び出しの時点で実行されるようになる。
例外処理
throwとtry-catch
using System;
class Program
{
static void Main(string[] args)
{
try
{
ThrowException();
}
catch (MyException ex) when (ex.Value >= 1)
{
Console.WriteLine("Catch MyException");
}
catch (Exception ex)
{
Console.WriteLine("Catch Exception: " + ex.Message);
throw; // 再スロー
}
}
static void ThrowException()
{
throw new MyException() { Value = 1 };
}
}
public class MyException : Exception
{
public int Value { get; set; }
}
// C# 7.0より例外を式としてスローできるようになった箇所
public class MyClass
{
public string Name { get; }
// 1ラムダ式や式形式メンバーの=>の後
public MyClass() => throw new Exception();
// 2null合算演算子の第2オペランド
public MyClass(string name) => Name = name ?? throw new Exception();
// 3三項演算子の第2・第3オペランド
public MyClass(int i) => Name = i > 0 ? GetNameById(i) : throw new Exception();
private string GetNameById(int i) => $"name-{i}";
}
|
例外の発生をthrow
句で記述できる。C#ではメソッド宣言にスローされる例外を記述することはできず、任意の例外がスローされる可能性がある。スローする例外は、定義済みの例外もしくはException
クラスを継承したユーザー定義の例外をスローできる。
例外処理はtry
-catch
構文で記述し、try
ブロックの中で発生した例外をcatch
ブロックで処理できる。catch
ブロックは、まず例外の型により複数宣言でき、スローされた例外が代入可能な最初のcatch
ブロックが実行される。代入可能なcatch
ブロックがない場合はメソッド呼び出し元に例外がスローされる。C# 6.0からcatch
ブロックにwhen
キーワードでさらにcatchするかどうかの条件を記述できるようになった。
catch
ブロック内で例外を再スローしたい場合は、throw
句を記述する。throw
句の後の例外インスタンスは省略でき、省略した場合は呼び出し階層を記録したスタックトレースが複雑にならないため、特別の理由がない限りは例外インスタンスを省略した方がいいだろう。
C# 7.0より、リスト17-1の最後に示した3カ所で例外のスローを式として記述できるようになった。1ラムダ式や式形式メンバーの=>
の後、2null合体演算子??
の第2オペランド、3条件演算子? :
の第2・第3オペランド(被演算子)、の3つである。これは「C# 6.0以降で式として記述できる箇所が増えたため、それに合わせて例外のスローも式として記述できるようにしたい」という要望に合わせたものである。
リソースの解放
finallyとusingによるリソースの解放
using System;
class Program
{
static void Main(string[] args)
{
var resource = new MyResource();
try
{
resource.Execute();
}
finally
{
resource.Close();
}
using (var r1 = new MyDisposableResource())
using (var r2 = new MyDisposableResource())
{
r1.Execute();
r2.Execute();
}
}
}
public class MyResource
{
public void Execute()
{
Console.WriteLine("MyResource.Execute");
}
public void Close()
{
Console.WriteLine("MyResource.Close");
}
}
public class MyDisposableResource : IDisposable
{
public void Execute()
{
Console.WriteLine("MyDisposableResource.Execute");
}
public void Dispose()
{
Console.WriteLine("MyDisposableResource.Dispose");
}
}
|
利用したインスタンスに対し、リソースの解放などのために特定のメソッドを呼び出す必要がある場面を考えよう。例外がスローされる状況でも必ず解放処理を行うために、try-finally
句を使うことができる。finally
句内の処理は例外が起きた場合でも必ず実行される。しかしこの構文は少し冗長であり、変数のスコープもtry
の外側に及んでしまう。
そのため、C#ではusing
ステートメント(※前述のusing
ディレクティブとは異なるキーワード)が用意されている。IDisposable
インターフェースを実装したクラスはusing
内に記述でき、ブロックを抜ける際に必ずその実装クラスのDispose()
メソッドが呼ばれるようになっている。
ラムダ式
ラムダ式の宣言
using System;
using System.Linq;
class Program
{
static void Main(string[] args)
{
Action a1 = () =>
{
Console.WriteLine("Action");
};
// Action
a1();
Action<int> a2 = i => Console.WriteLine(i);
// 2
a2(2);
Func<int> f1 = () => 1;
// 1
f1();
Func<string, string, int> f2 = (s1, s2) => int.Parse(s1 + s2);
// 54
f2.Invoke("5", "4");
// LINQ
// 9,1
new[] { 1, 3, 5 }
.Where(i => i <= 3)
.Select(i => i * i)
.OrderByDescending(i => i);
// イベント
Changed += (s, ea) => { Console.WriteLine("OnChanged"); };
}
static event EventHandler Changed;
}
|
ラムダ式はデリゲート型などを簡潔に記述するための文法である。ラムダ式を暗黙的な型宣言var
に代入することはできないが、指定された型に変換できるようにコンパイラーが判断する。基本的な記述方法は、(引数) => {式ブロック}
であり、引数が1つの場合は()
が省略でき、返り値の型は式ブロック内でreturn
しているインスタンスの型で判断する。
LINQのメソッド構文を記述するときやイベントの購読処理を記述するときによく利用される。
非同期処理(async/await)
async/await
using System;
using System.Diagnostics;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
// Mainメソッドはasyncにできない
// 非同期メソッドを同期的に待機する場合、GetAwaiter().GetResult() が使える
ExecuteAsync().GetAwaiter().GetResult();
}
static async Task ExecuteAsync()
{
var sw = Stopwatch.StartNew();
// 2秒待機
await TestAsync();
sw.Stop();
// await: 2 [sec]
Console.WriteLine($"await: {sw.Elapsed.Seconds} [sec]");
sw.Restart();
// この時点では待機しない
var task = TestAsync();
// 未await: 0 [sec]
Console.WriteLine($"未await: {sw.Elapsed.Seconds} [sec]");
await task;
// await: 2 [sec]
Console.WriteLine($"await: {sw.Elapsed.Seconds} [sec]");
sw.Restart();
// 一度完了したTaskをawaitすると、すぐに完了する
await task;
// 再await: 0 [sec]
Console.WriteLine($"再await: {sw.Elapsed.Seconds} [sec]");
var task2 = TestAsync();
sw.Restart();
sw.Restart();
// async voidメソッドを呼んでも待機しない
VoidAsync();
// async void: 0 [sec]
Console.WriteLine($"async void: {sw.Elapsed.Seconds} [sec]");
sw.Restart();
var r1 = await TestAsync2();
// await Task<int>: Result=1 2 [sec]
Console.WriteLine($"await Task<int>: Result={r1} {sw.Elapsed.Seconds} [sec]");
var t2 = TestAsync2();
sw.Restart();
// Task<T> のResultプロパティで結果を取得できるが、プロパティアクセスで待機することになる
var r2 = t2.Result;
// Get Task<int>.Result: Result=1 2 [sec]
Console.WriteLine($"Get Task<int>.Result: Result={r2} {sw.Elapsed.Seconds} [sec]");
sw.Restart();
// await同様、一度完了したTaskのResultプロパティは待機せずに取得できる
var r3 = t2.Result;
// Get Task<int>.Result again: Result=1 0 [sec]
Console.WriteLine($"Get Task<int>.Result again: Result={r3} {sw.Elapsed.Seconds} [sec]");
}
static async Task TestAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
}
static async void VoidAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
}
static async Task<int> TestAsync2()
{
await Task.Delay(TimeSpan.FromSeconds(2));
return 1;
}
}
|
C#ではasync
修飾子を持つ関数を非同期関数と呼び、非同期という用語はこのasync
を扱う説明に使っている。C#の非同期プログラミングはasync
のみならず、APIも利用できるが、この記事ではasync
/await
の使い方を中心とした説明にとどめる。
C# 5.0および6.0では、非同期関数は返り値がTask
型およびそのジェネリクス型のTask<TResult>
(いずれもSystem.Threading.Tasks
名前空間)、もしくはvoid
である必要がある。C# 7.0以降では、ある一定の規約に従った任意の型を戻り値にできるようになった。特にValueTask
型(System.Threading.Tasks
名前空間)を使った例を後述の項に載せている。
Task
/Task<TResult>
型を返り値とする非同期関数は、await
式により結果の取得を待機できる。サンプルコードでは非同期に2秒時間がかかる処理をしているが、await
することで呼び出し側が2秒待機していることが分かる。
await Task
はvoid
に相当し返り値を持たず、await Task<TResult>
はTResult
型に相当する返り値を持つ。非同期関数をawait
しない場合は、待機せず、その返り値であるタスクをawait
する、もしくはResult
プロパティにアクセスしようとした時点で待機する。一度待機して完了したタスクは、再度結果を参照する時には待機しない(=再実行されない)。
async void
な非同期関数は待機できない。主にイベントハンドラーの購読に登録する場合に利用することが多いが、例外がスローされた場合の処理が複雑になりがちであり、注意が必要である。
ローカル関数を使ったTaskのキャッシュ
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
// Mainメソッドはasyncにできない
// 非同期メソッドを同期的に待機する場合、GetAwaiter().GetResult() が使える
ExecuteAsync().GetAwaiter().GetResult();
}
static async Task ExecuteAsync()
{
// "Executing time consuming method..." は最初の1回のみ表示され
// 2回目以降はキャッシュされたTaskをすぐに返している
var r1 = await GetAsync();
var r2 = await GetAsync();
var r3 = await GetAsync();
}
static Task<int> cache;
static Task<int> GetAsync()
{
async Task<int> inner()
{
Console.WriteLine("Executing time consuming method...");
await Task.Delay(3000);
return 1;
}
cache = cache ?? inner();
return cache;
}
}
|
非同期メソッドの中には、「初回実行時のみ値を非同期で取得する必要があるが、1回取得すれば2回目以降は非同期処理を実行せずに初回実行時の結果だけを返したい」というユースケースがある。このような場合、最初に実行したときの結果であるTask
インスタンスを保持しておき、2回目以降はそのTask
インスタンスを返せばよい。C# 7.0で導入されたローカル関数を使うことで、このようなTask
インスタンスのキャッシュ処理も簡潔に記述できるようになった。
ValueTaskクラス
C# 7.0より、ある一定の規約に従った任意の型をasync
メソッドの返り値として利用できるようになった。この規約自体はやや複雑であるためこの記事では触れないが、ValueTask
クラスというものが標準でasync
メソッドの返り値の型として追加された。
ValueTask
は名前の通り、「値型として扱えるTask
」的な型である。実装としては、待機している処理が完了したときに得られる値、もしくは完了前であればTask
インスタンスのどちらかを保持する構造体となっている。また、C# 6.0以前でTask
もしくはTask<T>
を使っていたところを、そのままValueTask
もしくはValueTask<T>
に置き換えることができる。
執筆時点で筆者が調べた環境では、.NET Core 1.1(TargetFramework
をnetcoreapp1.1と指定)以外(調べた範囲は.NET Framework 4.7および4.6)も含めて、NuGetから「System.Threading.Tasks.Extensions」パッケージの追加が必要となっている。なおValueTask
クラス自体はNuGetライブラリをダウンロードしなくてもクラスライブラリに含まれているがasyncメソッドの返り値の型として利用するためにNuGetライブラリの追加が必要なケースがある。
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
async Task Inner()
{
var res = await SearchAsync(100);
Console.WriteLine(res);
res = await SearchAsync(1);
Console.WriteLine(res);
}
Inner().GetAwaiter().GetResult();
}
static async ValueTask<int> SearchAsync(int a)
{
if (a != 100)
return 0;
await Task.Delay(1000);
return 1;
}
}
|
これがパフォーマンス上のメリットとなる場合がある。async
キーワードの付いた非同期メソッドとなっているが、ほとんどの場合(=サンプルコードでは「100」以外の値の場合)、同期的に処理を返すような処理があったとする。返り値がTask
の場合、同期的に処理を行う場合でもTask
クラスをインスタンス化しないといけないため、GC(ガベージコレクション)のコストがかかるが、ValueTask
は構造体であるためValueTask
を生成してもGCのコストはかからない。
■
以上でC#の主要な文法を一通り説明した。文法を羅列するだけでもかなりの項目数だったが、本稿では実利用者目線でできるだけコンパクトにまとまるように努力した。ぜひ、日々のコーディングの「あれ、どう書くんだっけ?」という場面で役立てていただけるとうれしい。
1. C# 基礎文法【6.0対応】 ― 1回完結の最速再入門!
項目を羅列するだけでもかなり長くなってしまうC# 6.0の主要な文法を、実利用者目線でできるだけコンパクトにまとめた。日々のコーディングの「あれ、どう書くんだっけ?」を素早く解決するためのリファレンス。
2. C# 基礎文法 最速再入門【7.0対応】
「あれ、どう書くんだっけ?」を素早く解決するための、C# 7.0主要文法がコンパクトにまとまったリファレンス(全3回)。前編では、C#の歴史/開発ツール/プログラムの実行と制御/型と変数/演算子/ステートメントを説明する。
3. C# クラスの基本機能 最速再入門【7.0対応】
C# 7.0主要文法がコンパクトにまとまったリファレンス(全3回)。中編では、名前空間/クラス/メソッド/プロパティ/イベント/インデクサー/演算子オーバーロード/コンストラクターとデストラクターを説明する。