クラス

【C#】継承でのコンストラクタ引数指定とbase呼び出しの基本と応用

基底クラスに引数付きコンストラクタのみがある場合、派生クラスのコンストラクタ冒頭で: base(引数)と書き、必ず呼び出す必要があります。

引数なしコンストラクタが基底側にあれば暗黙呼び出しが行われます。

複数オーバーロードを用意すると生成パターンを柔軟に切り替えられ、初期化忘れによるバグを防げます。

目次から探す
  1. C#の継承とコンストラクタの関係
  2. baseキーワードの基本
  3. 典型的な実装例
  4. コンストラクタチェーンの応用
  5. デザインパターンでの活用
  6. 例外と安全性
  7. コードメンテナンスのコツ
  8. 誤りやすいポイント
  9. テスト観点
  10. パフォーマンス影響
  11. C# 9以降の新機能との組み合わせ
  12. 静的コンストラクタと継承
  13. ジェネリック型での注意点
  14. アクセシビリティとカプセル化
  15. 属性(アトリビュート)での構成
  16. 実務でのベストプラクティス
  17. まとめ

C#の継承とコンストラクタの関係

C#における継承は、クラス間でコードの再利用や機能の拡張を行うための基本的な仕組みです。

継承関係にあるクラスでは、基底クラス(親クラス)のコンストラクタがどのように呼び出されるかを理解することが重要です。

ここでは、継承時のコンストラクタの呼び出し順序や、引数の有無による違いについて詳しく解説します。

コンストラクタ呼び出しの順序

C#では、派生クラス(子クラス)のインスタンスを生成するとき、基底クラスのコンストラクタが必ず先に呼び出されます。

これは、基底クラスの初期化を先に行い、その上で派生クラスの初期化を行うためです。

具体的には、派生クラスのコンストラクタが呼ばれると、まず基底クラスのコンストラクタが実行され、その後に派生クラスのコンストラクタの処理が続きます。

これにより、基底クラスの状態が正しく初期化された状態で、派生クラスの処理が行われることが保証されます。

以下のサンプルコードで、呼び出し順序を確認してみましょう。

using System;
public class BaseClass
{
    public BaseClass()
    {
        Console.WriteLine("BaseClassのコンストラクタが呼ばれました");
    }
}
public class DerivedClass : BaseClass
{
    public DerivedClass()
    {
        Console.WriteLine("DerivedClassのコンストラクタが呼ばれました");
    }
}
public class Program
{
    public static void Main()
    {
        var obj = new DerivedClass();
    }
}
BaseClassのコンストラクタが呼ばれました
DerivedClassのコンストラクタが呼ばれました

このように、DerivedClassのインスタンス生成時に、まずBaseClassのコンストラクタが呼ばれ、その後にDerivedClassのコンストラクタが呼ばれていることがわかります。

この呼び出し順序は、基底クラスの初期化が完了していない状態で派生クラスの処理が行われることを防ぎ、オブジェクトの整合性を保つために重要です。

暗黙的な引数なしコンストラクタ

基底クラスに引数なしのコンストラクタ(デフォルトコンストラクタ)が存在する場合、派生クラスのコンストラクタで特にbaseキーワードを使って基底クラスのコンストラクタを呼び出さなくても、自動的にその引数なしコンストラクタが呼び出されます。

これを「暗黙的な呼び出し」と呼びます。

つまり、基底クラスにパラメータなしのコンストラクタがあると、派生クラスのコンストラクタは以下のように書いても問題ありません。

using System;
public class Person
{
    public string Name { get; set; }
    public Person()
    {
        Name = "Unknown";
        Console.WriteLine("Personの引数なしコンストラクタが呼ばれました");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(int employeeId)
    {
        EmployeeId = employeeId;
        Console.WriteLine("Employeeのコンストラクタが呼ばれました");
    }
}
public class Program
{
    public static void Main()
    {
        var emp = new Employee(123);
        Console.WriteLine($"Name: {emp.Name}, EmployeeId: {emp.EmployeeId}");
    }
}
Personの引数なしコンストラクタが呼ばれました
Employeeのコンストラクタが呼ばれました
Name: Unknown, EmployeeId: 123

この例では、Employeeのコンストラクタ内でbaseを明示的に呼び出していませんが、Personの引数なしコンストラクタが自動的に呼ばれています。

これにより、Nameプロパティが”Unknown”に初期化されていることが確認できます。

ただし、基底クラスに引数なしコンストラクタが存在しない場合は、この暗黙的な呼び出しはできません。

次のセクションで詳しく説明します。

明示的な引数付きコンストラクタ

基底クラスに引数付きのコンストラクタしか存在しない場合、派生クラスのコンストラクタでは必ずbaseキーワードを使って、どの基底クラスのコンストラクタを呼び出すかを明示的に指定しなければなりません。

これを怠るとコンパイルエラーになります。

例えば、以下のように基底クラスPersonが名前を受け取るコンストラクタのみを持っている場合を考えます。

using System;
public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
        Console.WriteLine($"Personのコンストラクタが呼ばれました。Name: {name}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    // baseを使って基底クラスのコンストラクタを明示的に呼び出す
    public Employee(string name, int employeeId)
        : base(name)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeのコンストラクタが呼ばれました。EmployeeId: {employeeId}");
    }
}
public class Program
{
    public static void Main()
    {
        var emp = new Employee("山田太郎", 1001);
        Console.WriteLine($"Name: {emp.Name}, EmployeeId: {emp.EmployeeId}");
    }
}
Personのコンストラクタが呼ばれました。Name: 山田太郎
Employeeのコンストラクタが呼ばれました。EmployeeId: 1001
Name: 山田太郎, EmployeeId: 1001

この例では、Employeeのコンストラクタでbase(name)と書くことで、Personのコンストラクタにnameを渡して呼び出しています。

これにより、基底クラスのNameプロパティが正しく初期化されます。

もしbase(name)を省略すると、コンパイル時に以下のようなエラーが発生します。

'Person' does not contain a constructor that takes 0 arguments

これは、基底クラスに引数なしコンストラクタが存在しないため、派生クラスのコンストラクタで基底クラスのコンストラクタを明示的に呼び出す必要があることを示しています。

この仕組みは、基底クラスの初期化に必要な情報を必ず渡すことを強制し、オブジェクトの整合性を保つために役立っています。

以上のように、C#の継承におけるコンストラクタの呼び出しは、基底クラスのコンストラクタの種類によって暗黙的に呼ばれる場合と明示的にbaseで呼び出す必要がある場合に分かれます。

これらのルールを理解しておくことで、継承関係のクラス設計やインスタンス生成時の初期化処理を正しく行えます。

baseキーワードの基本

書式と配置ルール

baseキーワードは、派生クラスのコンストラクタから基底クラスのコンストラクタを呼び出す際に使います。

書式は、派生クラスのコンストラクタの宣言の直後にコロン:を置き、その後にbaseキーワードと引数リストを記述します。

具体的な書式は以下の通りです。

public DerivedClass(引数リスト) : base(基底クラスの引数リスト)
{
    // 派生クラスのコンストラクタ本体
}

この配置は必須で、base呼び出しはコンストラクタの本体より前に書かなければなりません。

コンストラクタの本体内でbaseを呼び出すことはできません。

例えば、以下のように書きます。

public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int employeeId) : base(name)
    {
        EmployeeId = employeeId;
    }
}

この例では、Employeeのコンストラクタ宣言の直後に: base(name)があり、これが基底クラスPersonのコンストラクタを呼び出しています。

引数の受け渡し方法

base呼び出しでは、基底クラスのコンストラクタに渡す引数を指定します。

これらの引数は、派生クラスのコンストラクタのパラメータや、派生クラス内で計算・生成した値を渡すことが可能です。

例えば、派生クラスのコンストラクタ引数をそのまま基底クラスに渡す場合は以下のようになります。

public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int employeeId) : base(name)
    {
        EmployeeId = employeeId;
    }
}

また、派生クラスのコンストラクタ内で引数を加工して渡すことも可能です。

public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string firstName, string lastName, int employeeId)
        : base($"{firstName} {lastName}")
    {
        EmployeeId = employeeId;
    }
}

この例では、firstNamelastNameを結合して基底クラスのname引数に渡しています。

さらに、基底クラスのコンストラクタが複数の引数を受け取る場合も、同様にカンマ区切りで複数の引数を渡せます。

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int age, int employeeId)
        : base(name, age)
    {
        EmployeeId = employeeId;
    }
}

名前付き引数とbase呼び出し

C#では、基底クラスのコンストラクタに名前付き引数を使って値を渡すことも可能です。

名前付き引数を使うと、引数の順序に依存せずに値を渡せるため、可読性が向上します。

以下の例をご覧ください。

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int age, int employeeId)
        : base(age: age, name: name)  // 名前付き引数で順序を入れ替えて渡す
    {
        EmployeeId = employeeId;
    }
}

この例では、base呼び出しでagenameの順序を入れ替えて名前付き引数で渡しています。

これにより、引数の順序を気にせずに値を渡せるため、コードの意図が明確になります。

名前付き引数は、特に引数が多いコンストラクタや、デフォルト値が設定されている引数を省略したい場合に便利です。

ただし、名前付き引数を使う場合も、base呼び出しはコンストラクタ宣言の直後に書く必要がある点は変わりません。

これらの基本ルールを押さえることで、baseキーワードを使った基底クラスのコンストラクタ呼び出しを正しく記述できます。

特に、引数の受け渡し方法や名前付き引数の活用は、複雑な継承階層での初期化をわかりやすく保つために役立ちます。

典型的な実装例

単一レベル継承

基底クラスに引数付きコンストラクタのみ

基底クラスが引数付きコンストラクタのみを持つ場合、派生クラスのコンストラクタでは必ずbaseキーワードを使って基底クラスのコンストラクタを明示的に呼び出す必要があります。

