オブジェクト指向プログラミング (OOP) を 内部の動き から理解する

実行前(設計)と実行後(メモリ上でのライブ挙動)を分けて追跡する解説です。C#/.NETを想定した説明ですが、JVM系や他のOOP言語でも共通する概念が多くあります。


0. ゴール

  • 「クラス=設計図」「オブジェクト=実体」というお約束説明で終わらない。
  • プログラムが実際に走り、メモリにインスタンスが載り、メソッド呼び出しがどのように解決されるかをイメージする。
  • 「実行前に存在するもの」と「実行して初めて現れるもの」を区別する。

1. 実行前に存在する“静的世界” (型メタデータの段階)

レイヤ実体どこにあるかいつ作られるか何を保持するか
ソースコードclass Player
{
int hp;
void Attack() { … }
}
.csファイル開発時宣言(意図)
中間言語 + メタデータIL, 型定義テーブルアセンブリ(DLL/EXE)ビルド時フィールド型、メソッド署名、アクセス修飾子など
ランタイム型情報System.Type, メタデータ構造CLRロード領域ランタイムで初回使用時リフレクション情報、レイアウト、vtable等の解決ベース

ポイント: クラス定義は「まだメモリ上に あなたが操作するPlayerの個体 は存在しない」。あるのは 型の説明書(=メタデータ)だけ。


2. 実行で何が変わる?— new の流れを分解

var p = new Player(); が走ると、概ね以下の工程(概念モデル)を辿ります。

  1. 型ロード確認CLRが Player 型のメタデータを既にロード済みか確認。未ロードならアセンブリから読み込む。型情報テーブルを内部に展開。
  2. メモリサイズ決定フィールドの型(例: int hp, string name など)からインスタンスサイズが決まる。アライメント・ランタイムヘッダ分を含む。
  3. ヒープ確保管理ヒープ(GCヒープ)上に必要バイト数の連続領域を確保。先頭にオブジェクトヘッダ(型ポインタ、GC用情報など)が付く。
  4. フィールド初期化(ゼロクリア → 既定値設定)まずゼロ初期化。その後、フィールドのデフォルト値や初期化子があれば適用。
  5. コンストラクタ呼び出し.ctor が実行され、フィールドへ値代入・イベント購読など、開発者記述の初期化コードが走る。
  6. 参照が返るローカル変数 p に「ヒープ上オブジェクトへの参照値」(アドレスに相当)が格納される。参照型なら変数は“ポインタ的ハンドル”。

3. メモリに展開されたオブジェクトの概形(概念図)

[ ローカル変数 p ] ----> [オブジェクトヘッダ | 型ポインタ | フィールド領域... ]
                                   |                 +-- hp : int
                                   |                 +-- name : string参照
                                   +-- vtable間接(言語/CLR実装で差)
  • オブジェクトヘッダ: GC管理用フラグ、同期ブロックインデックスなど。
  • 型ポインタ: 「この実体は Player 型だ」とランタイムに伝える。動的ディスパッチ時の手掛かり。
  • フィールド領域: 実際のデータ。値型は埋め込み、参照型フィールドはさらに他オブジェクトを指す参照。

4. メソッド呼び出しが内部的にどう解決されるか

4.1 非仮想 (non-virtual) メソッド

コンパイラが静的に解決。呼び先が明確。ILのcall命令で直接ターゲットを呼ぶ。

4.2 仮想 (virtual) メソッド

動的ディスパッチ。実インスタンスの型に応じて実行時に呼び先が決まる。

概念フロー:

  1. p.Attack() 呼び出し。
  2. JIT生成コードが p の型ポインタをたどる。
  3. 型ごとの仮想メソッドテーブル(vtable) 参照。
  4. 「Attack」スロットに登録されたメソッド実装アドレス呼び出し。

派生型 BossPlayer : Player が override Attack() すると、そのスロットに別アドレス。


5. 継承時のメモリレイアウト (単純化)

class Character { int hp; }
class Player : Character { int exp; }

 new Player()
 GCヒープ:
 [ヘッダ][型=Player][hp][exp]
  • 基底クラス分のフィールドが前方にレイアウトされる(言語/実装依存だが概念として)。
  • キャスト Character c = p; のとき、参照アドレスは同じだが「見えるメンバ」が基底型に制限されるのはコンパイル時の静的型制約。メモリ自体は1塊。

