閉じる

【C#】 コンストラクタの使い方 初期化の基本とよくある落とし穴

コンストラクタは、クラスのインスタンス生成直後に必ず一度だけ実行され、オブジェクトの初期状態を整える特別なメソッドです。

本記事では、基本構文から初期化の順序、staticコンストラクタ、例外時の挙動、そして落とし穴と対策まで、初心者の方にも理解しやすいよう段階的に解説します。

C#のコンストラクタとは?使い方の基本

コンストラクタの役割と呼ばれ方(new時に実行)

コンストラクタは、new キーワードでインスタンスを生成したときに自動で実行される特別なメソッドです。

責務は、フィールドやプロパティの初期値設定、引数の検証、オブジェクトの不変条件(invariant)の確立などです。

通常のメソッドのように手動で呼び出すことはありませんし、戻り値を返すこともありません。

また、コンストラクタはインスタンスにつき一度だけ呼ばれます。

生成後の再初期化が必要な場合は、専用のメソッドを用意するなど別の設計を検討します。

コンストラクタの書き方(クラス名と同名/戻り値なし)

コンストラクタはクラス名と同名で、戻り値の型を一切書きません。

アクセス修飾子は通常 public を使いますが、生成パターンによって internalprivate にすることもあります。

C#
using System;

public class Person
{
    // 引数なしコンストラクタ(デフォルトコンストラクタ)
    public Person()
    {
        Console.WriteLine("Person() が呼ばれました");
    }

    // 引数付きコンストラクタ(オーバーロード)
    public Person(string name)
    {
        Console.WriteLine($"Person(string) が呼ばれました。name={name}");
    }
}

デフォルトコンストラクタと引数付きコンストラクタ

ポイントは次の通りです。

  • クラスにコンストラクタを1つも定義しない場合、引数なしのデフォルトコンストラクタが暗黙に提供されます。
  • いずれかのコンストラクタ(引数付きなど)を自分で定義した場合、デフォルトコンストラクタは自動生成されなくなります。必要であれば明示的に定義します。
C#
public class OnlyWithArgs
{
    public OnlyWithArgs(string name) { /* 省略 */ }
}

// これはコンパイルエラー: 引数なしコンストラクタが無いため
// var x = new OnlyWithArgs();