引数なしコンストラクタが存在しないため、暗黙的な呼び出しはできません。

以下の例では、Personクラスが名前を受け取るコンストラクタのみを持ち、Employeeクラスがそれを継承しています。

using System;
public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
        Console.WriteLine($"Personコンストラクタ: Name = {name}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int employeeId) : base(name)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId = {employeeId}");
    }
}
public class Program
{
    public static void Main()
    {
        var emp = new Employee("佐藤一郎", 101);
        Console.WriteLine($"Name: {emp.Name}, EmployeeId: {emp.EmployeeId}");
    }
}
Personコンストラクタ: Name = 佐藤一郎
Employeeコンストラクタ: EmployeeId = 101
Name: 佐藤一郎, EmployeeId: 101

この例では、Employeeのコンストラクタでbase(name)を使い、Personのコンストラクタにnameを渡しています。

これにより、基底クラスの初期化が正しく行われています。

基底クラスに複数コンストラクタを持つ場合

基底クラスが複数のコンストラクタを持つ場合、派生クラスのコンストラクタは必要に応じてどの基底クラスのコンストラクタを呼び出すか選択できます。

これにより、柔軟な初期化が可能です。

以下の例では、Personクラスに引数なしコンストラクタと引数付きコンストラクタがあり、Employeeクラスのコンストラクタでそれぞれを使い分けています。

using System;
public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person()
    {
        Name = "不明";
        Age = 0;
        Console.WriteLine("Personの引数なしコンストラクタが呼ばれました");
    }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"Personの引数付きコンストラクタが呼ばれました: Name={name}, Age={age}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    // 基底クラスの引数なしコンストラクタを呼び出す
    public Employee(int employeeId) : base()
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId = {employeeId}");
    }
    // 基底クラスの引数付きコンストラクタを呼び出す
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId = {employeeId}");
    }
}
public class Program
{
    public static void Main()
    {
        var emp1 = new Employee(201);
        Console.WriteLine($"Name: {emp1.Name}, Age: {emp1.Age}, EmployeeId: {emp1.EmployeeId}");
        var emp2 = new Employee("鈴木花子", 28, 202);
        Console.WriteLine($"Name: {emp2.Name}, Age: {emp2.Age}, EmployeeId: {emp2.EmployeeId}");
    }
}
Personの引数なしコンストラクタが呼ばれました
Employeeコンストラクタ: EmployeeId = 201
Name: 不明, Age: 0, EmployeeId: 201
Personの引数付きコンストラクタが呼ばれました: Name=鈴木花子, Age=28
Employeeコンストラクタ: EmployeeId = 202
Name: 鈴木花子, Age: 28, EmployeeId: 202

このように、基底クラスの複数のコンストラクタを使い分けることで、派生クラスの初期化パターンを増やせます。

多段継承チェーン

三階層での初期化順序

多段継承チェーンでは、基底クラスから派生クラスへと複数の継承階層が存在します。

C#では、インスタンス生成時に最も上位の基底クラスのコンストラクタから順に呼び出され、最後に最も派生したクラスのコンストラクタが実行されます。

以下の例は、PersonEmployeeManagerの三階層継承を示しています。

using System;
public class Person
{
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name;
        Console.WriteLine($"Personコンストラクタ: Name = {name}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    public Employee(string name, int employeeId) : base(name)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId = {employeeId}");
    }
}
public class Manager : Employee
{
    public string Department { get; set; }
    public Manager(string name, int employeeId, string department) : base(name, employeeId)
    {
        Department = department;
        Console.WriteLine($"Managerコンストラクタ: Department = {department}");
    }
}
public class Program
{
    public static void Main()
    {
        var mgr = new Manager("田中次郎", 301, "営業部");
        Console.WriteLine($"Name: {mgr.Name}, EmployeeId: {mgr.EmployeeId}, Department: {mgr.Department}");
    }
}
Personコンストラクタ: Name = 田中次郎
Employeeコンストラクタ: EmployeeId = 301
Managerコンストラクタ: Department = 営業部
Name: 田中次郎, EmployeeId: 301, Department: 営業部

この例では、Managerのインスタンス生成時に、PersonEmployeeManagerの順でコンストラクタが呼ばれています。

これにより、各階層の初期化が正しく行われています。

ダイヤモンド継承風シナリオへの対応策

C#は多重継承をサポートしていませんが、インターフェースの多重継承は可能です。

クラスの多重継承ができないため、いわゆる「ダイヤモンド継承問題」は発生しません。

しかし、似たような構造をインターフェースや委譲で実現する場合、基底クラスの初期化が複雑になることがあります。

例えば、複数のインターフェースを実装し、それぞれに初期化処理が必要な場合です。

このような場合は、以下のような対応策があります。

  • 委譲パターンの活用

複数の機能を持つクラスを作る際に、機能ごとに別クラスを作成し、メインクラスがそれらを内部で保持して処理を委譲します。

これにより、継承階層の複雑化を避けられます。

  • インターフェースのデフォルト実装(C# 8.0以降)

インターフェースにデフォルト実装を持たせることで、共通の初期化ロジックをインターフェース側で提供できます。

ただし、状態を持つ初期化は制限があります。

  • 明示的な初期化メソッドの設計

コンストラクタでの初期化が難しい場合、初期化専用のメソッドを用意し、呼び出し順序を明確に管理します。

以下は委譲パターンの簡単な例です。

using System;
public interface ILogger
{
    void Log(string message);
}
public class ConsoleLogger : ILogger
{
    public ConsoleLogger()
    {
        Console.WriteLine("ConsoleLogger初期化");
    }
    public void Log(string message)
    {
        Console.WriteLine($"Log: {message}");
    }
}
public class Service
{
    private readonly ILogger _logger;
    public Service(ILogger logger)
    {
        _logger = logger;
        Console.WriteLine("Service初期化");
    }
    public void Execute()
    {
        _logger.Log("処理を実行しました");
    }
}
public class Program
{
    public static void Main()
    {
        ILogger logger = new ConsoleLogger();
        var service = new Service(logger);
        service.Execute();
    }
}
ConsoleLogger初期化
Service初期化
Log: 処理を実行しました

このように、複数の機能を継承ではなく委譲で分離することで、初期化の責任を明確にし、複雑な継承問題を回避できます。

以上が典型的な継承におけるコンストラクタの実装例です。

単一レベルの継承から多段継承まで、基底クラスのコンストラクタの種類や継承階層の深さに応じて適切にbase呼び出しを使い分けることが重要です。

多重継承ができないC#では、委譲やインターフェースの活用で複雑な継承問題を回避する設計が求められます。

コンストラクタチェーンの応用

同一クラス内でのthis呼び出しとの併用

C#では、同一クラス内の別のコンストラクタを呼び出すためにthisキーワードを使います。

これにより、共通の初期化処理をまとめて記述でき、コードの重複を減らせます。

base呼び出しとthis呼び出しはどちらもコンストラクタの初期化リストで使いますが、同時に使うことはできません。

つまり、コンストラクタ宣言の後のコロン:に続くのはbasethisのどちらか一方だけです。

しかし、this呼び出しを使ったコンストラクタチェーンの先端でbase呼び出しを行うことは可能です。

これにより、同一クラス内の複数のコンストラクタを連結しつつ、最終的に基底クラスのコンストラクタを呼び出せます。

以下の例をご覧ください。

using System;
public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"Personコンストラクタ: Name={name}, Age={age}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; set; }
    // 基本コンストラクタ。base呼び出しで基底クラスを初期化
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId={employeeId}");
    }
    // 名前とIDだけ受け取り、年齢はデフォルト値で呼び出す
    public Employee(string name, int employeeId) : this(name, 30, employeeId)
    {
        Console.WriteLine("Employeeの簡易コンストラクタが呼ばれました");
    }
}
public class Program
{
    public static void Main()
    {
        var emp1 = new Employee("佐藤太郎", 25, 1001);
        var emp2 = new Employee("鈴木花子", 1002);
    }
}
Personコンストラクタ: Name=佐藤太郎, Age=25
Employeeコンストラクタ: EmployeeId=1001
Personコンストラクタ: Name=鈴木花子, Age=30
Employeeコンストラクタ: EmployeeId=1002
Employeeの簡易コンストラクタが呼ばれました

この例では、Employeeクラスに2つのコンストラクタがあります。

this呼び出しで引数の異なるコンストラクタを連結し、最終的にbase呼び出しでPersonのコンストラクタを呼んでいます。

これにより、共通の初期化処理を一箇所にまとめつつ、柔軟なインスタンス生成が可能です。

依存性注入との組み合わせ

依存性注入(Dependency Injection、DI)を使う場合、コンストラクタは依存オブジェクトを受け取る役割を持ちます。

継承階層でDIを使うときも、基底クラスの依存オブジェクトを派生クラスのコンストラクタで受け取り、base呼び出しで渡すパターンがよく使われます。

以下は、基底クラスがロガーを受け取り、派生クラスがさらに別の依存を受け取る例です。

using System;
public interface ILogger
{
    void Log(string message);
}
public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"Log: {message}");
    }
}
public class ServiceBase
{
    protected ILogger Logger { get; }
    public ServiceBase(ILogger logger)
    {
        Logger = logger;
        Logger.Log("ServiceBase初期化");
    }
}
public class UserService : ServiceBase
{
    private readonly string _userName;
    public UserService(ILogger logger, string userName) : base(logger)
    {
        _userName = userName;
        Logger.Log($"UserService初期化: UserName={userName}");
    }
    public void Execute()
    {
        Logger.Log($"ユーザー {_userName} の処理を実行");
    }
}
public class Program
{
    public static void Main()
    {
        ILogger logger = new ConsoleLogger();
        var userService = new UserService(logger, "山田太郎");
        userService.Execute();
    }
}
Log: ServiceBase初期化
Log: UserService初期化: UserName=山田太郎
Log: ユーザー 山田太郎 の処理を実行

