長年エンジニアやって「オブジェクト指向ってなんだったんだ?」って思ったので振り返ってみる。

「オブジェクト指向」の定義はAIに聞けば分かります。

若い頃に「オブジェクト指向」の勉強はしたものの、約〇〇年(恐ろしい…)の実務経験を振り返って思ったことは「使うっちゃ使うけど、今は取り立てて気にする必要がない環境になったなぁ…(ゲームに限る)」と思ったので、「オブジェクト指向」とは一体なんだったのかを振り返ってみようと思います。

もくじ

ぶっちゃけオブジェクト指向って必要なの?

※AIによる朗読

案件によるだろう…としか言えません。
ゲーム開発では「いまどきオブジェクト指向?ふっるwww」という扱いですが、知識があるに越したことはないです。

オブジェクト指向の話で必ず出て来るUMLのクラス図は、複雑すぎるシステムを理解するのに役立ちます。
シーケンス図も処理の流れを整理するのに役立ちます。
実際かなり役に立ちました。

クラス図

シーケンス図

オブジェクト指向の三大要素と呼ばれている、カプセル化、継承、ポリモーフィズムは、色んな使い方を試して一番マシな方法を取れば良いです。
「これだけやっておけば良い」みたいな最高に都合が良いテクニックは、私が知る限り、オブジェクト指向に限らず存在しません。

オブジェクト指向ってなに?

簡単に言うと、コードを書くときの指針です。
(指針の意味はAIに聞いてください)

指針がないと、いきあたりばったりのカオスなコード群ができあがります。
そんなコードの塊を受け継いだ人は地獄を見るでしょう。
(経験者は語る)

オブジェクト指向ってシステム設計に使えるの?

オブジェクト(クラス)単位の設計は細かすぎるので、まずもっと大きな単位で設計するのが先です。
そのあと、もっと細かい設計が必要になったときに使えます。

システムを作っているうちに、クラスの関係がゴチャゴチャするのを防ぐ目的としても使えます。
クラス図で書いてみて、もっとゴチャゴチャしない設計にできないかを検討するのに向いています。

クラス図は、自分用とかエンジニア間で共有するための資料としてなら使えます。
他人(特にエンジニアでない人)に見せるための資料としては、オブジェクト指向設計で作った資料は不向きです。

ゲーム開発にオブジェクト指向設計は必要?
リードエンジニアの意向で変わります。
「リードを任されているけど、オブジェクト指向設計なんて知らないから、設計方法は任せる」という人もいますし、「クラス図で設計を見せて」と言う人もいます。

私の場合、何らかのシステムを作るときに、大まかな設計図を書いたりはしますが、クラス単位では細かすぎて、設計図がごちゃごちゃになります。
そんなゴチャゴチャした設計図を人に見せても「めんどくさい」と思われるだけで、「情報共有会」みたいなミーティングをセッティングされて、口頭で答えるはめになることが多いです。

他人に見せる設計図はどれくらいの粒度にすればいいの?

カンファレンスや講演会に行くと、スライドにプレゼン資料を映しながら自社開発したシステムの自慢話を聞くことになりますが、あれくらいの大雑把なもので十分です。

他人に伝えるべきなのは、オブジェクトひとつひとつの細かい振る舞いではありません。
このオブジェクトの塊は何ができるの?という粒度の情報です。
設計図や資料を読む人も忙しいので、膨大な情報量の複雑な設計図や資料をじっくり解読する時間は取れません。
なので、Unreal Engine でいうところの、モジュール単位(オブジェクトの塊)とか、プラグイン単位(モジュールの塊)とか、オブジェクトよりも大きい単位で考えて、どうしても必要ならそこだけ掘り下げて、細かく設計すれば良いです。

自分用はどうすればいいの?

実際にコードを書いて行く中で、個々のオブジェクトを定義して、簡単な関係図を書いたりはします。

クラスやファイルが増えると、どこになにを書いたのか、どのクラスがどのクラスと繋がっているのか、どの処理がどの順番で呼ばれるのか…を忘れるからです。
ただ、作っていくうちに必ずオブジェクトには変更が加わるので、きっちりとした設計図を書いてしまうと、変更に合わせて設計図も更新しなければならない…という妄執に憑りつかれます。
案件が終わるまでずーっと「設計図更新しなきゃ…。」と思いながら進めていくことになりますが、業務はタイトなスケジュールで進められるので、更新するヒマなんてありません。
結局、最後には古くて役に立たない設計図が残ります。
誰も頼んでいないのに、不要な罪悪感を抱えたまま、作業を進めることになり、常に不要なプレッシャーを抱えることになります。
指示がないなら、設計図や資料は個人的なメモレベルにとどめるべきです。

資料は指示があるまで作らない

「オブジェクト指向」をベースに作った設計図なんて細かすぎて誰も見たくないですし、書いたところで更新する時間はないので、システムができあがって、偉い人に「書け」と言われた時だけ書けば良いです。
「書け」と言われることがたまにあるので、そのときに書き方を調べる程度で良いかも知れません。
どうしても必要なら個人的なメモ書き程度にとどめておくのが良いです。

オブジェクトってなに?

オブジェクト指向における「オブジェクト」とは、プログラムを実行しているときのみ存在するものです。
コードの中には存在しません。
クラスが設計図なら、オブジェクトは設計図を元に作ったモノのことを指します。
モノのことをエンティティと言ったりもしますが、オブジェクトと言うこともできます。
例えば、C++ や C# ならクラスから生成したインスタンスの(中身の)ことです。

ウェブ上に存在するオブジェクト指向に関する記事や、オブジェクト指向を扱う書籍などでは、クラスとオブジェクトが同じものとして扱われていたりしますが、クラスとオブジェクトは違うものです。
わりとよく同一視されますし、誰も気にしないので、混同したからといってどうということはないです。

まったく普及していませんが、オブジェクト指向データベースというものが存在します。
データベースにクラスの構造(プロパティ、メソッド、継承関係も含めて)をそのまま保存できるデータベースです。
私は一度も使ったことがなく、今まで知りませんでした。ゲームでも使いません。
メモリに生成したオブジェクトをそのままデータベースに保存できるらしいので、オブジェクト指向データベースではオブジェクトはディスクやストレージにも存在します。
ただ、マイナーすぎるのでこちらは例外とします。

