一日一つ、変わってく

上京して働く業務システム開発エンジニアのブログ

EffectiveC# 6.0/7.0 を読んだ感想と学び

※2024/4月現在、C# の最新verは12であることに注意

 C#をより良く書いていくためのtips集。細部的かつ具体的なtips集のため、なかなかスイスイと読んでいくのは難しい。もう少し抽象度の高い記述、コンセプト化があると読み進めやすいだろうなと思った。ただ、苦労しながら読んでいくうちに、筆者の一貫した価値観といったものを感じ取れ、中級以上を目指すための適切な心構えを感じ取ることができた。まとめると、抽象的な学びは下記の通り。

■本書のメッセージを抽象化して理解すると

  • 歴史のある言語なら、同じ目的を達成するために、複数のアプローチがある。それぞれのアプローチを知って、より良い方法を選択すること。新しいバージョンで登場した記法のほうが、簡潔にわかりやすくかけることが多い
    • 言語、ランタイムのバージョンアップごとに、今より便利に書ける書き方は無いか、チェックしておくべし
  • 言語ごとのリソース管理の手法は抑えておくこと。ガベージコレクション、デストラクタの仕様、アンマネージドリソースの解放手法
    • リソースリークには常に注意しよう
  • どのようなことをすると負荷がかかるか理解しよう。負荷がかかる処理を簡単に実行しないこと
  • 遅延評価を活用して、メモリを効率的に利用しよう
  • ジェネリックインターフェイス、デリゲートなどの機能を利用して、拡張的なプログラミングを行おう
  • コーディング漏れを防ぐような、初期化子やテクニックは積極的に利用しよう

 私にとっては知らない単語や概念もそこそこ出てくるので、都度LLMに意味や意図を解説したりしてもらいながら読み進めた。特にジェネリックに関しては、自分にとってはかなり発展的な活用方法が語れていた。知らないよりは知っていたほうがいい、という知見が多そう。 

 それぞれのtipsのメッセージにおいてはC#だけでなく、OOPにおいて汎用的にそうだな、といったものもある。意味や背景を理解していくことで、OOPへの習熟度も深められると感じた。

読んでいて参考になったメッセージ、理解内容の言語化

筆者としては、ローカル変数の型を宣言する場合にはvarを推奨します。これは筆者の経験に由来するもので、varを使用したほうが開発者にとって重要なポイント(コードの意味)に注力でき、ローカル変数の方という細部を気にせずに済ませられるからです。

開発者が初期化式から意味論的情報を明確に得られないようであれば、varを使うべきではなく、型を明記してその情報を伝えられるようにするべきです。

確かにそうね。

constよりもreadonlyを使用すること

C#にはコンパイル時定数と、実行時定数がある。constはコンパイル時定数であり、プリミティブな型しか適用できない。実行時定数は全ての型で利用できる、という話

キャストにはis またはasを使用すること

is asを利用すれば、変換結果はnullになる。nullチェックをすればよいので、try…catchも不要になり、コード量が少なくできる。

string.Format()を補間文字に置き換える

そのほうが楽だよね

文字列指定のAPIを使用しないこと

ここでハードコードされた文字列をnameof演算子に置き換えることによって、名前の変更操作をした場合でも整合性が維持されるようになります。

nameof演算子を使用する利点は、シンボル名の変更が変数名にも正しく反映されるという点です

IDEのサポートを受けて文字列値が書けるのがいいよね。

デリゲートを使用してコールバックを表現する

メソッドを変数として渡せます。ある処理を終えて特定の処理を呼び出す、ということを、いろいろなメソッドで行いたいなら、if分岐とかで頑張るのではなく、デリゲートを利用するとよいよね

イベントの呼び出し時にnull条件演算子を使用すること

?.を使って、安全にアクセスしよう

ボックス化およびボックス化解除を最小限に抑える

ボックス化とボックス化解除は、System.Objectやインターフェイス型が期待される場面において値型を使用する場合に必要となります。しかしこれらは常にパフォーマンスを落とす操作であることに注意が必要です。