この例では、UserServiceのコンストラクタでILoggerインスタンスを受け取り、base(logger)で基底クラスServiceBaseに渡しています。

これにより、基底クラスの初期化時に依存オブジェクトが利用可能となり、継承階層全体でDIが機能します。

DIコンテナを使う場合も、同様にコンストラクタ引数を通じて依存を注入し、base呼び出しで基底クラスに渡す設計が一般的です。

非同期初期化パターン

C#のコンストラクタは同期的にしか実行できず、async修飾子を付けることはできません。

そのため、非同期処理を伴う初期化はコンストラクタ内で直接行えません。

これを解決するために、非同期初期化パターンが使われます。

非同期初期化パターンでは、コンストラクタは最低限の同期初期化だけを行い、非同期処理は別のasyncメソッドで行います。

継承が絡む場合も、基底クラスと派生クラスでそれぞれ非同期初期化メソッドを用意し、呼び出し順序を制御します。

以下は基底クラスと派生クラスで非同期初期化を行う例です。

using System;
using System.Threading.Tasks;
public class BaseService
{
    public string BaseData { get; private set; }
    public BaseService()
    {
        Console.WriteLine("BaseServiceコンストラクタ");
    }
    public async Task InitializeAsync()
    {
        Console.WriteLine("BaseService非同期初期化開始");
        await Task.Delay(500); // 擬似的な非同期処理
        BaseData = "BaseDataがセットされました";
        Console.WriteLine("BaseService非同期初期化完了");
    }
}
public class DerivedService : BaseService
{
    public string DerivedData { get; private set; }
    public DerivedService()
    {
        Console.WriteLine("DerivedServiceコンストラクタ");
    }
    public async Task InitializeAsync()
    {
        Console.WriteLine("DerivedService非同期初期化開始");
        await base.InitializeAsync(); // 基底クラスの非同期初期化を呼び出す
        await Task.Delay(500); // 追加の非同期処理
        DerivedData = "DerivedDataがセットされました";
        Console.WriteLine("DerivedService非同期初期化完了");
    }
}
public class Program
{
    public static async Task Main()
    {
        var service = new DerivedService();
        await service.InitializeAsync();
        Console.WriteLine($"BaseData: {service.BaseData}");
        Console.WriteLine($"DerivedData: {service.DerivedData}");
    }
}
BaseServiceコンストラクタ
DerivedServiceコンストラクタ
DerivedService非同期初期化開始
BaseService非同期初期化開始
BaseService非同期初期化完了
DerivedService非同期初期化完了
BaseData: BaseDataがセットされました
DerivedData: DerivedDataがセットされました

この例では、コンストラクタは同期的に呼ばれ、非同期初期化はInitializeAsyncメソッドで行われています。

DerivedServiceの非同期初期化内でbase.InitializeAsync()を呼び出し、基底クラスの非同期初期化を先に完了させています。

このパターンにより、非同期処理を含む初期化を継承階層で安全かつ順序良く実行できます。

コンストラクタで非同期処理ができない制約を回避しつつ、オブジェクトの整合性を保てるため、非同期APIを扱う現代的なC#開発でよく使われます。

デザインパターンでの活用

Factory Methodパターンへの組み込み

Factory Methodパターンは、オブジェクトの生成をサブクラスに委譲するデザインパターンです。

基底クラスで生成メソッドのインターフェースを定義し、派生クラスで具体的な生成処理を実装します。

継承とコンストラクタの関係を活かし、base呼び出しを使って基底クラスの初期化を行いながら、派生クラスで生成ロジックをカスタマイズできます。

以下は、Factory Methodパターンにおけるコンストラクタとbase呼び出しの例です。

using System;
public abstract class Document
{
    public string Title { get; set; }
    // 基底クラスのコンストラクタで共通初期化
    protected Document(string title)
    {
        Title = title;
        Console.WriteLine($"Documentコンストラクタ: Title = {title}");
    }
    // Factory Method(生成メソッド)を抽象メソッドとして定義
    public abstract void Print();
}
public class Report : Document
{
    public string ReportData { get; set; }
    // base呼び出しで基底クラスの初期化を行う
    public Report(string title, string reportData) : base(title)
    {
        ReportData = reportData;
        Console.WriteLine("Reportコンストラクタが呼ばれました");
    }
    public override void Print()
    {
        Console.WriteLine($"レポート: {Title} - 内容: {ReportData}");
    }
}
public class Invoice : Document
{
    public decimal Amount { get; set; }
    public Invoice(string title, decimal amount) : base(title)
    {
        Amount = amount;
        Console.WriteLine("Invoiceコンストラクタが呼ばれました");
    }
    public override void Print()
    {
        Console.WriteLine($"請求書: {Title} - 金額: {Amount}円");
    }
}
public abstract class DocumentCreator
{
    // Factory Method
    public abstract Document CreateDocument(string title);
}
public class ReportCreator : DocumentCreator
{
    public override Document CreateDocument(string title)
    {
        // Reportの生成時に必要なデータを渡す
        return new Report(title, "レポートの詳細データ");
    }
}
public class InvoiceCreator : DocumentCreator
{
    public override Document CreateDocument(string title)
    {
        // Invoiceの生成時に必要な金額を渡す
        return new Invoice(title, 50000m);
    }
}
public class Program
{
    public static void Main()
    {
        DocumentCreator creator;
        creator = new ReportCreator();
        var report = creator.CreateDocument("月次レポート");
        report.Print();
        creator = new InvoiceCreator();
        var invoice = creator.CreateDocument("2024年4月請求書");
        invoice.Print();
    }
}
Documentコンストラクタ: Title = 月次レポート
Reportコンストラクタが呼ばれました
レポート: 月次レポート - 内容: レポートの詳細データ
Documentコンストラクタ: Title = 2024年4月請求書
Invoiceコンストラクタが呼ばれました
請求書: 2024年4月請求書 - 金額: 50000円

この例では、Documentが基底クラスとして共通の初期化をbase呼び出しで行い、ReportInvoiceが派生クラスとしてそれぞれの固有の初期化を行っています。

DocumentCreatorの派生クラスが生成処理を担当し、Factory Methodパターンの典型的な構造を示しています。

Template Methodパターンと初期化フック

Template Methodパターンは、アルゴリズムの骨組みを基底クラスで定義し、詳細な処理を派生クラスで実装するパターンです。

初期化処理においても、基底クラスのコンストラクタやメソッドで共通処理を行い、派生クラスに「フックメソッド」を用意して拡張ポイントを提供します。

C#のコンストラクタではvirtualabstractメソッドを呼び出すことができますが、基底クラスのコンストラクタから派生クラスのオーバーライドメソッドを呼ぶと、派生クラスの状態がまだ初期化されていないため注意が必要です。

安全に拡張ポイントを設けるためには、初期化用のメソッドを別途用意し、コンストラクタ後に呼び出す設計が推奨されます。

以下はTemplate Methodパターンの初期化フックを使った例です。

using System;
public abstract class DataProcessor
{
    public DataProcessor()
    {
        Console.WriteLine("DataProcessorコンストラクタ開始");
        Initialize();
        Console.WriteLine("DataProcessorコンストラクタ終了");
    }
    // 初期化のテンプレートメソッド(フック)
    protected virtual void Initialize()
    {
        Console.WriteLine("DataProcessorの初期化処理");
    }
    public void Process()
    {
        Console.WriteLine("処理開始");
        Execute();
        Console.WriteLine("処理終了");
    }
    // 派生クラスで実装する抽象メソッド
    protected abstract void Execute();
}
public class CsvProcessor : DataProcessor
{
    private string _filePath;
    public CsvProcessor(string filePath)
    {
        _filePath = filePath;
        Console.WriteLine($"CsvProcessorコンストラクタ: filePath = {_filePath}");
    }
    protected override void Initialize()
    {
        Console.WriteLine("CsvProcessorの初期化処理");
        // ここでファイルの存在チェックやリソース確保などを行う
    }
    protected override void Execute()
    {
        Console.WriteLine($"CSVファイルを処理中: {_filePath}");
    }
}
public class Program
{
    public static void Main()
    {
        var processor = new CsvProcessor("data.csv");
        processor.Process();
    }
}
DataProcessorコンストラクタ開始
CsvProcessorの初期化処理
DataProcessorコンストラクタ終了
CsvProcessorコンストラクタ: filePath = data.csv
処理開始
CSVファイルを処理中: data.csv
処理終了

この例では、DataProcessorのコンストラクタ内でInitializeメソッド(フック)を呼び出しています。

CsvProcessorはこのメソッドをオーバーライドして独自の初期化処理を実装しています。

ただし、CsvProcessorのコンストラクタ本体はDataProcessorのコンストラクタ呼び出し後に実行されるため、_filePathの初期化はInitialize呼び出し時点ではまだ完了していません。

このため、フックメソッド内で派生クラスの状態に依存する処理は避けるか、別途初期化メソッドを用意してコンストラクタ後に呼び出す設計が望ましいです。

このように、継承とコンストラクタの仕組みを活用してFactory MethodやTemplate Methodパターンに組み込むことで、柔軟で拡張性の高いオブジェクト生成や初期化処理を実現できます。

base呼び出しによる基底クラスの初期化と、派生クラスでのカスタマイズを適切に組み合わせることがポイントです。

例外と安全性

基底コンストラクタ例外発生時の挙動

C#において、継承関係のクラスでインスタンスを生成する際、基底クラスのコンストラクタが最初に呼び出されます。