現実世界の車をオブジェクトに例えるのは妥当か?

現実世界の車を「オブジェクト」に例える説明がよくあるんですが、現実世界の車を仮想空間のオブジェクトとして扱うには規模が大きすぎます。
現実世界の車は約3万個ほどの部品でできているので、これを仮想空間の車オブジェクトに例えると、車オブジェクトに紐づく車の部品オブジェクトと、そのオブジェクトのインスタンスを合わせて約3万個も定義したり、生成しなければならなくなってしまいます。

「いや、現実世界に存在するものを抽象化したものがオブジェクトなんだ」という声が聞こえてきそうです。

そんな定義は聞いたことがありませんが、現実世界にある車を抽象化(単純化)して車オブジェクトを定義するのは分かります。
車体の色やサイズや形がデータで、車を前進させるアクセルや車を減速させるブレーキがメソッドです…などと例えることがあります。

「アクセルペダルを踏まずにどうやって車を走らせるの?オートパイロットのEV車なの?」など、色々と疑問が沸いてきますが、現実世界の車を抽象化して車オブジェクトを定義するのは良いと思います。
ただ、この車オブジェクトの使い道が分からないので、定義が妥当かどうかを判断できません。

例えば、車体を見せる必要がないなら車体の色データは不要ですし、常に等速で走り続けるなら、ブレーキもアクセルも不要です。
要するに、使い道があるのかないのか分からないものを作りましょうと言っていることになります。
そんな曖昧なものは作る意味がないです。

「いやいや、オブジェクトが何かを説明するためのものなのだから、使い道は二の次だよ」という声が聞こえてきそうです。

使い道がないものを説明して何の意味があるのでしょうか?
それは意味のない説明です。

結局のところ、仮想空間でやりたいことを、なんだかよく分からないオブジェクトというものを組み合わせて実現しようとした試みがオブジェクト指向です。
最初にやりたいことを決めなければ、オブジェクトを定義する意味が生まれないので、その状態で話を進めても、無意味なことをし続けることになります。
つまり、目的を決めずにオブジェクトを定義することは無意味です。

では、どうやってオブジェクトを定義すればいいのか?

仮想空間のものなのだから、仮想空間で扱うものとして考えれば良く、みんな知ってる「ハローワールド」で例えれば良いのではないでしょうか。

例えば、

ハローワールドオブジェクトは、コンソールにハローワールドと表示するオブジェクトと定義します。
ハローワールドオブジェクトが持つデータは、コンソールに表示する文字列(ハローワールド)です。
また、コンソールにデータを出力するアウトプットメソッドを持ちます。

ここまで定義すれば、もうコードで書くことができます。
クラスを使える言語なら、なんでも構いません。

// C#
var greeter = new HelloWorld();
greeter.Output();

class HelloWorld {
  // データ
  private string Message=>"ハローワールド";

  // メソッド
  public void Output() {
    System.Console.WriteLine("{0}", Message);
  }
}
// C++
#include <iostream>
#include <string>

class HelloWorld {
  std::string message{ "ハローワールド" };
public:
  void Output() {
    std::cout << message << std::endl;
  }
};

int main() {
  HelloWorld greeter;
  greeter.Output();
  return 0;
}
# python
class HelloWorld:
    def __init__(self):
        self.message = "ハローワールド"

    def output(self):
        print(self.message)

if __name__ == "__main__":
    greeter = HelloWorld()
    greeter.output()
-- Lua
local HelloWorld = {}
HelloWorld.__index = HelloWorld

function HelloWorld:new()
    local o = setmetatable({}, self)
    o.message = "ハローワールド" -- UTF-8 ならマルチバイト文字も使えたはず
    return o
end

function HelloWorld:output()
    print(self.message)
end

-- 使用例
local greeter = HelloWorld:new()
greeter:output()

一応、オブジェクト指向の体(てい)をとってはいますが、この規模なら関数が一個あれば良い話です。
サンプルコードを提示しておいてなんですが、わざわざコード量を増やしてオブジェクトにする意味があるとは思えません。

オブジェクト指向には何が必要なのか?

ここまでの説明で、オブジェクトには決まった型があることが分かったのではないかと思います。
データとメソッドをクラスという設計図に書き、プログラムを実行中に生成してメモリに展開したものがオブジェクトです。
ただ、その設計図が妥当かどうかを判断するには、オブジェクトを作る明確な目的が必要です。

また、オブジェクトとして定義するほどの価値があるのかも、よく検討する必要があります。
関数やJSONなどの構造化したデータで済むものを、クラスを使って定義する必要があるなら、そうするに値する合理的な説明が必要です。
私は「何故、これはクラスでなければならないのか?何故、これはクラスのメソッドではなく関数にするのか?」などと、コードを書く前に自問自答して、明確な答えが出てからコードを書きます。

オブジェクト指向は現実世界の模倣とか正しくとらえること…という話もあるのですが、現実世界の抽象化なのか、模倣なのか、どっちなんでしょう?
抽象化は重要な点だけを抽出してシンプルな形に変えること、模倣はそのままコピーすることです。
このふたつは意味的にはまったく逆です。
矛盾する話が出ている時点で、その定義に意味はないです。

前述の通り、現実世界の車を、そのままオブジェクト指向で表現するのは無理があります。
現実世界のものを仮想空間のオブジェクトで定義するには、プログラムの動作環境に合わせて部品の設計を変更したり、仮想空間に不要な部品の洗い出しと省略といった過程が必要になりますが、その説明を省いているので、説明に無理が出ます。
他にも、本稿とは別視点での話として色々な記事がありますが、以下の記事が面白かったです。

ゲーム開発ではどういう流れでオブジェクト定義まで進むのか?
ゲーム開発では、「(これから作る)ゲームのこのシーンに車を出したい。」というような、とてもざっくりとした要望が出てきます。
要望を出すのはDだったり、Pだったり、スポンサーだったり、ケースバイケースです。
企画担当がその意図を汲み取って仕様書まで持って行ってくれます。

