C#が好きな3つの理由

自己紹介

初めまして。北澤 亮太 ( @Anteccq ) といいます。Sansan 株式会社でエンジニアとして日々業務に携わっています。

C# を学生の頃からずっと書いています。10年位前に WinForms で初めて GUI アプリを作ったときの楽しさを忘れられず、そこからずっと C# を続けています。

学生の時は WinForms をちょこっとやり、そのあと長い間 WPF アプリをずっと書いていました。UWP 触ったり WinUI3 を触ったりしながらふらふらしていたら、今の会社に新卒で拾っていただき、業務でサーバーサイド C# 開発に携わらせてもらえるようになりました。一般的な API からサーバーレス、フロントエンドなどさまざまなものまで、すべて C# 漬けでいろいろ書けています。

今日はそんな大好きな C# について、好きなところを好きなように、思いつくままに書きたいと思います。

C# の便利機能の紹介などは多くあると思いますが、「好き」を軸として書かれる記事は珍しいかもしれないですね。本当は 10 個書く予定だったのですが、思いのまま書いたところ文字量が多くなってしまいました。スクロールバーがとてつもなく小さくなってしまいそうだったので、3つに絞ってお送りします。書けなかった残りは、またどこかで書きたいと思います。

C# とは何か

知らない方のほうが少ないと思いますが、一応説明させていただきます。

C# は Microsoft が開発したプログラミング言語です。 2000 年ころから存在しています。他言語の良い部分を取り入れつつ、独自の革新的な言語機能もどんどん実装されており、 2024 年現在のバージョンは 12 になっています。C# 13 のプレビュー版も公開中です。知らない方も多いかもしれませんが、実は C# の開発者の方は TypeScript の開発者と同じ方です。

今の C# は、簡単なステップで「こんにちは」ができるくらい進化を遂げています。

  1. .NET SDK を入れます
    1. パッケージマネージャーからも勿論出来ます。

以下のコマンドを入力していきます。

> dotnet new console -o MyApplication
> cd MyApplication
> echo 'Console.WriteLine("Hello, C#");' > ./Program.cs
> dotnet run

Hello, C#

このくらい簡単になりました。Hello World するためのおまじないは、もう不要です。

C# のことをなんとなく古っぽい、レガシーな言語だと思っていませんか?自信を持って言います。「そんなことありません。 C# はモダンです。」

1. LINQ

LINQ は大好きです。

C# の良さを語る上で、統合言語クエリ LINQ は外せません! LINQ とは、ざっくりいえば、データの集合体をとても扱いやすく、そしてコード上も見やすくなるように設計された言語機能です。データ集合というのは、 int[] 配列や List など内部のコレクションで保持しているデータから、外部のデータベースにあるレコード群まで、さまざまなものが当てはまります。

LINQ にはさまざまな派生がありますが、ここではよく使われる LINQ to Objects かつメソッド構文をメインに話させていただきます(クエリ構文が生かせる場面をあまり知らないので、経験ある方はぜひ教えてください!)。

私の LINQ の好きなところは2つあります。

シンプルな使いやすさ

1つ目は、データ操作が爽快になることです。

コレクションを操作するときには、ベタに書けば、 for や foreach のブロック内で変換を行い、他のリストに詰めなおし、条件分岐を書き、そしてまた反復処理で変換を行い… という処理を延々と書いていくことになると思います。そうするとネストがどんどん深くなっていき、一時的なコレクションが複数出現し、コードが複雑になっていきます。1年後に自分がこの部分に手を加えるのを想像すると、寒気がします。

文字だけだとつらくなってしまうので、少しコードサンプルを見てみましょう。パラメーターとして名前を受け取り、コメントに書いてある処理を行った後に出力するプログラムです。

まずはベタに書いてみるバージョン。

// 名前がパラメーター
// 大文字小文字混在しているのをすべて大文字化
// 大文字化後に名前が DUMMY に完全一致する人物を除外
// 文字列が長い順に並べる
// 名前の文字数でグループ化
// グループごとに文字数と名前の一覧を出力
using System.Globalization;

