🟣

「モダンC#」に入門しよう!2025【.NET10/C#14】

に公開

はじめに

みなさん、C#書いてますか?

TIOBE Indexの2025年11月の見出しは「Is C# going to surpass Java for the first time in history?」でした。

11 月の見出し: C# は史上初めて Java を追い抜くことになるのか?
Pythonに代わって、プログラミング言語C#が最も急成長を遂げています。C#がこのペースを維持できれば、2025年のTIOBEプログラミング言語オブザイヤーになるかもしれません。C#はどのようにしてこれを達成したのでしょうか?
https://www.tiobe.com/tiobe-index/

なんか注目されてるらしいです、C#。ホントかなぁ?

C#は互換性を重視した慎重な言語なので、10年・20年前のコードがそのまま動いちゃう言語です。ところが同時に、C#は変化が激しい積極的な言語[1]でもあって、新文法や新記法をバンバン投入します。

C#って本当に誤解が多く、イメージで語られることが多い、過小評価された言語です。
で、その誤解・過小評価イメージの中心に居るのが「レガシーC#」。

  • 「C#はJavaみたいな言語でしょ?」っていう方、認識がかなり古いです(20年前相当)[2]
  • 「C#はWindows限定なんでしょ?」という方、それは10年前の情報です[3]
  • 「C#って冗長でしょ?」という方。それも情報が古い。4・5年前からスクリプトっぽく書けます[4]
  • 「C#でJIT実行なんだよね」という方。これももう違います

上みたいな例のC#は、C#でも「レガシーC#」 です。これは今の「モダンC#」には当てはまりません。正しい情報をちゃんと知ることが大事です。

でも、ふるーい書き方で困ってないよ、という方。
それ、もう公式に非推奨だったり、もっと便利な書き方があったりするかもしれません。
レガシーC#は、Pythonでいうなら2.x系、JavaScriptでいうなら3系や5系みたいなものです。

モダンC#はいきなりコードを全部変えなくてもOK!。気に入った書き方を少しずつ取り入ればOKです。レガシーとモダンが入り乱れてても互換性がしっかりあるので問題ありません。

新言語バージョンの「C#14」と新しいランタイムの「.NET10」が登場しました。
いい機会なので、「モダンC#」に入門しましょう!

https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/

モダンC#の定義

さて、「モダンC#」って何でしょう
わかりやすくするために、この記事では以下の定義とします!

  • 対象範囲: 過去5年以内にC#に追加された記法・文法・APIを使うこと
  • 例外: 一連の連続した機能が5年以内に登場している場合、5年前以前のものも含めて「モダンC#」とみなします

含まれないもの

以下は "もう"「モダンC#」じゃないって定義とします。すでに「基本」、場合によっては「レガシーより」かもしれません。

  • 拡張メソッド[5]: C#3.0(2007年)
  • LINQ[6]やラムダ式: C#3.0(2007年)
  • dynamic[7]: C#4.0 (2010年)
  • async/await[8]: C#5.0(2012年)
  • タプル記法/ValueTupleと分解構文: C#7.0(2017年)
  • 非同期stream await foreach / await using[9]: C#8.0(2019年)

なお今回の記事では、ランタイムの違いは対象にしません。
レガシーランタイムと呼べるものは.NET Framework, Mono, Unityと色々ありますが、モダンC#はそれらのランタイム上でも大半動くので大きな影響が少ないからです。

最新の .NET SDKpolyfill入れてね!

https://dotnet.microsoft.com/ja-jp/download

https://www.nuget.org/packages/PolySharp

モダンC#の特徴

モダン

では、具体的に「モダンC#」って何を指すのか 見ていきましょう…!

モダンC#で追加された機能・構文・記法を整理していくと、闇雲に追加しているんではなく、いくつかの流れがあることがわかります。

具体的に一つずつ見ていきます。

シンプルコード

シンプルコード

  • 難易度:⭐☆☆
  • 取り入れやすさ:⭐⭐⭐
  • オススメ度:⭐⭐⭐

どういうものか(シンプルコード)

モダンC#はシンプルコードです。やたらインデントが多くて横スクロールが必要になるC#はレガシーです。

これ、一言でざっくり言えば、 「モダンC#はスクリプト言語っぽい書き方ができる」 ってことです。

スクリプト言語でおなじみの書き方がモダンC#でもできます。

最近の 生成AI(LLM)にもコンテクスト消費が少ないので向いてます[10]。特にC#の対象アプリはスクリプト言語と違って規模が大きくなりがちなので、全般的にコード量が減るメリットは大きいです。

また、新記法の方がパフォーマンスが良い内部コードに変換される[11]ので、書きやすくパフォーマンスいい、なんていいとこ取りもできたりします。polyfill経由の下位互換性も高いので、まず取り入れるモダンC#の一歩として、シンプルコードはオススメです。

代表例:1行 Hello World

1行Hello Worldは、モダンC#では結構前(C#10, 2021年~)から可能です。

モダンC#は1行Hello World
Console.WriteLine("Hello World");

先日リリースの .NET10 ではそれに加えて1ファイル実行、shebangも使えます。

.NET10ではshebangつかえる
#!/usr/bin/env -S dotnet run
Console.WriteLine("Hello World");

もっと実用的な、大規模コードでもいろんなところがシンプルに書けるようになっています。

トップレベルで省略できる要素