要望の段階だと、「“出す”とはどういう状態?シーンに置くのか、シーンの中を走らせるのか、プロジェクションマッピングみたいにシーンのどこかに投影するのか?」
「車種は?そのゲームの世界観は?現代か未来かスチームパンクかでデザインも機構もまったく変わるんだけど?」
…などと、疑問のような、文句のようなものが次から次へと頭に浮かんでくるので、「その車がどういうものか?どういう目的で使う車なのか?」を企画担当と相談しながら、徹底的に詰めて詳細を決めます。
大きなシステムになると、一度のミーティングでは決まらないので、何をいつまでに決めるのかを決め、企画担当から仕様書が出て来るまで、定期的に進捗確認するのを繰り返します。
それがゲーム業界の要件定義です。

優秀な企画担当なら必要十分な仕様書がすぐに出てきますが、ゲーム開発では若手を積極的に使うので稀です。
また、実際にゲームをプレイしながら「いい感じに」調整したいという項目がいくつも出て来るので、仕様の段階で全てが決まることはありません。
「何を調整するのか?」が明確に決まっていれば作ることは可能です。
そうやって、足りない情報を埋めるやり取りを繰り返し、十分な情報が出そろってから設計に取り掛かります。

設計はオブジェクト指向設計を採用するとは限りません。
開発期間やプロジェクトで採用しているエンジン、チーム内のコードの組み方など、様々な状況を加味して一番マシな設計方法を採用します。
(マシ…と表現しているのは、万能なものはこの世に存在しないからです。)
また、ゲーム開発では設計をしないで、いきなりコードやデータを作り始めることも(多々)あります。

筆者はゲーム開発の経験しかないので、ゲーム以外ではどのような工程を経ているのかは分かりません。
ゲーム開発に限って言えば、プロジェクトが大きくなるほど、それに比例して要件定義にかかる時間が長くなります。

ヒャッハーが溜まるとたまらず「ヒャッハー!」と連呼するオブジェクト

以下のような冗談みたいなものでも、立派なオブジェクトです。

ヒャッハーが溜まるとたまらず「ヒャッハー!」と連呼するオブジェクト(ヒャッハーオブジェクト)の定義。
ヒャッハーを受け取った回数、ヒャッハーを出力する閾値(しきいち/いきち)、ヒャッハーを連呼する回数をデータとして持ちます。
ヒャッハーを受け取るメソッドと、コンソールに「ヒャッハー!」と出力するメソッドを持ちます。
ヒャッハーな気分になりたいときに使います。

「ヒャッハーな気分になりたいとき」というのがよく分からなければ、「お菓子を食べたい気分になったとき」とでも置き換えてみてください。
お菓子を食べたい気持ちがたかぶると、たまらず「ヒャッハー!」と連呼するオブジェクトでも良いです。

そんな人が現実世界にいたら、わりと通院をお勧めしたくなりますよね。
でも、これは仮想空間でやることなので、安心してください。

現実世界のなにを抽象化したり、なにを模倣すれば、こんなふざけたオブジェクトが出来上がるんでしょうか?
こんなことを思いつく筆者の頭がバグっているとしか思えませんが、オブジェクト指向が現実世界の抽象化や模倣と言われても違和感しかないでしょう。

仮想空間でやりたいことを実現するために、現実世界の仕組みを参考にして、仮想空間で実現可能な形に落とし込むこともある…という話なら分かります。
案件によっては、現実世界の仕組みを、それっぽく再現しなければならない場合もあるでしょう。
その場合、「どの部分をどう再現する必要があるのか?」を詳細に決めなければ、無駄なオブジェクトを作ってしまうことになります。

このサンプルには、現実世界をどうこう…という話はまったく当てはまりません。

// C#
var hyahhaa = new Hyahhaa();
do {
  hyahhaa.Give();
  hyahhaa.Shout();
  System.Console.ReadLine();
} while (true);

class Hyahhaa {
  public void Give() {
    Given++;
  }
  public void Shout() {
    if (Given < Limit) { return; }
    for (int i = 0; i < Count; i++) {
      System.Console.WriteLine("ヒャッハー!");
    }
    Given = 0;
  }
  private int Given { get; set; }
  private int Limit=>10;
  private int Count=>5;
}

コードの実行結果

改めてオブジェクトって何?

ここまでの説明で、以下のことがなんとなく分かったと思います。

・どうやら現実世界のものをそのまま使うのは無理らしい。
・クラスが設計図でオブジェクトは実体?
・オブジェクトはデータとメソッドを持っているようだ。
・使う目的を明確かつ詳細に決めないと無駄になる。

オブジェクトが複数ある場合はどうすればいいの?

オブジェクト指向が持てはやされていた(かどうかは分からない)理由としては、オブジェクトが複数あるときの関係性が明確になるかも知れないところです。
「かも知れない」としているのは、設計が悪いとオブジェクト同士の関係性がメチャクチャになって、オブジェクト指向で作る意味が全くないものを作り出してしまうためです。

では、関係性とはなんでしょう?

オブジェクトの関係性とは?

UMLクラス図で、オブジェクト(クラス)同士の関係性を表現する方法はいくつもあります。
「関連」「汎化」「集約」「コンポジット」「依存」…などなど。

こちらの記事に説明があります。

ゲーム開発だとクラス図を書くことがほぼないので、全て覚える必要はないと思いますが、ゲーム以外では分かりません。
私が簡略化したクラス図を書くことはあっても、資料として提供されたことは、これまでの経験でほとんどありません(大手案件ではあった)。

せっかくなので、ハローワールドを使って考えてみます。
前述のハローワールドオブジェクトは、関数一個に置き換えられるほど小さいものだったので、もっと大きくなるよう考えます。

色んな方法でハローワールドするシステムを作って欲しいという案件が来たとします。
なにをしたいのかもよく分かっていないクライアントを徹底的に問い詰めて、クライアントがやりたいことを全て聞き出し、以下の要件になったとします。

・ハローワールドの文字をいろんなデザインで表示したい。
・文字も背景もアニメーションして欲しい。
・デザインは後から簡単に追加できるようにしたい。
・デザインは選べなくて良い。
・一定時間でランダムにデザインを切り替えて欲しい。
・ウィンドウズのアプリとして実行したい。
・全画面表示だけでいい。
・MSストアには対応しなくて良い。

この要件を見てパッと思いつくのは、

「これ動画を再生するスクリーンセーバーじゃん…。」

ということです。
であれば、こんな設計にすれば良さそうです。

大したことをやらないわりに、あっちこっちに線が引かれていて、ゴチャゴチャしていますね。
これ以上、大きなシステムになったら、見るのが苦痛な設計図が出来上がりそうです。

