LoginSignup
293
300

More than 1 year has passed since last update.

本当のオブジェクト指向の話をしよう

Last updated at Posted at 2022-12-05

この記事はC#アドベントカレンダー5日目の記事です。
2時間くらい間に合いませんでした

はじめに

今年もアドカレの季節がやってきましたので、今年もさっそく記事を書いていきます。
ずっといつか書こうと思っていた、「オブジェクト指向って何?」に対する自分なりの答えです。
多分かなり長くなる気がするので、気長に読んでいただけると幸いです。

オブジェクト指向って何?

よくある説明

さっそくですが、「オブジェクト指向」とは何か。ひとまず既存の説明を検索してみましょう。

「ある役割を持ったモノ」ごとにクラス(プログラム全体の設計図)を分割し、モノとモノとの関係性を定義していくことでシステムを作り上げようとするシステム構成の考え方のこと。

オブジェクト指向とは、コンピュータプログラムの設計や実装についての考え方の一つで、互いに密接に関連するデータと手続き(処理手順)を「オブジェクト」(object)と呼ばれる一つのまとまりとして定義し、様々なオブジェクトを組み合わせてプログラム全体を構築していく手法。

確かに、オブジェクト指向という概念の辞書的な説明としては正しいでしょう。
しかし、いわゆる「わかりやすい」説明になるとどうでしょうか?
例えば、具体的な構成要素としてのクラス・インスタンスの説明として

より具体的なイメージをクラスとインスタンスで表現すると、次のように「たい焼き」に例えられる事が多いです。(以下略)

のように、よくヘンな例えが出てきます。

本記事のコンセプト

自分の意見ではありますが、既存のよくある説明というのは、どこか掴みどころがない、辞書的で抽象的な説明か、かえって正しい理解を阻害するような例えのどちらかがほとんどだと思うのです。
そこで、プログラミング初心者というよりは少し知識があるような人を対象としてイメージしつつ、「例」によらない、理論的で具体的なオブジェクト指向の話をすることにします。
なお、この記事の主張は基本的に「C#におけるオブジェクト指向」の話です。とはいえ、おおよそは現代のオブジェクト指向言語全般に当てはまるはずです。

結論

先に「一言で表すと?」に対する答えを書いておきましょう。

「何であるか」、ではなく「どう振る舞うか」を規定し、その関係に基いてコードを記述するモデル。

とはいえ、一言で表現できるほどエントロピーが少ない概念でもないので、説明を続けていきましょう。

オブジェクト指向以前 : データ型

ひとまず、「クラス」という概念にたどり着きたいのですが、クラスについてどうこう言う前にまずデータ型について考えておきましょう。
データ型、多くの場合は単純に「型」とは、今日のプログラミングでは基本的な概念であり、そして勿論クラスもそんなデータ型の一つであるからです。

データ型の誕生

はじめ、世界には広い広いメモリ空間の海があった。
その海は混沌としていて、固まるところがなく、まるで水に油が浮かんでいるかのようであった。
主は、「この海に秩序性を導入せよ」と命令したので、高級言語コードは、海に1本の柱を建て、「ここから左に32bitを int 型とする」と宣言した。
そしてコードが、宣言した領域をかき混ぜ、0で初期化したとき、世界に初めての島、変数が生まれた。
そののち、2つの型が出合い、それぞれ順にフィールド宣言を唱えたところ、この世界に初めての複合型、構造体が生まれた。
そうして、多くの型がこの世界に生み出されていった。

導入はさておき、説明に入りましょう。
まず、当たり前のようなことですが、メモリには(普段プログラミング言語の上で生活している私たちが意識するような意味での)境界線はありません。ただ一方向に永遠のごとく伸びているだけです。
しかも、メモリ上ではすべてのデータが同じようにバイナリ値、よくある言い方をするならば「0と1」で表現されており、データの種類を区別する方法は提供されていません。整数も、浮動小数点数も、文字も、すべて同じように何かのバイナリ値として存在しているのです。

