オブジェクト指向の学習を兼ねて、自分なりにオブジェクト指向の要点をまとめてみました。全4回に分けてオブジェクト指向の持つ特性の基本を学んでいきます。
コード例としてはC#を使用しています。
オブジェクト指向入門 一覧
- その1 概要編 ←ここ
- その2 カプセル化編
- その3 ポリモーフィズム編
- その4 ジェネリック編
オブジェクト指向とは?
オブジェクト指向はアラン・ケイがSmalltalk開発時に生み出した概念です。当初の考えではそれぞれのオブジェクトがメッセージをやり取りして処理を進めるという方式でしたが、先行言語のSimulaなどの影響でクラスを基にインスタンスを作るという形式になっていきました。この場合、インスタンスのメソッド(関数)を呼び出すことがメッセージングに当たります。
一般的なオブジェクト指向の主なファクターはクラスとインスタンスです。クラスは状態を保持するフィールド(平たく言えば変数)とメソッドを持ちます。フィールドメソッドはまとめてメンバと呼ばれます。インスタンスはクラスをメモリ上に具体化したものです。つまり、クラスはインスタンスの設計図と言えます。
概念的に説明すると、クラスをインスタンス化するとメモリ上にフィールドが展開されます。メソッドが呼び出された場合、そのフィールドを利用して処理を行います(処理の場所をポインタとして持っています)。場合によってはフィールドを書き換えることもあります。メモリに書き込んだり読み込むので終わりなC言語などとは違い、保持したクラスのメモリは壊されないようになります。だからと言ってメモリをいつまでも占有するわけにはいかないので、どこかしらの段階でメモリ上のインスタンスを削除する必要が出てきます。
C++の場合は手動でデストラクタを起動させてインスタンスを消去していましたが、現在はRAII(リソース確保=インスタンス化)と言って宣言時にメモリ取得、スコープ(コード区切り)を抜けた時点でメモリ消去という形が主流です。RAIIはRustなどでも見られる概念ですね。対してJavaやC#ではガベージコレクション(GC)と言い、不要なデータの処分を処理系に一任する形を取っています。これだとプログラマーはメモリを考慮しなくてもコードを書けます。RAIIはパフォーマンスクリティカルな環境を想定した言語で採用されることが多く、GCはメモリやCPUが潤沢な環境を前提としています。また、GCを採用している言語でも用済みになったら確実に破棄しておきたいインスタンス(ネットワーク接続など)を処理するためのRAII的機構を持っているものもあります1。
なお、C言語以前の構造化プログラミングと対比されることの多いオブジェクト指向ですが、大抵のOO言語が構造化プログラミング的な側面を持っています(C言語の上位互換として設計されたC++が分かりやすいですね)。そのため、C++やJava、C#で構造化プログラミング的なコード作りをすることももちろん可能ですが、言語の特性を活かしているとは言えません。
オブジェクト指向を最低限満たしたコードを以下に挙げます。
public class Integer
{
public int Value;
public int multiply(int factor)
{
Value = Value * factor;
return Value;
}
public static void Main()
{
Integer someValue = new Integer();
Integer otherValue = new Integer();
someValue.Value = 5;
otherValue.Value = 42; // これによりsomeValue.Valueは上書きされない。
Console.WriteLine(someValue.multiply(10)) // 50と表示される。
}
}
メソッドは関数とそれほど変わりありません。クラスのフィールドはこのようにメソッド内で変更することができます。
オブジェクト指向の最大のメリットは、処理とデータをまとめて扱えるというところにあります。そして、それらがインスタンス毎に完全に独立しているのも非常に重要です。また、これらのクラス内で共用されるstatic
メンバーというのもあります。この例で挙げられるMain
メソッドのようなオブジェクトに紐づいていると機能しないメソッドや、ユーティリティを実装するために使われます。
オブジェクト指向受容(需要)の歴史
オブジェクト指向普及には3つの段階があると考えられます。まず1960年代、SmalltalkやSimulaによりオブジェクト指向の基本概念が打ち立てられた時です。当時はまだオブジェクト指向が扱えるのは研究所などの限られた環境下のみでした。その間にも、オブジェクト指向の理論はどんどん確立されていきました。これらの言語が使われるのは現在稀ですが、先駆者として永久に不滅でしょう。私は敬意を表してファーストウェーブOO言語と呼ばせていただきます。
そして1980年代ごろ、C++やObjective-Cのように、既存のプログラミング言語をオブジェクト指向に対応させていく動きが活発化しました。これらはセカンドウェーブOO言語と呼べます。これにより今まで馴れ親しんだ書き方でオブジェクト指向を実装できるようになったので、オブジェクト指向が一般に普及していきました。特に圧倒的普及率であるC言語との互換性を重視したC++は、構造化プログラミングからオブジェクト指向プログラミングへの移行を促進しました。しかし、既存のスキームの中での改修がほとんどなので、使いやすいかどうかは別問題だったと言えます。
そんな中で、1990年代に入るとサードウェーブOO言語と言える完全新規の言語が多数登場するようになりました。この時代には機械学習で最も熱いPython、動的型付け言語で一番シェアが高い和製言語Ruby、仮想マシン(JVM)による移植性の高さと既存のOO言語よりも圧倒的な分かりやすさで一世を風靡したJava、Webページのおまけ程度だったはずがWebサービスの生命線になり、サーバーを動かせるほどにガチの言語に進化したJavaScriptなどの、現在に至るまで第1線で使われる言語が生まれています。時代は離れますが、GoやRustなどもサードウェーブと呼んでいいでしょう。これらの言語は、それまでの開発で培われた経験をもとに、よりオブジェクト指向を簡単に分かりやすく扱えるように作られています。
今回例に挙げているC#は、セカンドウェーブOO言語であるObject Pascal(Delphi)の開発者が作り出したサードウェーブOO言語です。つまりゼロベースで文法や稼働環境が考えられており、名前こそCを冠していますが、C言語とのつながりはそれほどでもありません。C言語などのローレベルを扱える器用さと、C++の分かりやすいアクセス指定、Javaの簡潔な分かりやすさが両立されている(いいとこ取り)のが特徴と言えます。
オブジェクト指向の2大原則
オブジェクト指向には、それを支える前提の2大原則があります。それがカプセル化とポリモーフィズムです。資料によってはOO言語の多くに採用されている継承を原則に含む場合もありますが、本質的にはポリモーフィズムを達成するための手段の一つであり、Rustのように継承を機能として搭載していない言語もあります。
カプセル化:変更されちゃいけないフィールドはしまっちゃおうね
カプセル化は、オブジェクトの中身が外に漏れてはいけないという考え方です。これはアクセス指定子というもので達成されます。
代表的なアクセス指定子はprivate
とpublic
です。private
が指定されたメンバはクラス内でしか参照できません。対して、public
が指定されたメンバはコードのどこからでも参照できるようになります。そのため、public
のフィールドは危険だということが分かりますね!
これにより、インスタンスのフィールドの安全性が確保され、グローバル変数(オブジェクト指向的に言えばpublic static
なフィールド)のような不確かさや、処理が追えなくなる危険性が無くなります。また、カプセル化という概念のおかげで、オブジェクトのことはオブジェクト自身で処理しなければならないという責任範囲が明確になります。
ポリモーフィズム:4次元ポケット的機構
ポリモーフィズムは説明も理解も難しい概念です。一口で説明すると「いろんな型を同じように扱えるようにする」ということでしょうか。例えばオブジェクト指向っぽいコードではないですが、こんな感じです。
public static int Add(int a, int b)
{
return a + b;
}
public static double Add(double a, double b)
{
return a + b;
}
このように引数によって同名メソッドを多重定義することをオーバーロード(Overload)と言います。アインズの方(Overlord)とはつづりが違います。 これもC言語では考えられないことでした。
これだけではプログラマーに過大な負荷を強いるだけなので、汎用的にポリモーフィズムを実現するための機構として、多くのOO言語で継承、インターフェイス、ジェネリック(Javaならジェネリクスまたは総称型2、C++ならテンプレート)を搭載しています。
継承の例
public class Harp // ハープ。チューニングなどの細かいことは省略
{
public int NumberOfStrings { get; } // コンストラクタでのみ設定可能
public Harp(int numberOfStrings)
{
NumberOfStrings = nunberOfStrings;
}
public virtual void Strum (int[] stringsToPlay) { /* ハープを弾く処理 */ }
}
public class AutoHarp : Harp // オートハープ。任意の弦を鳴らさないようにできるハープ
{
public AutoHarp(int numberOfStrings)
{
NumberOfStrings = nunberOfStrings;
}
public void Mute(int[] stringsToMute) { /* 任意の弦を鳴らさないようにする */ }
public void Strum (int start, int end) { /* オートハープを掻き鳴らす処理 */ }
}
public class Harper // ハープ奏者
{
private Harp MyHarp;
public Harper(Harp harp)
{
MyHarp = harp;
}
public void PlayCadenza() // カデンツァというコード進行を弾く
{
harp.Strum([1, 3, 5, 8]); // ドミソド
harp.Strum([1, 4, 6, 8]); // ドファラド
harp.Strum([2, 4, 5, 7]); // レファソシ
harp.Strum([1, 3, 5, 8]); // ドミソド
}
public static void Main()
{
AutoHarp autoHarp = new AutoHarp(8);
Harper harper = new Harper(autoHarp); // オートハープもハープとして扱える!
harper.PlayCadenza(); // 当然この処理も無事動作する
}
}
このようにHarp
を継承しているのであれば、どのクラスでもHarper
は同じように扱えます。これが継承によるポリモーフィズムです。
インターフェイスの例
継承の例をインターフェイスに置き換えます。
public interface IHarp //ハープが持つ機能を抽出
{
int NumberOfStrings { get; }
void Strum(int[] stringsToPlay);
}
public class Harp : IHarp
{
public int NumberOfStrings { get; }
public Harp(int numberOfStrings)
{
NumberOfStrings = nunberOfStrings;
}
public void Strum (int[] stringsToPlay) { /* ハープを弾く処理 */ } // インターフェイスの実装
}
public class AutoHarp : IHarp // オートハープ。任意の弦を鳴らさないようにできるハープ
{
public int NumberOfStrings { get; } // Harpを継承しないので自前で実装する必要がある。
public AutoHarp(int numberOfStrings)
{
NumberOfStrings = nunberOfStrings;
}
public void Mute(int[] stringsToMute) { /* 任意の弦を鳴らさないようにする */ }
public void Strum (int start, int end) { /* オートハープを掻き鳴らす処理 */ }
public override void Strum (int[] stringsToPlay) { /* オートハープを爪弾く処理 */ } // インターフェイスの実装
}
public class Harper // ハープ奏者
{
private IHarp MyHarp;
public Harper(IHarp harp)
{
MyHarp = harp;
}
public void PlayCadenza() // カデンツァというコード進行を弾く
{
harp.Strum([1, 3, 5, 8]); // ドミソド
harp.Strum([1, 4, 6, 8]); // ドファラド
harp.Strum([2, 4, 5, 7]); // レファソシ
harp.Strum([1, 3, 5, 8]); // ドミソド
}
public static void Main()
{
IHarp autoHarp = new AutoHarp(8); // オートハープだが、インターフェイスとしてインスタンス化できる。
Harper harper = new Harper(autoHarp);
harper.PlayCadenza();
}
}
これだけでは継承よりも手間が増えるだけに見えますが、いろいろと利点があります。それについてはその3をご覧ください。
ジェネリックの例
public interface IPercussion // 打楽器のインターフェイス
{
void Hit(); // 叩く
}
public class Cymbal : IPercussion // シンバル
{
public void Hit()
{
Console.WriteLine("シャーン!");
}
}
public class Woodblock : IPercussion // ウッドブロック
{
public void Hit()
{
Console.WriteLine("コン!");
}
}
public class Percussionist<TPercussion> where TPercussion : IPercussion, new() // 打楽器奏者
{
private TPercussion Percussion;
public Percussionist()
{
Percussion = new TPercussion();
}
public void Play()
{
Percussion.Hit();
}
public static void Main()
{
var cymbalist = new Percussionist<Cymbal>(); // シンバル奏者
var woodblockPlayer = new Percussionist<Woodblock>(); // ウッドブロック奏者
cymbalist.Play(); // シャーン!
woodblockPlayer.Play(); // コン!
}
}
これは完全に説明のためのコードなので、使い勝手が伝わりづらい部分がありますが、重要なのは型の情報をクラスに渡してインスタンス化することです。また、メソッドもジェネリックにすることができます。ポリモーフィズムの究極の形と言えますね。