オブジェクト指向設計のデメリット

実際に設計図を書いて見れば分かりますが、オブジェクト指向設計のデメリットは細かすぎるところです。
設計した本人には良いのですが、他人に見せる資料としては不向きです(相手がエンジニアでないなら尚更です)。

それから、実際にコードを書くと、「あ、この設計じゃダメだ」というところが出て来るので、作ってみたら設計図と全然違ってた…という結果になることもあります。
「絶対に設計図通りに実装しないとダメ!」というルールを決めてしまうと、遠回りな処理や、ややこしい手間がかかる処理にしないとならなくなったりもします。
設計にひっぱられて実装コストが高くなります。
設計至上主義でないなら実装を優先するので、実装と設計が合わなくなります。
ソフトウェア開発は製造や建築と違って、設計を無視したからといって、悪い結果になるとは限りません。
むしろ、「設計が悪いんだから、設計を直せ」と言うこともできてしまいます。

それから、機能ごとにオブジェクトを作るので、基本的にコード量が増えます。
コード量を抑えたい状況には不向きです。

ゲーム開発では最優先で動く状態のものを作ることを要求されるので、設計に時間をかけることができません。
設計に時間をかけることができないなら、オブジェクト指向に限らず不向きです。

「オブジェクト指向」とは別の話になるのですが、「ひとつのオブジェクトはひとつの責任だけ追うべき。」「ひとつのオブジェクトに色んな機能を持たせるべきではない。」という指標があります。
その指標を使って、5つのオブジェクトに責任を分散しました。

以下は、上図に記載されているオブジェクトの説明です。

ウィンドウオブジェクト

ウィンドウに動画を表示するためのデータと、表示するために必要なメソッドを持ちます。
ウィンドウに関わるものは、このオブジェクトが責任を負います。

以下はウィンドウオブジェクトと直接関係のあるオブジェクトだけ抽出し、よくある書き方に直したものです。

「動画オブジェクト」「動画切り替えオブジェクト」「動画ファイルオブジェクト」をデータとして持ち、「初期化メソッド」「描画メソッド」を持ちます。

ウィンドウズアプリなので、.Net の Form クラスや Window クラスから、このオブジェクトを生成したり、このオブジェクトのメソッドを呼び出したりすることになると思います。
動画を再生するだけなので、.Net は使わずに、SFML + sfeMovie を使うのも良さそうです。

動画オブジェクト

動画データの実体を管理する責任を負います。
動画データのロードをすることはできますが、実際のロード処理は「動画ローダーオブジェクト」が行います。

ウィンドウに表示する「動画データ」(mp4やaviなど)を持ち、動画データをロードする「ロードメソッド」を持ちます。
ロードメソッドには、ロードする動画のパスを与える必要があります。
ロードした動画のデータを「動画データ」として保持することになります。

動画切り替えオブジェクト

表示する動画の切り替え処理を管理する責任を負います。
動画データの切り替えタイミングの管理と、切り替え要求を出すまでを管理します。

「切り替えまでのカウンタ」「カウンタの閾値」をデータとして持ち、「初期化メソッド」「アップデートメソッド」を持ちます。

アップデートメソッドで、動画を表示してからの経過時間を調べて、「切り替えまでのカウンタ」に保存します。
「切り替えまでのカウンタ」が「カウンタの閾値」を超えたら、次に表示する動画をロードします。
次に表示する動画は「動画ファイルオブジェクト」から受け取り、動画のロードは「動画オブジェクト」が行います。

動画ファイルオブジェクト

表示可能な動画ファイルと、動画を表示する順番を管理する責任を負います。

「ロード可能な動画」をデータとして持ち、「ゲットパスメソッド」「シークメソッド」を持ちます。

「シークメソッド」でアプリの子フォルダを検索して、動画ファイルのパスを「ロード可能な動画」データに追加します。
「ゲットパスメソッド」は「ロード可能な動画」が保持している動画パスのリストを並べ替えて、先頭に来たパスをひとつだけ返します。

動画ローダーオブジェクト

与えられた動画パスの動画をロードし、ロードした動画データを呼び出し側に返す責任を負います。

「動画データ」をデータとして持ちます。
メソッドは持ちません。

コンストラクタで動画パスを受け取って、コンストラクタ内で動画のロードを行います。
ロードは同期的に行われ、ロードできなかった場合は、無効な「動画データ」を返します。
アプリ実行中に動画ファイルを削除されたり、読み込みロックされる可能性があるので、ロードできない可能性もあります。

サンプルコード

フレームワークやAPIに何を使おうか、AIに色々聞いて、WPF C# を使うのが一番簡単そうだったので、採用しました。
2025年8月時点で最新の .Net9.0 を使っています。
コードはほぼAIが提案してくれたもので、細かい部分を直すくらいしかしていません。

// MainWindow.xaml
<Window x:Class="VideoPlayerApp.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  Title="Video Player" Height="450" Width="800"
  WindowState="Maximized"
  WindowStyle="None">
  <Grid>
    <MediaElement x:Name="videoPlayer"
      LoadedBehavior="Manual"
      UnloadedBehavior="Stop"
      Stretch="Fill"
      MediaOpened="VideoPlayer_MediaOpened" />
  </Grid>
</Window>
// MainWindow.xaml.cs
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;

// クラス図に基づいて動画クラスを定義(このクラスのインスタンスが動画オブジェクト)
class 動画 {
  public MediaElement? 動画データ { get; set; }
  public void ロード(string 動画パス) {
    try {
      if (this.動画データ == null) {
        throw new Exception("動画のロードに失敗しました。");
      }
        var ローダー = new 動画ローダー(動画パス, this.動画データ);
      } catch (FileNotFoundException ex) {
        MessageBox.Show($"動画ファイルが見つかりません: {ex.Message}", "エラー", MessageBoxButton.OK, MessageBoxImage.Error);
        // 必要に応じて、さらに上位の層へ例外を伝播させる
        throw;
      } catch (Exception ex) {
        MessageBox.Show($"動画のロード中にエラーが発生しました: {ex.Message}", "エラー", MessageBoxButton.OK, MessageBoxImage.Error);
        throw;
      }
    }
}