しかし、これでは実際にプログラムを記述するうえで大変不自由です。
これを解決するのが高級プログラミング言語の仕事の1つなのです。
一般的な高級言語は「型」という概念を導入することで、この混沌とした低レベルメモリの世界に秩序を導入します。
型は原始的には以下の2点から構成されます。

  • その型のメモリ上での大きさが決定できること
  • その型をほかの型と区別できること

型に大きさが存在することで、あるコードが必要とするメモリの量を計算することが可能になります。
これにより、一次元的なメモリの世界を自動的に分割し、必要な量を必要なコードに割り当てることが実現され、プログラマが何も意識せずとも、メモリが分割されているかのように使用することができます。
とりわけ、ローカル変数などが配置されるスタック領域では、その管理はコンパイラ責任であり、各ローカル変数などの型情報をもとに、それぞれが必要な領域を計算し、その配置を最適になるように事前計算することで実現しており、大きさがわかることの重要性が高くなっています。
少し単純化していますが、例えば以下のようなケースが考えられます。自動計算であることにより、間違って他の変数の領域と重なってしまったり、必要以上の隙間ができたりしないことが実現できます。

static void Main()
{
	int a;  // ++++------------
	long b; // ----++++++++----
	int c;  // ------------++++
}

次に、型が区別可能であることにより、例えば、本質的には何も違いはない int 型の値と float 型の値(C#ではともに4バイトです)を取り違えなくて済みます。もちろん、メモリで違いはありませんが互換性もありませんので、これらを取り違えればプログラムは意図しない動作をする可能性が高いですから、これらは区別されなければなりません。試しに float の 1 を int と取り違えたとすると

using System.Runtime.CompilerServices;

float a = 1f;
int b = Unsafe.As<float, int>(ref a);
Console.WriteLine(b);
// 出力: 1065353216(3f800000)

コンパイラは、型を区別することで、演算子、関数などを含めた広い意味での「手続き」へと値を渡すときに、どの手続きを使用するか(=オーバーロード解決)や、そもそもコンパイルを通すかという判定を行うことが可能になります。

float a = 1f;

// Console.WriteLine(float) のオーバーロードに解決される
// 1065353216 と表示されることはない 
Console.WriteLine(a);

ここで、型システムはその型の意味するところなど知りもせず、単に異なるものを厳格に区別することだけを提供していることが重要です。
この場合の型は適切な手続きの振り分けを行う記号に過ぎないのです。

こうして、原始的なメモリ空間上に、型システムにより、区画化され秩序立ったプログラミング言語の世界が誕生しました。

構造体

多くのプログラミング言語は、原始的な型の組み合わせにより、複雑な型をプログラマ自身が定義する機能を持ちます。
原始的な複合型は、複数の型の値をメモリ上に連続で並べる構造体でしょう。
C# の構造体はある程度オブジェクト指向的な構文を持つため、非オブジェクト指向言語の構造体を想定してください。

struct Hoge
{
	int field1;
	long field2;
}

構造体には名前が付けられ、やはり他の原始的な型や構造体と区別できるようになっています。また、そのサイズも構成要素のサイズから計算可能になっています(この場合は、4 + 8 = 12byte になるでしょう)。
ここで、原始的な構造体では「どんな型から成るか」が本質的で、この場合、「最初の 4byte が int で、そこに 8byte の long が続く12byteの値」であることが重要です。とりわけ非オブジェクト指向言語では、構造体に命名することは必須ではなかったり、いわゆるメソッドを定義することはできなかったりと、純粋に型の組み合わせという印象が強くあります。

とはいえ、単純な型を組み合わせるだけで、あるいは何度も組み合わせていくことで、多くのデータを表現することができます。こうして、プログラマが表現したいデータを表す型を表現することができるようになったのです。

オブジェクト指向への道

いろいろな構造体

単純な構造体として、時刻を表現することを考えてみましょう。人間的な発想として自然なのは

struct Time1
{
	int hour;
	int minute;
	int second;
}

のように表現することでしょう。しかし、こうした形式の時刻同士を足し引きしたり、大小比較したりするのは案外コードが長くなるのは経験がある人も多いのではないでしょうか。それを踏まえれば

struct Time2
{
	int totalSeconds;
}

のような仕組みの方が適する場合もあるかもしれません。構造体を定義しているのに何も変わっていないように思うかもしれませんが、この場合は単なる整数の int と、一日の時刻を表す int を区別することで、関数の呼び出しで区別できるという利点があります。

もちろん、時刻を表現する方法なんていくらでもあるので、「丑3つ + X秒」のように表現してもいいわけで

struct Time3
{
	char timeName;
	int timeCount;
	int remainSeconds;
}

なんていうのもアリなわけです(実用性はさておき)。

型の共通化

さて、同じ「時刻」という概念を表す型が複数存在しうることが明らかになりましたが、
意味的に同じものを表現するからにはそれらを共通化して取り扱いたいという要望が出てきます。
例えば、これらどれでも標準出力に人間が読みやすい形式で出力するような関数が欲しくなるかもしれません。

しかし、ここで前のセクションで長々と説明したことを思い出して欲しいのです。型や構造体において、その大きさや、何から成るかという事項は本質的な事項であり、異なる型を厳密に区別することが型の意義であったわけです。
したがって、とりわけ大きさがそれぞれ違うようなこれらの型を共通化することは、そのままでは困難です。

こういったシーンで使われるテクニックが、ポインタによる型の共通化です。
ポインタは、任意の型を指し示すことができますが、それ自身はすべて1ネイティブ整数語長であるという点で同じ型であり、
すべての型を共通化できる能力を秘めているのです。
C言語などでは、void* により任意の型を取り扱うことで型を共通化して取り扱う関数が時々ありますね。

とはいえ、void* にしてしまえば、どのようにそのポインタの指し示す先にある型をどう取り扱うかは一切わからないため、
別の方法でその取り扱い方法を規定する必要があります。

オブジェクト指向

クラス

前セクションの、「異なる構造体を共通化する」という発想をより発展させていきましょう。
まず、必要なときにポインタにして取り扱うのではなく、最初からポインタにして取り扱うことを考えます。
こうすれば、非ポインタ・ポインタの両方を考える必要もなくなり、またポインタに絡むやや複雑なコーディングを
プログラマに強いることもなくなり、コーディングを単純化できます。

これがクラスという概念の一つの側面です。クラスは、new で生成したときから既にポインタ、C#では「参照」であり
その実体はサイズが実行時まで不定でも配置できるヒープ領域に確保することで、大きさや「何から成るか」の壁を超えることができます。

フィールドの隠蔽

ここからさらに一歩進んでみましょう。「何から成るか」の束縛からより逃れるために、型の構成要素を直接操作するのではなく
その操作方法を関数として型の側から提供してもらうことにしましょう。

そこで、フィールド自体は外部からは見えないように設定し、唯一フィールドを視認できる型内部にメソッドを配置します。

class Time2
{
	private int totalSeconds;

	public int TotalSeconds { get => totalSeconds; }

	public int Hour { get => totalSeconds / 3600; }

	public int Minute { get => totalSeconds % 3600 / 60; }

	public int Second { get => totalSeconds % 60; }
}

こうすることで、その型が内部的に、実体的にどうなっているかを関心事項外として、「何ができるのか」「どうふるまうのか」だけを
関心事項にすることができるのです。上記のクラスは

class Time1
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }
}

