C# 継承入門 ― WorkItem / ChangeRequest で学ぶ実践 OOP

対象読者

  • C# のクラスとオブジェクトはわかるが、継承はこれから
  • 実践的なコードで “派生クラスの作り方” を体感したい

ベースとなる資料

0. ストーリーとコードの目的

あなたは、とある開発チームのタスク管理ツールを自作しています。

  • WorkItem は “すべての作業カード” を表す基底クラス。新機能の開発でもバグ修正でも、一枚のカードにして看板(カンバン)に貼ります。
  • ChangeRequest は “既存 WorkItem の設計変更を依頼するカード”。「このクラスにプロパティを追加してほしい」「処理時間を短縮してほしい」といったリファクタリング系の要望を追跡します。

目的は 「共通情報を再利用しつつ、種類ごとの追加情報だけをシンプルに拡張できる継承設計」 を体験すること。

  • 自動採番 によってタスク ID を一意に保ち、
  • protected メンバー で派生クラスにもアクセスを許可し、
  • override で出力フォーマットを統一しながら多態性を担保します。

1. 何を作るのか

  • WorkItem : ID・タイトル・説明・所要時間を管理する “基底クラス”
  • ChangeRequest : WorkItem を継承し、元タスクを示す originalItemID を追加した “派生クラス”

ポイント

  • 再利用 : 共通ロジックをそのまま活用
  • 拡張 : ChangeRequest 独自のプロパティを追加

2. 完成コード全体

// ─── 基底クラス ─────────────────────────────────
public class WorkItem
{
    private static int currentID;

    protected int      ID          { get; set; }
    protected string   Title       { get; set; }
    protected string   Description { get; set; }
    protected TimeSpan JobLength   { get; set; }

    static WorkItem() => currentID = 0;                // 静的コンストラクタ
    public WorkItem()                                  // 既定コンストラクタ
    {
        ID = 0;
        Title = "Default title";
        Description = "Default description.";
        JobLength = TimeSpan.Zero;
    }
    public WorkItem(string title, string desc, TimeSpan joblen)
    {
        ID          = GetNextID();
        Title       = title;
        Description = desc;
        JobLength   = joblen;
    }

    protected int GetNextID() => ++currentID;

    public void Update(string title, TimeSpan joblen)
    {
        Title     = title;
        JobLength = joblen;
    }

    public override string ToString() => $"{ID} - {Title}";
}

// ─── 派生クラス ────────────────────────────────
public class ChangeRequest : WorkItem
{
    protected int OriginalItemID { get; set; }

    public ChangeRequest() { }                         // 既定コンストラクタ

    public ChangeRequest(string title, string desc,
                         TimeSpan jobLen, int originalID)
    {
        ID            = GetNextID();                   // ← 継承メンバー
        Title         = title;
        Description   = desc;
        JobLength     = jobLen;
        OriginalItemID = originalID;                   // ← 派生専用
    }
}

3. コード解剖

観点コード例ポイント
静的フィールドprivate static int currentID;採番カウンタを全インスタンスで共有
静的コンストラクタstatic WorkItem()最初のアクセス時に 1 回だけ実行
保護レベル protectedprotected int ID { get; set; }派生クラスから見えるが外部には公開しない
自動採番GetNextID()インスタンス生成毎に currentID++
メソッドの再利用change.Update(...)派生クラスでもそのまま使える
多態性override string ToString()ChangeRequest も同じ出力形式

4. 動かしてみる

var item = new WorkItem(
    "バグ修正",
    "自分のコードブランチのすべてのバグを修正する",
    new TimeSpan(3, 4, 0, 0));

var change = new ChangeRequest(
    "基底クラスの設計変更",
    "クラスにメンバーを追加する",
    new TimeSpan(4, 0, 0),
    1);

Console.WriteLine(item);      // 1 - バグ修正

change.Update(
    "基底クラスの設計を変更する",
    new TimeSpan(4, 0, 0));

Console.WriteLine(change);    // 2 - 基底クラスの設計を変更する

確認ポイント

  1. ID が連番で発行される
  2. ChangeRequest が Update と ToString を継承利用

