チュートリアル: タプル、レコード、構造体、およびクラスから選択する

Tip

この記事は、「 基礎 」セクションの一部であり、少なくとも 1 つのプログラミング言語を知り、C# を学習している開発者向けに作成されています。 プログラミングを初めて使用する場合は、「はじめに」から始 めてください。 クイック リファレンス テーブルについては、「 種類の選択」を参照してください。

C# アプリケーションの最初の設計上の決定事項の 1 つは、作成する種類を選択することです。 メニュー項目は class または recordにする必要がありますか? クイック計算では、 tuple または名前付き型を返す必要がありますか? 各選択肢は、コードが等価性、変更可能性、ポリモーフィズムをどのように処理するかを示します。 間違った選択は、定型句、バグ、またはその両方につながります。

このチュートリアルでは、メニュー項目、注文、センサーの読み取り値、および割引ポリシーを使用する小規模なコーヒー ショップ モデルを構築します。 特性を分析し、各概念に最適な C# 型を決定します。 その過程で、どの型を選択すべきかを示す設計上の要件を見極められるようになります。

このチュートリアルでは、次の操作を行います。

  • タプルが複数の値を返すのに適したタイミングを認識します。
  • レコード クラスを使用して不変データをモデル化し、値ベースの等価性を理解します。
  • レコード構造体を使用して、コピー可能な小さなデータを表します。
  • クラスを使用して変更可能な状態と動作を管理します。
  • 継承によってクラスを拡張して、ルールを追加または強化します。
  • インターフェイスを使用して、関連のない型間で共有機能を定義します。

[前提条件]

  • .NET SDK をインストールします。

一時的なグループ化にタプルを使用する

コーヒー ショップには、注文の合計数と 1 日の収益の両方を返すメソッドが必要です。 そのクラスまたは構造体を定義することもできますが、1 つのメソッドの 2 つの値が常に新しい型を正当化するとは限りません。

(int TotalOrders, decimal Revenue) GetDailySummary(int orders, decimal revenue)
    => (orders, revenue);

Console.WriteLine("=== Tuple: daily summary ===");
var summary = GetDailySummary(42, 1234.50m);
Console.WriteLine($"Orders: {summary.TotalOrders}, Revenue: {summary.Revenue:F2}");

var (orders, revenue) = summary;
Console.WriteLine($"Deconstructed: {orders} orders, {revenue:F2}");

GetDailySummary は (int TotalOrders, decimal Revenue)タプル(組)を返します。 呼び出し元は、各要素に名前でアクセスするか、両方をローカル変数に分解します。 クラスまたは構造体の定義は必要ありません。

この例でタプルが最適に動作する理由

グループ化がローカルであるため、タプルはここで機能します。1 つのメソッドで生成され、1 つの呼び出し元がそれを使用します。 名前付き要素は、完全な型指定の手間をかけずに意図を明確にします。 複数のメソッド間で同じタプル構造を繰り返し受け渡していることに気付いた場合、それはそのタプルを record または class に昇格させるべきサインです。 この進化は、このチュートリアルの後半で確認できます。 タプルの構文と機能の詳細については、「 タプル型」を参照してください。

変更できないデータにレコードを使用する

すべてのコーヒーショップにはメニューが必要です。 メニュー項目には、名前、価格、栄養ノートがあります。 項目が一覧表示されると、これらの値は変更されません。 両方とも "Latte at $4.50" を参照する 2 つのシステムは、別々のオブジェクトを作成した場合でも、同じことを話していると同意する必要があります。

位置指定レコードを宣言する:

record class MenuItem(string Name, decimal Price, string NutritionalNote);

コンパイラは、その 1 行からコンストラクター、分解子、 Equals、 GetHashCode、および ToString を生成します。 レコードを機能させるには、次の手順を実行します。

Console.WriteLine("\n=== Record class: MenuItem ===");
var latte = new MenuItem("Latte", 4.50m, "Contains dairy");
var latte2 = new MenuItem("Latte", 4.50m, "Contains dairy");
var seasonal = latte with { Name = "Pumpkin Spice Latte", Price = 5.25m };