と、外見は完全に同じはずです。
これにより、たしかにクラスは型でありながら、原始的な型と異なりそのサイズや構成ではなく、型名が表す自然言語的な意味や
メソッドにより定義されるふるまいに関心事項を移すことに成功しました。
実体、つまりどんな型のどんなフィールドから成るかという事実は型として本質的で、完全になくすことはできません。
しかし、アクセシビリティ修飾子により外部から隠ぺいすることで、オブジェクト指向の目的を達成できます。
アクセシビリティ修飾子と、それによる実装の隠ぺい、カプセル化は、オブジェクト指向の原点ともいえるでしょう。

コンストラクタ

さて、フィールドを隠ぺいし、メソッドやプロパティに置き換えたことで、外部からはそのクラスをどう作成していいのかわからなくなります。
あるいは、従来の構造体ベースの、単純に構成要素となる値を並べることで作成できるという、「実体がどうであるか」に依存する作成方法は
隠ぺいしてしまい、生成するための「ふるまい」だけを提供するのがオブジェクト指向らしいという言い方もできるでしょう。

そこで登場するのがコンストラクタです。これはメソッドのようにふるまい、そのクラスのフィールドに何を設定して初期化するのかを
クラスを設計する側が規定することができます。たとえば

class Time2
{
	public Time2(int hour,int minute,int second)
	{
		totalSeconds = hour * 3600 + minute * 60 + second;
	}