コード例(C#コンストラクタの基本)

基本的な使い方を短いコンソールアプリで確認します。

C#
using System;

public class Person
{
    public string Name { get; set; }

    // デフォルトコンストラクタ
    public Person()
    {
        Name = "Unknown";
        Console.WriteLine("Person() 実行: Name を Unknown に設定");
    }

    // 引数付きコンストラクタ
    public Person(string name)
    {
        Name = name;
        Console.WriteLine($"Person(string) 実行: Name を {name} に設定");
    }
}

public class Program
{
    public static void Main()
    {
        var p1 = new Person();              // デフォルトコンストラクタ
        var p2 = new Person("Alice");       // 引数付きコンストラクタ

        Console.WriteLine($"p1.Name={p1.Name}");
        Console.WriteLine($"p2.Name={p2.Name}");
    }
}
実行結果
Person() 実行: Name を Unknown に設定
Person(string) 実行: Name を Alice に設定
p1.Name=Unknown
p2.Name=Alice

コンストラクタでの初期化の実践

フィールドの初期化とreadonlyの設定

フィールドは宣言と同時(フィールド初期化子)またはコンストラクタ内で初期化できます。

値を生成後に変更させたくない場合は readonly を使います。

readonly フィールドは宣言時かコンストラクタ内でのみ代入可能です。

C#
using System;

public class User
{
    // オブジェクト生成後は変えられない識別子
    private readonly Guid _id = Guid.NewGuid(); // 宣言と同時に初期化

    public string Name { get; private set; }

    public User(string name)
    {
        // 生成時に値を確定させる
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("name は空にできません", nameof(name));

        Name = name;

        Console.WriteLine($"User() 実行: id={_id}, Name={Name}");
    }

    public Guid GetId() => _id;
}

public class Program
{
    public static void Main()
    {
        var u = new User("Bob");
        Console.WriteLine($"生成済みのID={u.GetId()}");

        // 次のような再代入は不可(コンパイルエラーの例)
        // u._id = Guid.Empty;
    }
}
実行結果
User() 実行: id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, Name=Bob
生成済みのID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

readonly は不変条件を守る重要な道具です。

外部から変更されないことが前提の値(識別子、作成時の設定値など)に向いています。

プロパティの初期値を設定する

プロパティの初期値は、プロパティ初期化子またはコンストラクタで設定します。

プロパティ初期化子はクラスの全コンストラクタで共通の初期値を与えたいときに便利です。

C#
using System;

public class Settings
{
    // プロパティ初期化子(C# 6.0+): すべてのコンストラクタで共通の初期値
    public int RetryCount { get; set; } = 3;
    public string Endpoint { get; set; } = "https://api.example.com";

    public Settings()
    {
        Console.WriteLine($"Settings() 実行: RetryCount={RetryCount}, Endpoint={Endpoint}");
        // ここで上書きも可能
        RetryCount += 1; // 例: 条件に応じた微調整
    }
}

public class Program
{
    public static void Main()
    {
        var s = new Settings();
        Console.WriteLine($"最終的な RetryCount={s.RetryCount}");
    }
}
実行結果
Settings() 実行: RetryCount=3, Endpoint=https://api.example.com
最終的な RetryCount=4

プロパティ初期化子やフィールド初期化子は、コンストラクタ本体の処理に入る前に実行される点に注意します。

共通の初期設定を簡潔に記述し、細かい調整や検証はコンストラクタ本体で行うのが分かりやすい設計です。

初期化子との違いと使い分け(object initializer)

オブジェクト初期化子は、コンストラクタ呼び出しの直後にプロパティへ代入を行う構文です。

コンストラクタでは実現しづらい「任意の一部の設定だけを上書き」する用途に向いています。

C#
using System;

public class Person
{
    public string Name { get; set; } = "Unknown";
    public int Age { get; set; }

    public Person()
    {
        Console.WriteLine($"Person() 実行: Name={Name}, Age={Age}");
    }
}

public class Program
{
    public static void Main()
    {
        // コンストラクタが先に実行され、その後に初期化子の代入が行われる
        var p = new Person { Name = "Carol", Age = 20 };
        Console.WriteLine($"初期化子適用後: Name={p.Name}, Age={p.Age}");

        // readonly フィールドには初期化子で代入できない点に注意(プロパティのみ)
    }
}
実行結果
Person() 実行: Name=Unknown, Age=0
初期化子適用後: Name=Carol, Age=20

オブジェクト初期化子は任意設定の指定に便利ですが、引数の検証や不変条件の確立はコンストラクタで行い、初期化子は「後から上書きされても困らない項目」に限定すると安全です。

初期化の順序と種類

フィールド初期化子→コンストラクタの実行順

インスタンス生成時には、概ね次の順番で初期化が行われます。

  1. インスタンスのフィールド初期化子と自動実装プロパティ初期化子が評価される
  2. コンストラクタ本体が実行される

順序を目で追えるよう、ログを出す例を見てみます。

C#
using System;

public class InitOrder
{
    private int field = Log("フィールド初期化子(インスタンス)");
    public int Prop { get; set; } = Log("プロパティ初期化子(インスタンス)");

    public InitOrder()
    {
        Console.WriteLine("コンストラクタ本体");
    }

    private static int Log(string message)
    {
        Console.WriteLine(message);
        return 0;
    }
}

public class Program
{
    public static void Main()
    {
        var o = new InitOrder();
    }
}
実行結果
フィールド初期化子(インスタンス)
プロパティ初期化子(インスタンス)
コンストラクタ本体

この順序を踏まえると、初期値はフィールドやプロパティ初期化子で与え、整合性の検証や条件付きの上書き処理はコンストラクタ本体で行うと見通しが良くなります。

staticコンストラクタのタイミングと用途

static コンストラクタは型に対して一度だけ実行される初期化処理です。

実行タイミングは「型の最初の使用時」(最初のインスタンス生成または最初のstaticメンバーアクセス)です。

パラメータを取れず、明示的に呼ぶことはできません。

C#
using System;
using System.IO;

public class AppConfig
{
    public static readonly string TempDir;

    // static コンストラクタ: 型が初めて使われるときに一度だけ実行
    static AppConfig()
    {
        Console.WriteLine("static コンストラクタ実行");
        TempDir = Path.GetTempPath();
    }

    public AppConfig()
    {
        Console.WriteLine("インスタンス コンストラクタ実行");
    }
}

public class Program
{
    public static void Main()
    {
        Console.WriteLine($"最初のアクセス: TempDir={AppConfig.TempDir}"); // ここで static ctor が走る
        var cfg = new AppConfig(); // 2回目以降は static ctor は走らない
    }
}
実行結果
static コンストラクタ実行
最初のアクセス: TempDir=C:\Users\...\AppData\Local\Temp\
インスタンス コンストラクタ実行

staticコンストラクタは、static readonly の初期化や、キャッシュの準備など「一度だけのセットアップ」に適しています。

例外発生時の挙動と注意点

例外が投げられたときの挙動は、インスタンスとstaticで異なります。

  • インスタンスコンストラクタで例外が発生した場合、そのオブジェクトは生成されません。new 式全体が例外で失敗します。
  • staticコンストラクタで例外が発生すると、TypeInitializationException で包まれ、以後その型は同じアプリケーションドメイン内で使用不能になります(同じ例外が再度発生)。
C#
using System;

public class BrokenInstance
{
    public BrokenInstance()
    {
        Console.WriteLine("BrokenInstance() 実行直前");
        throw new InvalidOperationException("インスタンス初期化に失敗");
    }
}

public class BrokenType
{
    static BrokenType()
    {
        Console.WriteLine("BrokenType static ctor 実行直前");
        throw new Exception("型初期化に失敗");
    }

    public static void Touch()
    {
        Console.WriteLine("Touch 呼び出し");
    }
}

public class Program
{
    public static void Main()
    {
        try
        {
            var a = new BrokenInstance();
            Console.WriteLine("生成成功(到達しない)");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"インスタンス生成失敗: {ex.GetType().Name}, {ex.Message}");
        }

        try
        {
            BrokenType.Touch(); // static ctor が発火して失敗
        }
        catch (TypeInitializationException ex)
        {
            Console.WriteLine($"型初期化失敗: {ex.GetType().Name}, 内部={ex.InnerException?.GetType().Name}");
        }

        // 再度アクセスしても同様に失敗する
        try
        {
            BrokenType.Touch();
        }
        catch (TypeInitializationException ex)
        {
            Console.WriteLine($"型初期化失敗(2回目): {ex.GetType().Name}");
        }
    }
}
実行結果
BrokenInstance() 実行直前
インスタンス生成失敗: InvalidOperationException, インスタンス初期化に失敗
BrokenType static ctor 実行直前
型初期化失敗: TypeInitializationException, 内部=Exception
型初期化失敗(2回目): TypeInitializationException