このとき、基底クラスのコンストラクタ内で例外が発生すると、派生クラスのコンストラクタは実行されず、インスタンスの生成は失敗します。

この挙動は、オブジェクトの整合性を保つために重要です。

基底クラスの初期化が正常に完了しない場合、派生クラスの初期化を続行することは危険であり、例外を伝播させて処理を中断するのが適切です。

以下の例で挙動を確認します。

using System;
public class BaseClass
{
    public BaseClass()
    {
        Console.WriteLine("BaseClassコンストラクタ開始");
        throw new InvalidOperationException("基底クラスの初期化でエラーが発生しました");
    }
}
public class DerivedClass : BaseClass
{
    public DerivedClass()
    {
        Console.WriteLine("DerivedClassコンストラクタ開始");
    }
}
public class Program
{
    public static void Main()
    {
        try
        {
            var obj = new DerivedClass();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"例外キャッチ: {ex.Message}");
        }
    }
}
BaseClassコンストラクタ開始
例外キャッチ: 基底クラスの初期化でエラーが発生しました

この例では、BaseClassのコンストラクタで例外が発生したため、DerivedClassのコンストラクタは一切実行されていません。

例外は呼び出し元に伝播し、try-catchで捕捉されています。

この挙動から、基底クラスのコンストラクタで例外が発生した場合は、派生クラスの初期化は中断されることを理解しておく必要があります。

したがって、基底クラスのコンストラクタ内では例外が発生しにくい設計や、例外処理を適切に行うことが望ましいです。

null許容参照型と初期化保障

C# 8.0以降で導入されたnull許容参照型(Nullable Reference Types)は、参照型の変数がnullを許容するかどうかを型システムで明示的に管理できる機能です。

これにより、コンパイル時にnull参照例外のリスクを減らすことが可能です。

継承とコンストラクタの関係においては、基底クラスや派生クラスのプロパティやフィールドの初期化が適切に行われているかが重要です。

null許容参照型を使うことで、初期化が保証されていない場合に警告が出るため、安全性が向上します。

以下の例を見てみましょう。

#nullable enable
using System;
public class Person
{
    // null許容でない参照型プロパティ
    public string Name { get; set; }
    public Person(string name)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }
}
public class Employee : Person
{
    // null許容でない参照型プロパティ
    public string Department { get; set; }
    public Employee(string name, string department) : base(name)
    {
        Department = department ?? throw new ArgumentNullException(nameof(department));
    }
}
public class Program
{
    public static void Main()
    {
        try
        {
            var emp = new Employee("山田太郎", null!);
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"例外キャッチ: {ex.ParamName} が null です");
        }
    }
}
例外キャッチ: department が null です

この例では、NameDepartmentのプロパティはnullを許容しません。

コンストラクタでnullチェックを行い、nullが渡された場合はArgumentNullExceptionをスローしています。

#nullable enableにより、コンパイラはnull許容性をチェックし、適切な警告を出します。

null許容参照型を使うことで、コンストラクタでの初期化漏れやnull代入のリスクを減らし、オブジェクトの状態を安全に保てます。

また、C# 11からはrequiredキーワードを使って、オブジェクト初期化時に必須のプロパティを指定できるようになりました。

これも初期化保障の一助となります。

機能役割・効果
null許容参照型nullの可能性を型システムで管理し、警告を出す
nullチェックコンストラクタでnullを検出し例外をスロー
requiredキーワードオブジェクト初期化時に必須プロパティを強制

これらを組み合わせることで、継承階層のコンストラクタで安全に初期化を行い、null参照例外の発生を防止できます。

コードメンテナンスのコツ

冗長なbase呼び出しの削減

継承階層で複数のクラスがそれぞれコンストラクタを持ち、基底クラスのコンストラクタをbaseキーワードで呼び出す場合、引数の受け渡しが複雑になることがあります。

特に、同じ引数を何度も渡すような冗長なbase呼び出しは、コードの可読性や保守性を低下させる原因となります。

冗長なbase呼び出しを減らすためのポイントは以下の通りです。

  • コンストラクタチェーンの活用

同一クラス内でthisキーワードを使い、複数のコンストラクタを連結させることで、基底クラスへのbase呼び出しを一箇所にまとめられます。

これにより、引数の受け渡しが一元化され、変更時の影響範囲を狭められます。

  • 引数の集約

複数の引数をまとめたクラスや構造体を作成し、それを基底クラスのコンストラクタに渡す方法です。

これにより、引数リストが短くなり、base呼び出しがシンプルになります。

  • デフォルト値の活用

基底クラスのコンストラクタにデフォルト引数を設定し、派生クラスからの呼び出し時に必要な引数だけを渡すことで、冗長な引数指定を減らせます。

以下の例は、this呼び出しを使ってbase呼び出しを一箇所にまとめたパターンです。

using System;
public class Person
{
    public string Name { get; }
    public int Age { get; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"Personコンストラクタ: Name={name}, Age={age}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; }
    // 基本コンストラクタでbase呼び出しを一度だけ行う
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId={employeeId}");
    }
    // 他のコンストラクタはthis呼び出しで基本コンストラクタに委譲
    public Employee(string name, int employeeId) : this(name, 30, employeeId)
    {
        Console.WriteLine("Employeeの簡易コンストラクタが呼ばれました");
    }
}
public class Program
{
    public static void Main()
    {
        var emp1 = new Employee("佐藤", 25, 1001);
        var emp2 = new Employee("鈴木", 1002);
    }
}
Personコンストラクタ: Name=佐藤, Age=25
Employeeコンストラクタ: EmployeeId=1001
Personコンストラクタ: Name=鈴木, Age=30
Employeeコンストラクタ: EmployeeId=1002
Employeeの簡易コンストラクタが呼ばれました

このように、base呼び出しは基本コンストラクタにまとめられ、他のコンストラクタはthisで委譲しています。

これにより、base呼び出しの冗長さが解消され、メンテナンスがしやすくなります。

プロパティ初期化の一元化

継承階層で複数のクラスがそれぞれプロパティを初期化する場合、初期化コードが分散し、変更時に見落としや重複が発生しやすくなります。

プロパティ初期化を一元化することで、コードの可読性と保守性を向上させられます。

一元化の方法としては以下が挙げられます。

  • 基底クラスで共通プロパティを初期化

基底クラスに共通のプロパティや初期化ロジックをまとめ、派生クラスは固有のプロパティだけを初期化します。

これにより、共通部分の初期化が一箇所に集約されます。

  • 初期化用メソッドの活用

コンストラクタ内で直接初期化するのではなく、初期化専用のメソッドを用意し、基底クラス・派生クラスでそれぞれ呼び出す設計です。

これにより、初期化処理の順序や内容を明確に管理できます。

  • オブジェクト初期化子の利用

C#のオブジェクト初期化子を使い、コンストラクタ外でプロパティをまとめて初期化する方法もあります。

ただし、継承階層での初期化順序には注意が必要です。

以下は、基底クラスで共通プロパティを初期化し、派生クラスで固有プロパティを初期化する例です。

using System;
public class Person
{
    public string Name { get; }
    public int Age { get; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"Person初期化: Name={Name}, Age={Age}");
    }
}
public class Employee : Person
{
    public int EmployeeId { get; }
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employee初期化: EmployeeId={EmployeeId}");
    }
}
public class Program
{
    public static void Main()
    {
        var emp = new Employee("田中", 40, 5001);
        Console.WriteLine($"Name={emp.Name}, Age={emp.Age}, EmployeeId={emp.EmployeeId}");
    }
}
Person初期化: Name=田中, Age=40
Employee初期化: EmployeeId=5001
Name=田中, Age=40, EmployeeId=5001

この例では、NameAgePersonで初期化され、EmployeeIdEmployeeで初期化されています。

初期化処理が役割ごとに分かれているため、コードの見通しが良くなり、変更も容易です。

冗長なbase呼び出しを減らし、プロパティ初期化を一元化することで、継承階層のコードがシンプルかつ保守しやすくなります。

これらのコツを意識して設計・実装を行うと、将来的な拡張や修正がスムーズに進みます。

誤りやすいポイント

引数なしコンストラクタ削除の影響

C#のクラスでは、引数なしのコンストラクタ(デフォルトコンストラクタ)が自動的に生成されるのは、クラス内に明示的なコンストラクタが一つも定義されていない場合のみです。

もし、引数付きコンストラクタを定義すると、引数なしコンストラクタは自動生成されません。

この仕様は継承時に特に注意が必要です。

基底クラスに引数なしコンストラクタが存在しない場合、派生クラスのコンストラクタで基底クラスのコンストラクタを明示的にbaseキーワードで呼び出さなければなりません。

これを怠るとコンパイルエラーになります。

以下の例で確認しましょう。

using System;
public class BaseClass
{
    // 引数付きコンストラクタのみ定義
    public BaseClass(string message)
    {
        Console.WriteLine($"BaseClassコンストラクタ: {message}");
    }
}
public class DerivedClass : BaseClass
{
    // 引数なしコンストラクタを定義し、base呼び出しを省略するとエラーになる
    public DerivedClass()
    {
        Console.WriteLine("DerivedClassコンストラクタ");
    }
}
public class Program
{
    public static void Main()
    {
        // コンパイルエラーになるためコメントアウト
        // var obj = new DerivedClass();
    }
}

このコードはコンパイルエラーになります。

エラーメッセージは以下のような内容です。

'BaseClass' does not contain a constructor that takes 0 arguments

これは、DerivedClassの引数なしコンストラクタが基底クラスの引数なしコンストラクタを暗黙的に呼び出そうとしますが、BaseClassに引数なしコンストラクタが存在しないためです。

この問題を解決するには、DerivedClassのコンストラクタで明示的にbase呼び出しを行うか、BaseClassに引数なしコンストラクタを追加します。