// クラス図に基づいて動画ローダークラスを定義(このクラスのインスタンスが動画ローダーオブジェクト)
class 動画ローダー {
  public 動画ローダー(string 動画パス, MediaElement メディア) {
    if (File.Exists(動画パス)) {
      メディア.Source           = new Uri(動画パス);
      メディア.LoadedBehavior   = MediaState.Manual; // 手動で再生制御を行う
      メディア.UnloadedBehavior = MediaState.Stop;
    } else {
      throw new FileNotFoundException("指定された動画ファイルが見つかりません。", 動画パス);
    }
  }
}

// クラス図に基づいて動画ファイルクラスを定義(このクラスのインスタンスが動画ファイルオブジェクト)
class 動画ファイル {
  public List<string> ロード可能な動画パス { get; set; } = new();
  public string ゲットパス() {
    if (this.ロード可能な動画パス.Count > 0) {
      // ロード可能な動画パスをシャッフルする
      var random = new Random();
      this.ロード可能な動画パス = this.ロード可能な動画パス.OrderBy(x => random.Next()).ToList();
      return this.ロード可能な動画パス[0]; // 最初の動画パスを返す
    } else {
      return ""; // 動画がない場合は空文字列を返す
    }
  }
  public void シーク() {
    // アプリがあるフォルダを取得
    string appDirectory = AppDomain.CurrentDomain.BaseDirectory;
    // アプリがあるフォルダのパスに動画を格納しているフォルダを連結
    string videoDirectory = Path.Combine(appDirectory, "videos");
    // 動画フォルダが存在するか確認
    if (Directory.Exists(videoDirectory)) {
      // 動画フォルダ内のすべての動画ファイルを取得
      string[] videoFiles = Directory.GetFiles(videoDirectory, "*.mp4");
      if (videoFiles.Length > 0) {
        this.ロード可能な動画パス.AddRange(videoFiles);
      } else {
        throw new Exception("動画フォルダに動画ファイルが見つかりません。");
      }
    } else {
      throw new DirectoryNotFoundException("動画フォルダが見つかりません。");
    }
  }
}

// クラス図に基づいて動画切り替えクラスを定義(このクラスのインスタンスが動画切り替えオブジェクト)
class 動画切り替え {
  readonly float カウンタの閾値 = 30.0f; // 動画を切り替えるまでの時間(秒)
  float 切り替えまでのカウンタ { get; set; }
  public void 初期化() {
    // ウィンドウを表示したらすぐにロードする
    this.切り替えまでのカウンタ = this.カウンタの閾値;
  }
  public void アップデート(float デルタタイム, 動画 動画オブジェクト, 動画ファイル 動画ファイルオブジェクト) {
    this.切り替えまでのカウンタ += デルタタイム;
    if (this.切り替えまでのカウンタ >= this.カウンタの閾値) {
      this.切り替えまでのカウンタ = 0.0f;
      try {
        string 次の動画パス = 動画ファイルオブジェクト.ゲットパス();
        動画オブジェクト.ロード(次の動画パス); // 次の動画のパスを指定
        動画オブジェクト.動画データ?.Play(); // 動画を再生
      } catch (Exception ex) {
        // 動画のロードに失敗した場合のエラー処理
        Console.WriteLine($"動画のロードに失敗しました: {ex.Message}");
      }
    }
  }
}

// クラス図に基づいてウィンドウクラスを定義(このクラスのインスタンスがウィンドウオブジェクト)
class ウィンドウ {
  public 動画 動画オブジェクト { get; } = new();
  動画ファイル 動画ファイルオブジェクト { get; } = new();
  動画切り替え 動画切り替えオブジェクト { get; } = new();
  public void 初期化(MediaElement ビデオプレイヤー) {
    // 動画オブジェクトの初期化
    this.動画オブジェクト.動画データ = ビデオプレイヤー;
    // 動画ファイルを検索
    this.動画ファイルオブジェクト.シーク();
    // 動画切り替えの初期化
    this.動画切り替えオブジェクト.初期化();
  }
  public void 描画(float デルタタイム) {
    this.動画切り替えオブジェクト.アップデート(デルタタイム, this.動画オブジェクト, this.動画ファイルオブジェクト);
  }
}

// WPFアプリケーションのメインウィンドウ
namespace VideoPlayerApp {
  public partial class MainWindow : Window {
    ウィンドウ ウィンドウオブジェクト = new();
    DispatcherTimer タイマー = new();
    DateTime 最後に取得した時間;
    public MainWindow() {
      this.InitializeComponent();
      this.ウィンドウオブジェクト.初期化(this.videoPlayer);
      this.タイマー.Interval = TimeSpan.FromMilliseconds(33); // 30FPSに近い更新頻度
      this.タイマー.Tick += this.OnTickTimer;
      this.最後に取得した時間 = DateTime.Now;
      this.タイマー.Start();
    }
    void OnTickTimer(object? sender, EventArgs e) {
      DateTime now = DateTime.Now;
      float デルタタイム = (float)(now - this.最後に取得した時間).TotalSeconds;
      this.最後に取得した時間 = now;
      this.ウィンドウオブジェクト.描画(デルタタイム);
    }
    void VideoPlayer_MediaOpened(object sender, RoutedEventArgs e) {
      if (this.ウィンドウオブジェクト.動画オブジェクト.動画データ == null) { return; }
      this.ウィンドウオブジェクト.動画オブジェクト.動画データ.MediaEnded += (s, ev) => {
        this.ウィンドウオブジェクト.動画オブジェクト.動画データ.Position = TimeSpan.FromSeconds(0);
        this.ウィンドウオブジェクト.動画オブジェクト.動画データ.Play();
      };
    }
  }
}

動画を再生できるか最低限のコードで確認しているときの様子

動作確認とサンプルコードの簡単な説明

サンプルなのでかなり雑に作ってます。
アプリを終了するには強制終了するしかないです。
ちゃんとしたアプリにするなら、動画が表示されるまでアニメーションするロードアイコンを表示するとか、ウィンドウをクリックすると「終了ボタン」が表示されるといった対応を入れた方が良いです。

WPF の仕組み上、xaml で宣言している videoPlayer 変数をウィンドウオブジェクトから動画オブジェクトに伝搬させる必要があったり、動画オブジェクトから動画ローダーオブジェクトに伝搬させる必要が出たので、設計図とは若干作りが違います。
こんな感じで、実際に作ってみると設計通りに行かなくなるものです。