コンストラクタでは、例外の種類とメッセージを適切に選ぶこと、staticコンストラクタで重い処理や不安定な処理を行わないことが重要です。

コンストラクタの落とし穴とベストプラクティス

nullや引数の検証(ガード)

コンストラクタはオブジェクトの入口です。

受け取った引数は必ず検証し、不正な値なら例外を投げるようにします。

ArgumentNullExceptionArgumentException を使い分けると、呼び出し側が原因を特定しやすくなります。

C#
using System;

public class Customer
{
    public string Name { get; }
    public int Age { get; }

    public Customer(string name, int age)
    {
        if (name is null)
            throw new ArgumentNullException(nameof(name));
        if (name.Length == 0)
            throw new ArgumentException("name は空にできません", nameof(name));
        if (age < 0 || age > 120)
            throw new ArgumentOutOfRangeException(nameof(age), "age は 0~120 の範囲で指定してください");

        Name = name;
        Age = age;
    }
}

public class Program
{
    public static void Main()
    {
        try
        {
            var c = new Customer("", -1);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"検証で例外: {ex.GetType().Name}, Param={((ex as ArgumentException)?.ParamName ?? "-")}");
        }
    }
}
実行結果
検証で例外: ArgumentException, Param=name

「通れば有効」な状態のみをコンストラクタから出すことが、後続のバグやnull参照例外の多くを防ぎます。