string[] nameParameters = ["Alice", "Bob", "Carol", "Dummy", "ive", "Isaac", "zoe"];

var transformedNames = new List<string>();
foreach(var name in nameParameters)
{
    transformedNames.Add(name.ToUpper());
}

transformedNames.RemoveAll(x => x == "DUMMY");

var groupByLength = new Dictionary<int, List<string>>();
foreach(var name in transformedNames)
{
    if(!groupByLength.ContainsKey(name.Length))
    {
        groupByLength.Add(name.Length, [name]);
        continue;
    }
    
    groupByLength[name.Length].Add(name);
}

var result = new SortedDictionary<int, List<string>>(groupByLength);

foreach (var item in result)
{
    Console.WriteLine($"{item.Key}: {string.Join(", ", item.Value)}");
}

これを LINQ にするとこうなります。

using System.Globalization;

string[] nameParameters = ["Alice", "Bob", "Carol", "Dummy", "ive", "Isaac", "zoe"];

var group = nameParameters
    .Select(name => name.ToUpper(CultureInfo.InvariantCulture))
    .Where(name => name != "DUMMY")
    .GroupBy(name => name.Length)
    .OrderBy(g => g.Key)
    .ToDictionary(g => g.Key);

foreach (var item in group)
{
    Console.WriteLine($"{item.Key}: {string.Join(", ", item.Value)}");
}

こう見るとかなり違いがあることが分かると思います。文字量 = 正義と言うつもりは全くないですが、ここまで差があるなら、 LINQ はシンプルで分かりやすいと言い切って良いのではないでしょうか。

LINQ を利用すると、ネストが深くなることなく、また行いたい処理単位でメソッドを分けて記述できるため、可読性が上がりつつ修正もかなり楽になります。速度やメモリ効率のパフォーマンスを究極まで追い詰める場合は、地道な for, foreach を行った方が良い場面もあると思います。しかしほとんどの場合では、 LINQ を利用した方が得られるメリットが大きく上回ってハッピーかと思います。

「 LINQ 分かれば凄いシンプルに出来るよ」というのが、私は好きです。

遅延評価

2つ目は遅延評価であるということです。コードを書いた際に、その行で即座に結果が返却される(即時評価)のではなく、結果を評価したいタイミングで変換や条件分岐などが処理されるということです。

LINQ が全て遅延評価というわけでは無いのですが、 LINQ の仕様として基本的に遅延評価であるというのは大きなメリットかなと思います。

具体的には、代表的なメソッドである Select(), Where(), SelectMany() は遅延評価になっています。どういうことかと言うと、これらのメソッドを呼びだすと、ラムダ式で記述した処理はその場では評価されず、 WhereIterator, SelectIterator などクラスのインスタンスがとりあえず返却される仕組みになっています。これらは内部に引数で渡された処理を保持していて、 Iterator パターンによって内部で MoveNext() が呼び出された時に、要素に対して逐次式を評価するようになっています。詳しくは LINQ の Select.cs のコードを観ていただければ分かりやすいです(GitHub Select.cs)。一旦 object を返却し、 反復処理によってメソッドが呼び出さるまで評価を遅延させるようになっています。

じゃぁ何が嬉しいのかと言うと、「最終的な結果で必要な分だけ」処理を行えば良いところです。

こちらもコードサンプルを示したいと思います。まずは遅延評価をしない、すべての処理において即時評価を行う場合です。

// 大文字小文字混在しているのをすべて大文字化
// D から始まる名前のみピックアップ
// DUMMY を除外する
// 最初の2つをピックアップ
using System.Globalization;

string[] nameParameters = ["Alice", "Dave", "Bob", "Carol", "Dummy", "Daisy", "ive", "Damien", "Isaac", "Jane", "zoe", "Dawn", "DUMMY"];

var upperedNames = new List<string>();
foreach(var name in nameParameters)
{
    upperedNames.Add(name.ToUpper(CultureInfo.InvariantCulture));
}