オブジェクト指向の三大要素
  1. カプセル化
  2. 継承
  3. ポリモーフィズム

カプセル化

オブジェクト指向かどうかに関わらず、カプセル化は重要です。
カプセル化の目的は以下のふたつです。

  1. 意図しないデータの変更を防ぐ。
  2. 外部参照可能なものを減らし、設計をシンプルにする。

カプセル化はクラス単位、ソースファイル単位、モジュール単位(関連があるソースファイル群)など、特定の範囲内で行います。

カプセル化の対象は、データ(変数)、メソッド(関数)、クラスや構造体に属さない関数、名前空間、エイリアス、マクロ…など、他のソースファイルに影響するものは全てが対象になります。
それらを公開することで、他のソースファイルで参照したり、他のソースファイルから値を変更できるようになります。

何も公開しないのが最も堅牢ですが、公開しなければ何もできません。

最も堅牢なクラスの例

//C++20
#include <hoge.h>
int main() {
  // Hoge クラスのコンストラクタが非公開なので
  // インスタンスを生成できない
  Hoge hoge; // エラー
  return 0;
}
//C++20
//hoge.h
class Hoge {
private:
  Hoge() {} // コンストラクタが非公開なのでインスタンス生成できない
};

Hoge クラスはインスタンスを生成できないので、最も堅牢です。
ただ、インスタンスを生成できないので、Hoge オブジェクトに対して何もすることができません。
このままでは存在する意味がありません。

Hoge オブジェクトに存在する意味を与えるには、Hoge オブジェクトの使用目的を決める必要があります。
とりあえず、Hoge オブジェクトの定義は以下とします。

メインループのループ回数をカウントする。
//C++20
//hoge.h
class Hoge {
public:
  int count{0};
};
//C++20
#include <iostream>
#include <string>
#include <hoge.h>
int main() {
  Hoge hoge;
  std::string input;
  do {
    hoge.count++;
    std::cin >> input;
  } while(input[0] != 'q');
  return 0;
}

コンストラクタを公開し(省略すると公開コンストラクタになります)、メンバ変数 count も公開しました。
これでメインループ内をループした回数をカウントできるようになりましたが、count を公開するのは大きな問題があります。

ループ回数をカウントするのが目的なので、ループする毎にインクリメントしてくれるだけで良く、それ以外の操作は必要ありません。
このままだと hoge.count = 0 にしたり、hoge.count *= 8 をしたり、hoge.count -= 2 と逆に減らしたり、count をどんな値にでもできてしまいます。

Hoge オブジェクトを別のソースファイルにある関数や、別のオブジェクトに伝搬させた場合、count の値がどのように扱われるのか?を Hoge 以外に委ねてしまうことになります。
これは Hoge オブジェクトがデータの管理責任を放棄するのと同じです。

//C++20
/* 略 */
class Moge {
public:
  Moge(Hoge& hoge) {
    // Moge は hoge.count を自由に変更できてしまう
    hoge.count = 55; // カレー
  }
};
int main() {
  /* 略 */
  do {
    hoge.count++;
    std::cin >> input;
    // ここで hoge.count が 55 になってしまうが
    // 意図した挙動ではない
    Moge moge{hoge};
  } /* 略 */
  return 0;
}

上記はサンプルコードなので、count がどこで変更されるのかを簡単に見つけることができますが、実際はもっと何重にも関数やオブジェクトのメソッドなどに伝搬され、count の変更箇所は非常に見つけにくくなります。

一般的に、データは外部から直接変更できないようにします。
C++ の場合は、外部からデータを変更するためのメソッドを用意し、そのメソッドの中でデータを変更します。
C# の場合は、データにアクセスするためのプロパティという仕組みがあるので、それを使います。

//C++20
//hoge.h
class Hoge {
public:
  void CountUp() { count++; }
private:
  int count{0};
};
//C++20
/* 略 */
class Moge {
public:
  Moge(Hoge& hoge) {
    // hoge.count が private なので外部からアクセスできない
    hoge.count = 55; // エラー
    // CountUp() を呼ぶことはできる。
    hoge.CountUp();
  }
};
int main() {
  /* 略 */
  do {
    // hoge.count が private なので外部からアクセスできない
    hoge.count++; // エラー
    // CountUp() を呼ぶことはできる。
    hoge.CountUp();

    std::cin >> input;

    Moge moge{hoge};

  } /* 略 */
  return 0;
}

上記のサンプルコードでは、外部から hoge.count に直接アクセスすることはできなくなりました。
CountUp() メソッドを呼ぶことで、hoge.count がインクリメントされます。

こうすることで、hoge.count の値が変更される箇所を特定しやすくなりました。
CountUp() メソッド内にブレークポイントを仕掛けたり、検索をかけるだけで変更箇所が見つかります。
少なくとも、hoge.count の値が簡単に壊されてしまうリスクは減りました。

しかし、まだ十分ではありません。
サンプルコードのように、CountUp() を二重、三重に呼び出すことができてしまいます。
CountUp() が公開されているので、Hoge オブジェクトを伝搬してしまえば、どこからでも呼び出すことができてしまいます。
このままでは、簡単に hoge.count の値を狂わせることができてしまいます。

//C++20
//hoge.h
class Hoge {
private:
  friend class Moge;
  void CountUp() { count++; }
  int count{0};
};
//C++20
/* 略 */
class Moge {
public:
  Moge(Hoge& hoge) {
    // Moge クラスだけが CountUp() を呼ぶことができる。
    hoge.CountUp();
  }
};
int main() {
  /* 略 */
  do {
    // CountUp() が private なので外部からアクセスできない
    hoge.CountUp(); // エラー

    std::cin >> input;

    Moge moge{hoge};

  } /* 略 */
  return 0;
}

C++ の friend class 指定は非常に強力です。
この仕組みによって、Hoge クラスの CountUp() メソッドは Moge クラス内部でのみ呼び出すことができるようになります。
main() 関数内や、Moge クラス以外で CountUp() メソッドにアクセスすることはできません。
これなら、hoge.count の値がおかしくなったときに、Moge クラス内を調べるだけで済みます。

余計なものを省いたサンプルコード

//C++20
//hoge.h
class Hoge {
  friend class Moge;
  void CountUp() { count++; }
  int count{0};
};
//C++
/* 略 */
class Moge {
public:
  Moge(Hoge& hoge) { hoge.CountUp(); }
};
int main() {
  /* 略 */
  do {
    std::cin >> input;
    Moge moge{hoge};
  } /* 略 */
  return 0;
}

