オブジェクト指向プログラミングは、最近の多くのプログラム言語で取り入れられている機能。
今回は、構造化プログラミング → オブジェクト指向(クラス,メソッド)、コンストラクタ、派生・継承、仮想関数の概念を紹介する。
オブジェクト指向プログラミングの歴史
最初のプログラム言語のFortran(科学技術計算向け言語)の頃は、処理を記述するだけだったけど、 COBOL(商用計算向け言語)ができた頃には、データをひとまとめで扱う「構造体」(C言語ならstruct {…}の考えができた。(データの構造化)
// C言語の構造体 // データの構造化
struct Person { // 1人分のデータ構造をPersonとする
char name[ 20 ] ; // 名前
int b_year, b_month, b_day ; // 誕生日
} ;
一方、初期のFortranでは、プログラムの処理順序は、if 文と goto 文で組み合わせて書くこと多く、処理がわかりにくかった。その後のALGOLの頃には、処理をブロック化して扱うスタイル(C言語なら{ 文 … }の複文で 記述する方法ができてきた。(処理の構造化)
// ブロックの考えがない時代の雰囲気をC言語で表すと
int i = 0 ;
LOOP: if ( i >= 10 ) goto EXIT ;
if ( i % 2 != 0 ) goto NEXT ;
printf( "%d " , i ) ;
NEXT: i++ ;
goto LOOP ; // 処理の範囲を字下げ(インデント)で強調
EXIT:
---------------------------------------------------
// C 言語で書けば
int i ;
for( i = 0 ; i < 10 ; i++ ) { // 処理の構造化
if ( i % 2 == 0 ) {
printf( "%d¥n" , i ) ;
}
}
---------------------------------------------------
! 構造化文法のFORTRANで書くと
integer i
do i = 0 , 9
if ( mod( i , 2 ) == 0 ) then
print * , i
end if
end do
このデータの構造化・処理の構造化により、プログラムの分かりやすさは向上し、このデータと処理をブロック化した書き方は「構造化プログラミング(Structured Programming)」 と呼ばれる。
この後、様々なプログラム言語が開発され、C言語などもできてきた。 一方で、シミュレーションのプログラム開発(例simula)では、 シミュレーション対象(object)に対して、命令するスタイルの書き方が生まれ、 データに対して命令するという点で、擬人法のようなイメージで直感的にも分かりやすかった。 これがオブジェクト指向プログラミング(Object Oriented Programming)の始まりとなる。略記するときは OOP などと書くことが多い。
この考え方を導入した言語の1つが Smalltalk であり、この環境では、プログラムのエディタも Smalltalk で記述したりして、オブジェクト指向がGUIのプログラムと親和性が良いことから、この考え方は多くのプログラム言語へと取り入れられていく。
C言語にこのオブジェクト指向を取り入れ C++ が開発される。さらに、この文法をベースとした Java などが開発されている。最近の新しい言語では、どれもオブジェクト指向の考えが使われている。
クラスの導入(構造体でオブジェクト指向もどき)
例えば、名前と年齢の構造体で処理を記述する場合、 以下の様な記載を行うことで、データ設計者とデータ利用者で分けて 仕事ができる。
// この部分はデータ構造の設計者が書く
// データ構造を記述
struct Person {
char name[10] ;
int age ;
} ;
// データに対する処理を記述
void setPerson( struct Person* p , char s[] , int a ) {
// ポインタの参照で表記
strcpy( p->name , s ) ;
p->age = a ;
}
void printPerson( struct Person* p ) {
printf( "%s %d¥n" , p->name , p->age ) ;
}
// -------------------------------------------
// この部分は、データ利用者が書く
int main() {
// Personの中身を知らなくてもいいから配列を定義(データ隠蔽)
struct Person saitoh ;
setPerson( &saitoh , "saitoh" , 55 ) ;
struct Person table[ 10 ] ; // 初期化は記述を省略
for( int i = 0 ; i < 10 ; i++ ) {
// 出力する...という雰囲気で書ける(手続き隠蔽)
printPerson( &table[i] ) ;
}
return 0 ;
}
このプログラムの書き方では、mainの中を読むだけもで、 データ初期化とデータ出力を行うことはある程度理解できる。 この時、データ構造の中身を知らなくてもプログラムが理解でき、 データ実装者はプログラムを記述できる。これをデータ構造の隠蔽化という。 一方、setPerson()や、printPerson()という関数の中身についても、 初期化・出力の方法をどうするのか知らなくても、 関数名から動作は推測できプログラムも書ける。 これを手続きの隠蔽化という。
C++のクラスで表現
上記のプログラムをそのままC++に書き直すと以下のようになる。特徴として 構造体を進化させた class 宣言の中に、データ構造とデータ構造を使う関数をまとめて記述する。
#include <stdio.h>
#include <string.h>
// この部分はクラス設計者が書く
class Person {
private: // ■ クラス外からアクセスできない部分
// データ構造を記述
char name[10] ; // メンバーの宣言
int age ;
public: // ■ クラス外から使える部分
// データに対する処理を記述
void set( char s[] , int a ) { // ■ メソッドの宣言
// pのように対象のオブジェクトを明記する必要はない。
strcpy( name , s ) ;
age = a ;
}
void print() {
printf( "%s %d¥n" , name , age ) ;
}
} ; // ← 注意ここのセミコロンを書き忘れないこと。
// この部分はクラス利用者が書く
int main() {
Person saitoh ;
saitoh.set( "saitoh" , 55 ) ;
saitoh.print() ;
// 文法エラーの例
printf( "%d¥n" , saitoh.age ) ; // ■ age は private なので参照できない。
return 0 ;
}
用語の解説:C++のプログラムでは、データ構造とデータの処理を、並行しながら記述する。 データ構造に対する処理は、メソッド(method)と呼ばれる。 データ構造とメソッドを同時に記載したものは、クラス(class)と呼ぶ。 そのclassに対し、具体的な値や記憶域が割り当てられたものをオブジェクト(object)とかインスタンス(instance)と呼ぶ。
コンストラクタ
データ構造を扱ううえで、データの初期化や廃棄処理は重要となるが、書き忘れをすることも多い。そこで、C++ ではコンストラクタで初期化を簡単に書ける。
// コンストラクタを使って書く
class Person {
private:
char name[10] ; // メンバーの宣言
int age ;
public:
Person( char s[] , int a ) { // ■ コンストラクタ
strcpy( name , s ) ;
age = a ;
}
void print() {
printf( "%s %d¥n" , name , age ) ;
}
} ;
int main() {
Person saitoh( "saitoh" , 57 ) ; // ■ コンストラクタで宣言&初期化
return 0 ;
}
派生と継承
オブジェクト指向では、元となったデータ構造(class ? struct ?)を拡張した時の記述が便利。例えば、前述の Person に住所も覚えたかったとしたら どう書くだろうか?
// オブジェクト指向を使わずに記述
// Personを要素として持つ PersonAddr を定義
struct PersonAddr {
struct Person person ;
char addr[ 20 ] ;
} ;
// PersonAddr のメソッド
void setPersonAddr( struct PersonAddr* p , char nm[] , int ag , char ad[] ) {
setPerson( p->person , nm , ag ) ;
strcpy( p->addr , ad ) ;
}
void printPersonAddr( struct PersonAddr* p ) {
printPerson( p->person ) ;
printf( "%s" , p->addr ) ;
return 0 ;
}
オブジェクト指向では、こういった場合には、Person を拡張した PersonAddr を定義する。この時、元となるクラス(Person)は基底クラス(あるいは親クラス)、拡張したクラス(PersonAddr)は派生クラス(あるいは子クラス)と呼ぶ。
// C++ 流に記述
class PersonAddr : public Person { // ■ Personから派生したPersonAddr
private: // ~~~~~~~~~~~~~派生
char addr[ 20 ] ;
public:
PersonAddr( char nm[] , int ag , char ad[] )
: Person( nm , ag ) { // ■ 基底クラスのコンストラクタで初期化
strcpy( addr , ad ) ; // ■ 追加部分の初期化
}
} ;
int main() {
Person tohru( "tohru" , 57 ) ;
PersonAddr saitoh( "saitoh" , 45 , "Fukui" ) ;
tohru.print() ; // tohru 57
saitoh.print() ; // saitoh 45 ■ 継承を使って表示
return 0 ;
}
この例では、saitoh は PersonAddr であり、それを表示するための PersonAddr::print() は定義されていないが、saitoh.print() を実行すると、基底クラスのメソッド Person::print() を使ってくれる。このように基底クラスのメソッドを流用してくれる機能を継承と呼ぶ。
仮想関数
前述の継承のプログラムでは、PersonAddr 専用の print() を定義してもいい。また基底クラスと派生クラスが混在する配列を作ることもできる。しかし、以下のようなプログラムでは問題が発生する。
class PersonAddr : public Person {
private:
char addr[ 20 ] ;
public:
PersonAddr( char nm[] , int ag , char ad[] )
: Person( nm , ag ) {
strcpy( addr , ad ) ;
}
void print() { // ■ PersonAddr 専用の print() を宣言してもいい
Person::print() ; // 基底クラス Person の print() を使う
printf( "%s" , addr ) ;
}
} ;
int main() {
Person tohru( "tohru" , 57 ) ;
PersonAddr saitoh( "saitoh" , 45 , "Fukui" ) ;
Person* family[] = { // tohru と saitoh のポインタ配列
&tohru , &saitoh // &saitoh は Person* に降格されている
} ;
tohru.print() ; // tohru 57
saitoh.print() ; // saitoh 45 Fukui
for( int i = 0 ; i < 2 ; i++ ) // tohru 57
family[ i ]->print() ; // ■ "saitoh 45"としか表示してくれない
return 0 ;
}
family[] のデータを全部表示したいのなら、”tohru 57, saitoh 45 Fukui” と表示されてほしい。
こういった場合に、データのclassに応じて適切なメソッドを呼び出すメカニズムとして仮想関数がある。
class Person {
private:
char name[ 20 ] ;
int age ;
public:
Person( char nm[] , int ag ) {
strcpy( name , nm ) ;
age = ag ;
}
virtual void print() { // ■ ここに virtual が追加された|仮想関数
printf( "%s %d" , name , age ) ;
}
} ;
class PersonAddr : public Person {
private:
char addr[ 20 ] ;
public:
PersonAddr( char nm[] , int ag , char ad[] )
: Person( nm , ag ) {
strcpy( addr , ad ) ;
}
virtual void print() { // ■ ここに virtual が追加された|仮想関数
Person::print() ;
printf( "%s" , addr ) ;
}
} ;
int main() {
Person tohru( "tohru" , 57 ) ;
PersonAddr saitoh( "saitoh" , 45 , "Fukui" ) ;
Person* family[] = { // tohru と saitoh のポインタ配列
&tohru , &saitoh
} ;
for( int i = 0 ; i < 2 ; i++ ) // tohru 57
family[ i ]->print() ; // ■ saitoh 45 Fukui と表示
// print は 仮想関数がそれぞれ定義してあるので
// tohru は、"tohru 57"と表示されるし
return 0 ; // saitoh は、"saitoh 45 Fukui"と表示される。
}
このプログラムでは、Person::print() と PersonAddr::print() がそれぞれ仮想関数で定義されているので、tohru, saitoh の各インスタンスには、型情報が埋め込まれている。このため family[0]->print() では “tohru 57” が表示されるし、family[1]->print() では “saitoh 45 Fukui” が表示される。
多態性・ポリモーフィズム
このように、派生クラスと仮想関数使ってプログラムを書くと、その派生クラスに応じた処理を呼び出すことができる。このように共通の基底クラスから様々な派生クラスを作りながらプログラムを書き、その派生クラス毎にそのデータに応じた処理を実行させることができる。インスタンスがデータ種別に応じた動きをすることは多態性(ポリモーフィズム)と呼ばれる。