// 解決例1: DerivedClassでbase呼び出しを明示的に行う
public DerivedClass() : base("デフォルトメッセージ")
{
    Console.WriteLine("DerivedClassコンストラクタ");
}
// 解決例2: BaseClassに引数なしコンストラクタを追加する
public BaseClass()
{
    Console.WriteLine("BaseClass引数なしコンストラクタ");
}

このように、基底クラスの引数なしコンストラクタの有無は、派生クラスのコンストラクタ設計に大きな影響を与えます。

特に、基底クラスのコンストラクタを変更・追加した際は、派生クラスのコンストラクタも見直す必要があります。

privateコンストラクタを持つ基底クラス

基底クラスにprivateアクセス修飾子のコンストラクタがある場合、そのコンストラクタは外部や派生クラスから呼び出せません。

これは、シングルトンパターンやファクトリーメソッドパターンなどで、インスタンス生成を制御するために使われることがあります。

しかし、基底クラスにprivateコンストラクタしか存在しない場合、派生クラスは基底クラスのコンストラクタを呼び出せず、インスタンスを生成できません。

これにより、派生クラスのコンストラクタはコンパイルエラーになります。

以下の例を見てみましょう。

using System;
public class BaseClass
{
    private BaseClass()
    {
        Console.WriteLine("BaseClassのprivateコンストラクタ");
    }
}
public class DerivedClass : BaseClass
{
    public DerivedClass()
    {
        Console.WriteLine("DerivedClassコンストラクタ");
    }
}
public class Program
{
    public static void Main()
    {
        // var obj = new DerivedClass(); // コンパイルエラー
    }
}

このコードはコンパイルエラーとなり、エラーメッセージは以下のようになります。

'BaseClass.BaseClass()' is inaccessible due to its protection level

これは、DerivedClassのコンストラクタが基底クラスのprivateコンストラクタを呼び出そうとしてアクセスできないためです。

この問題を回避するには、基底クラスにprotectedまたはpublicのコンストラクタを用意する必要があります。

例えば以下のようにします。

public class BaseClass
{
    protected BaseClass()
    {
        Console.WriteLine("BaseClassのprotectedコンストラクタ");
    }
}

これにより、派生クラスは基底クラスのコンストラクタを呼び出せるようになり、インスタンス生成が可能になります。

また、privateコンストラクタを持つ基底クラスは、通常は継承を想定していない設計であることが多いため、継承を行う場合は設計の見直しが必要です。

これらのポイントは、継承とコンストラクタ設計でよく陥りやすいミスです。

基底クラスのコンストラクタのアクセス修飾子や引数の有無を正しく理解し、派生クラスのコンストラクタ設計に反映させることが重要です。

テスト観点

単体テストでのモック作成

継承を利用したクラス設計では、基底クラスや派生クラスの振る舞いを個別にテストすることが重要です。

特に、外部依存や副作用を持つ処理が含まれる場合は、単体テストでモック(Mock)を活用して依存関係を切り離すことが効果的です。

モックは、テスト対象のクラスが依存するオブジェクトの振る舞いを模倣し、テストの独立性と再現性を高めます。

継承階層でのモック作成では、以下のポイントに注意します。

  • 基底クラスの依存をモック化する

基底クラスが外部サービスやリソースに依存している場合、派生クラスのテスト時に基底クラスの依存をモックに置き換えます。

これにより、基底クラスの副作用を抑えつつ派生クラスのロジックを検証できます。

  • 仮想メソッドのオーバーライドを利用する

テスト用に派生クラスを作成し、基底クラスの仮想メソッドをオーバーライドしてモック的な振る舞いを実装する方法もあります。

  • DI(依存性注入)を活用する

コンストラクタやプロパティで依存オブジェクトを注入できる設計にしておくと、テスト時にモックを簡単に差し替えられます。

以下は、基底クラスの依存をモック化して派生クラスをテストする例です。

using System;
using Moq; // Moqライブラリを使用
public interface ILogger
{
    void Log(string message);
}
public class ServiceBase
{
    protected ILogger Logger { get; }
    public ServiceBase(ILogger logger)
    {
        Logger = logger;
    }
    public virtual void Initialize()
    {
        Logger.Log("ServiceBase初期化");
    }
}
public class UserService : ServiceBase
{
    private readonly string _userName;
    public UserService(ILogger logger, string userName) : base(logger)
    {
        _userName = userName;
    }
    public override void Initialize()
    {
        base.Initialize();
        Logger.Log($"UserService初期化: {_userName}");
    }
}
using Xunit;
public class UserServiceTests
{
    [Fact]
    public void Initialize_LogsExpectedMessages()
    {
        // ILoggerのモック作成
        var mockLogger = new Mock<ILogger>();
        var userService = new UserService(mockLogger.Object, "テストユーザー");
        userService.Initialize();
        // ログが期待通り呼ばれたか検証
        mockLogger.Verify(logger => logger.Log("ServiceBase初期化"), Times.Once);
        mockLogger.Verify(logger => logger.Log("UserService初期化: テストユーザー"), Times.Once);
    }
}

この例では、ILoggerをモック化し、UserServiceInitializeメソッドが基底クラスの初期化と自身の初期化を正しく行っているかを検証しています。

モックを使うことで、ログ出力の副作用を抑えつつ動作確認が可能です。

継承階層のテスト戦略

継承階層が深くなると、テスト設計も複雑になります。

各クラスの責務を明確にし、適切なテスト範囲を設定することが重要です。

以下の戦略が有効です。

  • 基底クラスの単体テストを充実させる

基底クラスの共通機能や初期化処理は、基底クラス単体で十分にテストします。

これにより、派生クラスのテストでは基底クラスの動作を前提として安心して進められます。

  • 派生クラスは固有の機能にフォーカスする

派生クラスのテストでは、基底クラスの機能を再テストするのではなく、派生クラス固有の拡張やオーバーライド部分に注力します。

  • テスト用の派生クラスを作成する

抽象クラスや複雑な基底クラスの場合、テスト専用の派生クラスを作り、基底クラスの動作を検証することがあります。

  • モックやスタブを活用して依存を切り離す

依存オブジェクトや外部リソースをモック化し、テストの独立性を保ちます。

  • テストの重複を避ける

基底クラスのテストと派生クラスのテストで同じ機能を重複してテストしないように注意します。

以下は、基底クラスと派生クラスのテストを分けて設計した例です。

using Xunit;
public class ServiceBaseTests
{
    [Fact]
    public void Initialize_LogsBaseInitialization()
    {
        var mockLogger = new Mock<ILogger>();
        var serviceBase = new ServiceBase(mockLogger.Object);
        serviceBase.Initialize();
        mockLogger.Verify(logger => logger.Log("ServiceBase初期化"), Times.Once);
    }
}
public class UserServiceTests
{
    [Fact]
    public void Initialize_LogsUserServiceInitialization()
    {
        var mockLogger = new Mock<ILogger>();
        var userService = new UserService(mockLogger.Object, "テストユーザー");
        userService.Initialize();
        mockLogger.Verify(logger => logger.Log("UserService初期化: テストユーザー"), Times.Once);
    }
}

このように、基底クラスと派生クラスのテストを分割し、それぞれの責務に応じた検証を行うことで、テストの明確さと保守性が向上します。

継承階層のテストでは、モックを活用して依存を切り離し、基底クラスと派生クラスの責務を分けてテスト設計を行うことがポイントです。

これにより、テストの信頼性と効率が高まります。

パフォーマンス影響

構造体とクラスの違い

C#において、struct(構造体)とclass(クラス)はどちらもデータをまとめるための型ですが、メモリ管理やパフォーマンスに大きな違いがあります。

継承とコンストラクタの観点からも、それぞれの特徴を理解しておくことが重要です。

メモリ配置の違い

  • クラス

クラスは参照型であり、ヒープ上にインスタンスが割り当てられます。

変数はインスタンスへの参照(ポインタ)を保持します。

ガベージコレクションの対象となり、メモリ管理にオーバーヘッドがあります。

  • 構造体

構造体は値型であり、スタック上に直接データが格納されるか、配列や他のオブジェクトの一部として埋め込まれます。

ガベージコレクションの対象外で、メモリ割り当て・解放のコストが低いです。

継承の制約

  • クラス

クラスは単一継承が可能で、基底クラスのコンストラクタをbaseで呼び出せます。

多態性や仮想メソッドなどのオブジェクト指向機能をフルに活用できます。

  • 構造体

構造体は継承できません(System.ValueTypeを除く)。

そのため、base呼び出しも存在せず、継承に伴うコンストラクタチェーンはありません。

構造体はインターフェースの実装は可能です。

コンストラクタの違い

  • クラス

明示的なコンストラクタを定義でき、基底クラスのコンストラクタをbaseで呼び出せます。

引数なしコンストラクタの自動生成は、基底クラスの有無に依存します。

  • 構造体

引数なしコンストラクタは自動的に存在し、すべてのフィールドがデフォルト値に初期化されます。

ユーザー定義の引数なしコンストラクタはC# 10以降で可能ですが、基底クラスのコンストラクタ呼び出しはありません。

パフォーマンス面の考慮

  • 構造体は小さくて不変のデータを扱う場合に有効で、コピーコストが低く高速です。ただし、大きな構造体はコピーコストが増大し、逆にパフォーマンス低下を招くことがあります
  • クラスは参照渡しのため、大きなデータでもコピーコストが低いですが、ヒープ割り当てやガベージコレクションの影響を受けます
項目クラス (class)構造体 (struct)
型の種類参照型値型
メモリ配置ヒープスタックまたは埋め込み
継承単一継承可能継承不可
コンストラクタ呼び出しbase呼び出し可能base呼び出しなし
ガベージコレクション対象対象外
パフォーマンス大きなオブジェクトで有利小さなデータで高速