C#のオブジェクトは、System.Objct系のコレクションとかに格納はできてはしまうのだけど、パフォーマンスが良くないので、やめようね

親クラスの変更に応じる場合のみnew修飾子を使用すること

親クラスから継承している子クラスでの話。子クラス独自の定義をするときだけnewをつけましょう。親クラスと同じ仕様でいいなら、newをつけた意味は?となる

.NETのリソース管理を理解する

C#においては、ガベージコレクションは不要になったオブジェクトを自動的に検出して解放する。ガベージコレクションのタイミングは制御できない。

ファイナライザを使うよりも、IDisposaleインターフェイスを利用して、Disposeメソッドを利用して、リソースを開放するべし

デストラクタ…OOPにおいて、オブジェクトの破棄タイミングで呼び出されるメソッドのこと。言語によっては、ファイナライザ、デストラクタが区別されることもある。C#では、ファイナライザの利用は推奨されていない

ヒープのコンパクション…GCが行うメモリ最適化のこと。断片化しているメモリ空間を整理して、効率的にメモリを利用することができる

メンバには割当演算子よりもオブジェクト初期化子を使用すること

一旦インスタンス生成してから値をいれるのではなくて、コンストラクタを指定するのではなくて、インスタンス生成時に割り当てしましょう。初期化以降の変更を防ぐようなスコープ設定もできるし、ブロックも広げずに済むし。

staticメンバを適切に初期化すること

クラスに静的メンバーを保つ場合は、通常のコンストラクタではなく、staticコンストラクタで値を設定しよう

初期化ロジックの重複を最小化する

いろいろなパラメータのパターンに応じたコンストラクタを用意したい場合、オーバーロードよりも、できる限りデフォルト引数を使おう

不必要なオブジェクトの生成を避けること

ローカル変数が参照型であり、かつ非常に頻繁に呼び出されるメソッド中で使用する必要がある場合には、その変数をメンバ変数へ昇格すべきです

何度も同じインスタンスをnewすることは避けよう。それなら、ローカル変数でキャッシュしよう。無断な変数をたくさんつくることを避けよう。ヒープオブジェクトの生成と、破棄にはそれなりに負荷がかかるよ

コンストラクタ内では仮想メソッドを呼ばないこと

仮想メソッドは、オーバーライドされる可能性があるため、オブジェクトの初期化が完了ていないタイミングで、仮想メソッドを呼び出すと、派生元か派生先がどちらの仮想メソッドが呼ばれるか非常に把握が困難となる。

 オブジェクトの初期化が完了した後であれば、大丈夫。派生先で仮想メソッドを呼び出せば派生先の、派生元で呼び出せば派生元のメソッドが呼び出される。

標準的なDisposeパターンを実装する

  • IDispsalbeインターフェイスを実装してリソースを解放しよう
    • Disposeメソッドの中で、適切にリソース解放をしよう
      • マネージリソースのリソースが残るようなケースであれば、nullを格納するなどして、参照を切ろう
  • クラスが非マネージリソースを直接扱う場合に限り、防御策としてファイナライザを実装する
  • Disposeとファイナライザはいずれも、派生クラスにおいてリソース管理をそれぞれオーバライドできるよう、仮想メソッドに処理を委ねるようにしよう

ジェネリックによる処理

最低限必須となる制約を常に定義すること

用途ごとに想定した型の制約をwhere 句で、interfaceを指定しよう。ただ、型を多数定義しすぎると、使いづらくなってしまう。制約による安全性、制約によって発生する作業量のバランス等を考えて実装しよう

実行時の型チェックを使用してジェネリックアルゴリズムを特化しよう

ジェネリックの中で、型チェックを行い、型ごとの専用のアルゴリズムを用意できる。ジェネリックによって、共通する部分のコードの再利用性は高めつつ、よりよいアルゴリズムを型ごとに作成できる

IComapableとICompareにより順序関係を実装する