ファイルのトップレベルで省略できるようになったものたち:

  • トップレベルステートメント
    • Mainメソッドの省略(C#9)
    • クラスの省略(C#9)
  • 暗黙的なusing(C#10)
  • global using(C#10)
Mainメソッドの省略(C#9)

トップレベルステートメントにより、Mainメソッドを書かずに直接コードを書けます。

// レガシー
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World");
        var name = args.Length > 0 ? args[0] : "World";
        Console.WriteLine($"Hello, {name}!");
    }
}

// モダン(Mainメソッドの省略)
Console.WriteLine("Hello World");
var name = args.Length > 0 ? args[0] : "World";
Console.WriteLine($"Hello, {name}!");
// argsは自動的に使える

ポイント:

  • Mainメソッドの定義が不要になります
  • コマンドライン引数argsは自動的に使えます
  • asyncや戻り値も対応しています

https://learn.microsoft.com/ja-jp/dotnet/csharp/fundamentals/program-structure/top-level-statements

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/proposals/csharp-9.0/top-level-statements

クラスの省略(C#9)

トップレベルステートメントにより、クラス定義も省略できます。

// レガシー
namespace MyApp
{
    class Program
    {
        static void Main()
        {
            Console.WriteLine("Hello");
        }
    }
}

// モダン(クラスとMainの省略)
Console.WriteLine("Hello");

ポイント:

  • Programクラスの定義が不要になります
  • Mainメソッドの省略と合わせて使います
  • 小さなスクリプトやツールの作成が簡単になります
暗黙的なusing(C#10)

暗黙的なusingにより、よく使う名前空間が自動的にインポートされます。

// レガシー
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

Console.WriteLine("Hello");
var list = new List<int> { 1, 2, 3 };

// モダン(Implicit Usings有効時)
// using が不要!
Console.WriteLine("Hello");
var list = new List<int> { 1, 2, 3 };

プロジェクトファイル(.csproj)での設定:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

ポイント:

  • プロジェクトの種類に応じて自動的にusingが追加されます
  • SystemSystem.Collections.GenericSystem.Linqなどが自動インポートされます
  • 必要に応じて明示的にusingを追加できます

https://learn.microsoft.com/ja-jp/dotnet/core/project-sdk/overview#implicit-using-directives

`global using`(C#10)

global usingは、1箇所で宣言すればプロジェクト全体で使える名前空間を定義できます。

// GlobalUsings.cs(専用ファイルを作成するのが慣例)
global using System.Text.Json;
global using System.Collections.Concurrent;
global using MyApp.Models;
global using MyApp.Services;

// 他のすべてのファイルで自動的に使える
// Program.cs
var person = new Person("Alice", 30);  // MyApp.Modelsのusing不要
var json = JsonSerializer.Serialize(person);  // System.Text.Jsonのusing不要
Console.WriteLine(json);

エイリアスとの組み合わせ:

global using Json = System.Text.Json.JsonSerializer;

// どのファイルでも使える
var json = Json.Serialize(data);

ポイント:

  • プロジェクト全体で共通のusingを1箇所で管理できます
  • 各ファイルでのusing宣言が不要になります
  • 型エイリアスと組み合わせて使うと便利です

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/using-directive#the-global-modifier

型定義の簡略化

型を簡潔に定義できる記法:

  • record(C#9)、record struct(C#10)
  • ターゲット型new式 new()(C#9)
  • プライマリコンストラクタ(C#12)
  • 複雑な型エイリアス(using alias)(C#12)
  • フィールドに基づくプロパティ(C#14)
record(C#9)

record(Record型) は、データを保持するクラスを簡潔に定義できる機能です。自動的にEqualsGetHashCodeToStringが実装され、immutable(不変)なデータクラスが作れます。

// record の定義
record Person(string Name, int Age);

// 使用例
var person1 = new Person("Alice", 30);
var person2 = new Person("Alice", 30);

// 自動生成されたEqualsで比較
Console.WriteLine(person1 == person2);  // True(値による比較)

// 読みやすいToString
Console.WriteLine(person1);  // Person { Name = Alice, Age = 30 }

// withで部分的なコピー
var person3 = person1 with { Age = 31 };

ポイント:

  • 値ベースの等価性比較が自動的に実装されます
  • with式で簡単にプロパティの一部を変更したコピーが作れます
  • ToString()が読みやすい形式で自動実装されます

関連:「安全なコード

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/record

`record struct`(C#10)

record structは、recordの構造体版です。値型でありながらrecordの便利な機能を使えます。

// record struct の定義
record struct Point(int X, int Y);

// 使用例
var point1 = new Point(10, 20);
var point2 = new Point(10, 20);

Console.WriteLine(point1 == point2);  // True
Console.WriteLine(point1);  // Point { X = 10, Y = 20 }

// withで部分変更
var point3 = point1 with { X = 15 };
Console.WriteLine(point3);  // Point { X = 15, Y = 20 }

ポイント:

  • recordと同じく値ベースの等価性比較が可能です
  • 小さなデータ構造に適しています
  • 値型なのでstack割り当てされます

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/record

ターゲット型new式 `new()`(C#9)

ターゲット型new式は、左辺の型が明確な場合に右辺の型名を省略できる機能です。

// レガシー
Person person1 = new Person("Alice", 30);
List<int> list1 = new List<int>();

// モダン(右辺の型省略)
Person person2 = new("Alice", 30);
List<int> list2 = new();
Dictionary<string, int> dict = new();

// フィールド初期化でも便利
class MyClass
{
    private readonly List<string> items = new();
    private readonly Dictionary<int, string> map = new();
}

ポイント:

  • コードが簡潔になります
  • 型名の変更時に修正箇所が減ります
  • ジェネリック型で特に便利です

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/proposals/csharp-9.0/target-typed-new

プライマリコンストラクタ(C#12)

プライマリコンストラクタは、クラスや構造体の定義時に直接パラメータを宣言できる機能です。

// クラスでのプライマリコンストラクタ
class Person(string name, int age)
{
    // パラメータをそのまま使える
    public string Name { get; } = name;
    public int Age { get; } = age;

    public void Introduce()
    {
        // メソッド内でもパラメータが使える
        Console.WriteLine($"私は{name}{age}歳です");
    }
}

// record では元々サポート済み
record Product(string Name, decimal Price);

// 使用例
var person = new Person("Bob", 25);
var product = new Product("ノートPC", 120000);

ポイント:

  • コンストラクタのボイラープレートコードを削減できます
  • パラメータはクラス全体で使用可能です
  • recordでは最初から使える機能でした

https://learn.microsoft.com/ja-jp/dotnet/csharp/whats-new/tutorials/primary-constructors

複雑な型エイリアス(using alias)(C#12)

型エイリアス (using alias)は、複雑な型に短い別名を付けられる機能です。C#12からはジェネリック型やタプル型にも使えるようになりました。
ジェネリクスのジェネリクスとか、タプル記法とか、長くなりがちなのが省略できるのは嬉しい!!!

// 複雑なジェネリック型にエイリアス
using UserDict = System.Collections.Generic.Dictionary<string, System.Collections.Generic.List<int>>;

// タプル型にエイリアス
using Point = (int X, int Y);
using Range = (int Start, int End);

// 使用例
UserDict users = new();
users["Alice"] = [1, 2, 3];

Point point = (10, 20);
Console.WriteLine($"X={point.X}, Y={point.Y}");

Range range = (0, 100);
Console.WriteLine($"範囲: {range.Start}{range.End}");

ポイント:

  • 長い型名を短く読みやすくできます
  • ジェネリクスのジェネリクスとかタプルに意味のある名前を付けられます
  • ファイルスコープで使用可能です

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/using-directive#the-using-alias

フィールドに基づくプロパティ(C#14)

フィールドに基づくプロパティ(半自動実装プロパティとも呼ばれていました)は、バッキングフィールドを自動生成しつつ、getterやsetterにカスタムロジックを書ける機能です。

// レガシー(完全手動)
public class Person
{
    private string _name;
    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrEmpty(value))
                throw new ArgumentException("Name cannot be empty");
            _name = value;
        }
    }
}


// モダン(半自動実装プロパティ)
public class Person
{
  public string? Name
  {
    get;
    set
    {
      if (string.IsNullOrEmpty(value))
        ArgumentException.ThrowIfNullOrEmpty(value);
      field = value;    // fieldキーワードで自動生成されたバッキングフィールドにアクセス
    }
  }
}

// 初期化も簡潔に
public class Product
{
    public decimal Price
    {
        get;
        set => field = value < 0 ? 0 : value;
    } = 100m; // 初期値も設定可能
}

ポイント:

  • fieldキーワードでバッキングフィールドに直接アクセスできます
  • バッキングフィールドを明示的に宣言する必要がなくなります
  • 検証ロジックやカスタム処理を追加しながら、コードを簡潔に保てます
  • 初期値の設定も可能です

https://learn.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-14#field-keyword

インデントが1段階減らせる

インデント地獄から解放されます:

  • file-scoped namespace(C#10)
  • using宣言(C#8)
file-scoped namespace(C#10)

file-scoped namespaceは、ファイル全体に適用される名前空間宣言で、ネストの深さを1段階減らせます。

// レガシー
namespace MyApp.Services
{
    public class UserService
    {
        public void DoSomething()
        {
            // 3段階のインデント
        }
    }
}

// モダン(file-scoped namespace)
namespace MyApp.Services;

public class UserService
{
    public void DoSomething()
    {
        // 2段階のインデント
    }
}

ポイント:

  • ファイル全体のインデントが1段階減ります
  • 1ファイルに1つの名前空間の場合に使えます
    • 1ファイルに1つがほとんどなので、ほとんど全てで使えます
  • コードが読みやすくなります

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/namespace#using-statements-in-file-scoped-namespaces

using宣言(C#8)

using宣言は、usingステートメントのブロック{}を省略できる機能です。スコープの終わりで自動的にリソースが破棄されます。

// レガシー(usingステートメント)
void ReadFile1(string path)
{
    using (var reader = new StreamReader(path))
    {
        var content = reader.ReadToEnd();
        Console.WriteLine(content);
        // readerはここで破棄される
    }
}

// モダン(using宣言)
void ReadFile2(string path)
{
    using var reader = new StreamReader(path);
    var content = reader.ReadToEnd();
    Console.WriteLine(content);
    // readerはメソッドの終わりで破棄される
}

// 複数のリソースも簡潔に
void CopyFile(string source, string dest)
{
    using var sourceStream = File.OpenRead(source);
    using var destStream = File.Create(dest);
    sourceStream.CopyTo(destStream);
}

ポイント:

  • インデントが1段階減って読みやすくなります
  • 複数のリソースを扱う場合も簡潔に書けます
  • 変数のスコープが終わるとき(メソッドやブロックの終わり)に自動的に破棄されます

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/statements/using#:~:text=中かっこを必要としない using "宣言" を使用することもできます

コレクション操作

配列やリストの操作が便利に:

  • Index / Range(C#8)
  • コレクション式(C#12)
  • スプレッド要素(C#12)
  • インデックスへの暗黙のアクセス (C#13)
Index / Range(C#8)

Index/Rangeは、配列やリストの要素アクセスや範囲指定を簡潔に書ける機能です。

int[] numbers = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ];

// Index: 後ろから数える
int last = numbers[^1];      // 9 (最後の要素)
int secondLast = numbers[^2]; // 8 (後ろから2番目)

// Range: 範囲指定
int[] slice1 = numbers[2..5];   // { 2, 3, 4 }
int[] slice2 = numbers[..3];    // { 0, 1, 2 } (先頭から3つ)
int[] slice3 = numbers[5..];    // { 5, 6, 7, 8, 9 } (5番目から最後まで)
int[] slice4 = numbers[^3..];   // { 7, 8, 9 } (後ろから3つ)

ポイント:

  • ^演算子で後ろからのインデックスを指定できます
  • ..(範囲演算子)で範囲を指定できます
    • スプレッド要素とは異なります
  • 範囲指定は開始インデックスを含み、終了インデックスは含みません

https://learn.microsoft.com/ja-jp/dotnet/csharp/tutorials/ranges-indexes

コレクション式(C#12)

コレクション式は、統一された記法[]で様々なコレクション型を初期化できる機能です。

// 配列
int[] array = [1, 2, 3];

// List
List<int> list = [1, 2, 3];

// ImmutableArray
ImmutableArray<int> immutableArray = [1, 2, 3];

// Span / ReadOnlySpan
Span<int> span = [1, 2, 3];
ReadOnlySpan<int> readOnlySpan = [1, 2, 3];

// 空のコレクション
List<int> empty = [];

ポイント:

  • コレクションの種類に関わらず[]記法で統一できます
  • スプレッド要素(..)と組み合わせて使えます

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/operators/collection-expressions

https://zenn.dev/inuinu/articles/84c6d5ca85c41f

スプレッド要素(C#12)

スプレッド要素[12]は、JavaScriptの「スプレッド構文」やPythonの「スター演算子」に似た機能です。

List<int> listA = [1, 2, 3];
List<int> listB = [ .. listA, 4, 5];

// listBの後ろはRange、前はspread要素
List<int> listC = [ .. listB[1 .. ], .. listB[ .. 1]];

ポイント:

  • コレクション式と同時に導入されました
  • コレクション式の[]の中でのみ使える記法です
  • Range(..)と記法は似てますが厳密には別物です
インデックスへの暗黙のアクセス(C#13)

^演算子をオブジェクト初期化子で使用することができます。

public class TimerRemaining
{
    public int[] buffer { get; set; } = new int[10];
}

var countdown = new TimerRemaining()
{
    buffer =
    {
        [^1] = 0,
        [^2] = 1,
        [^3] = 2,
        [^4] = 3,
        [^5] = 4,
        [^6] = 5,
        [^7] = 6,
        [^8] = 7,
        [^9] = 8,
        [^10] = 9
    }
};

https://learn.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-13#implicit-index-access

文字列リテラル

複雑な文字列も書きやすく:

  • 生文字列リテラル(C#11)
  • UTF-8文字列リテラル(C#11)
生文字列リテラル(C#11)

生文字列リテラルは、エスケープ不要で複雑な文字列を書ける機能です。"""で囲むことで、改行やインデントもそのまま扱えます。

// レガシー(@文字列)
// ダブルクォートのエスケープが必要
var json = @"{
  ""name"": ""Alice"",
  ""age"": 30
}";

// バックスラッシュのエスケープが必要
var regex = "\\d{3}-\\d{4}";


// モダン(生文字列リテラル)
// エスケープ不要!
var json = """
  {
    "name": "Alice",
    "age": 30
  }
  """;

// バックスラッシュもそのまま
var regex = """\d{3}-\d{4}""";


// 文字列補間も使える
var name = "Bob";
var message = $"""
  Hello, {name}!
  Welcome to C# 11.
  """;

ポイント:

  • """で囲むことでエスケープが不要になります
  • インデントも自動調整されます(共通インデントが削除される)
  • $を付けることで文字列補間も使えます
  • JSON、正規表現、XMLなどの記述が簡潔になります

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/tokens/raw-string

UTF-8文字列リテラル(C#11)

UTF-8文字列リテラルは、u8サフィックスを付けることでUTF-8バイト列を直接生成できる機能です。

// レガシー(UTF-8への変換が必要)
// ヒープ割り当てが発生
byte[] utf8Bytes = Encoding.UTF8.GetBytes("Hello");


// モダン(UTF-8文字列リテラル)
// 直接UTF-8バイト列として生成
ReadOnlySpan<byte> utf8Bytes = "Hello"u8;

// 配列として使うことも可能
byte[] utf8Array = "Hello"u8.ToArray();

// 生文字列リテラルと組み合わせ
ReadOnlySpan<byte> jsonUtf8 = """
{
  "name": "Alice"
}
"""u8;

ポイント:

  • u8サフィックスで直接UTF-8バイト列を生成できます
  • ReadOnlySpan<byte>として扱えるのでメモリ効率が良いです
  • ネットワーク通信やファイルI/Oで便利です
  • 生文字列リテラルと組み合わせて使えます
    • ただし、C#14時点では文字列補間は組み合わせ不可

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/proposals/csharp-11.0/utf8-string-literals

どう書くべきか(シンプルコード)

シンプルコードについてはAnalyzerが教えてくれたり、CodeFixで自動修正できたりするので、これに任せてしまうのが楽だと思います。.NET SDK標準の.NETコードアナライザーだけでも十分です。

→ 「 シンプルコードのAnalyzer対応

プライマリコンストラクタは注意が少し必要です。
プライマリコンストラクタの引数がプロパティを生成するか、init-onlyかどうかがデフォルトで異なります。

特性 class record class struct record struct
自動生成プロパティ × ×
init-only ×(手動) ×(手動) ×(readonlyで◯)

C#14/.NET10での影響(シンプルコード)

C#的には、C#14ではもうあんまり変化がなくて、シンプルに書けるという意味ではだいぶ安定しています(モダンから基本より)。
ただ、.NET10の「ファイルベース実行」はshebangも使えてスクリプトっぽい動かしかたができるようになったので、こっちの変化が大きいと思います[13]

ファイルベース実行(.NET10)

  • dotnet run-files(.NET10)
    • プロジェクトファイル(*.csproj)不要
    • dotnet run file.cs: Debug実行
    • dotnet file.cs: Release実行
#!/usr/bin/env -S dotnet run
#:property LangVersion=13
#:package Microsoft.CodeAnalysis.CSharp@4.14.0

using Microsoft.CodeAnalysis.CSharp;

var tree = CSharpSyntaxTree.ParseText("class Class1;");
var root = await tree.GetRootAsync();
Console.WriteLine(root.GetFirstToken().Text);

https://ufcpp.net/study/csharp/cheatsheet/file-based-app/

フィールドに基づくプロパティ(C#14)

半自動実装プロパティとか言われてたやつです。

C#のプロパティは「自動実装プロパティ」のほうが基本になっていて、実は巷で言われるgetter/setterという説明はあんまり正しくありません。

https://zenn.dev/inuinu/articles/csharp-property-re-entry

そもそもベストプラクティスとして、表から見えるメンバー変数は全部プロパティにしろ、がC#のルールになってます[14]

フィールドに基づくプロパティは、その流れにある機能です。
”手動実装プロパティ”をしかたなく書いてたのが不要になって、やっと!って感じですね。

シンプルコードの互換性

シンプルコードを実現する各記法・構文は、ほとんどがシュガーシンタックスです。SDKさえ新しくすれば、ほとんどが古いレガシーランタイム(.NET FrameworkやUnity等)でも使えます。

例外のIndex/Rangerecordのプライマリコンストラクタもpolyfill(PolySharp等)でOKです。

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
using宣言 ✔️ C#8.0
Index / Range ✔️ C#8.0
record [15] ✔️ C#9.0
Mainメソッドの省略 ✔️ C#9.0
クラスの省略 ✔️ C#9.0
右辺の型省略 new() ✔️ C#9.0
record struct ✔️ C#10.0
Implicit Usings ✔️ C#10.0
global using ✔️ C#10.0
file-scoped namespace ✔️ C#10.0
生文字列リテラル ✔️ C#11.0
UTF8文字列リテラル ✔️ C#11.0
プライマリコンストラクタ ✔️ C#12.0
コレクション式 ✔️ C#12.0
型エイリアス(using alias) ✔️ C#12.0
インデックスへの暗黙のアクセス ✔️ C#13.0
dotnet run-files ✔️ C#9.0 [16]
フィールドに基づくプロパティ ✔️ C#14.0

シンプルコードのAnalyzer対応

シンプルコードに関連する標準の.NETコードアナライザールールは次のとおりです。

IDE規則(コードスタイル)
  • CA1051: 参照可能なインスタンス フィールドを宣言しない
  • IDE0005: 不要なインポートを削除する
  • IDE0063: 単純な using ステートメントを使用する
  • IDE0065: using ディレクティブの配置
  • IDE0090: new式の簡略化
  • IDE0160: ブロック スコープの名前空間を使用する
  • IDE0161: ファイル スコープの名前空間を使用する
  • IDE0210: 最上位レベルのステートメントに変換する
  • IDE0211: 'Program.Main' スタイルのプログラムに変換する
  • IDE0230: UTF-8 文字列リテラルを使用する
  • IDE0290: プライマリ コンストラクターを使用する
  • IDE0300: 配列にコレクション式を使用する
  • IDE0301: 空の配列にコレクション式を使用する
  • IDE0305: fluent にコレクション式を使用する
  • IDE0360: プロパティ アクセサーを簡略化する

パターンマッチ

パターンマッチ

  • 難易度:⭐⭐☆
  • 取り入れやすさ:⭐⭐☆
  • オススメ度:⭐⭐⭐

どういうものか(パターンマッチ)

モダンC#では全般的にパターンマッチが取り入れられています
あとから導入された他の言語と同じく、レガシーC#と比べると普段の書き味が大きく変わっています。

変数のスコープが変わったり、従来のキャスト((T)as T)が使われなくなったりととにかく影響が大きいです。
「😵‍💫う、読めない」となりがちなのもパターンマッチが大半だったりします。

巷では、「モダンC#を学ぶ」と書いて「パターンマッチを学ぶ」と読む
…とは言われてないですが(今思いつきました!)、でもそれくらいウェイトはあると思います(個人の感想です)。

他言語でおなじみの書き方も多いので、他言語からきた方も導入しやすいと思います。

なお、後述しますが、パターンマッチはシンプルコードの一つでもあります。

パターンマッチング後のモダンC#の特徴

パターンマッチを使うと、C#の書き方がガラッと変わります:

  • 変数スコープが変わる
  • キャスト((T)as)をほとんど使わなくなる
    • モダンC#では(T)as Tの出番が激減してます。
    • なぜなら、パターンマッチ(宣言パターン)でキャストできちゃうから。
  • 新しい便利な記法とパターンマッチは対になる関係
    • パターンマッチを書くと自然と新記法の導入になる

対応表

モダンC#で導入された新記法・構文はパターンマッチと対応があることが多いです。意識しないと、書き方が同じなので区別つかないこともあります。その意味でモダンC#のシンプルコードとパターンマッチには切っても切れない関係があります。

パターン 対応する記法等
型パターン(C#7) obj is int -
宣言パターン(C#7) obj is int intObj 変数スコープが変更
定数パターン(C#7) obj is false -
Span<char>の定数パターン(C#11) span is "abc" Span<T>/ReadOnlySpan<T> / UTF8文字列リテラル"abc"u8(C#11)
varパターン obj is var newValue var
破棄パターン(C#8.0) _ ラムダ ディスカード パラメーター (_, _) => 0
リレーショナルパターン(C#9.0) >= 3 and < 6 -
論理パターン(C#9.0) obj is A and B and/or/not
プロパティ パターン(C#8.0) segment is { Start.Y: 0 } オブジェクト初期化子{}
位置指定パターン(C#8.0) (0, 0) プライマリコンストラクタ、タプル記法(ValueTuple)、分解
リスト パターン(C#11) list is [] Index/Range(C#8.0)、コレクション式(C#12)
パターンマッチング全般 - switch式(C#8.0)

モダンC#でよく使うパターン

実際によく使うパターンを見てみましょう。

早期リターンの例
void DoSomething(string? x)
{
    // 早期リターン
    if(x is not string x2){ return; }
    Console.WriteLine(x2);
}
nullチェック&変数宣言の例
void DoSomething2(int? x)
{
    // nullチェック&変数宣言
    if(x is {} x2)
    {
        x2 += 10;
    }
    else
    {
        // ここもx2のスコープ
        x2 = 0;
    }
    // 実はここもx2のスコープ
    Console.WriteLine(x2);
}

参考:x is {} y というパターンマッチで null をはがせる

リストパターンで文字列マッチの例
//丸かっこで囲まれた文字列かどうかの判定
if(str is ['(', .., ')']){}

//マッチと抜き出し
if(str is ['(', .. var ms, ')'])
{
    // 正規表現の $1 みたいなことができる
    Console.WriteLine(ms);
}

参考:文字列の判定はstr is [~](リストパターン)でできる

どう書くべきか(パターンマッチ)

パターンマッチは頭に入ってないとなかなか書こうと思っても書けなかったりします。

まずは x is {}とかをみてギョッとしないように読み慣れておくことと、キャストは積極的にパターンマッチに置き換えるところから始めるといいと思います。

またリストパターンが「コレクションだけでなくて文字列にも使える」ことを覚えておくと、応用例が増えます。

型チェックとキャスト

レガシーC#
void Process(object obj)
{
    // キャストが失敗すると例外が発生
    try
    {
        string str = (string)obj;
        Console.WriteLine(str.Length);
    }
    catch (InvalidCastException)
    {
        // キャスト失敗時の処理
    }

    // asキャストは例外でないけど結局nullチェックいる
    var str2 = obj as string;
    if(str2 is null){
       return;
    }
}
モダンC#
void Process(object obj)
{
    // 型チェックとキャスト,nullチェックが同時にできる
    if (obj is string str)
    {
        Console.WriteLine(str.Length);
    }
}

nullチェックと早期リターン

レガシーC#
void DoSomething(string? text)
{
    if (text == null)
    {
        return;
    }
    Console.WriteLine(text);
}
モダンC#
void DoSomething(string? text)
{
    if (text is not string x)
    {
        return;
    }
    Console.WriteLine(x);
}

switch文での型判定

レガシーC#
string GetDescription(object obj)
{
    if (obj is int)
    {
        return "整数";
    }
    else if (obj is string)
    {
        return "文字列";
    }
    else
    {
        return "その他";
    }
}
モダンC#
string GetDescription(object obj) => obj switch
{
    int => "整数",
    string => "文字列",
    _ => "その他"
};

範囲チェック

レガシーC#
string GetGrade(int score)
{
    if (score >= 90)
    {
        return "A";
    }
    else if (score >= 80)
    {
        return "B";
    }
    else
    {
        return "C";
    }
}
モダンC#
string GetGrade(int score) => score switch
{
    >= 90 => "A",
    >= 80 => "B",
    _ => "C"
};

C#14/.NET10での影響(パターンマッチ)

大きな変更点はありません!もうほぼ「基本」?

互換性(パターンマッチ)

パターンマッチも基本的にシュガーシンタックスです。SDKさえ新しければ、ほとんどの機能が古いレガシーランタイムでも使えます。

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
型パターン ✔️ C#7.0
宣言パターン ✔️ C#7.0
定数パターン ✔️ C#7.0
varパターン ✔️ C#7.0
switch式 ✔️ C#8.0
プロパティパターン ✔️ C#8.0
位置指定パターン ✔️ C#8.0
破棄パターン ✔️ C#8.0
リレーショナルパターン ✔️ C#9.0
論理パターン(and/or/not ✔️ C#9.0
プロパティパターンの入れ子 ✔️ C#10.0
Span<char>の定数パターン ✔️ C#11.0
リストパターン ✔️ C#11.0

Analyzer対応(パターンマッチ)

パターンマッチに関連する標準の.NETコードアナライザールールは次のとおりです。

IDE規則(コードスタイル)
  • IDE0019: パターン マッチングを使用して as の後に null チェックが発生しないようにする
  • IDE0020: パターン マッチングを使用して、is チェックの後にキャスト(変数を含む)が発生しないようにする
  • IDE0038: パターン マッチングを使用して、is チェックの後にキャスト(変数を含まない)が発生しないようにする
  • IDE0066: switch 式を使用する
  • IDE0078: パターン マッチングの使用
  • IDE0083: パターン マッチングを使用する (not 演算子)
  • IDE0170: プロパティ パターンを簡略化する
  • IDE0260: パターン マッチングの使用

安全なコード

安全なコード

  • 難易度:⭐⭐☆
  • 取り入れやすさ:⭐⭐☆
  • オススメ度:⭐⭐⭐

どういうものか(安全なコード)

モダンC#は「null安全」になり、readonly structreadonly関数メンバー、init-onlyrequired修飾子で immutable 等の設定ができるようになり、手軽に安全なコードが書けるようになりました。

  • null安全(C#8.0+)
  • readonly struct(C#7.2)
  • readonly関数メンバー(C#8.0)
  • init-onlyプロパティ(C#9.0)
  • required修飾子(C#11.0)

シンプルコードで紹介したrecordもimmutableなデータ型という意味で安全なコードを実現する機能の一つです。

null安全(C#8.0+)

レガシーC#でも値型のT????.を使うことはできましたが、
C#8.0で「null 許容コンテキスト」がはいり、null安全になりました
コードの安全性がめっちゃ向上しました。

該当する記法:

  • null 許容コンテキスト(C#8.0)
    • #nullable enable or <Nullable>enable</Nullable>
  • null合体代入演算子 ??= 演算子(C#8.0)
  • 左辺の?.(C#14.0)
null許容コンテキスト
レガシーC#
// nullableの警告なし
string name = null; // コンパイル通る
モダンC#
#nullable enable

string name = null;  // 警告: null を非null許容参照型に割り当て
string? name2 = null; // OK

https://learn.microsoft.com/ja-jp/dotnet/csharp/nullable-references

null合体代入演算子 `??=` 演算子
レガシーC#
void Initialize(string? value)
{
    if (value is null)
    {
        value = "default";
    }
    Console.WriteLine(value);
}
モダンC#
void Initialize(string? value)
{
    value ??= "default";
    Console.WriteLine(value);
}

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/operators/null-coalescing-operator

左辺の`?.`
レガシーC#
if(A is not null)
{
  A.B = C;
}
モダンC#
A?.B = C;

https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14#null-conditional-assignment

前述のパターンマッチのキャストも実はnull安全に関係していたりします。
C#14で久しぶりに手が入りましたが、そろそろ「モダン」は卒業して「基本」になりそうな機能です。

readonly struct(C#7.2)

readonly struct は、すべてのフィールドが不変(readonly)である構造体を宣言できる機能です。

readonly structの定義
// readonly structの定義
readonly struct Point
{
    //フィールドにはreadonly必須になる
    public readonly string Name;
    //プロパティは不要(自動バッキングフィールドがreadonly扱い)
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y, string name = "")
    {
        Name = name;
        X = x;
        Y = y;
    }
}

// 使用例
var point = new Point(10, 20);
// point.X = 30; // コンパイルエラー:readonlyなので変更不可

ポイント:

  • 構造体全体が不変であることを保証します
  • フィールドはreadonly必須
  • コンパイラが防御的コピーを省略できるため、パフォーマンスが向上します
  • 値型でスレッドセーフなデータ構造を作るのに適しています

https://ufcpp.net/study/csharp/resource/readonlyness/#readonly-struct

readonly関数メンバー(C#8.0)

readonly関数メンバーは、構造体のメソッドやプロパティにreadonlyを付けて、そのメンバーが構造体の状態を変更しないことを保証する機能です。

readonly関数メンバー
struct Point
{
    public int X { get; set; }
    public int Y { get; set; }

    // このメソッドは状態を変更しない
    public readonly double GetDistance()
    {
        return Math.Sqrt(X * X + Y * Y);
    }

    // このプロパティも状態を変更しない
    public readonly bool IsOrigin => X == 0 && Y == 0;
}

ポイント:

  • 構造体のメンバーが状態を変更しないことを明示的に宣言できます
  • 防御的コピーを避けることでパフォーマンスが向上します
  • readonly structとの違い:全体ではなく、個別のメンバーに適用できます

https://ufcpp.net/study/csharp/resource/readonlyness/#readonly-member

init-onlyプロパティ(C#9.0)

init-onlyプロパティは、オブジェクト初期化時のみ設定可能で、その後は変更できないプロパティです。

init-onlyプロパティ
class Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

// 使用例
var person = new Person
{
    Name = "Alice",
    Age = 30
};

// person.Name = "Bob"; // コンパイルエラー:初期化後は変更不可

ポイント:

  • オブジェクト初期化子で設定できますが、その後は変更できません
  • readonlyよりも柔軟で、コンストラクタ以外でも初期化できます
  • immutableなデータクラスを簡単に作れます

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/init

required修飾子(C#11.0)

required修飾子は、オブジェクト作成時に必ず設定しなければならないプロパティを指定する機能です。

required修飾子
class Person
{
    public required string Name { get; init; } // 必須
    public required int Age { get; init; } // 必須
    public string? Address { get; init; } // オプショナル
}

// 使用例
var person = new Person
{
    Name = "Alice",
    Age = 30
    // Addressは省略可能
};

// var invalid = new Person { Name = "Bob" }; // コンパイルエラー:Ageが必須

ポイント:

  • コンパイル時に必須プロパティの設定を強制できます
  • init-onlyと組み合わせて使うことが多いです
  • 省略可能なプロパティを作れます

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/required

どう書くべきか(安全なコード)

  • 10億ドルの誤り」のnullを避けるためにnull許容コンテキストの有効化はモダンC#でマストだと思います
  • immutableもモダンC#に限らず重要なので、プロパティで細かく制御できるinit-onlyやrequiredは積極的に使うことでコードが安全になります
    • 「フィールドよりプロパティを優先すべき」、もinit-onlyの存在が大きい

C#14/.NET10での影響(安全なコード)

  • 左辺で?.が使えるようになります
//C#14.0+
A?.B = C;

//これまで
if(A is not null)
{
  A.B = C;
}

互換性(安全なコード)

安全なコード機能は基本的にコンパイラレベルの機能です。SDKさえ新しければ、ほとんどが古いランタイムでも使えます。

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
#nullable enable ✔️ C#8.0
readonly struct ✔️ C#7.2
readonly関数メンバー ✔️ C#8.0
??=演算子 ✔️ C#8.0
init-onlyプロパティ ✔️ C#9.0
record(immutable) [17] ✔️ C#9.0
required修飾子 ✔️ C#11.0
左辺の?. ✔️ C#14.0

Analyzer対応(安全なコード)

安全なコードに関連する標準の.NETコードアナライザールールは次のとおりです。

IDE規則(コードスタイル)
  • IDE0029: Null チェックを簡素化できます(null合体演算子??を使用)
  • IDE0030: Null チェックを簡素化できます(null合体演算子??を使用)
  • IDE0031: null値の反映を使用する(null条件演算子?.
  • IDE0041: is null チェックを使用する
  • IDE0150: 型チェックより null チェックを優先します
  • IDE0240: Null 許容ディレクティブが冗長です
  • IDE0241: null 許容ディレクティブが不要
  • IDE0270: Null チェックを簡素化できます

ゼロコストな高パフォーマンス

ゼロコストな高パフォーマンス

  • 難易度:⭐⭐⭐
  • 取り入れやすさ:⭐☆☆
  • オススメ度:⭐⭐☆

どういうものか(ゼロコストな高パフォーマンス)

モダンC#では、Native AOT(Ahead-of-Time)対応とソースジェネレータの活用が超重要です。

ソースジェネレータ(C#9~)

ソースジェネレータは、コンパイル時にコードを自動生成する機能です。
リフレクションやボックス化の代わりにコンパイル時にコードを生成することで、Native AOT対応とパフォーマンス向上を実現します。

https://learn.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/#source-generators

ボイラープレートコードを書かない、という意味ではシンプルコードにも関係があります。

代表的な公式・準公式APIの使用例:

参考:C# SourceGenerator関連のメモ

Native AOT

Native AOT」は、.NETアプリ[18]をネイティブコードに事前コンパイルする技術です。

https://zenn.dev/inuinu/articles/csharp-native-aot

「C#はJIT」というのはレガシーC#のことであって、モダンC#ではAOT/JITを使い分けます

特にライブラリ作る人は検討必須ですが、普通のアプリでも速度や起動時間が全然変わるので影響デカいです。

またモダンC#では「どのライブラリを使うか?」という技術選定の判断材料として「NativeAOT対応かどうか」というのも重要なポイントです。
レガシーC#で「鉄板」と言われていたライブラリもNativeAOT対応が進んでいないとモダンC#では選ばれなくなりつつあります…!

どう書くべきか(ゼロコストな高パフォーマンス)

「ソースジェネレータ」を自作することは普通のコードではあまりなくて、標準APIで用意されたものや対応ライブラリ[19]を利用することが多いと思います。

パフォーマンスのためだけでなくボイラープレートコード削減の意味でも使いやすくなっているので、使えるところはどんどん使っていくべきかなと思います。

NativeAOT対応はなかなか大変です。ライブラリ対応が進んでいないと使いたくても使えないとかあります。AOTよりJITの方が早いパターンも結構あるのでやれば早くなるというわけでもない、というのも難しい。その代わりに高速起動やビルド後のサイズ削減とか色々メリットも多いんですけどね!

JSON シリアライズ
レガシーC#
リフレクションを使用 → Native AOT非対応
using Newtonsoft.Json;

var person = new Person { Name = "Alice", Age = 30 };
string json = JsonConvert.SerializeObject(person);
モダンC#
ソースジェネレータでコード生成 → Native AOT対応
using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSerializable(
    typeof(Person),
    GenerationMode = JsonSourceGenerationMode.Serialization
)]
partial class AppJsonContext : JsonSerializerContext { }

var person = new Person { Name = "Alice", Age = 30 };
var json = JsonSerializer.Serialize(person, AppJsonContext.Default.Person);

https://learn.microsoft.com/ja-jp/dotnet/standard/serialization/system-text-json/source-generation

正規表現
レガシーC#
リフレクションを使用 → Native AOT非対応
using System.Text.RegularExpressions;

var regex = new Regex(@"\d+");
var match = regex.Match("abc123");
モダンC#
ソースジェネレータでコード生成 → Native AOT対応
using System.Text.RegularExpressions;

partial class MyRegex
{
    [GeneratedRegex(@"\d+")]
    public static partial Regex NumberPattern {get;};
}

var match = MyRegex.NumberPattern.Match("abc123");

https://learn.microsoft.com/ja-jp/dotnet/standard/base-types/regular-expression-source-generators

MVVM ObservableProperty
レガシーC#
public class ViewModel : INotifyPropertyChanged
{
    private string _name;
    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
        }
       }

    public event PropertyChangedEventHandler PropertyChanged;
}
モダンC#
using CommunityToolkit.Mvvm.ComponentModel;

[INotifyPropertyChanged]
public partial class MyViewModel{
  // ソースジェネレータが自動生成 → Native AOT対応
  [ObservableProperty]
  public partial string Name { get; set; }
}

C#14/.NET10での影響(ゼロコストな高パフォーマンス)

  • .NET10ではNative AOT対応がさらに強化されます

https://learn.microsoft.com/ja-jp/dotnet/core/whats-new/dotnet-10/runtime#nativeaot-type-preinitializer-improvements

互換性(ゼロコストな高パフォーマンス)

SG自体は技術的制限で.NET Standard2.0規格で作る必要があったり、SG自体がコンパイル時に動作する関係で、互換性は高いです。

一方、NativeAOTの場合、アプリの場合はランタイム同梱になり、ライブラリは.NET用ではなくネイティブバイナリのライブラリになります。そのためレガシーランタイムで使う、というのは難しいです。

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
ソースジェネレータ ✔️ C#9.0
Native AOT ✔️[20] -

Analyzer対応(ゼロコストな高パフォーマンス)

ゼロコストな高パフォーマンスに関連する標準の.NETコードアナライザールールは次のとおりです。

CA規則(コード品質)とIL規則
  • CA1416: プラットフォームの互換性を検証する
  • CA1420: プロパティ、型、または属性にはランタイムマーシャリングが必要
  • CA1421: DisableRuntimeMarshallingAttributeが適用されている場合、メソッドはランタイムマーシャリングを使用します
  • CA1848: LoggerMessageデリゲートを使用する
  • CA1869: JsonSerializerOptionsインスタンスのキャッシュと再利用(System.Text.Jsonソースジェネレーター使用時に重要)
  • IL3000: 1つのファイルとして発行するときにアセンブリファイルパスにアクセスしないようにする
  • IL3001: 単一ファイルとして発行するときにアセンブリファイルパスにアクセスしないようにする
  • IL3002: 1つのファイルとして発行するときに、RequiresAssemblyFilesAttributeで注釈が付けられたメンバーを呼び出さないようにする
  • IL3003: RequiresAssemblyFilesAttribute注釈は、すべてのインターフェイス実装またはオーバーライドで一致する必要があります

https://learn.microsoft.com/ja-jp/dotnet/core/deploying/native-aot/

安全なポインタ

安全なポインタ

  • 難易度:⭐⭐⭐
  • 取り入れやすさ:⭐⭐☆
  • オススメ度:⭐⭐☆

どういうものか(安全なポインタ)

C#には2種類の"ポインタ"がある」っていうと、「🫢えっ」とびっくりされるかもしれません。

C#は昔からunsafeブロックの中で使える「(危険な)ポインタ(unmanaged pointer)」がありました。これはC言語のポインタそっくりの使い方ができ、わかっている人向けの機能でした。

モダンC#では、もう一つの"ポインタ"(安全なポインタ、managed pointer)ことref系がコード全般で使えるようになっています。.NET APIの内部実装もこれに置き換わることで、ハイパフォーマンスなコードが書けるようになっています。最近の「早いC#」はこれも要因だったりします[21]

モダンC#はパフォーマンスを意識しないで気軽にスクリプトのように書ける用になっている一方で、"安全なポインタ"をつかうことでハイパフォーマンスを追求できる、という2面性があります。どちらかに特化した言語は多いですが、この使い分けできるのが面白いところですね。

該当する記法:

  • いろいろな箇所でのref
    • in引数(C#7.2)
    • ref戻り値/refローカル(C#7.2)
    • refフィールド / readonly refフィールド(C#11.0)
    • scoped ref(C#11.0)
    • ref readonly 引数(C#12.0)
    • allows ref struct(C#13.0)
      • ジェネリクスでSpan<T>とか使えるように
  • stackalloc
    • 安全なstackalloc(C#7.2)
    • 配列初期化でstackalloc(C#7.3)
    • 入れ子になった式のstackalloc(C#8.0)
  • ref構造体/Span
    • ref struct(C#7.2)
    • Span<T>/ReadOnlySpan<T>(C#7.2)
    • Span<T>/ReadOnlySpan<T>ref struct(C#11.0)
    • paramsSpan<T>(C#13.0)
    • First class Span (C#14.0)

どう書くべきか(安全なポインタ)

標準APIの中で使われていたりするので、単に新しいSDKにするだけでもメリットが得られます。

それでも足りない、パフォーマンスを意識しなきゃ、となったらSpan<T>ref系の出番です。特に、C#のstringや配列は決してパフォーマンスが良くないので、Span<T>の出番は多いです。

Span<T>/ref structは使い方に強い制限がある代わりにパフォーマンスを追求できる、というものなのでそこまで気軽ではないですが、unsafeのポインタに比べればはるかに簡単!

AnalyzerがCodeFix提案してくれたりするので最初はそこから始めればいいかなと思います!

文字列の部分取得

レガシーC#
var text = "Hello, World!";
// 新しい文字列オブジェクトを作成(ヒープ割り当て)
var substring = text.Substring(0, 5);
モダンC#
var text = "Hello, World!";
// ヒープ割り当てなし、メモリ効率的
ReadOnlySpan<char> span = text.AsSpan(0, 5);

配列の部分操作

レガシーC#
int[] numbers = { 1, 2, 3, 4, 5 };
// 新しい配列を作成(ヒープ割り当て)
int[] slice = new int[3];
Array.Copy(numbers, 1, slice, 0, 3);
モダンC#
int[] numbers = [ 1, 2, 3, 4, 5 ];
// ヒープ割り当てなし、元の配列を参照
Span<int> slice = numbers.AsSpan(1, 3);

スタック上のバッファ

レガシーC#
void ProcessData()
{
    // ヒープ割り当て、GCの対象
    byte[] buffer = new byte[256];

    // 処理...
}
モダンC#
void ProcessData()
{
    // スタック割り当て、GC不要、高速
    Span<byte> buffer = stackalloc byte[256];

    // 処理...
}

ref構造体の定義

レガシーC#
// 通常の構造体(ヒープにもコピー可能)
struct Point
{
    public int X;
    public int Y;
}
モダンC#
// ref構造体(スタックのみ、高速)
ref struct Point
{
    public int X;
    public int Y;
}

C#14/.NET10での影響(安全なポインタ)

  • First-class Span(C#14)
    • Span<T>が第1級オブジェクト、最優先して使われるようになりました
    • つまり単に便利なクラスではなくてC#の基本!

互換性(安全なポインタ)

Span<T>はレガシーランタイムでも実装や効率が異なります(slow span)が使えます。allows ref structが.NET9+なのがちょっと痛いですが、それ以外は比較的互換性が高いです。

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
in 引数 ✔️ C#7.2
ref戻り値/refローカル ✔️ C#7.2
安全なstackalloc ✔️ C#7.2
Span<T>/ReadOnlySpan<T> ✔️[24] C#7.2
ref struct ✔️ C#7.2
配列初期化でstackalloc ✔️ C#7.3
入れ子になった式のstackalloc ✔️ C#8.0
refフィールド ✔️ C#11.0
scoped ref ✔️ C#11.0
ref readonly 引数 ✔️ C#12.0
allows ref struct ✔️ C#13.0
paramsSpan<T> ✔️ C#13.0
ref structのインターフェイス実装 ✔️ C#13.0
First-class Span ✔️ C#14.0

Analyzer対応(安全なポインタ)

安全なポインタに関連する標準の.NETコードアナライザールールは次のとおりです。

CA規則(コード品質)
  • CA1831: 該当する場合、文字列に範囲ベースのインデクサーの代わりにAsSpanを使用します
  • CA1832: 配列のReadOnlySpanまたはReadOnlyMemory部分を取得するために、範囲ベースのインデクサーの代わりにAsSpanまたはAsMemoryを使用します
  • CA1833: 配列のSpanまたはMemory部分を取得するために、範囲ベースのインデクサーの代わりにAsSpanまたはAsMemoryを使用します
  • CA1835: MemoryReadOnlyMemoryベースのオーバーロードを優先します(Stream.ReadAsync/WriteAsync
  • CA1844: サブクラス化時に非同期メソッドのメモリベースのオーバーライドを提供する(Stream
  • CA1845: スパンベースを使用する(string.ConcatAsSpan
  • CA1846: AsSpanSubstringより優先します
  • CA2014: ループ内でstackallocを使用しません(スタックオーバーフローのリスク)
  • CA2015: MemoryManager<T>から派生した型にはファイナライザーを定義しません
  • CA1021: outパラメーターを使用しません(API設計ガイドライン)
  • CA1045: 型を参照によって渡しません(ref/outの使用制限)
  • CA2265: Span<T>nullまたはdefaultを比較しないでください
IDE規則(コードスタイル)
  • IDE0302: stackallocにコレクション式を使用する
  • IDE0250: 構造体はreadonlyに設定可能
  • IDE0251: メンバーはreadonlyに設定可能
  • IDE0380: 不要なunsafe修飾子を削除する

トレイトやロール

トレイトやロール

  • 難易度:⭐⭐☆
  • 取り入れやすさ:⭐⭐⭐
  • オススメ度:⭐⭐⭐

どういうものか(トレイトやロール)

プログラミングの概念に「トレイト(trait)」や「ロール(role)」といったものがあります[25]。近いものとしてはC#には古くから「拡張メソッド」がありメソッドに関して近いことができていました。

https://ja.wikipedia.org/wiki/トレイト

モダンC#ではこの方向性が更に強化され、特にC#14では「拡張メンバー(extension member)」がextensionキーワードによる新構文とともに追加されています。

  • デフォルトinterfaceメンバー(C#8.0)
  • Extensionメンバー(C#14.0)

デフォルトinterfaceメンバー(C#8.0)

デフォルトinterfaceメンバーは、インターフェイスにデフォルト実装を持つメソッドやプロパティを定義できる機能です。これにより、インターフェイスに新しいメンバーを追加しても、既存の実装クラスを壊さずに機能拡張できます。

トレイトやロールの観点から見ると、この機能は型に振る舞いを後付けで追加できる仕組みとしても機能します。

使い方
// デフォルト実装を持つインターフェイス
public interface ILogger
{
    void Log(string message);  // 抽象メソッド(実装必須)

    // デフォルト実装(オプション)
    void LogError(string message)
    {
        Log($"[ERROR] {message}");
    }

    // デフォルトプロパティ
    string LogLevel => "Info";
}

// 既存の実装クラス(LogErrorを実装していなくても動作)
public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
    // LogErrorとLogLevelはインターフェイスのデフォルト実装を使用
}

// 使用例
ILogger logger = new ConsoleLogger();
logger.Log("Normal log");
logger.LogError("Something went wrong");  // デフォルト実装が呼ばれる
Console.WriteLine(logger.LogLevel);  // "Info"
トレイト的な使い方の例
// 共通の振る舞いを定義するインターフェイス
public interface IComparable<T>
{
    int CompareTo(T other);

    // トレイトとして振る舞いを追加
    bool IsGreaterThan(T other) => CompareTo(other) > 0;
    bool IsLessThan(T other) => CompareTo(other) < 0;
    bool IsEqualTo(T other) => CompareTo(other) == 0;
}

// 実装クラスはCompareToだけ実装すれば良い
public class Score : IComparable<Score>
{
    public int Value { get; set; }

    public int CompareTo(Score other)
    {
        return Value.CompareTo(other.Value);
    }
    // IsGreaterThan、IsLessThan、IsEqualToは自動的に使える
}

// 使用例
// デフォルトインターフェイスメンバーはインターフェイス型経由で呼び出す
var score1 = new Score { Value = 85 };
var score2 = new Score { Value = 90 };

//一旦、インターフェイスにしないと使えないところは微妙
IComparable<Score> comparable1 = score1;
IComparable<Score> comparable2 = score2;

Console.WriteLine(comparable1.IsLessThan(score2));     // True
Console.WriteLine(comparable2.IsGreaterThan(score1));  // True
  • ポイント:
    • インターフェイスにデフォルト実装を持つメンバーを定義できます
    • 既存の実装クラスを壊さずにインターフェイスを拡張できます
    • トレイト(共通の振る舞いを型に追加)的な使い方ができます
    • ただし、状態(フィールド)は持てません

https://learn.microsoft.com/ja-jp/dotnet/csharp/advanced-topics/interface-implementation/mixins-with-default-interface-methods

Extensionメンバー(C#14.0)

C#14では、インスタンスメンバー(メソッド、プロパティ)、静的メンバー(メソッド、プロパティ)、演算子(オペレーター) が拡張できます。

まず定義方法としては、旧拡張メソッドと同じくコンテナになる静的クラスの中に定義します[28]

public static class SomeExtension
{
    // この中にextension定義を書く
}

次にextensionキーワードを使ってコンテナクラスの中の「拡張ブロック」に定義を書いていきます。インデントがまた増えた…![29]

コンテナクラスへの定義
public static class SomeExtension
{
  //インスタンス拡張
  extension(string source)
  {
    public bool IsNullOrEmptyProp => string.IsNullOrEmpty(source);
    public bool IsInstanceMethod() => source.Length > 0;

    //インスタンス拡張ブロックでも静的拡張プロパティの定義ができる
    public static bool IsStaticProp2 => true;
  }
  extension<T>(T source)  //ジェネリック拡張版
  {
    public string ToSomeString() => source?.ToString() ?? "null";
  }

  //静的拡張
  extension(string)
  {
    public static bool IsStaticProp => true;
    public static bool IsStaticMethod(string str) => string.IsNullOrEmpty(str);
  }
  extension<T>(T)  //ジェネリック拡張版
  {
    //略
  }
}

インスタンスメンバー用と静的メンバー用で拡張ブロックの書き方が少し異なります。インスタンスメンバー用の拡張ブロックの中で静的メンバーも定義できるのがちょっとおもしろい。拡張の対象の型指定にはジェネリクス(型引数)もつかえます。

  • ポイント:
    • extensionキーワードで既存の型にメンバーを追加できます
    • インスタンスメンバー(メソッド、プロパティ)と静的メンバーの両方が拡張可能です
    • 演算子(オペレーター)も拡張できます
    • ジェネリック型にも対応しています
    • ただし、フィールドと自動実装プロパティは拡張できません(状態を持てない)
    • 旧・拡張メソッドとバイナリ互換性があります

https://learn.microsoft.com/ja-jp/dotnet/csharp/programming-guide/classes-and-structs/extension-methods

どう書くべきか(トレイトやロール)

「インターフェイスのデフォルト実装メンバー」ですが、下位互換性のための使いにくさやレガシーランタイムで動かない[30]などがあって、いまいち使いどころが限定的です。トレイトやロールといった使い方向けではあんまりなかったりします。

一方、「Extensionメンバー」はC#に入ったばかりなのであまりベストプラクティスはまとまってません。しかし、Trait/Roleがある他の言語のベストプラクティスを調べると次のようなことが言えるそうです。

※調べれば調べるほど、これだけで何本も記事にできそう…!

もしこの書き方がメジャーになると、using static構文やConditionalWeakTable<T1,T2>といった今までのマイナーな存在が俄然注目を受けそうです…!

interface + Extensionを組み合わせる

//触れないインターフェイスがあっても…
interface ISomeService{}

//実装込みで定義拡張できる!
static class SomeServiceExtension{
    extension(ISomeService service)
    {
        public bool IsAwaking => /*...*/
        public void DoSomething()
            => Console.WriteLine("Doing something...");
    }
}

//DIとかで便利!
class X (ISomeService service){
    void M() {
        if(service.IsAwaking){
            service.DoSomething();
        }
    }
}
  • クラスや構造体ではなくインターフェイスを拡張すると、次のメリットがあります

    • 再利用性が高くなる
    • クラスの継承とかに依存しない
    • 「契約単位での振る舞いの共通化」が実現できる[31]
      • インターフェイスのデフォルト実装だけでも実現できるが、柔軟性が低い
    • DI/Mockとかにも向いてる
    • よく考えるとLINQの旧:拡張メソッドはこの形式
  • インターフェイスのデフォルト実装だけだと…

    • 「契約単位での振る舞いの共通化」は実現できる
    • 柔軟性がない
    • スコープ制御が↓の using staticを使ったもの に比べると弱い
    • .NET Core3.1 / .NET Standard 2.1以降のランタイムが必要
      • Extensionメンバーはpolyfillがあれば使えるっぽい

using staticを使って拡張を明示的に選ぶ

機能ごとに拡張をクラスわけしてusing staticする
namespace MyExtensions;
public static class LogExtension {
    extension(string str){
        public void Log()
            => Console.WriteLine($"[LOG]:{str}");
    }
}
public static class FileExtension {
    extension(string str){
        public bool TrySave(string path){
            /* ... */
        }
    }
}
LogExtensionだけ選んで使える
//using MyExtensions; ← これだと全部とりこんじゃう

//↓一部だけ選んで使える
using static MyExtensions.LogExtension;
"abc".Log();    //[LOG]:abc
  • なるべく拡張は機能ごとにクラスでわけ、using static[32]を使って必要な拡張を選んで個別に取り込む
  • using staticはクラス単位でimportできる
    • 普通のusingだと同じ名前空間のものを全部取り込んでしまう
  • この方法のメリット
    • 依存関係を明示的に管理できる
    • ファイル単位でどの拡張が使われているかが明確になる
    • 隠れた機能注入(namespace単位の自動混入)を防げる
      • 拡張できるものが増えた&今後も増える予定なので以前の旧拡張メソッドよりも名前被り問題が潜在化

拡張で状態も記録してほしい場合はConditionalWeakTable<T1,T2>を使って代替する

  • C#14時点では"拡張フィールド"や"拡張自動実装プロパティ"はない
  • Role的に状態も持たせたい場合、ConditionalWeakTable<T1,T2>(CWT)を使う[33]
CWTを使った疑似Role(状態を持つ拡張メンバー)
//名前しか持ってない…!
record Person(string Name);

//拡張のコンテナクラス自体にCWTのフィールドを持たせる
public static class FakeRoleExtension
{
    //CWTは弱参照テーブルなのでPersonが消えれば一緒にStateも消える
    static readonly ConditionalWeakTable<Person, State> _states = [];

    sealed class State { public int Age; }

    extension(Person p)
    {
        public int Age
        {
            get => _states.TryGetValue(p, out var s) ? s.Age : 0;
            set => _states.GetOrCreateValue(p).Age = value;
        }
    }
}
using static FakeRoleExtension;

var p = new Person("John")
{
    Age = 100
};

Console.WriteLine($"Person: {p.Age}");  //Person: 100

https://ufcpp.net/study/csharp/RmWeakReference.html

C#14/.NET10での影響(トレイトやロール)

何と言っても「Extensionメンバー」です。C#14の目玉。

互換性(トレイトやロール)

デフォルトinterfaceメンバーはランタイムの対応が必要で、古いランタイム[34]だと動かせません。ですがExtensionメンバーはどうやらpolyfillで実現できるらしく[35]、拡張メソッドは新旧でバイナリ互換があるそうです[36]

機能 SDKだけでOK 要polyfills 要ランタイム 言語ver.
デフォルトinterfaceメンバー ✔️[37] C#8.0
Extensionメンバー ✔️? ✔️?[38] C#14.0

Analyzer対応(トレイトやロール)

現時点では、デフォルトインターフェイスメンバー(C#8.0)や拡張メンバー(C#14.0)に特化したAnalyzerルールは提供されていません。

おわりに

matome

この記事のまとめ

  • モダンC#の定義: 過去5年以内(C#9.0/2020年以降)に追加された記法・文法・APIを活用するスタイル
  • 6つの重要な流れ:
    • シンプルコード: プライマリコンストラクタ、コレクション式、ファイルスコープ名前空間などで冗長性を削減
    • パターンマッチ: 型パターン、プロパティパターン、リストパターンなどで条件分岐を簡潔に記述
    • 安全なコード: null安全性、required修飾子、init専用セッターで実行時エラーを防止
    • ゼロコストな高パフォーマンス: Native AOT、ソースジェネレータ(GeneratedRegexSystem.Text.Jsonなど)で高速化
    • 安全なポインタ: Span<T>ref structscoped refなどでガベージコレクション不要な高速処理を実現
    • トレイト/ロール: デフォルトインターフェイスメンバー、拡張メンバー(Extension Members)で多重継承的な機能を提供
  • 互換性の高さ: レガシーC#とモダンC#は共存可能。段階的な導入がしやすい設計
  • polyfillの活用: PolySharpなどを使えば古いランタイムでも新しい記法の多くが利用可能
  • C#14の注目機能: First-class Span、拡張メンバー(Extension Members)で更なる進化
  • エコシステムの充実: 各機能に対応したアナライザー(CA/IDE/IL規則)とCode Fixで、移行と学習をサポート
  • 継続的な進化: C#は互換性を保ちながら、年1回のペースで新機能を積極的に追加中

「モダンC#」は、C#の進化を象徴するスタイルです。
新しい記法や機能をガンガン使うことで、より簡潔で安全なコードが書けちゃいます。

C#14と.NET10の登場で、「モダンC#」の可能性はさらに広がってます。
ぜひ、これらの新機能を活用して、次世代のC#プログラミングを楽しんでください!

参考資料

脚注
  1. あまりにも考えなしに追加しすぎじゃないか、って批判もあったりします。 ↩︎

  2. Javaも進化していますが新記法・新文法の追加にはかなり後ろ向きに見えます。C#とは対象的 ↩︎

  3. Unity(iOS/Android), ASP.NET Core(Linux)を考えると実行ホストOSの多くはWindowsじゃないほうが多いのでは…? ↩︎

  4. まあ、.NET APIのメソッド名とかが長いのはどうしようもないっちゃないですが… ↩︎

  5. 拡張メンバーがC#14で入って旧記法となるのでもうレガシーかも ↩︎

  6. クエリ式はもう誰も使ってないので事実上レガシーな機能かもしれません。null対応も微妙なのでちょっとレガシーより ↩︎

  7. これがあるのでC#は完全な静的型言語じゃないそうです。部分的に動的な静的型言語だとか…ややこしい! ↩︎

  8. C#のasync/awaitは主要言語だと多分最初に実装。そのためこなれてなくて、ちょっとレガシーよりなところがあります。そもそも内部実装も何回かかわってる模様。 ↩︎

  9. ランタイムasyncと合わせてモダンC#にしても良かったんですが、ランタイムasyncはプレビューだし、C#の言語自体は特に変わらないし、見送りました。 ↩︎

  10. が、生成AIくんが提示してくるC#はレガシーC#でコンテクストが無駄に消費されるコードなんですよね。。。ちゃんと指示してもすぐ忘れるし。 ↩︎

  11. 例:生文字列リテラル ↩︎

  12. MSLearnでは、日本語版は「Spread 要素」「スプレッド演算子」、英語版は「spread element」で訳が一致しません ↩︎

  13. 個人的には公式のMSBuildが駆逐されるんじゃないか、と思ったりしてます ↩︎

  14. CA1051:参照可能なインスタンス フィールドを宣言しません ↩︎

  15. プライマリコンストラクタだけはpolyfill必要 ↩︎

  16. 言語としてはver.9.0以降。ランタイムは.NET10以降が必要 ↩︎

  17. プライマリコンストラクタだけはpolyfill必要 ↩︎

  18. ネイティブバイナリのライブラリも作成できます ↩︎

  19. PolySharpとかもSGで作られている ↩︎

  20. .NET 7以降のSDKが必要 ↩︎

  21. C++に匹敵することもあるとか言われていたりします。C# 13 and .NET 9 is faster than C++ on Linux ↩︎

  22. Managed pointers in .NET ↩︎

  23. C#にはcall by value(値渡し)、call by reference(参照渡し)、pass a reference by value(参照の値渡し)、call-by-address(ポインタ渡し)の4種類があります ↩︎

  24. Slow Span ↩︎

  25. この名称を使う言語としては、Rust、PHP、Perl、Scala、Swiftなどがあるそうです。 ↩︎

  26. https://github.com/dotnet/csharplang/blob/main/meetings/2022/LDM-2022-09-26.md#roles--extensions ↩︎

  27. 拡張プロパティもパッキングフィールドが生成できないので自動実装プロパティではなく、シンプルな方のプロパティの追加です。 ↩︎

  28. このコンテナクラスは名前が被ったときの区別に使います。 ↩︎

  29. 一応、省略記法の提案があります。C#15以降に使えるようになるかも。 ↩︎

  30. .NET Standard2.1+ ↩︎

  31. SwiftやKotlinだとよくあるらしいです ↩︎

  32. using staticは調べると「いつ使うねん」って記事がいくつか出てきますが、やっと活用の場が! ↩︎

  33. https://github.com/dotnet/csharplang/discussions/8696#discussioncomment-13080399 ↩︎

  34. Unityはいけるけど、.NET FrameworkはNG ↩︎

  35. 調べたタイミングでは、PolySharpが準備中でした ↩︎

  36. https://devblogs.microsoft.com/dotnet/csharp-exploring-extension-members/ ↩︎

  37. .NET Standard2.1+ ↩︎

  38. 手元のテストではなくても動きました ↩︎

  39. C#にはcall by value(値渡し)、call by reference(参照渡し)、pass a reference by value(参照の値渡し)、call-by-address(ポインタ渡し)の4種類があります ↩︎

Discussion