var namesStartingWithD = new List<string>();
foreach(var name in upperedNames)
{
    if(name.StartsWith('D'))
        namesStartingWithD.Add(name);
}

namesStartingWithD.RemoveAll(x => x == "DUMMY");

Console.WriteLine($"{string.Join(", ", namesStartingWithD[..2])}");

//Output: DAVE, DAISY

この場合、実際に列挙を行うのはコレクション先頭から Daisy までの部分で良いはずです。ただし LINQ を利用しないケースでは数回に渡りコレクションを全走査して処理しています(もちろん RemoveAll() でも全走査しています)。

サンプルは多少極端に書いていますが、DUMMY を除外するという処理が他クラスのメソッドとして提供されている場合など適切に処理が切り出されている場合、上記のようなコードだと効率的なコードが書き辛くなります。

LINQ では遅延評価を深く意識せずに利用できます。

string[] nameParameters = ["Alice", "Dave", "Bob", "Carol", "Dummy", "Daisy", "ive", "Damien", "Isaac", "Jane", "zoe", "Dawn", "DUMMY"];

var pickUpNames = nameParameters
    .Select(name => name.ToUpper(CultureInfo.InvariantCulture))
    .Where(name => name.StartsWith('D'))
    .Where(name => name != "DUMMY")
    .Take(2);

Console.WriteLine($"{string.Join(", ", pickUpNames)}");

やはりシンプルに見えますね。

この場合、 LINQ の評価は string.Join() のタイミングで行われます。要素が最初の Alice から列挙され、最後の Take(2) が満たされる Daisy のタイミングで列挙が終了します。そのため全走査されることはありません。コレクション全要素を取り出す必要がないため、パフォーマンス良く処理することができます。そのため膨大なデータを扱う際には、 LINQ を利用したほうが基本的にはパフォーマンスが良くなります。

たとえコードのそれぞれの処理がメソッドで切り出されていたとしても、返り値を IEnumerable インターフェースを実装している型にすれば、内部処理でも LINQ の処理結果を返せますし、外部もそのまま LINQ を継続して利用出来ます。

ほぼ遅延評価の説明になってしまいましたが、シンプルに書けつつ、遅延評価のメリットを享受できるのが LINQ の好きなところです。

即時評価の ToList(), ToArray() は乱用してはダメですよ、適切に使いましょう!ということをとにかく伝えたいです。