重い処理やI/Oを避ける(非同期は不可)

コンストラクタはasyncにできず、awaitも使えません。

重いI/O(ファイル読み込み、HTTPアクセス、DB接続確立など)をコンストラクタで行うと、例外時に復旧が難しくなったり、応答性が悪化したりします。

代わりに非同期ファクトリメソッドを用意します。

C#
using System;
using System.IO;
using System.Threading.Tasks;

public class ConfigLoader
{
    private readonly string _path;
    public string Content { get; private set; } = "";

    // コンストラクタは最小限の不変条件だけを確立
    private ConfigLoader(string path)
    {
        if (string.IsNullOrWhiteSpace(path))
            throw new ArgumentException("path は必須です", nameof(path));
        _path = path;
    }

    // 非同期ファクトリメソッドでI/Oを実行
    public static async Task<ConfigLoader> CreateAsync(string path)
    {
        var loader = new ConfigLoader(path);
        loader.Content = await File.ReadAllTextAsync(path);
        return loader;
    }
}

public class Program
{
    public static async Task Main()
    {
        // 例: 実在するパスを指定してください
        try
        {
            var loader = await ConfigLoader.CreateAsync("appsettings.json");
            Console.WriteLine($"読み込み文字数={loader.Content.Length}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"読み込み失敗: {ex.GetType().Name}");
        }
    }
}
実行結果
読み込み失敗: FileNotFoundException

I/Oは例外が起きやすいため、呼び出し側でリトライやタイムアウト、キャンセルを制御できるよう、コンストラクタ外へ切り出すのが実務的です。

コンストラクタでvirtualを呼ばない

コンストラクタ内でvirtualメソッドを呼ぶと、派生クラスでのオーバーライドが「派生クラスのコンストラクタより先に」呼ばれてしまい、未初期化の状態にアクセスする危険があります。

C#
using System;

public class Base
{
    public Base()
    {
        Console.WriteLine("Base() 開始");
        // 悪い例: virtual を呼ぶ
        Initialize(); // ここで派生クラスのオーバーライドが動く
        Console.WriteLine("Base() 終了");
    }

    protected virtual void Initialize()
    {
        Console.WriteLine("Base.Initialize()");
    }
}

public class Derived : Base
{
    private string _message;

    public Derived()
    {
        _message = "準備完了";
        Console.WriteLine("Derived() 本体");
    }

    protected override void Initialize()
    {
        // Derived() 本体がまだ走っていないため _message は null
        Console.WriteLine($"Derived.Initialize(): _message の長さ={( _message == null ? "null" : _message.Length.ToString())}");
    }
}

public class Program
{
    public static void Main()
    {
        var d = new Derived();
    }
}
実行結果
Base() 開始
Derived.Initialize(): _message の長さ=null
Base() 終了
Derived() 本体

対策として、コンストラクタから呼ぶメソッドはprivateまたはsealedにして仮想呼び出しを避けるか、初期化フローを見直します。

オーバーライドが必要な拡張点は、生成後に明示的に呼び出す仕組みにするのが安全です。

不変条件(invariant)を確立してから公開

コンストラクタの目的は「常に有効な状態のオブジェクト」を作ることです。

次の点に注意します。