カプセル化の目的を再度確認します。

  1. 意図しないデータの変更を防ぐ。
  2. 外部参照可能なものを減らし、設計をシンプルにする。

ある程度カバーはできたと思いますが、まだ以下の懸念点があります。

  1. Moge オブジェクトを複数個所で生成できてしまう。
  2. Moge クラスの中で CountUp() を複数回実行できてしまう。

  3. に関してはインスタンスをひとつに制限することは可能です。
    AIに「シングルトン」について質問してみてください。
    シングルトンの作り方も色々ありますが、ふたつ以上のインスタンスを生成できないようにするか、うっかり生成してしまったときにアサートやエラーを出すことで回避できそうです。

  4. に関しては、そもそも Hoge クラスを削除して、Moge のコンストラクタでカウントすればいいのでは?と思います。

//C++20
#include <cassert>
/* 略 */
class Moge {
  inline static Moge* instance{nullptr};
  inline static int count{0};
public:
  static int32 GetCount() { return count; }
  Moge() {
    assert(instance == nullptr);
    count++;
    instance = this;
  }
  ~Moge() { instance = nullptr; }
};
int main() {
  /* 略 */
  do {
    Moge moge1;
    std::cout << Moge::GetCount() << std::endl;
    Moge moge2; // assert にひっかかり、ここでクラッシュするので、この行を削除するしかない

    std::cin >> input;
  } /* 略 */
  return 0;
}

実装コードは増えましたが、Moge クラスの設計自体はシンプルです。
コンストラクタを宣言するか、静的メソッドの GetCount() を呼ぶことしかできません。

このように徹底的にリスクを回避することで、オブジェクトは堅牢になります。
意図しないデータの変更を防ぎ、外部からのアクセスを必要最小限にすることで設計がシンプルになります。

カプセル化は複数のオブジェクトに対しても有効です。
上図のように、同系統のオブジェクトをまとめて、名前空間やモジュールなどの範囲に閉じ込めます。
必要最小限のクラスと、必要最小限のデータやメソッドだけを公開し、それ以外を非公開にすることで堅牢な仕組みを作ることができます。

継承

C++ と C# においては、親クラスの public または protected 指定したデータとメソッドを子クラスが受け継ぐ仕組みです。

// C#
class Parent {
  public void Output() { ConsoleWriteLine(); }
  protected int data = 0;
  private void ConsoleWriteLine() {
    Console.WriteLine("Parent data={0}", data);
  }
}
// C#
class Child : Parent {}
// C#
var child = new Child();
child.Output(); // 親クラスの Outpupt() を呼び出せる

親クラスの private データとメソッドにはアクセスできません。

// C#
class Child : Parent { // Parent を継承
  Child() {
    data = 999;         // OK
    Output();           // OK
    ConsoleWriteLine(); // エラー
  }
}

ただ継承しただけでは、親クラスのメソッドをそのまま実行するだけです。
親クラスのデータを、子クラス側で変更することはできます。

じゃあ、親クラスのメソッドは変更できないのか?というと、そんなことはありません。
virtual と override を使うことで、親クラスのメソッドを子クラスで変更することができます。

// C#
class Parent {
  public virtual void Output() { /* 略 */ } // virtual を付けた
  /* 略 */
}
// C#
class Child : Parent {
  public virtual override void Output() {
    // virtual が付いていれば override を付け足すことで
    // 子クラス側で処理を変更できるようになる
    Console.WriteLine("Child data={0}", data);

    // 子クラス側で親クラスの処理を呼ぶこともできる。
    Parent::Output();
  }
}

インターフェースクラス

// C#
interface IHoge {
  void Output(); // 実装コードは書かない
}
// C++
class IHoge {
public:
  virtual void Output() = 0; // 純粋仮想(pure virtual)メソッド
};
// C++
class Hoge : public IHoge {
public:
  virtual void Output() override {
    // 子クラスで実装コードを書く
  }
};
class Moge : public IHoge {
  virtual void Output() override {
    // 子クラスで実装コードを書く
  }
};

/* 略 */

std::vector<IHoge*> hoges;
hoges.push_back(new Hoge);
hoges.push_back(new Moge);
for (IHoge* hoge : hoges) {
  hoge->Output();
}

継承の仕組みはこれだけです。
使い始めると便利な機能だと気づきますが、継承をする度にクラスが持っている全てのデータとメソッドを把握しにくくなることにも気づくと思います。

継承を乱用すると保守コストが上がります。
短いサンプルコードでは限界があるので、膨大なコード量を持つ Unreal Engine の代表的なクラスを例として挙げます。

Unreal Engine のソースコードは誰でもダウンロードすることができます。
興味があればダウンロードしてみてください。
⇒ Unreal Engine のソース コードをダウンロードする

Unreal Engine では AActor クラスを多く利用します。
シーン(レベル)に配置するものは全て、この AActor を継承する必要があるためです(UIを除く)。
このクラスは以下のように何重にも継承しているため、AActor が持つデータやメソッドを把握するには、全ての親クラスをたどって調べる必要があります。

UObjectBase
└ UObjectBaseUtility
 └ UObject
  └ AActor

実際、どれくらいの情報量なのかと言うと、AActor の定義だけで4000行ある上に、全ての親クラスの定義を含めると6000行を超えます。
ほとんどのデータとメソッドのドキュメントが十分ではないので、何に使うものなのかを把握するには、プログラムを実行して処理を追跡する必要があるのですが、追跡してもよく分からないことがほとんどです。

Unreal Engine はゲームに限らず、物理シミュレーションや映像製作の用途でも使えるシステムなので、このような情報量になってしまうのは仕方のないところもあります。
これだけの膨大な情報量になると、全容をなんとなく把握するだけで何か月もかかってしまうので、学習コストが高すぎます。
また、何らかの問題が起きたとき、原因が分からず、解決方法も分からずに詰む可能性があります。

UIを実装するのに使う UUserWidget は更に複雑です。
以下に継承関係が分かるダイアグラムを図示します。

Unreal Engine のソースコードはEpicの開発者に限らず大勢のエンジニアが開発に貢献し、長年積み上げて来たものなので、これ以上シンプルな設計にするのは容易ではありません。
「どうすればもっとシンプルで使いやすくなるのか?」は私にも分かりません。