6. 参照型 vs 値型 (C#特有の重要ポイント)

項目参照型(class)値型(struct)
メモリ配置通常GCヒープ場所に埋め込まれる(スタック/配列/他オブジェクト内)
変数代入参照コピー値コピー
null不可(Nullable除く)
ボクシング該当なしobject等にアップキャスト時、ヒープに箱詰め

内部動き上の注意: 値型をメソッド引数で渡すとコピー(既定は値渡し)。大きいstructはコスト増。参照型はポインタ1本コピーで済む。


7. ガーベジコレクション (GC) とオブジェクト寿命

  1. ルート参照(スタック上のローカル、静的フィールド、CPUレジスタなど)からたどれるオブジェクトを「生存」とマーク。
  2. 到達不能なオブジェクト領域は回収対象。
  3. 世代別GC(Gen0/1/2)で短命オブジェクトを効率回収。
  4. コンパクションでメモリを詰め、参照更新(内部的にポインタ書き換え)を行うこともある。

実行中オブジェクトのアドレスがGCで動く可能性があるので、固定が必要な場合はpin。


8. アクセス修飾子はどう「効いている」のか?

private, public, protected などは基本的にコンパイル時チェック

ランタイムは「やろうと思えばリフレクションで書き換え可能」なのが現実。

つまり、言語レベルのカプセル化は“開発者間の契約 + コンパイラ診断”が中心で、OSレベル権限制御のような強制とは異なる。


9. インターフェイス呼び出しの内部 (ざっくり)

インターフェイスは多重継承的契約を提供するが、実体はクラス。

実行時には「オブジェクトの型がこのインターフェイスを実装しているか」をマッピングするテーブルがあり、呼び出しはそのテーブルを通じて実装メソッドに解決される(JITが最適化して直接化する場合もある)。


10. ジェネリック型の内部

C#/.NETのジェネリックはリフレクション可能な再ified generics(型引数情報が実行時も残る)で、List<int>と List<string> はJIT時にそれぞれ最適化されたコードパスを得られる場合がある(値型最適化など)。

Javaの型消去(erasure)系とは内部挙動が異なる重要ポイント。


11. 実行前後を可視化して学ぶ(授業向けミニ手順)

11.1 手順概要

  1. WinFormsでボタン [生成] を押すまで何もない(インスタンス未生成)状態をUIで表示。
  2. ボタンクリックイベント内で new Player() を行い、生成直後のフィールド値をラベルに表示。
  3. 別ボタン [Attack] でメソッド実行し、インスタンスメソッド呼び出し=「内部でthis参照経由」動作を可視化。
  4. Player → BossPlayer に派生して仮想メソッド上書き、ボタンで型差を観察。

11.2 可視化アイディア

  • 左パネル: 「型(設計図)情報」静的表示。
  • 右パネル: 実行時「生成済みインスタンス一覧」グリッド。GC世代は疑似表示でOK。
  • ログウィンドウ: Alloc Player(size=…) -> ref 0x1234 のような疑似メモリログ。

12. 「内部の動き」まとめフローチャート

ソース -> コンパイル -> アセンブリ(IL+メタデータ)
                   ↓ 実行開始
          CLRが型読み込み(Typeロード)
                   ↓ new
         [GCヒープ確保] [ゼロ初期化]
                   ↓ .ctor呼び出し
           参照を変数に格納
                   ↓ メソッド呼び出し (静的 or 仮想ディスパッチ)
                   ↓ 到達不能 → GC回収

13. よくある誤解と内部視点での修正

誤解内部視点でどう正す?
「クラスはオブジェクトです」いいえ。クラスは“まだ作っていないオブジェクトの型情報”。実インスタンスは newして初めてメモリ上に確保される。
「変数にオブジェクトが入る」参照型では変数に入るのは参照値(アドレス相当)。実体はヒープ。
「privateなら絶対アクセス不可」コンパイル時保護。リフレクション等で突破可能。設計上の契約と考える。
「GCだからメモリは無限」到達不能にならなければ解放されない。大きな配列参照を保持すると回収されない。

14. 学習用ミニ実験コード(概念スケッチ)

public class Player
{
    public int Hp;
    public string Name;

    public Player(string name, int hp = 100)
    {
        Name = name;
        Hp = hp;
        Console.WriteLine($"[ALLOC Player] this={GetHashCode()} Hp={Hp}");
    }

    public virtual void Attack()
    {
        Console.WriteLine($"{Name} attacks! (virtual)");
    }
}

public class BossPlayer : Player
{
    public BossPlayer(string name, int hp = 500) : base(name, hp) {}

    public override void Attack()
    {
        Console.WriteLine($"{Name} unleashes BOSS attack!! (override)");
    }
}

テスト:

Player p = new Player("Hero");
Player b = new BossPlayer("Demon King");

p.Attack(); // Player.Attack
b.Attack(); // BossPlayer.Attack (仮想ディスパッチ)
Player up = b;
up.Attack(); // 依然としてBossPlayer.Attack(実体に基づく)

ここで up の静的型は Player だが、実体は BossPlayer。内部では型ポインタを参照してvtableスロットを解決するため、オーバーライド版が呼ばれる。


15. 「内部動き」視点で授業に盛り込むチェック問題

Q1. class Enemy { } を定義しただけでは、メモリにEnemyは存在しますか?

Q2. var e1 = new Enemy(); var e2 = e1; の後、e1 のフィールドを書き換えると e2 に影響するのはなぜ?

Q3. virtual/override により実行時にメソッドが切り替わる仕組みを一言で言うと?

Q4. GCに回収される条件は?「参照到達性」の観点で。

Q5. struct と class をサイズの大きな配列で使ったとき、コピーコストで何が変わる?

番号問題解答
Q1class Enemy { } を定義しただけでは、メモリに Enemy は存在しますか?存在しません。 まだインスタンスは生成されておらず、あるのはアセンブリに格納された「型メタデータ」だけです。実体が確保されるのは new Enemy() が実行された瞬間です。  
Q2var e1 = new Enemy(); var e2 = e1;のあと、e1 のフィールドを書き換えると e2 に影響するのはなぜ?e1 と e2 は 同じヒープ上のオブジェクトを指す参照値 をコピーしただけだからです。参照型の変数同士はアドレス(ハンドル)を共有するため、片方を通じた変更はもう一方からも見えます。  
Q3virtual / override により実行時にメソッドが切り替わる仕組みを一言で言うと?動的ディスパッチ(仮想メソッドテーブル = vtable によるルックアップ) です。呼び出し時に実体型の vtable を参照して正しい実装アドレスへジャンプします。  
Q4GC に回収される条件は?「参照到達性」の観点で。いずれの GC ルート(スタック、静的フィールド、CPU レジスタなど)からも参照されなくなったとき に「到達不能」と判定され、回収対象になります。  
Q5struct と class をサイズの大きな配列で使ったとき、コピーコストで何が変わる?– class 配列: 要素代入・引数渡しではポインタ(参照)1本のコピーだけなので軽量。- struct 配列: 各要素の実体データを丸ごとコピーするため、サイズが大きいほどコストが高く、パフォーマンスに影響しやすい。  

16. 次に進むなら

  • ILDASM / ILSpy でILレベルを覗いて「call」「callvirt」の違いを見る。
  • SOS/WinDbgやdotnet-dumpでヒープ状態を観察。
  • BenchmarkDotNetでstruct vs classのパフォーマンス差を計測。
  • Unity環境なら new のタイミング・GCスパイク(フレーム落ち)を実測し、「内部動きがゲーム性能に直結」する体験に繋げる。

まとめ(40秒版)

クラスは「まだ存在しないオブジェクトの設計情報」。new が走るとランタイムが型情報をもとにヒープ領域を確保し、初期化して参照を返す。メソッド呼び出しでは静的/仮想/インターフェイスで解決経路が異なり、オーバーライドは実体型を参照して決まる。寿命は参照到達性で管理され、到達不能になればGCが回収する。— この「実行前後の落差」がOOP理解のカギです。

訪問数 2 回, 今日の訪問数 4回