	private int totalSeconds;

	public int TotalSeconds { get => totalSeconds; }

	public int Hour { get => totalSeconds / 3600; }

	public int Minute { get => totalSeconds % 3600 / 60; }

	public int Second { get => totalSeconds % 60; }
}

とすれば、やはりこのクラスが内部的にどのように時刻を表現しているのかという実体を隠ぺいし、生成方法というふるまいだけを
外にみせることができるのです。

これで、生成方法という観点でも、実際に時・分・秒をそれぞれ保持する実装のクラスと全く同じになったといえるでしょう。
こうして、型から実体、「何であるか」という要素を次々と剥ぎ取り、「どうふるまうか」へ関心事項を移行するのが
オブジェクト指向の考え方であると言えるでしょう。

継承

単純な継承

オブジェクト指向により、実体からふるまいへ関心事項を転換したところで、次の段階として
「ふるまいが同じなら実体が違っても同じように扱える」というものが登場するのは必然でしょう。

その一つのケースとして、既存のクラスの拡張を考えてみます。純粋な継承です。
既存のクラスのフィールド・メソッドのすべて、つまりそのクラスとしての「ふるまい」をすべてそのまま利用し、
独自にふるまいを付け足すことが可能になります。

class Time1
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }
}

class Time1WithMilliSecond : Time1
{
	public int MilliSecond { get; }
}

ここで、Time1WithMilliSecondTime1 ではありませんTime1 と同様に ふるまい ます。
したがって、Time1 クラスの値値引数にとる、つまり Time1 として ふるまう ことを期待しているメソッドに
Time1WithMilliSecond を渡すことが可能です。

static void SomeFunc(Time1 time1)
{
	throw new NotImplementedException();
}

Time1WithMilliSecond withMilliSecond = new Time1WithMilliSecond();
SomeFunc(withMilliSecond);

というように、オブジェクト指向により、つまり実体からふるまいへの移行により、拡張が容易になりました。

仮想関数

さて、先ほどの SomeFunc で、日本語で時刻を標準出力に書き出す場合を考えましょう。

static void PrintTime(Time1 time1)
{
	Console.WriteLine(time1.GetString());
}

Time1WithMilliSecond withMilliSecond = new Time1WithMilliSecond();
PrintTime(withMilliSecond);

class Time1
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }

	public string GetString()
	{
		return $"{Hour}{Minute}{Second}秒";
	}
}

class Time1WithMilliSecond : Time1
{
	public int MilliSecond { get; }
}

この場合、Time1WithMilliSecondTime1 としてふるまうことはできます。
しかし、「時刻を日本語で書き出す」というからには、ミリ秒を追加して拡張したならばそれも書き出すべきという人もいるでしょう。
すなわち、「ふるまい」とは、そのメソッドの識別子名の通りであること、より人間的な意味で「同じ」であるべき だという見方です。

