【オブジェクト指向でなぜつくるのか?】OOP, クラス, 型付け, 継承, ポリモーフィズム, パッケージ, 例外, ガベージコレクション, コンパイラ, インタプリタ, 中間コード, メモリ領域

この記事は約21分で読めます。

はじめに

2022年に購入して積読だった「オブジェクト指向でなぜつくるのか 第3版 知っておきたいOOP、設計、アジャイル開発の基礎知識」を最近読んだので、まとめ記載。

プログラミング言語の歴史の部分が、とてもわかりやすかった。

プログラミング言語の歴史:機械語→アセンブリ言語→高級言語→構造化プログラミング

機械語

  • コンピュータは2進数で書いた機械語しか解釈できない。
Java
# 機械語によるプログラム例
A10010
8B160210
01D0
A10410

アセンブリ言語

  • 機械語を人間がわかりやすい記号に置き換えて表現したもの。
  • アセンブラと呼ばれる別のプログラムに読み込ませて、アセンブリ言語から機械語を生成する
Java
# アセンブリ言語によるプログラム(Z=X+Y)例
MOV   AX, X
MOV   DX, Y
ADD   AX, DX
MOV   Z, AX

高級言語

  • コンピューターが理解する命令を1つ1つ記述するのではなく、より人間にわかりやすい「高級な」形式で表現したもの
  • FORTRAN(1957年)・COBOL(1960年)
Java
# FORTRANによるプログラム例
Z = X+Y

構造化プログラミング:GOTO文の廃止・サブルーチンの独立性強化

1)GOTO文(プログラム内の任意のラベルに無条件で分岐する命令)を廃止

  • GOTOレスプログラミング。具体的には、ロジックを基本3構造のみ使用:順次進行・条件分岐(if/case)・繰り返し(for/while)の3構造。

GOTOを使った例

C
#include <stdio.h>

int main() {
    int i = 0;
start_loop:
    if (i < 10) {
        printf("%d ", i);
        i++;
        goto start_loop;
    }
    return 0;
}

GOTOを使わない例

  • for ループを使用して同じタスクを実行しており、構造化されたアプローチを取っている
C
#include <stdio.h>

int main() {
    for (int i = 0; i < 10; i++) {
        printf("%d ", i);
    }
    return 0;
}

2)サブルーチンの独立性の強化

  • ローカル変数と値渡しの仕組みを導入して、グローバル変数の使用を最小限にした

グローバル変数を使った例:

C
#include <stdio.h>

int counter = 0; // グローバル変数

void incrementCounter() {
    counter++;
}

int main() {
    incrementCounter();
    printf("Counter: %d", counter);
    return 0;
}

このコードでは、counter がグローバル変数として宣言されており、どの関数からでもアクセス可能。

ローカル変数と値渡しを使った例:

C
#include <stdio.h>

int incrementCounter(int counter) {
    return counter + 1;
}

int main() {
    int counter = 0; // ローカル変数
    counter = incrementCounter(counter);
    printf("Counter: %d", counter);
    return 0;
}

このコードでは、countermain 関数のローカル変数として宣言されており、incrementCounter 関数に値として渡されている。この方法は関数の独立性を高め、グローバル変数に頼ることを避けることができる。

しかし、ローカル変数はサブルーチン呼び出しが終わると消えてしまう一時的な変数。サブルーチンの実行期間を超えて保存する必要のある情報はサブルーチンの外側、つまりグローバル変数として保持せざるを得ない課題が残った。

C
#include <stdio.h>

void saveResults(int result) {
    // 擬似的なコードで、結果をグローバルなストレージに保存
    // この例では実装されていないが、ファイルやデータベースへの保存を想定
}

int calculateAndSave(int a, int b) {
    int sum = a + b;
    saveResults(sum); // この関数は結果を保存する必要がある
    return sum;
}

int main() {
    int result = calculateAndSave(3, 4);
    printf("Result: %d", result);
    return 0;
}

この例では、saveResults が実行される際に、結果を保存するためのグローバルなメカニズム(例えば、ファイルシステム、データベース、または外部のサーバ)が必要。一時的な計算結果はローカル変数に保持されているが、永続的に保存する必要がある場合には、それをサポートするための外部システムが必要になる。これは、サブルーチンが独立性を持ちつつも、ある種の情報を永続的に保存する必要がある場合の課題を示している。