カスタムソートを実装したいときは、上記のライブラリを使いましょう

破棄可能な型引数をサポートするようにジェネリック型を作成すること

ジェネリック型を設計する際、型引数としてIDisposableを受け入れつつ、ジェネリック型自身にもIDisopsableを実装しよう。

 →ジェネリック型のインスタンスが破棄される際に、型引数のDisposeメソッドを呼び出すことで、リソースリークを防ぐことができる。

ジェネリックの共変性と反変性をサポートする

同じジェネリックに、継承グループが同じ型を指定してしていくことがある場合に、有用なテクニックの話。ジェネリックのオブジェクトを作成するときに、共変性をサポートしている場合、同じ継承グループであれば、指定した型以外ででも、そのジェネリックオブジェクトに別の型指定したジェネリックオブジェクトを突っ込めるよ、という話。親クラスの型に、子クラスのインスタンスを格納できるよ、という話に近い。反変性は、その逆で、親クラスの型指定したジェネリックのオブジェクトを、子クラスの型を指定したジェネリックオブジェクトに代入できる、という話。

型パラメータにおけるメソッドの制約をデリゲートとして定義する

ジェネリッククラスの型パラメータに、デリゲートを制約として指定することで、特定のメソッドの存在を要求できる → 一般的なinterfaceと同じように、呼び出し側では、実際に指定する型パラメータがなんであれ、指定されたクラスのパラメータを気にせずに動かせる。ストラテジーパターン実現のためのテクニックっぽいかな。

26 ジェネリックインターフェイスとともに古いインターフェイスを実装すること

  • 前段として、ジェネリックが実装される前の.Net Frameworkでは、インターフェイスしかなかった。汎用的なビジネスロジックを実装する際に、型をobject型にするしかなかった
  • ジェネリックが登場した後は、型を引数として渡せるようになったため、ボックス化、アンボックス化のオーバーヘッドを減らせるようになった
  • 一方で、ライブラリ的に使われるロジックの場合や、古い実行環境での活用を想定されている場合は、インターフェイスのみの実装も許可して、汎用的に使えるようにしよう、という話

27 最小限に制限されたインターフェイスを拡張メソッドにより機能拡張する

拡張メソッドの基本的な話。拡張メソッドを使えば、インターフェイスの構造を変えることなく、メソッドを設けることができるよ、という話。

28 構築された型に対する拡張メソッドを検討すること

オブジェクトモデルに対する実装も、拡張メソッドの採用を検討してよい。データモデルと実装を切り分けることができるし、使い回しやすくなる

LINQを扱う処理

29 コレクションを返すメソッドではなく、イテレータを返すメソッドとすること

LINQを使ったなら、IEnumerableで返しましょう、という話。遅延評価を使って、メモリの消費量を軽減しよう、という話

30 ループよりもクエリ構文を使用すること

クエリ構文でコレクションを処理すれば、遅延評価が使えるので、パフォーマンスが良いケースがある。全てそうとは言い切れないが、基本的にはクエリ形式のほうがパフォーマンスが良い。

31 シーケンス用の組み合わせ可能なAPIを作成する

イテレーターを返す細かいメソッドを自分で作ることで、カスタムロジックをシーケンスに対して組み合わせて実行できる。シーケンスに関して複雑な処理を組みあわせて行いたい場合は、この手法を用いよう。

32 反復処理をAction、Predicate、Funcと分離する

行いたいデリゲートの内容に合わせて、適切なデリゲート型を利用し、組み合わせて、実装しよう、という話。voidで動かしたい部分、true,falseの判定をしたい部分、データに対してフィルターして値を返したいロジックなど、適切なデリゲート型を選ぼう

33 要求に応じてシーケンスの要素を生成しよう

yield returnを使って、必要なタイミングで要素を生成しよう という

34 関数引数を使用して役割を分岐しよう

デリゲートを使って、外からデリゲートを注入するようにして、柔軟で拡張性のあるロジックを作成しよう

