CプログラマのためのC++オブジェクト指向入門
さて今回はC++と「オブジェクト指向」について簡単に話してみましょう
「オブジェクト指向」の話は難しそうだからと拒否反応を示す人もいるかもしれませんね。でも本当は全く難しいことなんてないんです
その証拠に Fig.1 を見てください
Fig.1
FILE* fpi = fopen( "filename", "rb" ); if( fpi ){ long lFileSize = -1; fseek( fpi, 0, SEEK_END ); lFileSize = ftell( fpi ); if( lFileSize > 0 ){ size_t cbBuffer = ( size_t )lFileSize; char* pbBuffer = malloc( cbBuffer ); if( pbBuffer ){ fseek( fpi, 0, SEEK_SET ); fread( pbBuffer, cbBuffer, 1, fpi ); ... free( pbBuffer ); } } fclose( fpi ); }
これは昔からあるC言語のコードですね。これならきっと拒否反応はないと思います
でもこんなコードの中にも実は「オブジェクト指向」があります
それは FILE* です
この FILE* とは何者でしょうか
この FILE* は、それを使う側のプログラマにとってシンタックス的には単なる「詳細不明な構造体へのポインタ」ですが、セマンティクス的には「ファイルを抽象化したオブジェクト」ですね
「ファイルを抽象化したオブジェクト」なんて難しく言わないで「ファイルを操作するために必要なデータの塊」と言っても同じことですが、そう言ってしまっては「オブジェクト指向」になりません
これは逆に言えば「そう言わなければオブジェクト指向になる」と云うことです。つまり「オブジェクト指向」なんて云うのはただプログラマが「それ」を「データの塊」と考えるか「オブジェクト」と考えるかの違いくらいしかないということです
そこで、今、個々の FILE* が指すデータの塊をオブジェクトのインスタンスと呼び、fread や ftell, fgets などの FILE* を引数に取る関数群をインターフェイスと呼べば、何だか「オブジェクト指向」な気分になってきませんか?
そうです。これは本当に「オブジェクト指向」なのです
どうでしょうか。こう考えれば「オブジェクト指向」なんて難しくもなんともなんですよね
慣れないC++の文法に惑わされずに昔馴染みのC言語の文法で見てみると「オブジェクト指向」なんて簡単じゃないですか?
さて、では「オブジェクト指向」が怖くなくなったところでここからはC++です
まずは Fig.1 でも使ったC言語の標準入出力関数群をC++のクラスでラッピングしてみるとどうなるかやってみましょう
まずは「インターフェイス」です
Fig.1 に出てくる FILE* を引数に取る関数は fopen, fseek, ftell, fread, fclose の5つですから、これらをメソッド(メンバ関数)とするクラスを考えると Fig.2 のようになるでしょうか
Fig.2
class CFile { public: bool Open( const char* path, const char* fopen_mode ); void Close(); size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ); long Seek( long lOffset, int nFlags ); long Tell(); };
ではこれを実装してみましょう
Fig.3
class CFile { FILE* m_fp; public: bool Open( const char* path, const char* fopen_mode ); void Close(); size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ); int Seek( long lOffset, int nFlags ); long Tell(); CFile(); ~CFile(); }; CFile::CFile(): m_fp(NULL) { } CFile::~CFile(){ } bool CFile::Open( const char* path, const char* fopen_mode ){ bool fSuccess = true; if( m_fp ){ fSuccess = false; } if( fSuccess ){ m_fp = fopen( path, fopen_mode ); if( !m_fp ){ fSuccess = false; } } return fSuccess; } void CFile::Close(){ if( m_fp ){ fclose( m_fp ); m_fp = NULL; } } size_t CFile::Read( void* pBuffer, size_t cbBlock, size_t cBlocksToRead ){ size_t cBlocksRead = 0; if( m_fp ){ cBlocksRead = fread( pBuffer, cbBlock, cBlocksToRead, m_fp ); } return cBlocksRead; } int CFile::Seek( long lOffset, int nFlags ){ int result = -1; if( m_fp ){ result = fseek( m_fp, lOffset, nFlags ); } return result; } long CFile::Tell(){ long lOffset = -1; if( m_fp ){ lOffset = ftell( m_fp ); } return lOffset; }
どうでしょうか。中身はC言語でお馴染みの標準入出力関数ですが、外見はすっかりC++になりましたね
ではこのクラスを Fig.1 のコードに適用してみましょう
Fig.4
CFile file; if( file.Open( "filename", "rb" )){ file.Seek( 0, SEEK_END ); long lFileSize = file.Tell(); if( lFileSize > 0 ){ size_t cbBuffer = ( size_t )lFileSize; char* pbBuffer = malloc( lFileSize ); if( pbBuffer ){ file.Seek( 0, SEEK_SET ); file.Read( pbBuffer, cbBuffer, 1 ); ... free( pbBuffer ); } } file.Close(); }
見た目は Fig.1 とあまり変わらないですね
違う所と云えば引数に指定していた FILE* がなくなって、関数名の前に CFileクラスのインスタンスが入ってるくらいでしょうか
C++もこうしてみるとC言語と大して変わらないと思えてくるのではないでしょうか
さてではここで、コンパイラが Fig.4 のコードをどのように展開するかを考えてみましょう
C++の static でないメソッド(メンバ関数)はクラスではなくインスタンスに関連付けられているものですから、コンパイラはそこで指定されているインスタンスの静的な型から実際に呼び出すべき関数の名前を決定します
またC++クラスの static でないメソッド(メンバ関数)には自分自身のインスタンスを指す暗黙のポインタ変数 this がありますが、これはそのメソッド(メンバ関数)の暗黙の第一引数として実装されています
この2つを踏まえて Fig.3 のコードをC言語風に書くと Fig.5 の様になるでしょうか
Fig.5
struct CFile { FILE* m_fp; }; CFile_CFile( CFile* const this ){ this->m_fp = NULL; } CFile_~CFile( CFile* const this ){ } bool CFile_Open( CFile* const this, const char* path, const char* fopen_mode ){ bool fSuccess = true; if( this->m_fp ){ fSuccess = false; } if( fSuccess ){ this->m_fp = fopen( path, fopen_mode ); if( !this->m_fp ){ fSuccess = false; } } return fSuccess; } void CFile_Close( CFile* const this ){ if( this->m_fp ){ fclose( this->m_fp ); this->m_fp = NULL; } } size_t CFile_Read( CFile* const this, void* pBuffer, size_t cbBlock, size_t cBlocksToRead ){ size_t cBlocksRead = 0; if( this->m_fp ){ cBlocksRead = fread( pBuffer, cbBlock, cBlocksToRead, this->m_fp ); } return cBlocksRead; } int CFile_Seek( CFile* const this, long lOffset, int nFlags ){ int result = -1; if( this->m_fp ){ result = fseek( this->m_fp, lOffset, nFlags ); } return result; } long CFile_Tell( CFile* const this ){ long lOffset = -1; if( this->m_fp ){ lOffset = ftell( this->m_fp ); } return lOffset; }
ここで1つおもしろいことに気付きませんか?
Fig.5 の CFile構造体の宣言からはメソッド(メンバ関数)がなくなっていますね
これは「C++ではクラス宣言のときにメソッド(メンバ関数)をクラスの {} の中に書くけれども、実際にはその中にメソッド(メンバ関数)の実体があるわけではない」ということなのです
つまりクラスのメソッド(メンバ関数)と言っても暗黙の第一引数に this を取るだけの普通の関数だと云うことです
さて、先程コンパイラはメソッド名(メンバ関数名)の前に指定されているインスタンスの静的な型から際に呼び出すべき関数の名前を決定すると言いました
しかしここで、例えば Fig.6 のような状況ではどうでしょうか
Fig.6
class CFile2 : public CFile { char m_path[MAX_PATH]; public: bool Open( const char* path, const char* fopen_mode ); void Close(); CFile2(); ~CFile2(); }; CFile2::CFile2(): CFile() { m_path[0] = '\0'; } CFile2::~CFile2(){ } bool CFile2::Open( const char* path, const char* fopen_mode ){ bool fSuccess = CFile::Open( path, fopen_mode ); if( fSuccess ){ strcpy( m_path, path ); printf( "%s: file open succeeded.\n", m_path ); } return fSuccess; } void CFile2::Close(){ CFile::Close(); printf( "%s: file closed.\n", m_path ); m_path[0] = '\0'; }
bool FileOpenTest( CFile* pFile, const char* path, const char* fopen_mode ){ bool fSuccess = pFile->Open( path, fopen_mode ); if( fSuccess ){ pFile->Close(); } return fSuccess; }
CFile2 file2; if( FileOpenTest( &file2, "filename", "rb" )){ // Open成功 }
CFile クラスから新たに CFile2 という「派生クラス」を定義しました
そしてその CFile2 クラスのインスタンス file2 を作ります
ここでそのインスタンス file2 のアドレスを FileOpenTest という関数に渡してみましょう
FileOpenTest関数の引数は CFile* となっていますが、CFile2 は CFile の派生クラスなので CFile* を期待しているところに CFile2* を渡すことは全く問題ありません
しかし、この Fig.6 は期待通りの動きはしないでしょう
実際に実行してみると FileOpenTest関数から呼び出されるのは CFile2::Open と CFile2::Close ではなく、CFile::Open と CFile::Close が呼び出されてしまっていることがわかると思います
これはなぜでしょうか
なぜなら、コンパイラはそこに指定されているインスタンスの静的な型を見て実際に呼び出すべき関数の名前を決定しているので、pFile の型、即ち CFile のメソッド(メンバ関数)が呼び出されてしまうからです
これはC言語風に書けば Fig.7 の様になっていると言えるでしょうか
Fig.7
bool FileOpenTest( CFile* pFile, const char* path, const char* fopen_mode ){ bool fSuccess = CFile_Open( pFile, path, fopen_mode ); if( fSuccess ){ CFile_Close( pFile ); } return fSuccess; }
FileOpenTest関数の中では予め決まった関数(CFile::Open と CFile::Close)が呼ばれるようになっています
これでは折角 pFile に CFile2 のインスタンスを渡しているのが全く無意味になってしまっていますね
さて困りました
ここで pFile に CFile* を渡した時には CFile::Open と CFile::Close, CFile2* を渡した時には CFile2::Open と CFile2::Close と云うように、指定されたインスタンスの実際の型に応じて自動的に適切なメソッド(メンバ関数)が呼び出されるようにすることはできないのでしょうか
もちろんできます
C++にはこういった状況を解決する為に用意されているものがあります
それは「仮想関数」です
Fig.8 の様に CFile のクラス宣言のメソッド(メンバ関数)の前にキーワード「virtual」を指定してみましょう
Fig.8
class CFile { FILE* m_fp; public: virtual bool Open( const char* path, const char* fopen_mode ); virtual void Close(); virtual size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ); virtual int Seek( long lOffset, int nFlags ); virtual long Tell(); CFile(); ~CFile(); };
すると Fig.6 の FileOpenTest は期待通り CFile2::Open と CFile2::Close を呼び出すようになります
これは一体どう云う仕組みなのでしょうか
コンパイラがコンパイルする過程をまたC言語風に書いてみると、CFile は Fig.9 のようになり、CFile2 は Fig.10 のようになります
Fig.9
struct CFileVtbl { bool (*Open)( CFile* const this, const char* path, const char* fopen_mode ); void (*Close)( CFile* const this ); size_t (*Read)( CFile* const this, void* pBuffer, size_t cbBlock, size_t cBlocks ); int (*Seek)( CFile* const this, long lOffset, int nFlags ); long (*Tell)( CFile* const this ); }; bool CFile_Open( CFile* const this, const char* path, const char* fopen_mode ){ bool fSuccess = true; if( this->m_fp ){ fSuccess = false; } if( fSuccess ){ this->m_fp = fopen( path, fopen_mode ); if( !this->m_fp ){ fSuccess = false; } } return fSuccess; } void CFile_Close( CFile* const this ){ if( this->m_fp ){ fclose( this->m_fp ); this->m_fp = NULL; } } size_t CFile_Read( CFile* const this, void* pBuffer, size_t cbBlock, size_t cBlocksToRead ){ size_t cBlocksRead = 0; if( this->m_fp ){ cBlocksRead = fread( pBuffer, cbBlock, cBlocksToRead, this->m_fp ); } return cBlocksRead; } int CFile_Seek( CFile* const this, long lOffset, int nFlags ){ int result = -1; if( this->m_fp ){ result = fseek( this->m_fp, lOffset, nFlags ); } return result; } long CFile_Tell( CFile* const this ){ long lOffset = -1; if( this->m_fp ){ lOffset = ftell( this->m_fp ); } return lOffset; } const CFileVtbl c_CFileVtbl = { CFile_Open, CFile_Close, CFile_Read, CFile_Seek, CFile_Tell, }; struct CFile { const CFileVtbl* const lpVtbl; FILE* m_fp; }; CFile_CFile( CFile* const this ){ this->lpVtbl = &c_CFileVtbl; this->m_fp = NULL; } CFile_~CFile( CFile* const this ){ }
Fig.10
struct CFile2Vtbl { // まず最初に基底クラスである CFile の仮想関数テーブルをここに bool (*Open)( CFile* const this, const char* path, const char* fopen_mode ); void (*Close)( CFile* const this ); size_t (*Read)( CFile* const this, void* pBuffer, size_t cbBlock, size_t cBlocks ); int (*Seek)( CFile* const this, long lOffset, int nFlags ); long (*Tell)( CFile* const this ); // 基底クラスの後ろに CFile2 で新たに追加した仮想関数を置く // (でも CFile2 で追加された仮想関数は無い) }; const CFile2Vtbl c_CFile2Vtbl = { CFile2_Open, CFile2_Close, CFile_Read, CFile_Seek, CFile_Tell, }; struct CFile2 { // まず最初に基底クラスである CFile を丸ごとここに置く // (ただし仮想関数テーブルだけは CFile2 に差し替える) const CFile2Vtbl* const lpVtbl; FILE* m_fp; // 基底クラスの後ろに CFile2 のメンバ変数を置く char m_path[MAX_PATH]; }; CFile2_CFile2( CFile2* const this ){ // コンストラクタの先頭では必ず基底クラスのコンストラクタがまず最初に呼ばれる CFile_CFile(( CFile* )this ); // その後で自分の初期化 this->lpVtbl = &c_CFile2Vtbl; m_path[0] = '\0'; } CFile2_~CFile2( CFile2* const this ){ // デストラクタの最後では必ず基底クラスのデストラクタが呼ばれる CFile_~CFile(( CFile* )this ); }
CFile構造体も CFile2構造体も先頭に新しく CFileVtbl, CFile2Vtblと云う構造体へのポインタが増えてますね
CFileVtbl構造体も CFile2Vtbl構造体も見れば分かる通りこれは関数ポインタのテーブルですね
別に覚える必要はありませんがこれは「仮想関数」のテーブルなので「仮想関数テーブル」と言うこともあります
コンパイラは今呼び出そうとしているメソッド(メンバ関数)が「仮想関数」である場合には、インスタンスの「仮想関数テーブル」を参照するようなコードを出力します
つまりC言語風に書けば Fig.11 の様になっていると言えるでしょうか
Fig.11
bool FileOpenTest( CFile* pFile, const char* path, const char* fopen_mode ){ bool fSuccess = pFile->lpVtbl->Open( pFile, path, fopen_mode ); if( fSuccess ){ pFile->lpVtbl->Close( pFile ); } return fSuccess; }
どうでしょう。こうしてみると「仮想関数」も「継承」も簡単じゃないですか?
さて、「仮想関数」も「継承」も分かったところで少し練習しておきましょう
実は Fig.8 はこのままの実装では問題になるケースがあります
それは何でしょうか
その答えは、まず Fig.12 を見てください
Fig.12
void delete_CFile( CFile* pFile ){ if( pFile ){ delete pFile; } }
この様な関数があったときこれをコンパイラはどのようなコードに展開するでしょうか
メンバ関数を呼び出すとき、コンパイラはインスタンスの静的な型を見て実際に呼び出すべき関数の名前を決定しているんでしたね
明示的には指定されていませんが、Fig.12 でも実はあるメンバ関数が呼び出されています
それはデストラクタ ~CFile です
これもまたC言語風に書いてみましょう
Fig.13
void delete_CFile( CFile* pFile ){ if( pFile ){ CFile_~CFile( pFile ); free( pFile ); } }
さあもう分かったのではないでしょうか
先程と同じようにこの関数に CFile2 のインスタンスを渡してみると本当はここで CFile2_~CFile2 を呼び出して欲しいのに常に CFile_~CFile が呼ばれるようになってしまっていますね
これが問題となるケースです
こんなときはどうするのかはもうお分かりですね
そうです。こんなときは virtual を指定するんでしたね
ですから、Fig.8 は Fig.14 のようにデストラクタにも virtual を指定する必要がありました
Fig.14
class CFile { FILE* m_fp; public: virtual bool Open( const char* path, const char* fopen_mode ); virtual void Close(); virtual size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ); virtual int Seek( long lOffset, int nFlags ); virtual long Tell(); CFile(); virtual ~CFile(); };
C++ で「基底クラスのデストラクタには必ず virtual を指定しろ」と言われるのはこのためなのです
それでは今回はこの辺で終わりにしましょう
最後に1つ
今回サンプルに使った CFile と云うクラスですが、実際には Fig.15 の様にするのがお薦めです
なぜこれがお薦めなのかはまた次回以降の講義で
Fig.15
struct IFile { virtual long AddRef() = 0; virtual long Release() = 0; virtual bool Open( const char* path, const char* fopen_mode ) = 0; virtual void Close() = 0; virtual size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ) = 0; virtual long Seek( long lOffset, int nFlags ) = 0; virtual long Tell() = 0; }; IFile* FileOpen( const char* path, const char* fopen_mode ); class CFile : public IFile { long m_cRef; FILE* m_fp; public: virtual long AddRef(); virtual long Release(); virtual bool Open( const char* path, const char* fopen_mode ); virtual void Close(); virtual size_t Read( void* pBuffer, size_t cbBlock, size_t cBlocks ); virtual int Seek( long lOffset, int nFlags ); virtual long Tell(); static CFile* CreateInstance(); CFile(); virtual ~CFile(); }; CFile* CFile::CreateInstance(){ return new CFile; } CFile::CFile(): m_cRef(1), m_fp(NULL) { } CFile::~CFile(){ Close(); } long CFile::AddRef(){ return m_cRef++; } long CFile::Release(){ if( --m_cRef == 0 ){ delete this; return 0; } return m_cRef; } bool CFile::Open( const char* path, const char* fopen_mode ){ bool fSuccess = true; if( m_fp ){ fSuccess = false; } if( fSuccess ){ m_fp = fopen( path, fopen_mode ); if( !m_fp ){ fSuccess = false; } } return fSuccess; } void CFile::Close(){ if( m_fp ){ fclose( m_fp ); m_fp = NULL; } } size_t CFile::Read( void* pBuffer, size_t cbBlock, size_t cBlocksToRead ){ size_t cBlocksRead = 0; if( m_fp ){ cBlocksRead = fread( pBuffer, cbBlock, cBlocksToRead, m_fp ); } return cBlocksRead; } int CFile::Seek( long lOffset, int nFlags ){ int result = -1; if( m_fp ){ result = fseek( m_fp, lOffset, nFlags ); } return result; } long CFile::Tell(){ long lOffset = -1; if( m_fp ){ lOffset = ftell( m_fp ); } return lOffset; }
IFile* FileOpen( const char* path, const char* fopen_mode ){ IFile* pFile = CFile::CreateInstance(); if( pFile ){ if( !pFile->Open( path, fopen_mode )){ pFile->Release(); pFile = NULL; } } return pFile; }
IFile* fpi = FileOpen( "filename", "rb" ); if( fpi ){ fpi->Seek( 0, SEEK_END ); long lFileSize = fpi->Tell(); if( lFileSize > 0 ){ char* pbBuffer = malloc( lFileSize ); if( pbBuffer ){ fpi->Seek( 0, SEEK_SET ); fpi->Read( pbBuffer, cbBuffer, 1 ); ... free( pbBuffer ); } } fpi->Release(); fpi = NULL; }