OOP(オブジェクト指向プログラミング)

  • 3つの要素(クラス・ポリモーフィズム・継承)を導入
  • プログラムの無駄を省いて整理整頓する仕組み

クラス:まとめて、隠して、たくさん作る

まとめる:サブルーチンとグローバル変数をクラス内にまとめる。

  • メソッド:クラスにまとめたサブルーチン
  • インスタンス変数(属性、フィールド):クラスにまとめたグローバル変数。仲間内だけのグローバル変数
Java
// 構造化プログラミングによるファイルアクセス処理
// アクセス中のファイル番号を格納するグローバル変数
int fileNum;

// ファイルをオープンするサブルーチン
// 引数にパス名を受け取る
void openFile(String pathName) {/* ロジックは省略 */}

// ファイルをクローズするサブルーチン
void closeFile {/* ロジックは省略 */}

//ファイルから1文字を読み込むサブルーチン
char readFile() {/* ロジックは省略 */}
Java
// クラスを使ってまとめる
class TextFileReader {
	// アクセス中のファイル番号を格納するグローバル変数
	int fileNum;
	
	// ファイルをオープンするサブルーチン
	// 引数にパス名を受け取る
	void open(String pathName) {/* ロジックは省略 */}
	
	// ファイルをクローズするサブルーチン
	void close() {/* ロジックは省略 */}
	
	//ファイルから1文字を読み込むサブルーチン
	char read() {/* ロジックは省略 */}
}

Personクラスのnameメンバはpublicで宣言されているため、Personクラスのインスタンスを通じて外部からアクセス可能。しかし、nameにアクセスするにはPersonクラスのインスタンスが必要であり、この意味でnameはグローバル変数とは異なる。

privateで宣言されている場合、クラスの外部からのアクセスが禁止されるのでperson.nameには直接アクセスできない。

C++
#include <iostream>
#include <string>

class Person {
public:
    std::string name;  // public インスタンス変数

    Person(std::string n) : name(n) {}
};

int main() {
    Person person("John");
    std::cout << person.name << std::endl;  // インスタンスを介してアクセス

    return 0;
}

グローバル変数

C++
#include <iostream>

int globalVariable = 100;  // グローバル変数

void displayGlobalVariable() {
    std::cout << "Global variable: " << globalVariable << std::endl;
}

int main() {
    displayGlobalVariable();  // グローバル変数を表示
    return 0;
}

隠す:private/public

  • クラスやメソッド、インスタンス変数の宣言部にprivate/publicを指定することで、アクセスをクラス内部だけに限定したり、他のアプリケーションからも呼び出すなどを柔軟に設定できる
Java
// インスタンス変数を隠す
// クラスとメソッドを公開する
public class TextFileReader {
	// アクセス中のファイル番号を格納するグローバル変数
	private int fileNum;
	
	// ファイルをオープンするサブルーチン
	// 引数にパス名を受け取る
	public void open(String pathName) {/* ロジックは省略 */}
	
	// ファイルをクローズするサブルーチン
	public void close() {/* ロジックは省略 */}
	
	//ファイルから1文字を読み込むサブルーチン
	public char read() {/* ロジックは省略 */}
}

たくさん作る

  • インスタンス:クラスで定義したインスタンス変数が確保されるメモリ領域
  • 従来のサブルーチン呼び出しの場合は、単に呼び出すサブルーチン名を指定するだけだった。しかし、OOPの場合は、呼び出すメソッド名に加えて対象とするインスタンスを指定する。これにより、どのインスタンス変数を処理対象とするかを特定できる。
  • クラスに書くメソッドのロジックは単純になる。インスタンスが複数同時に動くことを意識する必要がない。
Java
// インスタンスをたくさん作る
// TextFileReaderクラスから2つのインスタンスを作る。
TextFileReader reader1 = new TextFileReader();
TextFileReader reader2 = new TextFileReader();

reader1.open("C:\path\to\file1.txt"); // 1番目のファイルをオープンする
reader2.open("C:\path\to\file2.txt"); // 2番目のファイルをオープンする

char ch1 = reader1.read(); // 1番目のファイルから1文字読み込む
char ch2 = reader2.read(); // 2番目のファイルから1文字読み込む