インライン化と最適化のヒント

C#のJITコンパイラは、メソッドのインライン化やその他の最適化を自動的に行いますが、継承やコンストラクタの設計によって最適化の効果が変わることがあります。

パフォーマンスを意識した設計のポイントを紹介します。

メソッドのインライン化

  • インライン化とは

メソッド呼び出しのオーバーヘッドを減らすため、呼び出し先のメソッドのコードを呼び出し元に展開する最適化です。

これにより、関数呼び出しのコストが削減され、CPUのパイプライン効率が向上します。

  • 仮想メソッドとインライン化

仮想メソッドは動的ディスパッチ(ランタイムでのメソッド決定)を伴うため、インライン化が難しくなります。

sealed修飾子やoverrideでオーバーライドしたメソッドをsealedにすると、JITがインライン化しやすくなります。

  • コンストラクタのインライン化

コンストラクタは通常インライン化されますが、複雑な初期化やbase呼び出しが多い場合はインライン化が制限されることがあります。

シンプルなコンストラクタ設計がパフォーマンス向上に寄与します。

最適化のヒント

  • シンプルな継承階層を保つ

深い継承階層はメソッド呼び出しのオーバーヘッドを増やし、JITの最適化を妨げることがあります。

可能な限り継承階層を浅くし、必要に応じて委譲を活用しましょう。

  • sealedキーワードの活用

クラスやメソッドにsealedを付けることで、JITが仮想呼び出しを静的呼び出しに変換しやすくなり、インライン化が促進されます。

  • 値型の適切な利用

小さなデータ構造は構造体で表現し、コピーコストを抑えつつインライン化の恩恵を受けられます。

ただし、大きな構造体は逆効果になるため注意が必要です。

  • 不要なオーバーライドを避ける

オーバーライドが多いと仮想呼び出しが増え、パフォーマンスに影響します。

必要な場合のみオーバーライドし、可能な限りsealedで制限しましょう。

最適化ポイント効果・注意点
仮想メソッドのsealedインライン化促進、呼び出しコスト削減
継承階層の浅さメソッド呼び出しオーバーヘッドの低減
シンプルなコンストラクタインライン化されやすく初期化コストが低い
値型の適切利用コピーコストとメモリ効率のバランスを考慮

パフォーマンスを意識した継承とコンストラクタ設計では、構造体とクラスの特性を理解し、JITの最適化を妨げないシンプルな設計を心がけることが重要です。

これにより、効率的で高速なアプリケーションを実現できます。

C# 9以降の新機能との組み合わせ

recordと継承コンストラクタの相性

C# 9で導入されたrecordは、不変データを簡潔に表現できる参照型で、値の等価性やコピー機能が標準で備わっています。

recordはクラスと同様に継承が可能で、基底recordのコンストラクタをbaseキーワードで呼び出すことができます。

ただし、record特有の機能と継承コンストラクタの相性には注意点があります。

recordの基本的な継承とコンストラクタ

recordは主に「位置パラメータ付きコンストラクタ(positional parameters)」を使って宣言されることが多く、これにより自動的にプロパティとコンストラクタが生成されます。

派生recordは基底recordの位置パラメータをbase呼び出しで渡す必要があります。

以下の例を見てみましょう。

using System;
public record Person(string Name, int Age);
public record Employee(string Name, int Age, int EmployeeId) : Person(Name, Age);
public class Program
{
    public static void Main()
    {
        var emp = new Employee("山田太郎", 30, 1234);
        Console.WriteLine(emp);
    }
}
Employee { Name = 山田太郎, Age = 30, EmployeeId = 1234 }

この例では、Employeeのコンストラクタでbase(Name, Age)のように基底Personのコンストラクタを呼び出しています。

recordの位置パラメータはコンストラクタ引数としても機能するため、base呼び出しは必須です。

recordの継承における注意点

  • イミュータブル性の維持

recordは不変オブジェクトを想定しているため、継承してもプロパティは基本的にinitアクセサを持ちます。

派生recordで新たに追加したプロパティもinitで定義されることが多く、コンストラクタでの初期化が重要です。

  • with式との互換性

recordwith式で簡単にコピーと部分的な変更が可能ですが、継承階層でwithを使う場合、基底と派生のプロパティが正しくコピーされるように設計されている必要があります。

  • カスタムコンストラクタの追加

位置パラメータ付きコンストラクタ以外にカスタムコンストラクタを追加する場合、base呼び出しを明示的に行い、基底recordの初期化を確実に行う必要があります。

public record Employee : Person
{
    public int EmployeeId { get; init; }
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
    }
}

このように、recordの継承では基底の位置パラメータをbaseで渡すことが基本であり、コンストラクタチェーンを正しく設計することが重要です。

init-onlyプロパティと初期化順序

C# 9で導入されたinitアクセサは、プロパティの値をオブジェクト初期化子やコンストラクタ内でのみ設定可能にし、不変性を保ちながら柔軟な初期化を可能にします。

init-onlyプロパティは継承階層の初期化順序に影響を与えるため、注意が必要です。

init-onlyプロパティの特徴

  • initアクセサは、オブジェクトの生成時(コンストラクタやオブジェクト初期化子)にのみ値を設定でき、生成後は読み取り専用となります
  • これにより、不変オブジェクトのような設計が容易になります

初期化順序のポイント

継承階層でinit-onlyプロパティを使う場合、基底クラスと派生クラスのプロパティがどのタイミングで初期化されるかを理解しておく必要があります。

  • コンストラクタ内の初期化

コンストラクタ内でinitプロパティに値を設定すると、基底クラスのコンストラクタが先に呼ばれ、その後派生クラスのコンストラクタが実行されます。

基底クラスのinitプロパティは基底コンストラクタ内で初期化され、派生クラスのinitプロパティは派生クラスのコンストラクタ内で初期化されます。

  • オブジェクト初期化子の利用

オブジェクト初期化子でinitプロパティを設定する場合、コンストラクタ呼び出し後に初期化子が実行されます。

つまり、コンストラクタで設定した値をオブジェクト初期化子で上書き可能です。

以下の例で確認します。

using System;
public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
        Console.WriteLine($"Personコンストラクタ: Name={Name}, Age={Age}");
    }
}
public record Employee : Person
{
    public int EmployeeId { get; init; }
    public Employee(string name, int age, int employeeId) : base(name, age)
    {
        EmployeeId = employeeId;
        Console.WriteLine($"Employeeコンストラクタ: EmployeeId={EmployeeId}");
    }
}
public class Program
{
    public static void Main()
    {
        var emp = new Employee("佐藤", 28, 1001)
        {
            Age = 30 // オブジェクト初期化子で上書き
        };
        Console.WriteLine(emp);
    }
}
Personコンストラクタ: Name=佐藤, Age=28
Employeeコンストラクタ: EmployeeId=1001
Employee { Name = 佐藤, Age = 30, EmployeeId = 1001 }

この例では、Ageプロパティは基底クラスのコンストラクタで28に初期化されましたが、オブジェクト初期化子で30に上書きされています。

initアクセサにより、生成後の変更はできませんが、初期化時に柔軟に値を設定可能です。

注意点

  • initプロパティは生成時にのみ設定可能なため、継承階層での初期化順序を誤ると、意図しない値の上書きや未初期化状態になることがあります
  • 基底クラスのinitプロパティを派生クラスのコンストラクタで設定する場合、base呼び出しの引数として渡すか、派生クラスのコンストラクタ内で明示的に設定する必要があります

recordinit-onlyプロパティはC# 9以降の強力な機能であり、継承とコンストラクタ設計に新たな選択肢を提供します。

これらを活用する際は、コンストラクタチェーンや初期化順序を正しく理解し、意図した不変性と柔軟性を両立させることが重要です。

静的コンストラクタと継承

静的初期化のタイミング

C#の静的コンストラクタは、クラスの静的メンバーを初期化するために用いられ、クラスが初めてアクセスされた際に一度だけ自動的に呼び出されます。

静的コンストラクタは引数を持たず、明示的に呼び出すことはできません。

継承関係における静的コンストラクタの呼び出しタイミングは以下のようになります。

  • 基底クラスの静的コンストラクタは、基底クラスが初めてアクセスされたときに呼び出されます。
  • 派生クラスの静的コンストラクタは、派生クラスが初めてアクセスされたときに呼び出されます。
  • 基底クラスの静的コンストラクタが先に呼ばれ、その後に派生クラスの静的コンストラクタが呼ばれる。

つまり、派生クラスの静的コンストラクタが呼ばれる前に、基底クラスの静的コンストラクタは必ず実行されます。

以下のサンプルコードで動作を確認しましょう。

using System;
public class BaseClass
{
    static BaseClass()
    {
        Console.WriteLine("BaseClassの静的コンストラクタが呼ばれました");
    }
    public static void BaseMethod()
    {
        Console.WriteLine("BaseClassの静的メソッド");
    }
}
public class DerivedClass : BaseClass
{
    static DerivedClass()
    {
        Console.WriteLine("DerivedClassの静的コンストラクタが呼ばれました");
    }
    public static void DerivedMethod()
    {
        Console.WriteLine("DerivedClassの静的メソッド");
    }
}
public class Program
{
    public static void Main()
    {
        // DerivedClassの静的メソッドを呼び出すと、基底クラスの静的コンストラクタが先に呼ばれる
        DerivedClass.DerivedMethod();
        // 基底クラスの静的メソッドを呼び出すと、基底クラスの静的コンストラクタが呼ばれる(既に呼ばれている場合は呼ばれない)
        BaseClass.BaseMethod();
    }
}
BaseClassの静的コンストラクタが呼ばれました
DerivedClassの静的コンストラクタが呼ばれました
DerivedClassの静的メソッド
BaseClassの静的メソッド