  • フィールドやプロパティは、必須の値をすべて設定し終えてから外部に公開します。
  • コンストラクタ内でthisをイベントに登録したり、別スレッドへ渡したりして「未完成のthis」を外部に漏らさないようにします(これをthis泄漏と呼びます)。

悪い例を、わざと例外が起きる形で示します。

C#
using System;

public static class Publisher
{
    public static event Action? Global;

    public static void Raise() => Global?.Invoke();
}

public class Leaky
{
    private string _name; // 必須フィールド

    public Leaky()
    {
        // 悪い順序: まだ _name を設定していないのに公開してしまう
        Publisher.Global += OnEvent;
        Publisher.Raise(); // ここで再入して OnEvent が走る

        _name = "Ready"; // ここに到達する前に OnEvent が呼ばれる
    }

    private void OnEvent()
    {
        // _name は未設定(null)の可能性があり、NullReferenceException の原因
        Console.WriteLine(_name.Length);
    }
}

public class Program
{
    public static void Main()
    {
        try
        {
            var bad = new Leaky();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"this の早期公開により失敗: {ex.GetType().Name}");
        }
    }
}
実行結果
this の早期公開により失敗: NullReferenceException

対策は簡単です。

必須フィールドをすべて初期化し、整合性が取れてからイベント登録や外部公開を行います。

必要ならコンストラクタをprivateにして、検証と初期化を完了させたうえでファクトリメソッドから完成品を返す設計も有効です。

まとめ

本記事では、C#のコンストラクタについて、基本構文、初期化の手段、実行順序、staticコンストラクタの役割、例外時の挙動、そして実務でつまずきがちな落とし穴と回避策を解説しました。

重要な要点は、次の通りです。

  • コンストラクタはnew時に一度だけ実行され、戻り値を持ちません。引数の検証と不変条件の確立が主な責務です。
  • フィールドやプロパティ初期化子はコンストラクタ本体より先に評価されます。共通の既定値を与えるのに便利です。
  • オブジェクト初期化子はコンストラクタ完了後にプロパティへ代入します。検証や必須値の確定はコンストラクタ側で行い、初期化子は任意設定の上書きに使います。
  • staticコンストラクタは型初回使用時に一度だけ実行され、失敗すると型全体が使用不能になります。重い処理や不安定な処理は避けます。
  • コンストラクタでvirtualを呼ばない、I/Oや非同期を持ち込まない、thisを早期に公開しないといったベストプラクティスを守ることで、安全で予測可能な初期化が実現します。

最後に、初期化手法ごとの違いを簡単に整理します。

手法実行タイミング設定対象特徴/注意
フィールド初期化子コンストラクタ本体より前フィールド簡潔に初期値を与えられる。不変条件の一部を表現しやすい
自動実装プロパティ初期化子コンストラクタ本体より前プロパティすべてのコンストラクタで共通の既定値を持たせるのに有効
インスタンスコンストラクタ初期化子の後すべて検証と不変条件の確立の中心。async不可、重い処理は避ける
オブジェクト初期化子コンストラクタ完了後プロパティ任意設定の上書きに有効。readonlyフィールドは不可
staticコンストラクタ型初回使用時に一度だけstaticメンバー失敗すると型が使用不能。重い処理は避ける

これらを踏まえ、コンストラクタを「最小限かつ堅牢な初期化の場所」として設計することが、後々の保守性と安全性を大きく高めます。

この記事を書いた人
エーテリア編集部
エーテリア編集部

C#の入門記事を中心に、開発環境の準備からオブジェクト指向の基本まで、順を追って解説しています。ゲーム開発や業務アプリを目指す人にも役立ちます。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!