5. 設計のコツ

  1. “is-a” 判定 ― ChangeRequest is a WorkItem → 継承で OK
  2. 基底クラスは最小公倍数 ― 共通する“本質”だけ
  3. 静的 vs. インスタンス責務 ― 採番は静的、内容はインスタンス
  4. override を忘れずに ― 共通 API を崩さず振る舞い変更

6. よくある質問

QA
派生コンストラクタから base(...)を呼ばないのは?基底が既定コンストラクタを持つので暗黙呼び出し可。パラメーター付きのみなら base(...) 必須。
new でメンバー隠蔽した方が早い?多態性が失われるため基本は override 推奨。
静的フィールドはスレッド安全?単純インクリメントならほぼ問題なし。厳密さが必要なら Interlocked.Increment を使用。

7. 練習課題 ✍️

難易度課題
★☆☆(初級)ChangeRequest に Priority プロパティ(Low / Medium / High)を追加し、Update で編集できるようにせよ。
★★☆(中級)WorkItem を継承した BugTicket を実装し、Severity 列挙体 (Critical / Major / Minor) を保持。ToStringを [Critical] 3 - NullReferenceException の形式にオーバーライドせよ。
★★★(上級)WorkItem を abstract クラス に変更し、抽象メソッド decimal CalculateCost() を追加。ChangeRequest と BugTicket で独自計算ロジックを実装し、List<WorkItem> に混在させて合計コストを求めるテストコードを書け。

★☆☆(初級) Priority プロパティ追加

public enum Priority { Low, Medium, High }

public class ChangeRequest : WorkItem
{
    public Priority Priority { get; set; } = Priority.Medium;

    public ChangeRequest(string title, string desc, TimeSpan jobLen, int originalID, Priority priority = Priority.Medium)
        : base(title, desc, jobLen)
    {
        OriginalItemID = originalID;
        Priority = priority;
    }

    // 基底の Update を拡張
    public void Update(string title, TimeSpan jobLen, Priority priority)
    {
        base.Update(title, jobLen);
        Priority = priority;
    }

    public override string ToString() => $"[{Priority}] {base.ToString()}";
}

★★☆(中級) BugTicket クラス実装

public enum Severity { Critical, Major, Minor }

public class BugTicket : WorkItem
{
    public Severity Severity { get; }

    public BugTicket(string title, string desc, TimeSpan jobLen, Severity severity)
        : base(title, desc, jobLen)
    {
        Severity = severity;
    }

    public override string ToString() => $"[{Severity}] {ID} - {Title}";
}

★★★(上級) 抽象メソッド CalculateCost 実装

// 抽象基底クラス化
public abstract class WorkItem
{
    // 既存メンバーはそのまま…
    public abstract decimal CalculateCost();
}

// ChangeRequest のコスト計算(時給 8,000 円換算)
public override decimal CalculateCost() => (decimal)JobLength.TotalHours * 8000m;

// BugTicket のコスト計算:深刻度でレートを変更
public override decimal CalculateCost() => Severity switch
{
    Severity.Critical => (decimal)JobLength.TotalHours * 10000m,
    Severity.Major    => (decimal)JobLength.TotalHours * 7000m,
    Severity.Minor    => (decimal)JobLength.TotalHours * 5000m,
    _                 => 0m
};

テストコード

var tasks = new List<WorkItem>
{
    new ChangeRequest("UI 改善", "アラート文言を変更", TimeSpan.FromHours(2), 5, Priority.Low),
    new BugTicket("NullReferenceException 修正", "起動直後に落ちる", TimeSpan.FromHours(3), Severity.Critical)
};

decimal total = tasks.Sum(t => t.CalculateCost());
Console.WriteLine($"合計コスト: {total:C0}");

8. まとめ

  • 継承は 再利用 と 拡張 を両立する強力な道具
  • ただし “is-a” が成り立つか、基底クラスが肥大しすぎていないかを常に確認
  • 実際にコードを書いて 自動採番 と override の動作 を体験すると理解が深まる
訪問数 3 回, 今日の訪問数 1回