この例では、DerivedClassの静的メソッドを呼び出した際に、まずBaseClassの静的コンストラクタが呼ばれ、その後DerivedClassの静的コンストラクタが呼ばれています。

BaseClassの静的コンストラクタは2回目の呼び出しでは実行されません。

静的フィールドの依存関係

静的フィールドはクラス単位で共有され、静的コンストラクタで初期化されることが多いです。

継承階層で静的フィールド同士に依存関係がある場合、初期化の順序に注意が必要です。

依存関係の問題

基底クラスの静的フィールドが派生クラスの静的フィールドに依存している場合や、その逆の場合、静的コンストラクタの呼び出し順序により未初期化のフィールドを参照してしまうリスクがあります。

C#の仕様では、静的コンストラクタはクラスが初めてアクセスされたタイミングで呼ばれ、基底クラスの静的コンストラクタが先に呼ばれます。

そのため、基底クラスの静的コンストラクタ内で派生クラスの静的フィールドを参照すると、まだ初期化されていない可能性があります。

using System;
public class BaseClass
{
    public static string BaseValue = DerivedClass.DerivedValue + "から参照";
    static BaseClass()
    {
        Console.WriteLine("BaseClassの静的コンストラクタ");
    }
}
public class DerivedClass : BaseClass
{
    public static string DerivedValue = "派生クラスの値";
    static DerivedClass()
    {
        Console.WriteLine("DerivedClassの静的コンストラクタ");
    }
}
public class Program
{
    public static void Main()
    {
        Console.WriteLine(BaseClass.BaseValue);
    }
}
BaseClassの静的コンストラクタ
から参照

この例では、BaseClassの静的フィールドBaseValueDerivedClass.DerivedValueに依存していますが、DerivedClassの静的フィールドはまだ初期化されていません。

そのため、DerivedClass.DerivedValuenullのまま参照され、結果として"から参照"だけが出力されています。

対策

  • 静的フィールドの依存関係を避ける

基底クラスの静的フィールドが派生クラスの静的フィールドに依存しないよう設計します。

  • 遅延初期化を利用する

Lazy<T>やプロパティの遅延初期化を使い、必要なタイミングで初期化を行う方法があります。

  • 静的初期化の順序を明確にする

静的コンストラクタ内で依存関係の初期化を明示的に制御し、順序を保証します。

静的コンストラクタと継承においては、初期化のタイミングと依存関係を正しく理解し、設計段階で問題を回避することが重要です。

これにより、予期せぬnull参照や初期化漏れを防ぎ、安全で安定したコードを実現できます。

ジェネリック型での注意点

制約付きジェネリック基底クラス

C#のジェネリック型は、型パラメータに対して制約(constraints)を付けることで、特定の型やインターフェースを実装した型のみを受け入れることができます。

継承関係において、基底クラスがジェネリックで制約付きの場合、派生クラスはその制約を満たす型を指定しなければなりません。

制約付きジェネリック基底クラスを使う際のポイントは以下の通りです。

  • 基底クラスの型パラメータに制約を付ける

例えば、where T : classwhere T : new()などの制約を付けることで、参照型や引数なしコンストラクタを持つ型に限定できます。

  • 派生クラスでの型指定時に制約を満たす必要がある

派生クラスは基底クラスの制約を満たす型を指定しなければコンパイルエラーになります。

  • 制約により基底クラスのコンストラクタ設計が影響を受ける

例えば、new()制約があると基底クラスでnew T()が使えますが、制約がないと使えません。

以下の例で制約付きジェネリック基底クラスを示します。

using System;
public class Repository<T> where T : class, new()
{
    public T CreateInstance()
    {
        // new()制約があるため、引数なしコンストラクタでインスタンス生成可能
        return new T();
    }
}
public class User
{
    public string Name { get; set; } = "未設定";
}
public class UserRepository : Repository<User>
{
    // Userはclassで引数なしコンストラクタを持つため制約を満たす
}
public class Program
{
    public static void Main()
    {
        var repo = new UserRepository();
        var user = repo.CreateInstance();
        Console.WriteLine(user.Name);
    }
}
未設定

この例では、Repository<T>where T : class, new()という制約があり、Userクラスはこれを満たしています。

CreateInstanceメソッドでnew T()が使えるため、派生クラスUserRepositoryは特別な実装なしにインスタンス生成が可能です。

型パラメータに依存するコンストラクタ設計

ジェネリック基底クラスのコンストラクタ設計は、型パラメータの特性に依存することがあります。

特に、型パラメータの制約や型の性質によって、コンストラクタの引数や初期化処理が変わる場合があります。

注意すべきポイントは以下の通りです。

  • 型パラメータの制約に応じた初期化

例えば、new()制約がある場合はnew T()でインスタンス生成が可能ですが、制約がない場合はファクトリーメソッドや依存注入を使う必要があります。

  • 型パラメータの型情報を利用した処理

型パラメータの型に応じて異なる初期化を行いたい場合、typeof(T)is演算子を使って型チェックを行うことがあります。

ただし、これは設計上の複雑化を招くため注意が必要です。

  • コンストラクタ引数として型パラメータのインスタンスを受け取る

ジェネリック基底クラスのコンストラクタで、型パラメータのインスタンスや関連する依存オブジェクトを引数として受け取り、初期化に利用するパターンがあります。

以下の例は、型パラメータに依存したコンストラクタ設計の例です。

using System;
public interface IService
{
    void Execute();
}
public class ServiceA : IService
{
    public void Execute()
    {
        Console.WriteLine("ServiceAの処理");
    }
}
public class ServiceB : IService
{
    public void Execute()
    {
        Console.WriteLine("ServiceBの処理");
    }
}
public class Processor<T> where T : IService
{
    private readonly T _service;
    // コンストラクタで型パラメータのインスタンスを受け取る
    public Processor(T service)
    {
        _service = service;
    }
    public void Run()
    {
        _service.Execute();
    }
}
public class Program
{
    public static void Main()
    {
        var processorA = new Processor<ServiceA>(new ServiceA());
        processorA.Run();
        var processorB = new Processor<ServiceB>(new ServiceB());
        processorB.Run();
    }
}
ServiceAの処理
ServiceBの処理

この例では、Processor<T>のコンストラクタでT型のインスタンスを受け取り、RunメソッドでExecuteを呼び出しています。

型パラメータTIServiceを実装する必要があり、異なるサービスを柔軟に扱えます。

ジェネリック型の継承とコンストラクタ設計では、型パラメータの制約を適切に設定し、初期化方法を工夫することが重要です。

これにより、安全かつ柔軟なコードが実現できます。

アクセシビリティとカプセル化

protectedコンストラクタの活用

protectedコンストラクタは、クラスのインスタンス化を制限しつつ、継承による拡張を許可するために使われます。

具体的には、protectedコンストラクタを持つクラスは、そのクラス自身や派生クラスからのみインスタンス化が可能であり、外部からの直接インスタンス化を防ぎます。

これにより、カプセル化を強化しつつ、継承を活用した設計が可能になります。

利用シーン

  • 抽象的な基底クラスの代替

抽象クラスにしたくないが、直接インスタンス化は避けたい場合にprotectedコンストラクタを使います。

これにより、基底クラスの機能を派生クラスに継承させつつ、基底クラス単体のインスタンス生成を防げます。

  • ファクトリーパターンとの組み合わせ

インスタンス生成を制御したい場合に、protectedコンストラクタを使い、ファクトリーメソッドやファクトリークラスからのみインスタンスを生成させる設計が可能です。

using System;
public class BaseClass
{
    protected BaseClass()
    {
        Console.WriteLine("BaseClassのprotectedコンストラクタ");
    }
}
public class DerivedClass : BaseClass
{
    public DerivedClass()
    {
        Console.WriteLine("DerivedClassのpublicコンストラクタ");
    }
}
public class Program
{
    public static void Main()
    {
        // var baseObj = new BaseClass(); // コンパイルエラー: protectedコンストラクタのため外部からは不可
        var derivedObj = new DerivedClass(); // OK
    }
}

この例では、BaseClassprotectedコンストラクタにより、BaseClassの外部からのインスタンス化はできません。

一方、DerivedClasspublicコンストラクタを持つため、外部からインスタンス化可能です。

メリット

  • 不適切なインスタンス生成を防止し、設計の意図を明確にできます
  • 継承を前提としたクラス設計で、基底クラスの初期化処理を安全に管理できます

internal継承とアセンブリ境界

internalアクセス修飾子は、同一アセンブリ内からのみアクセス可能なメンバーやクラスを定義します。

継承においてinternalを活用すると、アセンブリ境界をまたいだアクセス制御やカプセル化を強化できます。

internalクラスの継承

  • internalクラスは同一アセンブリ内でのみ継承可能です。別アセンブリからはアクセスできないため、継承もできません
  • publicクラスがinternalクラスを基底クラスとして継承することは可能ですが、そのinternal基底クラス自体は外部から見えません

internalメンバーの継承

  • 基底クラスのinternalメンバーは同一アセンブリ内の派生クラスからアクセス可能ですが、別アセンブリの派生クラスからはアクセスできません
  • これにより、アセンブリ単位でのカプセル化が実現できます
// AssemblyA内のコード
namespace AssemblyA
{
    internal class InternalBase
    {
        protected internal void ProtectedInternalMethod()
        {
            Console.WriteLine("InternalBaseのProtectedInternalMethod");
        }
    }
    public class PublicDerived : InternalBase
    {
        public void CallBaseMethod()
        {
            ProtectedInternalMethod();
        }
    }
}
// AssemblyB内のコード(別アセンブリ)
using AssemblyA;
public class ExternalDerived : PublicDerived
{
    public void Test()
    {
        // ProtectedInternalMethodは呼べない(internalのため)
        // ProtectedInternalMethod(); // コンパイルエラー
    }
}