Console.WriteLine(latte);
Console.WriteLine(seasonal);
Console.WriteLine($"Same reference (latte vs latte2): {ReferenceEquals(latte, latte2)}");
Console.WriteLine($"Value equal (latte vs latte2):    {latte == latte2}");
Console.WriteLine($"Value equal (latte vs seasonal):  {latte == seasonal}");

同じデータを持つ 2 つの MenuItem インスタンスは、個別のオブジェクトであっても等しくなります。 この動作は、値ベースの等価性を示しています。 with式は、元のバリアントを変更せずに季節バリアントを作成します。

レコード クラスは、ID がオブジェクト参照ではなくデータから取得され、インスタンスが作成後にほとんど変更されない場合に適しています。 箱から出してすぐに、判読可能なToString()出力、構造的等価性、withサポートを利用することができます。 詳細な説明については、レコードとレコードに関するチュートリアルを参照してください。

小さい値型にレコード構造体を使用する

コーヒーマシンには温度測定値を報告する温度計が組み込まれています。 各読み取り値は小さい (数値と単位) であり、ログ、アラート、ダッシュボードにコピーされます。 1つのコピーを変更して他のコピーに影響を与えたくない。

レコード構造体を宣言します。

record struct Measurement(double Value, string Unit);

レコード構造体を使用します。

Console.WriteLine("\n=== Record struct: Measurement ===");
var temp = new Measurement(72.5, "°F");
var copy = temp;

copy = copy with { Value = 23.0, Unit = "°C" };

Console.WriteLine($"Original: {temp.Value}{temp.Unit}");
Console.WriteLine($"Copy (converted): {copy.Value}{copy.Unit}");

tempをcopyに割り当てると、独立した値が作成されます。 with式は、元のレコード クラスと同じパターンに触れることなく、参照によるコピーではなく、割り当て時のコピー動作で新しい値を生成します。

レコード構造体は、データが小さい (いくつかのプリミティブ フィールド) 場合に適合し、コピーはヒープ割り当てよりも安価です。 レコードクラスと同様に値の等価性とwith サポートが提供され、背後に真の値セマンティクスがあります。 測定、座標、および同様の軽量データは自然な候補です。 詳細については、「レコードと構造体の型」を参照してください。

変更可能な状態と動作が必要な場合にクラスを使用する

顧客がカウンターに上がると、バリスタは注文を開始し、一度に 1 つずつ項目を追加します。 合計が増加すると、状態が "保留中" から "準備完了" に変わり、同じアイテムを含む場合でも、同時に 2 つの注文が個別の注文になります。

class Order : IOrder
{
    public virtual string Status { get; set; } = "Pending";
    private readonly List<(string Name, decimal Price)> _items = [];

    public void AddItem(string name, decimal price) => _items.Add((name, price));

    public decimal Total => _items.Sum(i => i.Price);

    public override string ToString() =>
        $"Order [{Status}]: {string.Join(", ", _items.Select(i => i.Name))} - Total: {Total:F2}";
}
Console.WriteLine("\n=== Class: Order ===");
var order = new Order();
order.AddItem("Latte", 4.50m);
order.AddItem("Croissant", 3.25m);
order.Status = "Ready";

Console.WriteLine(order);

Order クラスは、項目を追跡し、実行中の合計を計算し、設定可能なStatusを公開します。 クラスは、有効期間中に変化する変更可能な状態を保持し、動作 (メソッド) は型の目的の中心であり、ID が重要であるため、クラスはここで適切なツールです。同じ項目を持つ 2 つの順序は、まだ異なる順序です。 詳細については、「 クラス、構造体、およびレコード」を参照してください。

クラスを拡張する必要がある場合に継承を使用する

コーヒーショップでは、ケータリングイベントを開始します。 ケータリング注文は引き続き注文であり、項目と合計が含まれますが、ゲスト数も追跡され、キッチンが準備完了をマークする前にマネージャーの承認が必要です。 Orderのロジックを複製するのではなく、特殊なクラスを派生させます。