UObject, AActor, UUserWidget を使わずにゲームを作ることはできないので、これらのクラスやその親クラスの内容を変更すると、プロジェクトに存在するほぼ全てのクラスが影響を受けることになります。
これは影響を受けた全ての箇所をテストし直さなければならなくなる…ということです。
テストを省ける箇所も多々あると思いますが、それを把握するには全ての影響箇所を洗い出す必要があります。
洗い出しに膨大な時間がかかりそうなことは、簡単に想像できますね。恐ろしいです…。

継承の注意点をまとめると以下になります。

  • 継承を多用すると、そのクラスの全容を把握しにくくなる。
  • 親クラスの使用箇所が多いと、親クラス変更時のシステムに与える影響が大きくなる。
     ⇒ テストにかかるコストが爆増する。
  • システムが大きくなるほど継承を乱用しがち。継承も必要最小限にとどめるべき。

ポリモーフィズム

既にサンプルコードで簡単な使い方を紹介しているので、説明も簡単に済ませます。
ポリモーフィズムは継承とセットの機能です。

// C++
class Base {
public:
  virtual ~Base() {}
  virtual void Test() = 0;
};
class HogeA : public Base {
public:
  virtual void Test() override {
    std::cout << "HogeA" << std::endl;
  }
};
class HogeB : public Base {
public:
  virtual void Test() override {
    std::cout << "HogeB" << std::endl;
  }
};
int main() {
  std::vector<Base*> hoges;
  hoges.push_back(new HogeA);
  hoges.push_back(new HogeB);
  for (Base* hoge : hoges) {
    hoge->Test();
  }
}

実行結果

HogeA
HogeB

親クラスが同じなら、親クラスの型を使って、ひとつの配列やコレクションに登録できます。
この配列やコレクションにアクセスすると、親クラスのインスタンスにアクセスしているように見えますが、実際は登録した子クラスにアクセスすることができます。
なので、インスタンスのメソッドを呼び出すと、親クラスのメソッドではなく、登録した子クラスのメソッドが呼ばれます。

例えば、ゲームのキャラクターは全て、GameObject クラスを親クラスにする…というルールにすれば、シーンに存在する全てのキャラクターや家のドアなどをひとつの配列やコレクションで管理できます。
Enemy だろうが、Boss だろうが、Player だろうが、Human だろうが、Mob だろうが、Animal だろうが、HomeDoor だろうが、親が同じなら全部まとめて処理できます。

この仕組みを駆使して、よく使うテクニックとして体系化したものが「デザインパターン」と呼ばれるものです。
デザインパターンは、オブジェクト指向を学習する上で避けては通れないものです。

オブジェクト指向でできることはだいたい決まっています。
それが何十年も前に既に考案されていました。
デザインパターンを知らなくても、C++ C# Java などでコードを書いていれば、自ずと見つかるものです。
ただ、おそらくその全ては、あなたが生まれる前にもう既に見つかっています。
そんな二度手間を取るのは非効率以外の何物でもありません。

デザインパターンの情報は妥当なものから、間違っているものまで、ネットには大量に情報があります。
インターネットで検索して、見つかった名前をAIに聞くのも良いですし、中古で書籍を購入するのも良いと思います。

最後にお薦めの書籍や資料を紹介して、ポリモーフィズムの説明を終わりたいと思います。

デザインパターンのお薦め書籍と資料
※この手の書籍は高いです。
※図書館を利用する場合、国会図書館に行かないとないです。
※古本屋や中古ショップでも激レアです。
※新刊を購入する場合、都内だと多摩センター駅の丸善か立川駅のジュンク堂くらいにしか置いてないです。
(都内の大型書店は全てまわりました…新宿の紀伊国屋書店、秋葉原の書泉グランデにもあるかも?)
(取り寄せならどこでも買えますが一週間くらい待つ必要あり)

【洋書】
英語ですが、基本パターンを網羅しています。デザインパターンの原典と呼ぶことができる書籍です。

【日本語】
分かりやすいですが、網羅はしていません。

無料の資料
Unity テクノロジーズの電子書籍が無料配布されています。ゲーム向けですが、分かりやすいです。
ゲームエンジンの Unity 向けになりますが、他にもゲーム開発に役立つ沢山の資料が無料配布されています。

オブジェクト指向以外に使えるものはないの?

コード設計やコーディングの指標は沢山あります。
名称だけ書きますので、詳細はAIに聞いてください。
学習した内容を元に、色々なコードを書くことをお勧めします。

  • 設計
    • データ駆動設計
      DDD(Data-Driven Design)
      ECS(Entity Component System)
    • ドメイン駆動設計
      DDD(Domain-Driven Design) ※どちらも DDD なので注意
    • テスト駆動開発
      TDD(Test Driven Development)
  • アーキテクチャ
    • MVC(Model-View-Controller)
    • MVP(Model-View-Presenter)
    • MVVM(Model-View-ViewModel)
    • VIPER(View, Interactor, Presenter, Entity, Router)
  • More C++ Idioms
    イディオムとは定石のことです。デザインパターンと似ていますが、デザインパターンに分類されない細かいテクニックのことです。
    C++ に限らず、どのプログラム言語にもあるはずです。
  • DRY(Don’t Repeat Yourself)
  • WET(Write Everything Twice)
  • SOLID
  • KISS(Keep It Simple, Stupid)
  • YAGNI(You Ain’t Gonna Need It)
  • SoC(Separation of Concerns)
  • LoD(Law of Demeter)
  • Tell, Don’t Ask
  • CoC(Convention over Configuration)
  • PoET(Principle of Least Astonishment)
  • AHA(Avoid Hasty Abstractions)

なんとかドリブンについてまとめている記事

参考資料
Web
書籍

オブジェクト指向の成り立ちから現在に至るまでの流れを知りたいなら、こちらの書籍がお勧めです。
ほどよい文章量にまとめてくださっています。
※Kindle Unlimited を利用しているなら無料で読めます。

こちらの書籍はチーム開発するエンジニアの必読書だと私は思っています。

関連記事

画像とサンプルコードの生成には Google Gemini 2.5 Flash を使用しました。
記事の朗読には Google AI Studio の Generate Speech を使用しました。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です