そのために、継承したときに継承元のメソッドの中身を書き換えられるようにしましょう。
仮想関数呼び出しのしくみや仮想関数テーブルの話は今回は割愛させてもらいます。
C#では仮想関数呼び出しのコストとの妥協点として、明示的に仮想にしたメソッドのみが仮想関数になりますから

static void PrintTime(Time1 time1)
{
	Console.WriteLine(time1.GetString());
}

Time1WithMilliSecond withMilliSecond = new Time1WithMilliSecond();
PrintTime(withMilliSecond);

class Time1
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }

	public virtual string GetString()
	{
		return $"{Hour}{Minute}{Second}秒";
	}
}

class Time1WithMilliSecond : Time1
{
	public int MilliSecond { get; }

	public override string GetString()
	{
		return $"{Hour}{Minute}{Second}{MilliSecond}ミリ秒";
	}
}

となり、「コンピューター的に同一」のふるまいを期待するのではなく、「人間的な意味で同一」のふるまいをさせることができます。
これにより、プログラマフレンドリーな、直観的でわかりやすいコードを記述することに大いに貢献します。

実装のない継承

インターフェイス

しかし、実際のところ従来の単純な継承はあまり使いません。
これは、すべてのフィールドを自動的に引き継いでしまうため、多重継承の問題が生じたり、
そもそもオブジェクト指向的でないという理由があるからです。

代わりによく使用するのは、interface やごくまれに abstract class のような、フィールドのない
単にふるまいだけを規定するような型です。これにより、よりふるまいのみに注目したプログラミングを可能にします。

interface ITime
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }
}

class Time1 : ITime
{
	public int Hour { get; }

	public int Minute { get; }

	public int Second { get; }
}

class Time2 : ITime
{
	public Time2(int hour, int minute, int second)
	{
		totalSeconds = hour * 3600 + minute * 60 + second;
	}

	private int totalSeconds;

	public int TotalSeconds { get => totalSeconds; }

	public int Hour { get => totalSeconds / 3600; }

	public int Minute { get => totalSeconds % 3600 / 60; }

	public int Second { get => totalSeconds % 60; }
}

ここまで来ると、やりたかったことがたいてい実現できた感があります。
クラスにより、既存の型の制約を破る土壌を作成する。
カプセル化により、何であるかを隠ぺいし、ふるまいのみを提供する。
コンストラクタにより、作成をふるまいにする。
インターフェイスによりふるまいを定義し、それをもとに全く実体の違う型を共通化する。

どうふるまうか、は何であるかに比べはるかに自由です。
ふるまいはそのままに、クラスの仕様を変更する。
単体テスト時と、本番時でクラスを差し替える。
ライブラリ利用者に、インターフェイスで規定されるふるまいをする型を提供してもらう。
こういった柔軟な開発が可能になるわけです。

ふるまいの組み合わせとジェネリクス

実体、つまりフィールドに一切依存しない interface という概念により、クラスはより自由に、いくつでも interface を実装することができます。
これにより、小さな、ありふれたふるまいを interface として定義し、その組み合わせによりクラスのふるまいを位置づけることができます。
「何であるか」は、完全に下方の包含関係にあるものしか満たすことができませんが、「どうふるまうか」はメソッド次第でいくらでも満たしうるのです。
IEnumerable , IComparable ... これらは小さなインターフェイスの例でしょう。

こうした基礎的なインターフェイスを背景に、何かロジックやコンテナ型を定義するときに、ジェネリクスにより、
「最小限必要」なふるまいだけを要求し、具体的な型との結合を行わないことで、疎結合で再利用性の高いコードを記述できます。
もちろん、制約がない、つまりC#の型であればなんでもよい、というのも究極的な汎用性のあり方でしょう。

最後に

最初に言っておいた結論をもう一度書いておきます。

「何であるか」、ではなく「どう振る舞うか」を規定し、その関係に基いてコードを記述するモデル。

いろいろと言葉足らずだったりわかりにくかったりしたところがあるかもしれませんが、今の自分なりの「オブジェクト指向とは?」への答えが表現できていると幸いです。

293
300
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
293
300