この例では、InternalBaseinternalクラスであり、同一アセンブリ内のPublicDerivedは継承してProtectedInternalMethodを呼び出せます。

しかし、別アセンブリのExternalDerivedProtectedInternalMethodにアクセスできません。

アセンブリ境界での設計ポイント

  • 内部実装の隠蔽

internalを使うことで、アセンブリ外部に公開したくない基底クラスやメンバーを隠蔽でき、APIの公開範囲を制御できます。

  • 安全な拡張ポイントの提供

同一アセンブリ内でのみ継承やアクセスを許可し、外部からの不正な拡張や利用を防止できます。

  • テスト用のInternalsVisibleTo属性

テストプロジェクトからinternalメンバーにアクセスしたい場合、InternalsVisibleTo属性を使って特定のアセンブリにアクセスを許可できます。

protectedコンストラクタとinternal継承は、C#のアクセス制御を活用したカプセル化の重要な手段です。

これらを適切に使い分けることで、継承階層の設計を堅牢かつ安全に保てます。

属性(アトリビュート)での構成

DIコンテナでのコンストラクタ選択

依存性注入(Dependency Injection、DI)コンテナは、オブジェクトの生成や依存関係の解決を自動化するために使われます。

DIコンテナはクラスのコンストラクタを呼び出して依存オブジェクトを注入しますが、複数のコンストラクタが存在する場合、どのコンストラクタを使うかを指定する必要があります。

C#では、属性(アトリビュート)を使ってDIコンテナに対してコンストラクタ選択のヒントを与えることができます。

代表的な属性としては、[ActivatorUtilitiesConstructor](Microsoft.Extensions.DependencyInjection)や、他のDIフレームワークで提供される[InjectionConstructor]などがあります。

例:[ActivatorUtilitiesConstructor]属性の利用

using System;
using Microsoft.Extensions.DependencyInjection;
public class ServiceA
{
    public ServiceA()
    {
        Console.WriteLine("ServiceAの引数なしコンストラクタ");
    }
    [ActivatorUtilitiesConstructor]
    public ServiceA(string message)
    {
        Console.WriteLine($"ServiceAの引数付きコンストラクタ: {message}");
    }
}
public class Program
{
    public static void Main()
    {
        var services = new ServiceCollection();
        services.AddTransient<ServiceA>();
        var provider = services.BuildServiceProvider();
        // DIコンテナは[ActivatorUtilitiesConstructor]が付いたコンストラクタを優先して呼び出す
        var service = provider.GetService<ServiceA>();
    }
}
ServiceAの引数付きコンストラクタ:

この例では、ServiceAに引数なしと引数付きのコンストラクタが存在しますが、[ActivatorUtilitiesConstructor]属性が付いた引数付きコンストラクタがDIコンテナによって優先的に選択されます。

引数の解決ができない場合はデフォルト値や例外になることがあります。

ポイント

  • 複数コンストラクタがある場合、どれを使うか明示的に指定できます
  • DIコンテナによってサポートされる属性は異なるため、使用するフレームワークのドキュメントを確認すること
  • 属性がない場合、多くのDIコンテナは引数の多いコンストラクタを優先するか、引数なしコンストラクタを使います

シリアライザ用コンストラクタの指定

JSONやXMLなどのシリアライザは、オブジェクトの復元時にコンストラクタを使ってインスタンスを生成します。

複数のコンストラクタがある場合、どのコンストラクタを使うかを指定しないと、シリアライズ・デシリアライズが正しく動作しないことがあります。

C#の属性を使って、シリアライザに特定のコンストラクタを使うよう指示できます。

代表的な属性には、[JsonConstructor](System.Text.JsonやNewtonsoft.Json)があります。

例:[JsonConstructor]属性の利用

using System;
using System.Text.Json;
using System.Text.Json.Serialization;
public class Person
{
    public string Name { get; }
    public int Age { get; }
    public Person()
    {
        Name = "Unknown";
        Age = 0;
    }
    [JsonConstructor]
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}
public class Program
{
    public static void Main()
    {
        string json = "{\"Name\":\"山田太郎\",\"Age\":35}";
        var person = JsonSerializer.Deserialize<Person>(json);
        Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
    }
}
Name: 山田太郎, Age: 35

この例では、Personクラスに引数なしコンストラクタと引数付きコンストラクタがありますが、[JsonConstructor]属性が付いた引数付きコンストラクタがデシリアライズ時に使われます。

これにより、JSONのプロパティを正しくマッピングしてオブジェクトを生成できます。

ポイント

  • シリアライザが複数のコンストラクタを持つクラスを正しく復元するために、どのコンストラクタを使うか明示的に指定できます
  • System.Text.JsonNewtonsoft.Jsonなど、使用するシリアライザによって属性名や挙動が異なる場合があるため注意が必要でしょう
  • 属性を付けない場合、シリアライザは引数なしコンストラクタを使うか、例外を投げることがあります

属性を活用してコンストラクタの選択を制御することで、DIコンテナやシリアライザとの連携がスムーズになり、柔軟かつ安全なオブジェクト生成が可能になります。

使用するフレームワークやライブラリの仕様に合わせて適切に属性を付与しましょう。

実務でのベストプラクティス

API設計での公開範囲調整

API設計において、クラスやメンバーの公開範囲(アクセス修飾子)を適切に設定することは、堅牢で使いやすいインターフェースを提供するために非常に重要です。

特に継承を伴うコンストラクタ設計では、公開範囲の調整がAPIの安全性や拡張性に大きく影響します。

公開範囲の基本的な考え方

  • public

どこからでもアクセス可能です。

APIの利用者に公開するメンバーやコンストラクタに使います。

  • protected

派生クラスからのみアクセス可能です。

継承を前提とした拡張ポイントや、外部からの直接利用を避けたいコンストラクタに適しています。

  • internal

同一アセンブリ内でのみアクセス可能です。

ライブラリ内部の実装詳細を隠蔽し、外部からの誤用を防ぎます。

  • private

クラス内部のみアクセス可能です。

カプセル化の最も強いレベルで、外部に公開しないメンバーに使います。

コンストラクタの公開範囲設計例

  • 基底クラスのコンストラクタはprotectedにする

基底クラスを直接インスタンス化させたくない場合、protectedコンストラクタにして派生クラスからのみ生成可能にします。

これにより、APIの利用者が誤って基底クラスを直接使うことを防げます。

  • 派生クラスのコンストラクタはpublicにする

実際に利用されるクラスのコンストラクタはpublicにして、API利用者が自由にインスタンスを生成できるようにします。

  • 内部用のコンストラクタはinternalにする

ライブラリ内部でのみ使うコンストラクタはinternalにして、外部からのアクセスを制限します。

具体例

public class BaseService
{
    protected BaseService()
    {
        // 基底クラスの初期化処理
    }
}
public class UserService : BaseService
{
    public UserService()
    {
        // 派生クラスの初期化処理
    }
}

この設計では、BaseServiceは直接インスタンス化できず、UserServiceを通じてのみ利用されることを意図しています。

APIの利用者にとっては、使うべきクラスが明確になり、誤用を防止できます。

ライブラリ開発時の後方互換性維持

ライブラリやフレームワークを開発する際は、既存ユーザーのコードが新バージョンでも動作し続ける「後方互換性」を維持することが重要です。

継承とコンストラクタ設計においても、後方互換性を考慮したベストプラクティスがあります。

後方互換性を損なう変更例

  • 既存のpublicコンストラクタの削除やシグネチャ変更

これにより、ユーザーのコードがコンパイルエラーや実行時エラーになる可能性があります。

  • 基底クラスのコンストラクタのアクセス修飾子変更

例えばpublicからprotectedに変更すると、派生クラスのコンパイルが通らなくなることがあります。

  • 新しい必須パラメータの追加

コンストラクタに新たな必須引数を追加すると、既存の呼び出しコードが動作しなくなります。

後方互換性を保つための対策

  • 新しいコンストラクタはオーバーロードで追加する

既存のコンストラクタは残しつつ、新しい引数を受け取るコンストラクタを追加します。

これにより、既存コードは影響を受けず、新機能を利用したいユーザーだけが新しいコンストラクタを使えます。

  • デフォルト引数を活用する

新しいパラメータにデフォルト値を設定し、既存の呼び出しコードがそのまま動作するようにします。

  • protectedコンストラクタの追加

基底クラスにprotectedコンストラクタを追加し、派生クラスの拡張をサポートしつつ、既存のpublicコンストラクタは維持します。

  • 非推奨属性[Obsolete]の活用

すぐに削除せず、非推奨であることを明示し、ユーザーに移行期間を提供します。

具体例

public class Service
{
    // 既存のコンストラクタは残す
    public Service()
    {
        // 初期化処理
    }
    // 新しいオーバーロードを追加
    public Service(string config) : this()
    {
        // 新しい初期化処理
    }
}

このように、既存のコンストラクタを残しつつ新しい機能を追加することで、後方互換性を保ちながらAPIを進化させられます。

API設計での公開範囲調整と後方互換性の維持は、実務での継承とコンストラクタ設計における重要なポイントです。

これらを意識することで、堅牢で拡張性の高いライブラリやアプリケーションを開発できます。

まとめ

この記事では、C#の継承におけるコンストラクタの基本から応用、最新機能との組み合わせ、パフォーマンスやテスト、設計のベストプラクティスまで幅広く解説しました。

basethisの使い分け、静的コンストラクタの挙動、ジェネリック型の注意点、属性によるコンストラクタ選択など、実務で役立つ知識を網羅しています。

これらを理解し適切に活用することで、安全で拡張性の高いC#コードを効率的に設計・実装できるようになります。

関連記事

Back to top button
目次へ