reader1.close(); // 1番目のファイルをクローズする
reader2.close(); // 2番目のファイルをクローズする

OOPではクラスを型として利用できるので、プログラム内のルールとして強制する仕組みが備わった。

型としてのクラスは、数値型や文字列型と同様に、変数定義やメソッドの引数、戻り値の宣言などで指定できる。

静的型付けと動的型付け

静的型付け言語

  • 変数や式の型がコンパイル時に決定され、実行時に変更されないプログラミング言語
  • コンパイラが型の整合性を検査することで、型に関連するエラーやバグを早期に発見することができる
  • Java, C#, C++, Go, TypeScriptなど
  • 変数の型は宣言時に明示される。変数に他の型の値を代入しようとすると、コンパイルエラーが発生する。
C++
int number = 10; // 整数型の変数numberを宣言し、初期値10を代入
Java
// クラスを使った型宣言
// 変数の型にクラスを指定する
TextFileReader reader;

// メソッドの引数の型にクラスを指定する
int getCount(TextReader reader) {/* ロジックは省略 */}

// メソッドの戻り値の型にクラスを指定する
TextReader getDefaultReader() {/* ロジックは省略 */}

動的型付け言語

  • 変数や式の型が実行時に動的に決定される(宣言時ではなく、値が代入されるタイミングで決まる)プログラミング言語。
  • 型の宣言を省略できるため、柔軟性が高く、簡潔なコードを書くことができる。
  • Python, JavaScript, Ruby, PHP, smalltalk

下記Pythonコードの例では変数numberの型が宣言されていないが、値が代入されることで自動的に整数型として解釈される。そのため、同じ変数に後で文字列などの異なる型の値を代入することも可能。しかし、実行時の型エラーが発生する可能性があるため、注意が必要。

Python
number = 10 # 整数型の値10を変数numberに代入

ポリモーフィズム(polymorphism):共通メインルーチン

  • 共通メインルーチン:サブルーチンを呼び出す側のロジックを一本化する仕組み
  • ポリモーフィズムは、「たくさんの形」という意味。プログラミングの世界では、ポリモーフィズムは「いろいろなクラスのオブジェクトが、同じインターフェイス(同じ名前の操作)で利用できること」を指す。

たとえば複数の異なる種類の支払い方法を処理するシステムを考えてみる。各支払い方法(クレジットカード、デビットカード、PayPalなど)には異なる処理フローがあるかもしれないが、全ての支払い方法は共通のインターフェースである「支払いを処理する」ことができる。

Java
interface Payment {
    boolean process(double amount);
}

class CreditCard implements Payment {
    public boolean process(double amount) {
        System.out.println("クレジットカードで" + amount + "円を支払います。");
        // クレジットカードの支払い処理
        return true;
    }
}

class DebitCard implements Payment {
    public boolean process(double amount) {
        System.out.println("デビットカードで" + amount + "円を支払います。");
        // デビットカードの支払い処理
        return true;
    }
}

class PayPal implements Payment {
    public boolean process(double amount) {
        System.out.println("PayPalで" + amount + "円を支払います。");
        // PayPalの支払い処理
        return true;
    }
}

public class PaymentProcessor {
    public static void processPayment(Payment paymentMethod, double amount) {
        if (paymentMethod.process(amount)) {
            System.out.println("支払いが成功しました。");
        } else {
            System.out.println("支払いに失敗しました。");
        }
    }
    
    public static void main(String[] args) {
        Payment creditCard = new CreditCard();
        Payment debitCard = new DebitCard();
        Payment payPal = new PayPal();

        processPayment(creditCard, 3000);
        processPayment(debitCard, 1500);
        processPayment(payPal, 4500);
    }
}

この例では、Payment インターフェースは process メソッドを持っており、異なる支払い方法(CreditCardDebitCardPayPal)はこのインターフェースを実装している。PaymentProcessor クラスの processPayment メソッドは、Payment オブジェクトと支払い金額を受け取り、ポリモーフィズムによって対応する支払い方法の処理を実行する。メインメソッドでは、異なる支払いオブジェクトで processPayment を呼び出しているが、それぞれの支払いクラスでオーバーライドされた process メソッドによって異なる出力が得られる。

ポリモーフィズムを実現するためには、共通インターフェース(異なるクラスが同じメソッドを持っていること)が実装されていることが前提である。