2. クライアントも、サーバーサイドも (with C# 大統一理論)

C# はどこでも何でも動きます。というと言いすぎになりますが、大体のことは C# で出来ます。

  • デスクトップアプリを作りますか? MAUI があります(個人的には WPF が大好きですが Win 限定ですね)モバイルも MAUI でできます。UWP なら XBOX 上でも動かせます。
  • Web API を作りますか? ASP .NET Core があります。フロントエンドには Blazor Wasm や Uno Platform があります。
  • ゲームを作りますか? Unity があります。

というように、色んなことを C# でできるようになりました。

いろいろなことができるので、じゃあ全部 C# でやれば良いじゃないかとなります。僕も実際そう思います。クライアントアプリを WPF や MAUI で開発し、バックエンド API として ASP .NET Core を使えば、すべて C# の世界が生まれます。ゲーム業界の方であれば、 Unity + ASP .NET Core と言うと馴染みが深いと思います。SPA を考えるなら、 Blazor WebAssembly + ASP .NET Core API の組み合わせも十分考えられると思います。

そうした全部 C# でやったら幸せじゃないか?というのが、現 Cysharp 代表の 河合さん (neuecc)が提唱する C# 大統一理論です。僕はこの理論が大好きです。

「じゃあ全部 C# で何が嬉しいんだ」ということを、これから書いていきたいと思います。

API Definition as Class

C# で統一することは何が良いのか?色々ありますが、 API - クライアント間のインターフェースを、 C# の型(やインターフェース)で表して共有できることが、一番恩恵が大きいです。

一般的には、サーバーサイド - クライアント間のやりとりには API の仕様書を基にした JSON モデルが利用され、その JSON に合わせたコード定義をサーバー - クライアントがそれぞれが抱えています。それぞれが抱えているということは、 API を利用しているクライアントが多ければ多いほどそれだけ API 仕様のコード定義が複製されていきます。

サーバーサイドとクライアントが C# で統一されているならどうなるか?API 定義を C# のクラス(とインターフェース)としてコードを共有することができるのです。正直これが全てで、 C# 以外のいらんことに頭を回さなくてよいというのがすごい楽です。

やり方はとても簡単で、

  1. まずAPI 仕様用のクラスとインターフェースだけを定義したプロジェクトを作成する。
  2. クライアント、サーバーサイドのプロジェクトで定義プロジェクトを参照する。
  3. 参照した定義を利用してパラメーター、レスポンスの実装を書く。

だけです。

API の仕様に変更が加わった際は、定義プロジェクトを変更すればクライアントとサーバーサイド両方に変更が漏れなく入ります。クライアントだけ、サーバーサイドだけ変更入れ忘れていたということはまずなくなりますし、属性等 C# の言語機能から Visual Studio など IDE の強力な機能まで、恩恵を最大限に受けることができます。

コードファーストとの組み合わせ

突然ですが、直近で C# 大統一理論を感じた体験を書こうと思います。

Blazor Webassembly 製のフロントエンドと ASP .NET Core 製の BFF を gRPC-Web で通信するということを取り組んだ時期がありました。gRPC-Web に関する部分は、コードファーストな protobuf-net.Grpc を利用しました。

最初に言いますと、私は一切 .proto ファイルを記述していません。私は C# 以外は全く書いていません。本当です。すいません、 ReadMe.md は書きました。

先ほど書いたことと同じになりますが、私がやったことは

  1. 共有プロジェクトで C# のインターフェース & クラスで API を定義(App.Shared プロジェクト)
  2. サーバーサイドとクライアントのプロジェクトで App.Shared を参照
  3. App.Shared の定義を利用してサーバーサイドの処理を実装
  4. 同じようにクライアントの処理を実装
    1. サーバーサイドへの通信は DI コンテナから受け取ったインターフェースのメソッドを呼び出すだけ
    2. gRPC-Web を使っている意識は全く無い

だけです。コードレビューも C# のみで完結します。 もちろん gRPC-Web の仕様などはおさえておく必要がありますが、 C# からは「ただメソッドを呼び出すだけ」だったので「え、本当に gRPC-Web 使ってるの?」というくらい C# で世界が完結していました。修正も C# だけで考えれば良いため、ファイル種類別のフォーマットなど、余計な知識に気を払う必要もありません。またクライアントもサーバーも同じ C# のため、機能毎に担当者を縦割りし、サーバーとクライアントを一人の担当者が作業することが可能でした。この開発の柔軟性もかなり良い感じでした。

もう少し詳しい「Blazor Wasm + gRPC-Web による C# 大統一理論」の話は、こちらのスライドをご覧ください: https://speakerdeck.com/sansantech/sansan-20240515-2

speakerdeck.com

こういった、C# に強力に紐づいたライブラリ機能を、ネットワークを超えた開発でも受けられるというのは、 C# の「どこでもうごく」ことの強さではないでしょうか。

3. Dispose - using が好き

ここまでは、まぁ C# 開発者みんな好きだよね、という感じの話をしましたが、最後は個人的に好きな機能を話したいと思います。

私は IDisposable.Dispose() と、それを簡単に利用できるようにした using ステートメントの組み合わせが大好きです。もちろん IAsyncDisposable.DisposeAsync() もです。どこが好きなのかと言うと、終わった時に自動でやってほしいものを、言語機能に投げることが出来るのが良いです。ファイナライザも活用することがありますが、ここでは Dispose に焦点を置いて話します。

よくある使い方としては、アンマネージドリソースの後処理に使われます。データベースのコネクションの終了や、ファイルロックの解放などですね。

await using var fileStream = File.OpenWrite(filePath);

上記のようなコードを一度は書いたことがあるのではないでしょうか?ざっくりと説明しますが、この変数のスコープから処理が抜けると、自動的に fileStream.DisposeAsync() メソッドが呼び出され、その後インスタンスが破棄されます。using ステートメントを利用した場合は、そのブロックを抜けたタイミングでメソッドが呼び出されます。単純な機能です。

それで、この Dispose, DisposeAsync は何を書けるかというと、なんでもかけます。ファイルのロックを解放することもできますし、何らかのログ出力を記述することもできます。データベースへのアクセスもできます。画面上のポップアップも、 Dispose を利用して、なんらかの処理後に自動で消えるようにできたりします。

少し前ですが、ロック用のレコードをデータベースに書き込むことによって、あるデータに対して同時に複数の処理が行われないようにするという処理を書いたことがありました。とりあえずここに書いてみることにします。以下のようになりそうです。

//本来は DI コンテナから受け取る部分
ILockService lockService = new LockService();
IAbcService abcService = new AbcService();

var resource = new TestResource();

try
{
    //ロックが取得出来るまで待つなどの処理をする
    var resourceLock = await lockService.LockAsync(resource.Id);
    // なんらかのしょり
    await abcService.ProcessAsync(resource); 
}
finally
{
    await resourceLock.ReleaseAsync(resource.Id);
}

Release は必ず行われる必要があるため、 try-finally で必ず行われるようにします。

このコードでも十分です。目的は達成出来ているので間違ってはいません。ただ、行う処理が abcService.ProcessAsync() だけでなく、さまざまな処理を行ったりネストが深くなったりした時には、resourceLock.ReleaseAsync() がきちんと呼び出されているかを確認するのは大変です。このコードのレビュワーも、「どこでロック解放してるんだ…?」と目を凝らして見る必要があります。

そこで、 ResourceLock クラスで IAsyncDisposable を実装してみることにします。 DisposeAsync() の中で ReleaseAsync を呼び出すようにすれば…

class ResourceLock : IAsyncDisposable
{
    bool isDisposable;

    async Task DisposeAsync()
    {
        if(isDisposable)
            return;
        
        await ReleaseAsync();
        isDisposable = true;
    }
}

さっきのコードはこうなります。

ILockService lockService = new LockService();
IAbcService abcService = new AbcService();

var resource = new TestResource();

await using var resourceLock = await lockService.LockAsync(resource.Id);
    // なんらかのしょり
await abcService.ProcessAsync(resource); 

ネストも無くなってハッピー!コードレビュワーも await using 宣言されているかどうか気を付ければよいだけなので、目にも精神にも優しいコードになりました。

このような感じで、オブジェクトを破棄させる前に終了させないといけないことや、破棄させなければいけないことをシンプルに書けるのが好きです。めちゃくちゃ汎用性も高いので、「自動で処理が行われるようにしたい」系のものには、Dispose - using の利用を真っ先に考えることにしています。

実はこれは糖衣構文で、内部的には前のコードと変わりません。シンプルではあるんですが、「スコープから抜ける際に必ず実行される」のが言語機能として保証されているのはやはり助かります。かける処理も汎用的なので、お気に入りなイチオシ機能です。

Disposable なオブジェクトを Dispose していない場合に警告を発するアナライザーなど世に出ているので、併用したり、自作して組み合わせ使うとより効果的だと思います。

おわりに

私は C# が好きです。学生の時の WEB アプリを作る実習で、講義では JavaScript を教えられましたが、全部 C# で作って提出したというくらいには好きです。

今回、そんな C# の好きなところを好きなままに書く機会をいただけたので、気持ちのままに書いてみました。ありがとうございます。まだまだ書きたい部分はありますが、頑張って凝縮に凝縮を重ねて3つに絞ってお送りしました。

いつになるか分からないですが、どこかで続編を書きたいと思います。お楽しみに。