この記事は「NEXTSCAPE Advent Calendar 2020」の14日目です。
1. はじめに
ネクストスケープでエンジニアをしている醍醐です。
.NET 5.0が2020年11月にリリースされましたね。
私が所属する部署でも、早速.NET 5.0を利用した開発プロジェクトが立ち上がっています。
.NET 5.0と共にC#のバージョンも9.0にアップデートし、言語仕様にもいくつかの機能追加が行われました。
フレームワーク自身のアップデートも沢山の機能が盛り込まれていますが、言語仕様もどんどん進化しています。バージョンだけ見ても 9.0 ですからね。
本投稿のタイトルにした「最新のC#が最良のC#」、まぁ当然といえば当然ですよね。
前バージョンで足りていなかったと思われる機能 や 開発者からのフィードバック から新しい機能追加を行っているはずですから。
この投稿では、C# 9.0の新しい言語仕様のうち以下の3つについて見ていきたいと思います。
- init専用セッター
- record型
- ターゲット型のnew式
また、私が新しいC#言語仕様を理解するときにいつも取っている手法・考え方を加えた形で説明をしたいと思います。
(ちなみにこの投稿タイトルの「最新のC#が最良のC#」は「最新のポルシェが最良のポルシェ」という車好きな人は聞いたことがあるんじゃないか的なところから持ってきている、私はそんな車好きおじさんです。ポルシェ持ってませんが。)
では早速C# 9.0の新機能について見ていきます。
2. init専用セッター
プロパティ定義時に initキーワード を使用することにより、初期化時のみ設定可能なプロパティ定義が行えます。
使い方は簡単で、以下のようにプロパティ定義のアクセサ定義に init を追加します。
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
// 使い方
var person = new Person { FirstName = "すけお", LastName = "ねくすと" };
person.LastName = "Hoge"; // これはコンパイルエラーとなる。
同様の事をC# 8.0以前で行う場合、以下のように明示的にコンストラクタを用意しました。
// C# 8.0以前で実装する場合
public class Person
{
public string FirstName { get; }
public string LastName { get; }
public Person(string firstName, string lastName)
{
this.FirstName = firstName;
this.LastName = lastName;
}
}
// 使い方
var person = new Person("すけお", "ねくすと");
initキーワードを使うことでimmutableなオブジェクトをより簡単に定義することができるようになりました。
2.1. initは何者か?
動作上は上記説明の範囲で使いこなすことが可能です。
ただ、initっていったい何者なのでしょうか?
C#の言語仕様の拡張は下図にあるように
「C#言語へのシンタックスシュガーによる機能追加」
と
「C#言語およびJITコンパイラやCLRへの機能追加に及ぶ機能追加」
があります。
例えば 型推論のvar などは非常に簡易なC#シンタックスシュガーであり、C#コンパイラにより解決されIL上では推論解決された明示的な型で扱われています。
この辺りはコンパイル後のILを ildasm や IlSpy の様な.NET逆アセンブラツールを使うと目に見えて理解することができます。
では、initを使ったPersonクラスをコンパイルしたアセンブリ(dll)のILを確認してみます。
ILが長いので、コンパイラがinitを解釈した部分のみ抜き出しました。
//...省略...
.method public hidebysig specialname
instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) set_FirstName (
string 'value'
) cil managed
{
//...省略...
「set_FirstName」これはsetアクセサを定義したときに出力されるILです。
ただし「modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit)」という、通常の setアクセサ 定義を行った場合には付かないものが付いています。
IsExternalInitは、CLRのJitコンパイラに対するメタデータで、このsetterはinitによるものであることをJitコンパイラに対して示しています。
modreqはCLSの仕様に以前より組み込まれていたもので、modは「Custom modifiers」を表し、reqは「Required」を表しています。つまり、任意のカスタム属性を付与する仕組みがCLSで用意されていました。
modreqは、Jitコンパイラに対して「このカスタム属性を解釈する必要があり、解釈できない場合はこのメンバーには触れないように」という指示を表しています。
つまり、C# 9.0CLR環境は「set_FirstNameにIsExternalInitメタデータが付与されていることを解釈し、FirstNameは初期化子でのみ設定出来るように制御を行います」。仮にこのILをC# 8.0CLRが読み込んだ場合、「解釈できないIsExternalInitというメタデータが付いており、modreq指定されている為、このset_FirstNameには触れない(使わない)動作」をします。
3. record型
これは結構大き目の機能追加なので既にブログ等で解説記事を書かれてる方も多いですね。
record型は、classみたいに使えて、裏側でいろんな定型コードを自動生成してくれる便利機能な型定義キーワードです。
3.1. 早速使ってみます。
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
// 使い方
var person = new Person() { FirstName = "すけお", LastName = "ねくすと" };
Console.WriteLine(person);
classみたいに定義できます、というかrecordはclassを定義するためのシンタックスシュガーです。
実行結果が以下になります。
Person { FirstName = すけお, LastName = ねくすと }
Console.WriteLine(person)で、リッチにオブジェクト文字列が出力されました。この辺りが冒頭で説明した「定型コードを自動生成してくれる便利機能な型定義」に該当します。
既に述べたようにrecordはC#言語に対するシンタックスシュガーなのでコンパイルされたILを確認すればどんな定型コードを自動生成してくれたのかを確認することができます。
ここではIlSpyでC#コードに逆コンパイルしてみました。ちょっと長いのですが、以下にペタッと貼り付けます。
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using ConsoleApp6;
public class Person : IEquatable<Person>
{
protected virtual Type EqualityContract
{
[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
get
{
return typeof(Person);
}
}
public string FirstName { get; init; }
public string LastName { get; init; }
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Person");
stringBuilder.Append(" { ");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(" ");
}
stringBuilder.Append("}");
return stringBuilder.ToString();
}
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("FirstName");
builder.Append(" = ");
builder.Append((object?)FirstName);
builder.Append(", ");
builder.Append("LastName");
builder.Append(" = ");
builder.Append((object?)LastName);
return true;
}
[System.Runtime.CompilerServices.NullableContext(2)]
public static bool operator !=(Person? r1, Person? r2)
{
return !(r1 == r2);
}
[System.Runtime.CompilerServices.NullableContext(2)]
public static bool operator ==(Person? r1, Person? r2)
{
return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
}
public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(FirstName)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(LastName);
}
public override bool Equals(object? obj)
{
return Equals(obj as Person);
}
public virtual bool Equals(Person? other)
{
return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(FirstName, other!.FirstName) && EqualityComparer<string>.Default.Equals(LastName, other!.LastName);
}
public virtual Person <Clone>$()
{
return new Person(this);
}
protected Person(Person original)
{
FirstName = original.FirstName;
LastName = original.LastName;
}
public Person() { }
}
気になるコードをいくつかピックアップします。
public class Person : IEquatable<Person>
はい、recordは IEquatable インターフェイスをインプリメントしたclassとしてコンパイルされました。
IEquatableは.NET側で定義されている以下のようなインターフェイスです。
Equals()メソッドでオブジェクトインスタンスの等価性を判断するロジックを実装します。
public interface IEquatable<T>
{
bool Equals(T? other);
}
ToString()
ToString()がオーバーライドされ、文字列によるオブジェクト表現をリッチに構築するコードが実装されています。
比較演算子(==)のオーバーロードとbool Equals(Person? other)メソッド
classは参照型なので、通常の比較演算子(==)の振る舞いとしては「同一オブジェクトであるか?」の比較がなされます。しかし、recordの定型コードとして出力されたコードでは「プロパティ値の同一性の比較」が行われます。つまり、値型のような比較の振る舞いになります。
var person1 = new Person() { FirstName = "すけお", LastName = "ねくすと" };
var person2 = new Person() { FirstName = "すけお", LastName = "ねくすと" };
Console.WriteLine(person1 == person2);
// 出力
true
ちなみに、比較として以下のようなclass定義を行った場合、参照型であるclassに対する比較演算子の振る舞いは以下です。
public class PersonClass
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
var person3 = new PersonClass() { FirstName = "すけお", LastName = "ねくすと" };
var person4 = new PersonClass() { FirstName = "すけお", LastName = "ねくすと" };
Console.WriteLine(person3 == person4);
// 出力
false
3.2. こんな定義もできる
以下のように定義することもできます。
public record Person(string FirstName, string LastName);
この場合、以下のようなDeconstruct()メソッドが生成されます。
public void Deconstruct(out string FirstName, out string LastName)
{
FirstName = this.FirstName;
LastName = this.LastName;
}
Deconstruct()により、以下のようにオブジェクトプロパティを分解して変数に取得できます。
var person1 = new Person("すけお", "ねくすと");
(string fName, string lName) = person1;
Console.WriteLine($"{lName} {fName}");
// 出力
ねくすと すけお
3.3. with式
ここまで見てきたように record による型定義は「immutableなオブジェクト定義」になります。
immutableオブジェクトは、一部のプロパティを変更してコピーしたいことがあります。
これは、with式を使って行うことができます。
// Person定義
public record Person(string FirstName, string LastName);
//
var person1 = new Person("すけお", "ねくすと");
var person1_1 = person1 with { LastName = "だいご" };
Console.WriteLine(person1 == person1_1);
Console.WriteLine(person1_1);
// 出力
False
Person { FirstName = すけお, LastName = だいご }
リスト13のILをIlSpyで逆コンパイルしたcsコードはリスト14です。
Cloneメソッドが利用され、LastNameプロパティが変更されています。
Person person1 = new Person("すけお", "ねくすと");
Person person2 = person1.<Clone>$();
person2.LastName = "だいご";
Person person1_1 = person2;
Console.WriteLine(person1 == person1_1);
Console.WriteLine(person1_1);
3.4. どこでrecordを使うのか?
record型で定義したクラスはimmutableであり、使い道としては「Data Transfer Object」であったり「DDDにおける値オブジェクト」であったりが思いつくのではないかと思います。
(この投稿ではこれ以上深く語らないですのですが、機会があればAdvent Calendar外で投稿するかもですm(_ _)m)
4. ターゲット型のnew式
型推論が可能である場合、「new式」で型が省略できるようになりました。
非常に簡易な例は以下です。
List<int> list = new();
でも、これはあまりうれしくないですね。
むしろ、従来のvarを利用した以下の実装の方が視認性も良いでしょう。(タイプ数にこだわったらリスト15の方が少ないタイプ数で記述可能ですが・・・)
var list = new List<int>();
これも完全なるC#言語に対するシンタックスシュガーなので、C#コンパイラは以下の内容として解釈したILを出力します。
List<int> list = new List<int>();
公式docsでも触れられていますが、この新しいnew式は「フィールド定義時」に便利です。
フィールドを定義する際、従来は型定義とnew定義の両方で明示的に型名を記述する必要がありました。
しかし、新しいnew式を利用すると以下のように省略記述が可能になります。
public class A {
// ↓↓↓OK:冗長な記述が必要
private Dictionary<string, List<Person>> personDic1 = new Dictionary<string, List<Person>>();
// ↓↓↓エラー。varはフィールド定義に使用できない。
private var personDic2 = new Dictionary<string, List<Person>>();
// ↓↓↓OK:new側で型の明示を省略。
private Dictionary<string, List<Person>> personDic3 = new();
}
5. ILレベルで何が起こったのかを調べる
C# 9.0の新機能のうち3つについて見てきました。
このような新機能を習得する際、私はまず「docsから新機能の概要と使い方を学ぶ」、次に「新機能のコードに対するILレベルでの実装を確認し、C#コンパイラが何をしたのか?ILレベルではどのような実装になったのか?」を見ています。
こうすると色々見えてくる部分があるんですよね。docsで機能を把握するまでだと、なにか魔法のキーワードを使っているようで、使いどころであったり、ちょっとしたハックな使い方などが見えてきません。
少し古いところで言うと、以前登場したdynamicキーワードというものは、csコード上は非常にシンプルになんでも入れられる動的オブジェクトですが、ILレベルでは結構な処理が動く仕組みになっていることを確認できたり。
そんな見方も新機能を習得する際の1つの方法なのではないかと思っています。
6. まとめ
世の中では最新言語仕様の使用を禁止するような開発現場が有るとか無いとか、そんな都市伝説を耳にします^^;
varの利用に厳しかったり、Linqの利用に厳しかったりするのでしょうか・・・
今回はC# 9.0という事でスレッド回りの話は出していませんが、async/await、Task.Run()、Paralell.Foreachなどを使わずに、Threadクラスのみ使うとか嫌ですよね。。嫌だという精神論のみならず、開発生産性の悪化・メンテナンス性の悪化につながります。
最新機能を使うという事は、簡潔にスマートにC#コードを記述することにつながると思います。チームメンバー全員が最低限の継続的学習を行うことが前提となりますが、それが正常進化な開発方式だと思います。
(勿論、顧客要件や諸事情によりどうにもならない事もあるでしょうが・・・)
少なくともネクストスケープにおいては、最新のフレームワーク・言語を積極的に利用し、Azureクラウドサービスについても有益なものは何でも使ってソリューションの構築を目指します。
ということで、皆さんも.NET 5.0 / C# 9.0ライフを楽しみましょう!
[2020.12.14 13:54 注意事項追記]
現在リリースされている.NET 5.0にはLTSが付いていません。
.NET Core and .NET 5 Support Policyに記述されているように、ロードマップ上 次の.NET 6.0においてLTSが付けられる予定になっています。実運用システムでの開発採用時には、最新情報をウォッチしておくことをお勧めします。