継承:クラスの共通部分を別クラスにまとめる仕組み

  • 継承したメソッドの引数や戻り値の型はスーパークラスに合わせておく。
  • 継承関係にあるサブクラスは、すべてスーパークラスと同じ方法で呼び出すことができる。
オブジェクト指向でなぜつくるのか 第3版

ポリモーフィズムと継承

ポリモーフィズムはしばしば継承と関連して使われるが、必ずしも継承を必要とするわけではない。ポリモーフィズム自体は「多態性」を指し、異なるデータ型に対して同一のインターフェースが使える性質を意味する。

継承を使ったポリモーフィズムは、基底クラスやインターフェースのメソッドをサブクラスでオーバーライドし、同一のインターフェースを介して異なる振る舞いをするオブジェクトを作成するという形で現れる。

しかし、例えば関数ポインタやジェネリクス(C++のテンプレート、Javaのジェネリックス、C#のジェネリクスなど)を使用することで、継承を使用しないポリモーフィズムも実現可能。

カプセル化

  • オブジェクトの詳細な実装を隠蔽し、外部から直接アクセスされることを防ぐ概念
  • ゲッター(getter)とセッター(setter)はカプセル化の一部として機能する。
  • ゲッター:プライベートな変数の値を外部に提供するために使用される。直接アクセスする代わりに、ゲッターを介して値を読み取ることで、クラスの内部表現が外部に露出することが防がれる。
  • セッター:
  • クラスの外部からプライベート変数の値を設定するために使用される。直接変数に値を設定するのではなく、セッターメソッドを通じて値を設定することで、値の検証や追加の処理を行う機会を提供し、クラスの整合性を保つことができる。

OOPの進化:パッケージ・例外・ガベージコレクション・モジュール

パッケージ:ただの入れ物

  • クラスと異なり、メソッドやインスタンス変数を定義できない。
  • クラスの名前の重複を全世界で避ける。

例外

  • 戻り値とは違う形式で、メソッドから特別なエラーを返す仕組み。

ガベージコレクション

  • インスタンスを削除する処理をシステムが自動的に実行する仕組み。
  • ガベージコレクタと呼ぶ専用のプログラムが、適切なタイミングでヒープ領域の状態を調べ、空きメモリ領域が少なくなったことを検知するとガベージコレクション処理を起動する。
  • スタック領域とメソッドエリア(ネットワークの根元)から辿れないインスタンスがガベージの対象となる。

モジュール(Ruby)

  • 複数のクラスにまたがるメソッドや定数を1箇所にまとめる機能を持つ
  • ミックスイン(Mixin): モジュール内に定義されたメソッドをクラスに取り込み(includeまたはprepend)、クラスにメソッドを追加することができる。これにより、多重継承のような効果を実現できる
  • クラスと同じようにインスタンスメソッドを定義はできるが、オブジェクトを直接生成はできない
Ruby
module MyModule
  # モジュール内でメソッドを定義
  def module_method
    puts "Hello from the module method!"
  end
end

class MyClass
  # モジュールをクラスに取り込む
  include MyModule
end

my_object = MyClass.new
my_object.module_method  # "Hello from the module method!" を出力

OOPのプログラムが動く仕組み

コンパイラとインタプリタ・中間コード

オブジェクト指向でなぜつくるのか 第3版

コンパイラ方式:C・C++・Go・Fortran・Rustなど

  • プログラムに書かれた命令(ソースコード)を、コンピュータが理解できる機械語に変換(=コンパイル)してから実行する。
  • コンパイラ:機械語に変換するプログラムのこと。
  • メリット
    • 実行速度が速い。コンピューターは機械語を直接読んでから動作するので、プログラム命令を解釈する余分な動作が不要。コンパイルされたアプリケーションは、起動が速かったり、データ処理が迅速だったりする。
  • デメリット
    • 実行するのに手間がかかる。プログラムを書いてもすぐに実行できず、まずコンパイルを行う必要がある。
    • 機械語は、特定のハードウェアやプロセッサに依存するため、異なる環境では通常実行できない(ハードウェアやOSの仮想化技術を利用すれば実行できる)。例えば、Windows上で書かれた機械語プログラムは、Linux上では実行できない。
    • 機械語は、コンピュータのプロセッサが直接実行する命令セット。そのため、プロセッサのアーキテクチャや命令セットが異なると、同じ機械語プログラムでも動作しないことがある。

インタプリタ方式:Python・Ruby・PHP・JavaScript・Perlなど

  • ソースコードに書かれたプログラムの命令をその場で、行単位で逐次解釈しながら実行する。
  • いきなりソースコードを読みながら動くので、コンパイラ不要。
  • メリット:手軽に実行できる。
  • デメリット:実行速度が遅い。メリット・デメリットは各々コンパイラ方式の逆

中間コード方式:Java・C#

  • ソースコードを特定の機械語に依存しない中間コードに変換する→異なるマシンに同じプログラムを配布することが可能になる。
  • 中間コードが機械語とは異なる抽象化された形式であり、CPUが直接理解できる命令セット(=機械語)ではないので、CPUがそのまま読み込んで実行できない。このため、中間コードを解釈して動作する仕組み(仮想マシン:virtual machine)が必要になる。
  • 仮想マシンは、中間コードであるバイトコードを解釈して機械語に変換し、その内容に基づいてプログラムを実行する。Javaの場合は、Java Virtual Machine(JVM)がこれに相当する。

メモリ領域:静的・ヒープ・スタック領域

プログラムのメモリ領域は、静的領域、ヒープ領域、スタック領域の3つに分けて管理する。

静的領域

  • プログラムの開始時に確保され、プログラムが終了するまでメモリ上に残る。永続性が最も高い。
  • プログラムの実行中、ずっと存在し続ける変数(静的変数やグローバル変数)を格納するために使用される。

スタック領域

  • 関数やメソッドが呼び出されるときに使用されるメモリ領域。関数のパラメータ、ローカル変数、戻り値、および関数の呼び出し情報(呼び出し元のアドレスなど)が割り当てられる。
  • スタック領域のデータは関数呼び出しのスコープに限定されており、関数が終了すればそのデータも消去されるため、永続性は低い。
  • LIFO(Last In First Out):新しい情報を上に積み上げ、使うときは一番上に乗っているものから使う。

ヒープ領域

  • 動的にメモリを割り当てるために使用される領域で、動的メモリ割り当て(例: C++でのnewdelete、Javaでのオブジェクト生成)によって管理される。動的に割り当てられることから、サイズが柔軟に変化し、構造が一定ではない「ヒープ」という名称が使われている。英語で「heap」という単語は元々「たくさんの物が無秩序に積み重なった山」を意味する。
  • ヒープに割り当てられたメモリは、プログラマが明示的に解放するか、プログラムが終了するまで保持される。
  • インスタンスそのものはスタック領域ではなく、ヒープ領域に配置される。これにより、関数の実行が終了してスタックフレームが解放されても、インスタンスは生き続けることができ、他の関数やメソッドから参照され続けることができる。

クラス情報

  • メソッドに書かれたコード情報は、1クラスにつき1つだけロードする(静的領域)。インスタンス変数の値は各々で変わるが、メソッドに書かれたコード情報は変わらないため。

インスタンス

  • インスタンス生成の命令を実行するたびに、そのクラスのインスタンスを格納するために必要な大きさのメモリがヒープ領域に割り当てられる。
  • OOPを使って書いたプログラムは、有限のメモリ領域であるヒープ領域を大量に使って動く。→大量の情報をまとめて読み込んで処理するアプリケーションのプログラムを書くときは、その処理でヒープ領域をどれだけ使うかをあらかじめ見積もっておくなどの対応が必要となる。
  • インスタンスを格納する変数には、インスタンスそのものではなく、インスタンスの「ポインタ(メモリ領域の場所を示す情報)」が格納される。インスタンスの大きさは、そのクラスが持つインスタンス変数の数と型に依存するが、インスタンスへの参照(ポインタ)の大きさは固定なので、インスタンスの大きさに関係なく、常に同じ形式でインスタンスを管理できる。
  • スーパークラスから継承したメソッドとインスタンス変数のメモリ配置は全く異なる。スーパークラスのインスタンス変数は、ヒープ領域にあるサブクラスの全てのインスタンスにコピーして保持する。
オブジェクト指向でなぜつくるのか 第3版

コメント

タイトルとURLをコピーしました