class CateringOrder : Order
{
    public int MinimumGuests { get; }
    public string? ApprovedBy { get; private set; }

    public CateringOrder(int minimumGuests) => MinimumGuests = minimumGuests;

    public void Approve(string manager) => ApprovedBy = manager;

    public override string Status
    {
        get => base.Status;
        set
        {
            if (value == "Ready" && ApprovedBy is null)
                throw new InvalidOperationException(
                    "A catering order requires manager approval before it can be marked ready.");
            base.Status = value;
        }
    }

    public override string ToString() =>
        $"Catering [{Status}] for {MinimumGuests}+ guests, approved by: {ApprovedBy ?? "(none)"} - Total: {Total:F2}";
}

CateringOrder は基底クラスから AddItem と Total を再利用します。 Status オーバーライドでは、契約が厳格になっており、事前承認なしに Status = "Ready" を呼び出すと、例ががスローされます。

Console.WriteLine("\n=== Inheritance: CateringOrder ===");
var catering = new CateringOrder(minimumGuests: 20);
catering.AddItem("Coffee (serves 20)", 45.00m);
catering.AddItem("Pastry platter", 60.00m);

try
{
    catering.Status = "Ready";
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Blocked: {ex.Message}");
}

catering.Approve("Sam");
catering.Status = "Ready";
Console.WriteLine(catering);

この 1 つの派生クラスは、次の 3 つの継承の概念を示しています。

  • 状態の追加: MinimumGuests と ApprovedBy は派生クラスにのみ存在します。
  • 動作の追加: Approve は新しく、基本 Order は承認について知りません。
  • オーバーライドされた動作: Status セッターは、基底クラスにはないビジネス ルールを適用します。

継承は、新しい型 が 基本型の特殊なバージョンであり、ルールを追加または締め付けるときに既存の状態と動作を再利用する必要がある場合に適しています。 型がコントラクトだけでなく実装を共有する場合、共有基底クラスはインターフェイスよりも自然です。

インターフェイスを使用して共有機能を定義する

コーヒーショップでは、ハッピーアワー、ロイヤルティリワード、季節のスペシャルなど、さまざまなプロモーションを行っています。 チェックアウト プロセスでは、各ポリシーの詳細を知らなくても、現在アクティブな割引を適用する必要があります。 チェックアウトを 1 つのクラスに関連付けることなく、"割引を適用できるものは何でも" と言う方法が必要です。

interface IDiscountPolicy
{
    decimal Apply(decimal total);
}

class HappyHourDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total * 0.80m;
}

class LoyaltyDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total - 1.00m;
}

Checkoutメソッドは任意のIDiscountPolicyを受け入れるため、チェックアウト ロジックを変更せずに新しいポリシーを導入できます。

static decimal Checkout(decimal total, IDiscountPolicy policy) => policy.Apply(total);

Console.WriteLine("\n=== Interface: discount policy ===");
decimal subtotal = 12.00m;
Console.WriteLine($"Happy hour (20% off): {Checkout(subtotal, new HappyHourDiscount()):F2}");
Console.WriteLine($"Loyalty ($1 off):     {Checkout(subtotal, new LoyaltyDiscount()):F2}");

インターフェイスはコントラクトを宣言します。これは、実装する型が提供する必要があるメンバーのセットです。 このインターフェイスはここで機能します。割引の種類は関係がないため (基底クラスは共有されません)、チェックアウトではそれらを一様に扱う必要があります。 インターフェイスはまた、テストを簡単にします。本番コードに触れることなく、スタブポリシーに差し替えることができます。 詳細については、「 インターフェイス」を参照してください。

書体の選択肢を進化させましょう

これらの決定はいずれも永続的なものはありません。 実際、破壊的変更が考慮されるライブラリをリリースする前に、簡単に変更できます。 要件が増えるにつれて、単純型をより充実した型に昇格させます。 3 つの一般的な進化を次に示します。

タプル → record: グループ化が表示され続ける

