C#のクラス継承:overrideとnewの違いとコンストラクターの罠

C#の継承を.NET 10で確認。virtual/overrideとnew(隠ぺい)の実際の出力差、警告CS0108、sealed・abstract・base、コンストラクターから仮想メソッドを呼ぶとNullReferenceExceptionになる理由まで。

2026年11月改訂: この記事は2021年に韓国語で書いた継承の講座を、日本語の読者向けに書き直したものです。元の例はすべて .NET 10.0.14(C# 14)でコンパイル・実行し直しました。出力は変わりませんでしたが、「private メンバーは継承できない」という説明は不正確で、new の例は new と override の実際の違いを示せていませんでした。元記事のエラー引用には個人 PC のユーザーフォルダーのパスが含まれていたため、エラーコードとメッセージだけを残しています。コンストラクターから仮想メソッドを呼ぶ罠、abstract と sealed override、C# 14 の field キーワードを追加しました。

C# の継承の書き方は知っているけれど、override と new がどこで違う動きをするのか、基底クラスのコンストラクターの中で何が起きるのかは曖昧、という人向けの記事です。先に結論を書くと、変数の型が基底クラスのとき、override は実体のメソッドを、new は基底クラスのメソッドを呼びます。 また、基底コンストラクターから virtual メソッドを呼ぶと、派生クラスのフィールドが空のまま派生クラスのコードが動き、警告なしに NullReferenceException になることがあります。

継承の基本とコンストラクターの呼び出し順

クラス名の後ろに : 基底クラス を書くと継承です。C# のクラスが持てる基底クラスは1つだけで、複数の型の性質を持たせたいときはインターフェイスを使います。

2021年の例をそのまま .NET 10 で動かしました(メッセージは元の韓国語を日本語にしています)。

class Parent
{
    public int num;
    public Parent() => Console.WriteLine("基底クラスのコンストラクターが呼ばれました");
}
class Child : Parent
{
    public Child(int num)
    {
        this.num = num;
        Console.WriteLine("派生クラスのコンストラクターが呼ばれました");
    }
    public void DisplayValue() => Console.WriteLine("num の値は {0} です", num);
}
// Main: new Child(20).DisplayValue();
基底クラスのコンストラクターが呼ばれました
派生クラスのコンストラクターが呼ばれました
num の値は 20 です

コンストラクター自体は継承されず、派生クラスのコンストラクターより先に基底クラスのコンストラクターが呼ばれます。ただし、自動で呼ばれるのは引数なしの基底コンストラクターだけです。基底クラスに Parent(int num) しかなければ、派生側で : base(num) と書く必要があり、忘れるとこうなります。

error CS7036: 'num' の必要なパラメーター 'Parent.Parent(int)' に対応する特定の引数がありません

元記事には「破棄されるときは逆に派生クラスから」という説明もありました。C# のファイナライザー(~Child())が派生クラスから順に動くのは確かですが、いつ動くかは GC 次第なので、C++ のデストラクターのような後始末のタイミングは期待できません。ファイルや接続を閉じるのは IDisposable と using の仕事です(例外処理の記事の using の節を参照)。

private メンバーは「継承されない」わけではない

2021年版は「private で宣言したメンバーは継承できない」と書いていました。正確には、派生クラスのオブジェクトの中に存在するが、派生クラスのコードから名前で触れないです。

class Parent { private int secret = 42; public int Peek() => secret; }
class Kid : Parent { public int Read() => secret; }   // コンパイルエラー
error CS0122: 'Parent.secret' はアクセスできない保護レベルになっています

Read() を消して new Kid().Peek() を呼ぶと 42 が返ります。値は派生クラスのオブジェクトの中にあるということです。派生クラスにだけ見せたいなら protected にします。

this と base

派生クラスに同じ名前のフィールドがなければ、this.num と base.num は同じフィールドです。違いが出るのは派生クラスが同じ名前を宣言し直したときで、それが次の節の「隠ぺい」です。現場で base を一番よく使うのは、フィールドではなく override したメソッドの中で base.Speak() のように基底の実装を呼び足すときです。

virtual/override と new は出力が違う

元の virtual/override の例はそのまま動きました。virtual を外すと次のエラーになります。

error CS0506: 'Child.A()': 継承されたメンバー 'Parent.A()' は virtual、abstract または override に設定されていないためオーバーライドできません

メッセージのとおり、オーバーライドできるのは virtual だけでなく abstract と、すでに override されたメソッドもです。

new の例から new を消してコンパイルすると、警告が2つ出て、実行はできます。

warning CS0108: 'Child.x' は継承されたメンバー 'Parent.x' を非表示にします。非表示にする場合は、キーワード new を使用してください。
warning CS0108: 'Child.A()' は継承されたメンバー 'Parent.A()' を非表示にします。非表示にする場合は、キーワード new を使用してください。

元記事は Child child = new Child(); 経由でしか呼んでいなかったので、override と同じ結果に見えていました。基底クラス型の変数から呼ぶと違いが出ます。元のコードの最後に3行足しました。

Parent asParent = child;          // 同じオブジェクト、変数の型だけ Parent
asParent.A();
Console.WriteLine("x : {0}", asParent.x);
基底クラスの A() を呼び出し
x : 100
派生クラス(Child)の A() を呼び出し
x : 200
基底クラスの A() を呼び出し
x : 100

同じ Child のオブジェクトなのに、3組目は基底クラス版が呼ばれました。override と new を並べるともっとはっきりします。

Animal[] zoo = [new Dog(), new Cat()];
foreach (Animal a in zoo)
    Console.WriteLine($"{a.GetType().Name,-4} Speak()={a.Speak(),-6} Name()={a.Name()}");
Cat c = new Cat();
Console.WriteLine($"Cat as Cat   : Name()={c.Name()}");

abstract class Animal
{
    public virtual string Speak() => "...";
    public string Name() => "animal";               // virtual ではない
}
class Dog : Animal
{
    public override string Speak() => "Woof";
    public new string Name() => "dog";              // 隠ぺい
}
sealed class Cat : Animal
{
    public override string Speak() => base.Speak() + "Meow";
    public new string Name() => "cat";
}
Dog  Speak()=Woof   Name()=animal
Cat  Speak()=...Meow Name()=animal
Cat as Cat   : Name()=cat
図:変数 Animal a が Dog のオブジェクトを指している。virtual で override された a.Speak() は実行時の型 Dog に従い Woof を出力する。Dog が new で隠した a.Name() は変数の型 Animal に従い animal を出力する。
override は実行時のオブジェクトの型で、new はコンパイル時の変数の型でメソッドが選ばれる。Animal a = new Dog() を .NET 10 で実行した出力。

override は実行時のオブジェクトの型でメソッドを選び、new はコンパイル時の変数の型で選びます。List<Animal> や Animal 型の引数として扱った瞬間に、new で隠したメソッドは素通りされます。new は「基底ライブラリが後から同名メソッドを追加して名前がぶつかった」といった場面の応急処置で、ポリモーフィズムが欲しいなら常に virtual/override です。CS0108 は放置せず、意図した隠ぺいなら new を付けて意図をコードに残しましょう。

sealed、abstract、sealed override

元の sealed の例は今も同じエラーになります。

error CS0509: 'Child': シール型 'Parent' から派生することはできません

2026年の視点で足しておきたいのは3点です。

  • abstract クラスは new できず(error CS0144: 抽象型またはインターフェイス 'Shape' のインスタンスを作成できません)、本体のない abstract メソッドは派生クラスが必ず実装します。忘れると error CS0534: 'Square' は継承抽象メンバー 'Shape.Area()' を実装しません。
  • sealed override はクラス全体ではなくメソッド1つだけを封印します。孫クラスで再度オーバーライドすると error CS0239: 'Ring.Area()': 継承されたメンバー 'Circle.Area()' はシールされているため、オーバーライドできません。
  • 継承される前提で設計していないクラスは、最初から sealed にしておくのが安全です。後で封印を外すのは互換性を壊しませんが、継承して使われた後に封印するのは破壊的変更です。JIT が sealed クラスの仮想呼び出しを直接呼び出しに置き換えられる、という副次的な効果もあります。
abstract class Shape
{
    public abstract double Area();
    public override string ToString() => $"{GetType().Name} area={Area():F2}";
}
class Circle(double r) : Shape                     // C# 12 のプライマリコンストラクター
{
    public sealed override double Area() => Math.PI * r * r;
}

コンストラクターから virtual メソッドを呼ぶ罠

今回の改訂で一番伝えたいところです。基底クラスのコンストラクターが virtual メソッドを呼ぶと、オブジェクト自体はすでに派生クラスの型なので、派生クラスの override が実行されます。 しかしその時点では、派生クラスのコンストラクター本体はまだ動いていません。

var r = new Report("2026-11");
Console.WriteLine(r.Describe());

class Base
{
    public Base() => Console.WriteLine($"Base ctor: {Describe()}");
    public virtual string Describe() => "base";
}
class Report : Base
{
    private readonly string _title = "Monthly";   // フィールド初期化子
    private readonly string _month;                // コンストラクター本体で代入
    public Report(string month) { _month = month; }
    public override string Describe() => $"{_title} {_month.ToUpper()}";
}
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Report.Describe() in …/Program.cs:line 14
   at Base..ctor() in …/Program.cs:line 6
   at Report..ctor(String month) in …/Program.cs:line 13

null 許容参照型が有効な既定のプロジェクトでも、警告は1つも出ませんでした。 _month は null 非許容の string と宣言されているので、コンパイラは安全だと判断します。ToUpper() を外して状態だけ出力すると、順番が見えます。

Base ctor -> Describe() = "Monthly (null) len=-1"
Report ctor -> Describe() = "Monthly 2026-11 len=7"

_title はもう入っていて、_month だけが null です。C# ではフィールド初期化子は基底コンストラクターより前に、コンストラクター本体は基底コンストラクターの後に実行されるからです。Java はフィールド初期化子も基底コンストラクターの後なので、Java 経験者ほど混乱しやすいポイントです。

流れ図。手順1:new Report でフィールド初期化子が実行され、_title が Monthly になる。手順2:Base コンストラクターが実行されて仮想メソッド Describe を呼び、Report.Describe に届くが _month はまだ null で NullReferenceException になる。手順3(例外が起きなかった場合だけ到達):Report のコンストラクター本体が _month を代入する。
C# での new Report("2026-11") の生成順。Base() の中の仮想呼び出しは、Report のコンストラクター本体が _month を代入する前に Report.Describe() に届く。

対処はシンプルです。

  • 基底コンストラクターから virtual/abstract メソッドを呼ばない。必要な値は : base(値) で渡す。
  • どうしても必要なら、派生クラスが使うフィールドはコンストラクター本体ではなく、フィールド初期化子かプライマリコンストラクターの引数で埋める。
  • 初期化後の処理は、生成が終わってから呼ぶ Initialize() やファクトリーメソッドに移す。

プロパティ(get/set)と C# 14 の field キーワード

元記事後半の get/set の例も出力は同じでした。5文字以上の "Kelley" を入れても "John" のまま、という例です。

mc.Name : John
mc.Name : Bree
mc.Name : Bree      ← "Kelley" の代入は黙って無視される

ただ、不正な値を黙って捨てる setter はデバッグを難しくします。呼び出し側は代入が成功したと思い込むからです。今は例外を投げるのが一般的で、C# 14 の field キーワードを使えば private フィールドを別に宣言する必要もありません。

class MyClass
{
    public string Name
    {
        get;
        set => field = value.Length < 5 ? value
            : throw new ArgumentException($"Name must be shorter than 5: '{value}'", nameof(value));
    } = "John";
}
Bree
Name must be shorter than 5: 'Kelley' (Parameter 'value')

LangVersion を13に下げると error CS9260: Feature 'field keyword' is not available in C# 13.0. となり、C# 14 の機能であることを確認できました。

まとめ:継承を使うときの判断基準

やりたいこと 使うもの
派生クラスごとに動作を変え、基底クラス型で扱う virtual + override
基底に既定の実装がなく、派生側が必ず実装する abstract クラス + abstract メソッド
これ以上のオーバーライドを禁止 sealed override(メソッド)/sealed(クラス)
たまたま名前がぶつかった new(CS0108 を解消)、できれば改名
基底コンストラクターで派生側の値が必要 : base(値) で渡す。コンストラクター内で virtual を呼ばない
複数の親の機能が欲しい 継承ではなくインターフェイスかコンポジション(フィールドで保持)

同じ約束を複数のクラスに守らせたいとき、抽象クラスとインターフェイスのどちらにするかはインターフェイスの記事の判断表で続けます。値型の構造体はそもそも継承できず、その理由と代わりの手段は構造体の記事で扱います。

確認環境:.NET SDK 10.0.403/ランタイム 10.0.14、C# 14(net10.0 の既定)、macOS(Apple Silicon Mac)。エラーメッセージは日本語 SDK の出力そのままです。

よくある質問

override の代わりに new を使うと何が変わる?

変数の型が派生クラスなら、どちらも派生クラスのメソッドが呼ばれます。差が出るのは基底クラス型の変数から呼んだときで、override は実体(派生クラス)のメソッド、new は基底クラスのメソッドが呼ばれます。Animal a = new Dog(); で a.Speak() は Woof、new で隠した a.Name() は animal を出力しました。new を書き忘れても動作は同じで、warning CS0108 が出るだけです。

コンストラクターから virtual メソッドを呼ぶと何がまずい?

基底クラスのコンストラクターが先に動くので、その中から呼ばれた override メソッドは、派生クラスのコンストラクター本体がまだ実行されていないオブジェクトを見ることになります。コンストラクター本体で代入するフィールドは null のままで、.NET 10 では NullReferenceException になり、コンパイラの警告は1つも出ませんでした。フィールド初期化子(= 値)は基底コンストラクターより先に実行されるので値が入っていました。

private メンバーは継承されない?

private フィールドも派生クラスのオブジェクトの中にそのまま存在します。派生クラスのコードから名前でアクセスできないだけです(error CS0122)。基底クラスの public メソッド経由なら値を読めます。派生クラスにだけ公開したいときは protected を使います。

  • #C#
  • #継承
  • #override
  • #virtual
  • #abstract
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