
C# 14 について紹介する記事「Introducing C# 14」の内容について、整理しました。
Extension members
Extension members は C# 14 の headline feature です。この新しい構文は、既存の拡張メソッドと完全な互換性がある。
Extension members によって、拡張プロパティ、拡張演算子、および、静的な拡張メンバーが使えるようになる。
次のコードは extension block の例を示す。
public static class EnumerableExtensions
{
// インスタンススタイルの拡張メンバー: 'source' がレシーバ変数
extension<TSource>(IEnumerable<TSource> source)
{
// 拡張プロパティ
public bool IsEmpty => !source.Any();
// 拡張メソッド(中身は簡略化)
public IEnumerable<TSource> Where(Func<TSource, bool> predicate)
{
// 実装では 'source' をフィルタする
throw new NotImplementedException();
}
// 静的な拡張プロパティ
public static IEnumerable<TSource> Identity => Enumerable.Empty<TSource>();
// 拡張として提供される静的ユーザー定義演算子
public static IEnumerable<TSource> operator +(
IEnumerable<TSource> left,
IEnumerable<TSource> right) => left.Concat(right);
}
}
使用例:
int[] data = ...;
// インスタンス拡張プロパティへのアクセス:
if (data.IsEmpty) { /* ... */ }
// 静的拡張の演算子 + へのアクセス:
var combined = data + [ 4, 5 ];
// 静的拡張プロパティへのアクセス:
var empty = IEnumerable<int>.Identity;
例2(過去の拡張メソッドの書き方):
public static class EnumerableExtensions
{
public static bool IsEmpty<TSource>(this IEnumerable<TSource> source)
=> !source.Any();
}
新しい書き方(C# 14 のコード置き換え):
public static class EnumerableExtensions
{
extension<TSource>(IEnumerable<TSource> source)
{
public bool IsEmpty => !source.Any();
}
}
従来から出来た拡張メソッドの書き方を新しい書き方で置き換えすることができる。また、それに加えて追加できる構文パターンが増えている。
そのほか this に相当する変数のターゲットが同じものをまとめることができる。コードが整理されるはず。
ポイントを整理:
- extension
(IEnumerable source) TSource= generic 型のパラメータsource= this に相当する変数
今回の extension members は拡張メソッド以外に拡張プロパティ、拡張演算子、static 拡張メンバーを追加することができる。
C# のコードは ReactiveProperty や UniRx の登場以降、結構な癖のあるコードを生成できるようになったと思う。以下がその例:
isButton.Skip(1)
.TimeInterval()
.Where(x => x.Interval.TotalSeconds <= LongPressThreshold && !x.Value)
.Subscribe(_ => OnShortPress())
.AddTo(this);
このはコードは、実際的に便利なんだけど、この構文が嫌いな人がいるのも事実だと思う。この書き方をするとなにをしているのかわかりづらくなってしまって、明らかにコメントが増える。コメントを書かないと読めないし、コメントを書かないままにすると逆にコードの品質を保ちづらいから、個人的には一長一短にも思っていたりする。
あと extension members もやろうと思えば、プログラミングの構文を結構なオレオレ仕様に置き換える一助になってしまうと思う。従来と同じでそんなに作りまくるものじゃないと思う。(そういうライブラリを作りたいなら別かもしれないけど)
headline feature なのかもしれないけど、多用するものなのかは考える必要がある。
More productivity for you
この一連の言語機能は共通の goal を持っています。形式的な boilerplate ではなくて、ドメインロジックに集中できるようにする。それぞれの機能単体は、数行・数文字のコーディングの節約だけど、組み合わせることでコードは削減できるし、意図がより明確に伝わるコードになる。
- The field keyword
- Unbound generic types and nameof
- Simple lambda parameters with modifiers
- Null-conditional assignment
- Partial events and constructors
The field keyword
ほとんどのプロパティは、最初はシンプルな自動実装プロパティとして書かれる。accessor にちょっとしたロジックを入れたくなることがある。(null coalescing, clamping, normalization, raising a guard...)C# 14 以前は、その要求を満たすためには、完全な手書きの「backing field」のパターンに書き換えるしかなかった。
private string _message = "";
public string Message
{
get => _message;
init => _message = value
?? throw new ArgumentNullException(nameof(value));
}
キーワード field は (middle step on that evolution path) 途中にあたる中間ステップを提供する。つまり、自動実装プロパティのコードを保ったまま、必要なところにだえ最小限のロジックを差し込むイメージ。
public string Message { get; init; } を定義したあと、init では null を禁止したいコード。ポイントは get/set のどちらかにだけ簡単なロジックのコードを追加しやすくなった。これだと backing field の _message を宣言しなくていい
public string Message
{
get;
init => field = value
?? throw new ArgumentNullException(nameof(value));
}
例2:
public int Volume
{
get;
set => field = Math.Clamp(value, 0, 100);
}
Unbound generic types and nameof
generic 型の名前だけを使ってログや例外を出力したい場合、文字列リテラルを書き込むか、具体化された(型引数付きの)型を使う必要があった。
// 以前 var listTypeName = nameof(List<int>); // "List" const string Expected = "List";
今回から nameof がバインドされていない generic 型を受け付けるようになっている。<int> のような適当な型引数を選んでおく必要がない。
var listTypeName = nameof(List<>); // "List"
これは単純に意図として "型定義の名前" が欲しいだけ、のコードが明確になる。 例を挙げると:
throw new InvalidOperationException(
$"Type {nameof(Dictionary<,>)} must have exactly one key.");
こうすることで Dictionary<TKey,TValue> について話をしてることを、そのまま書くことができる。
要約すると、ダミーを書かなくなった。
Simple lambda parameters with modifiers
delegate で out のようなパラメータ修飾子を使う場合、すべてのパラメータに完全な型注釈をつける必要があった:
delegate bool TryParse<T>(string text, out T value); TryParse<int> parse = (string text, out int result) => int.TryParse(text, out result);
TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
すごく細かいけど、すべてに int をつけていたものが、型の推論が適切になったから out の後ろには int が無い。それだけ。
これが
outrefinscopedといった修飾子で、すべての型を指定しなくてよくなったから楽だよ、と。
Null-conditional assignment
ガード付きの代入を行うときは、明示的な null check が必要だった。
// 以前
if (customer is not null)
{
customer.Order = CreateOrder();
customer.Total += CalculateIncrement();
}
null-conditional operator を使って、直接の代入ができる。
customer?.Order = CreateOrder(); customer?.Total += CalculateIncrement();
これもインデントが減るだけ。でも、実際的にはこうなってるはず:
// customer?.Order = CreateOrder();
if (customer is not null)
{
customer.Order = CreateOrder();
}
// customer?.Total += CalculateIncrement();
if (customer is not null)
{
customer.Total += CalculateIncrement();
}
? で、右式は必要なときだけ評価されるようになる。
Partial events and constructors
partial の機能が拡張された。主に event と constructor をファイル間で分散できるようになった。基本的にはジェネレータがやりやすくなった、という話が主になると思う。
public partial class Widget(int size, string name)
{
// イベントの宣言側(field-like イベント)
public partial event EventHandler Changed;
}
public partial class Widget
{
public partial event EventHandler Changed // イベントの定義側宣言
{
add => _changed += value;
remove => _changed -= value;
}
private EventHandler? _changed;
// 実装側の宣言では、コンストラクター本体のロジックを追加できる
public Widget
{
Initialize();
}
}
MVVM ToolKit などのようなパッケージのできることが増えていきそう。
More performance for your users
Implicit span conversions
Span<T> / ReadOnlySpan<T> は、割り当て(アロケーション)を発生させない API の中核となる型です。C# 14 では、配列・Span・ReadOnlySpan の間に暗黙の変換が追加されました。
以前の書き方:
// 以前 string line = ReadLine(); ReadOnlySpan<char> key = line.AsSpan(0, 5); // 明示的な AsSpan ProcessKey(key); int[] buffer = GetBuffer(); Span<int> head = new(buffer, 0, 8); // 明示的な Span コンストラクター Accumulate(head);
新しい書き方:
// C# 14 以降 string line = ReadLine(); ProcessKey(line[..5]); int[] buffer = GetBuffer(); Accumulate(buffer[..8]);
文字列のスライスの意図をインラインで表現できる。AsSpan(x, y) を書かなくてよくなった。
整理すると
line[..5]は先頭から5文字分のスライスを意味するbuffer[..8]は先頭から8要素分のスライス
range を使ったスライス構文自体は以前から使えたけど void ProcessKey(ReadOnlySpan<char> key) { ... } のようにメソッドの引数が ReadOnlySpan だと自動的に割り当て(アロケーション)を発生させない変換をしてくれる、という話。
なので、あまり気にしなくても自動的にそういう動きをするようになった、という話。
User defined compound assignment
C# 14 では、複合代入演算子(+=, -= など)を明示的に宣言できるようになりました。
以前の書き方:
BigVector sum = BigVector.Zero;
foreach (var v in values)
{
sum = sum.Add(v); // 各イテレーションで中間結果を生成
}
新しい書き方:
BigVector sum = BigVector.Zero;
foreach (var v in values)
{
sum += v; // ユーザー定義の operator += が直接呼ばれる
}
public struct BigVector(float x, float y, float z)
{
public float X { get; private set => value = field; } = x;
public float Y { get; private set => value = field; } = y;
public float Z { get; private set => value = field; } = z;
// 通常の二項演算子 +
public static BigVector operator +(BigVector l, BigVector r)
=> new(l.X + r.X, l.Y + r.Y, l.Z + r.Z);
// 複合代入用の演算子 +=
public void operator +=(BigVector r)
{
X += r.X;
Y += r.Y;
Z += r.Z;
}
}
従来の += 演算子は a += b は a = a + b に展開されるだけだった。そのせいでこのコードは a + b という中間オブジェクトができる。(なので、制限があって a をその場で書き換える実装ができない)
ベクトルや大きな構造体だとコードの置換ではなくて、専用メソッドを呼び出す動きをすればパフォーマンス的に有利になる可能性がある。