GetDailySummaryタプルは単一のメソッド内では問題なく動作しますが、それをレポート、ダッシュボード、テストなどに渡し始めると、名前付き型を使用する価値が出てきます。 タプルをレコードに昇格させ、計算プロパティを追加します。

record class DailySummary(int TotalOrders, decimal Revenue)
{
    public decimal AverageTicket => TotalOrders > 0 ? Revenue / TotalOrders : 0m;
}

以前にタプルを分解した呼び出し元は、追加の処理なしにToString()が利用でき、値の同等性を持ち、AverageTicketのような派生データを自然に配置できるようになりました。

Console.WriteLine("\n=== Evolve: tuple -> record ===");
var daily = new DailySummary(120, 525.75m);
Console.WriteLine(daily);
Console.WriteLine($"Average ticket: {daily.AverageTicket:F2}");

構造体→クラス: 継承が必要です

ショップのメンテナンス チームは、校正済みの測定値 (オフセットで調整されたセンサー値) を求めます。 Measurementレコード構造体は生データには適していますが、構造体は継承をサポートしないため、調整されたバリアントを派生させることはありません。 クラス階層に昇格する:

class SensorReading(double value, string unit)
{
    public double Value { get; } = value;
    public string Unit { get; } = unit;

    public virtual string Display() => $"{Value}{Unit}";
}

class CalibratedReading(double value, string unit, double offset)
    : SensorReading(value, unit)
{
    public double Offset { get; } = offset;

    public override string Display() => $"{Value + Offset}{Unit} (offset {Offset:+0.0;-0.0})";
}

CalibratedReading は SensorReading から継承し、オフセットを含めるために Display() をオーバーライドします。 構造体またはレコード構造体では、このパターンを使用することはできません。

Console.WriteLine("\n=== Evolve: struct -> class ===");
var raw = new SensorReading(72.5, "°F");
var calibrated = new CalibratedReading(72.5, "°F", offset: -0.3);

Console.WriteLine($"Raw:        {raw.Display()}");
Console.WriteLine($"Calibrated: {calibrated.Display()}");

クラス → クラス + インターフェイス: 型間でポリモーフィズムが必要です

Orderクラスはそれ自体でうまく機能しますが、CateringOrderが存在すると、チェックアウト、レポート、印刷はすべて、どのような具体的な種類であるかを気にせずに任意の順序を処理する必要があります。 呼び出し元が実際に依存しているメンバーとのインターフェイスを抽出します。

interface IOrder
{
    string Status { get; set; }
    decimal Total { get; }
}

OrderとCateringOrderの両方がこの契約を既に満たしている。 これで、1 つのメソッドで次のいずれかの型が処理されるようになりました。

Console.WriteLine("\n=== Evolve: class -> class + interface ===");
static void PrintOrderSummary(IOrder o) =>
    Console.WriteLine($"  {o.Total:F2} [{o.Status}]");

var walkIn = new Order();
walkIn.AddItem("Mocha", 5.00m);
walkIn.Status = "Ready";

var banquet = new CateringOrder(minimumGuests: 50);
banquet.AddItem("Coffee service", 90.00m);
banquet.Approve("Alex");
banquet.Status = "Ready";

Console.WriteLine("All orders:");
foreach (IOrder o in new IOrder[] { walkIn, banquet })
    PrintOrderSummary(o);

インターフェイスを抽出しても、 Order や CateringOrderは変わりません。これにより、共有シェイプが明示的になるだけで、テストも簡単になります。

クイック デシジョン ガイド

選択する種類がわからない場合は、次の表を開始点として使用します。

Question 最適
1 つのメソッドからいくつかの値を返しますか? タプル
等価性が値によってある不変データ Record クラス
コピー可能で等価比較できる小さな値データですか? レコード構造体
変更可能な状態、動作、または参照 ID クラス
既存のクラスの特殊なバージョンですか? 派生クラス
関連のない型間の共有機能 Interface

これらのいずれかがきちんと収まらない場合は、型を組み合わせることを検討してください。 たとえば、クラスはインターフェイスを実装でき、レコードは構造体にすることができます。 完全な比較については、「 種類の選択」を参照してください。