35 拡張メソッドをオーバーロードしない

複数の名前空間で同名の拡張メソッドがある場合、どのメソッドが呼ばれるか曖昧になる

36 クエリ式とメソッド呼び出しの対応を把握する

ラムダ式の処理から、シンプルで読みやすいクエリ式への移行を検討する場合、メソッドではどのように処理が行われるのか、ということを把握しておこう

37 クエリを即時評価ではなく、遅延評価すること

遅延評価の基本的な話。イテレーターを受け取っていても、ソートのような全件操作が必要な操作を早い段階で行わないように気をつける

38 メソッドよりもラムダ式を使用すること

読んでもあまり、自分の言葉でメリットを言語化することができていない

39 FuncやAction内では例外をスローしないこと

デリゲートの中で例外をスローすると例外発生源医がわかりづらくなる。シーケンスに対して例外が発生しないことを保証した後に、デリゲートに値を渡すべき

40 即時実行と遅延実行を区別すること

それぞれの特性を理解して、評価して、アルゴリズムを作成すること

41 コストのかかるリソースを維持し続けないこと

メソッドが外部の変数を参照する場合、そのメソッド実行時にはクロージャによって専用のクラスが作られる。長い間大きくメモリを消費するクロージャを作らないように気をつける。usingを使って参照スコープを絞るなど行うこと。nullを参照先に代入して参照を切るなど。

42 IEnumerableとIQueryableを区別しよう

ORMを実装しているなら、IQuerableを使ってデータベースのソースにLINQチックにアクセスできる

43 クエリに期待する意味をSIngle()やFirts()を使用して表現すること

Singleはただ1つの要素を返すことを期待するのに対して、First()は複数の要素の中から1つを取り出す操作

44 束縛した変数を変更しないこと

クロージャに渡されている変数を、クロージャの外で変更しないこと。予期せぬ動作につながる。

第5章 例外処理

45 契約違反を例外として報告すること

例外報告はコストが高いので、制御フローとして利用しないこと。正常実行していないときにのみ利用すること。例外をスローする可能性があるメソッドを作成する場合、その例外を発生させ得る条件をチェックするようなメソッドを用意するべき。例外クラスに処理させるのではなく、ハンドリングして通知する。

46 usingおよびtry…finallyを使用してリソースの後処理を行う

 disopseを使うよりも、usingを使ってリソースを開放する。途中で例外がお凍っても、確実にDisposeを利用できる。コンパイラでは、内部的には、try…finallyを用意する。

47 アプリケーション固有の例外クラスを作成する

 独自の例外処理クラスを設けることで、必要な情報をより伝えることができる。

48 例外を強く保証すること

まず、基本的な例外保証を満たすこと。関数内で例外がスローされて処理が別の場所に移っても、リソースリークを行わせないこと。強い例外保証では、例外によって処理が修了した場合でもプログラムの状態を変化させないことを目指す。データ変更は下記のガイドに従いって行う

  • 変更対象のデータのための防御的コピーを用意する
  • 作成した防御的コピーに対して値を変更を行う、その際、例外スローされる可能性のある処理も含まれる
  • コピーを元のデータと置き換える。この処理では例外をスローできない

デリゲートおよび、リソース解放を担う処理では、デリゲートに登録するメソッドは実行されない。

49 catchからの再スローよりも例外フィルタを使用する

catch句に条件ロジックを追加するのではなく、where 句を書いてスローする。例外フィルタを追加することで、例外を完全に制御できる場合にのみcatch句が実行されるように例外処理を実行できる。catch句に一度入ってしまうと、パフォーマンスにかなりの影響が出る。例外フィルタの場合、フィルタで例外が処理できない場合にはスタックの巻き戻しが怒らず、catch句も実行できないため、パフォーマンスの改善が見込める。

50 例外フィルタの副作用を活用する

catch句に入る前に、例外フィルタでログを記述するようなメソッドを用意することで、網羅的に例外の情報を収集して、好きなようにロギングができる