ホーム » スタッフ » 斉藤徹 » 講義録 » オブジェクト指向 (ページ 11)

オブジェクト指向」カテゴリーアーカイブ

2025年5月
 123
45678910
11121314151617
18192021222324
25262728293031

検索・リンク

オブジェクト指向プログラミング2018全講義録

  1. オブジェクト指向(2018) / ガイダンス
  2. 引数渡しと構造体からオブジェクト指向へ
  3. コンストラクタと複素数クラスと隠蔽化
  4. 複素数とクラス・隠蔽化の演習
  5. 派生と継承
  6. 仮想関数
  7. 図形と仮想関数の継承方法
  8. UMLの概要
  9. UMLと構造図
  10. UML振る舞い図
  11. オブジェクト指向とソフトウェア工学

オブジェクト指向とソフトウェア工学

オブジェクト指向プログラミングの最後の総括として、 ソフトウェア工学との説明を行う。

トップダウン設計とウォーターフォール型開発

ソフトウェア工学でプログラムの開発において、一般的なサイクルとしては、 専攻科などではどこでも出てくるPDCAサイクル(Plan, Do, Check, Action)が行われる。 この時、プログラム開発の流れとして、大企業でのプログラム開発では一般的に、 トップダウン設計とウォーターフォール型開発が行われる。

トップダウン設計では、全体の設計(Plan)を受け、プログラムのコーディング(Do)を行い、 動作検証(Check)をうけ、最終的に利用者に納品し使ってもらう(Action)…の中で、 開発が行われる。設計の中身も機能仕様や動作仕様…といった細かなフェーズになることも多い。 この場合、コーディングの際に設計の不備が見つかり設計のやり直しが発生すれば、 全行程の遅延となることから、前段階では完璧な動作が必要となる。 このような、上位設計から下流工程にむけ設計する方法は、トップダウン設計などと呼ばれる。また、処理は前段階へのフィードバック無しで次工程へ流れ、 川の流れが下流に向かう状態にたとえ、ウォーターフォールモデルと呼ばれる。

引用:Think IT 第2回開発プロセスモデル

このウォーターフォールモデルに沿った開発では、横軸時間、縦軸工程とした ガントチャートなどを描きながら進捗管理が行われる。

引用:Wikipedia ガントチャート

一方、チェック工程(テスト工程)では、 要件定義を満たしているかチェックしたり、設計を満たすかといったチェックが存在し、 テストの前工程にそれぞれ対応したチェックが存在する。 その各工程に対応したテストを経て最終製品となる様は、V字モデルと呼ばれる。

引用:@IT Eclipseテストツール活用の基礎知識

しかし、ウォーターフォールモデルでは、前段階の設計の不備があっても前工程に戻るという考えをとらないため、全体のPDCAサイクルが終わって次のPDCAサイクルまで問題が残ってしまう。 巨大プロジェクトで大量の人が動いているだから、簡単に方針が揺らいでもトラブルの元にしかならないことから、こういった手法は大人数巨大プロジェクトでのやり方。

ボトムアップ設計とアジャイル開発

少人数でプログラムを作っている時(あるいはプロトタイプ的な開発)には、 部品となる部分を完成させ、それを組合せて全体像を組み上げる手法もとられる。 この方法は、ボトムアップ設計と呼ばれる。このような設計は場当たり的な開発となる場合があり設計の見直しも発生しやすい。

また、ウォーターフォールモデルでは、前工程の不備をタイムリーに見直すことができないが、 少人数開発では適宜前工程の見直しが可能となる。 特にオブジェクト指向プログラミングを実践して隠蔽化が正しく行われていれば、 オブジェクト指向によるライブラリの利用者への影響を最小にしながら、ライブラリの内部設計の見直しも可能となる。 このような外部からの見た挙動を変えることなく内部構造の改善を行うことリファクタリングと呼ばれる。

一方、プログラム開発で、ある程度の規模のプログラムを作る際、最終目標の全機能を実装したものを 目標に作っていると、全体像が見えずプログラマーの達成感も得られないことから、 機能の一部分だけ完成させ、次々と機能を実装し完成に近づける方式もとられる。 この方式では、機能の一部分の実装までが1つのPDCAサイクルとみなされ、 このPDCAサイクルを何度も回して機能を増やしながら完成形に近づける方式とも言える。 このような開発方式は、アジャイルソフトウェア開発と呼ぶ。 一つのPDCAサイクルは、アジャイル開発では反復(イテレーション)と呼ばれ、 短い開発単位を繰り返し製品を作っていく。この方法では、一度の反復後の実装を顧客に見てもらい、 顧客とプログラマーが一体となって開発が行われる。

引用:コベルコシステム

エクストリームプログラミング

アジャイル開発を行うためのプログラミングスタイルとして、 エクストリームプログラミング(Xp)という考え方も提唱されている。 Xpでは、5つの価値(コミュニケーション,シンプル,フィードバック,勇気,尊重)を基本とし、 開発のためのプラクティス(習慣,実践)として、 テスト駆動開発(コーディングでは最初に機能をテストするためのプログラムを書き、そのテストが通るようにプログラムを書くことで,こまめにテストしながら開発を行う)や、 ペアプログラミング(2人ペアで開発し、コーディングを行う人とそのチェックを行う人で役割分担をし、 一定期間毎にその役割を交代する)などの方式が取られることが多い。

リーン開発は、品質の良いものを作る中で無駄の排除を目的とし、本当にその機能は必要かを疑いながら、優先順位をつけ実装し、その実装が使われているのか・有効に機能しているのかを評価ながら開発をすすめる。

伽藍とバザール

これは、通常のソフトウェア開発の理論とは異なるが、重要な開発手法の概念なので「伽藍とバザール」を紹介する。

伽藍とは、優美な寺院のことであり、その設計・開発は、優れた設計・優れた技術者により作られた完璧な実装を意味している。バザールは有象無象の人の集まりの中で作られていくものを意味している。

たとえば、伽藍方式の代表格である Microsoft の製品は、優秀なプロダクトだろうが、中身の設計情報などを普通の人は見ることはできない。これに対しバザール方式の代表格の Linux は、インターネット上にソースコードが公開され、誰もがソースコードに触れプログラムを改良してもいい。その中で、新しい便利な機能を追加しインターネットに公開されれば、良いコードは生き残り、悪いコードは淘汰されていく。

バザール方式は、オープンソースライセンスにより成り立っていて、このライセンスが適用されていれば、改良した機能はインターネットに公開する義務を引き継ぐ。このライセンスの代表格が、GNU パブリックライセンス(GPL)であり、公開の義務の範囲により、BSD ライセンスApacheライセンスといった違いがある。

UML振る舞い図

参考資料図をもとに振る舞い図の説明を行う。

ユースケース図

1507131131_211x192.png

ユーザなど外部からの要求に対する、システムの振る舞いを表現するための活用事例を表す図がユースケース図。 システムを構築する際に、最初に記述するUMLであり、システムに対する処理要件の全体像を理解するために記述する。 ユーザや外部のシステムは、アクターとよび人形の絵で示す。楕円でシステムに対する具体的な処理をユースケースとして記述する。 関連する複数のユースケースをまとめて、サブジェクトとして示す場合もある。

アクティビティ図

処理順序を記述するための図にはフローチャートがあるが、上から下に処理順序を記述するため、縦長の図になりやすい。また、四角枠の中に複雑なことを書けないので、UMLではアクティビティ図を用いる。

初期状態から、終了状態までの手順を示すためのがアクティビティ図。 複数の処理を並行処理する場合には、フォークノードで複数の処理を併記し、最終的に1つの処理になる部分をマージノードで示す。 通常の処理は、角丸の長方形で示し、条件分岐はひし形で示す。

ステートチャート図(状態遷移図)

ステートチャート図は、処理内部での状態遷移を示すための図。 1つの状態を長丸長方形で示し、初期状態から終了状態までを結ぶ。 1つの状態から、なんらかの状態で他の状態に遷移する場合は、分岐条件となる契機(タイミング)とその条件、およびその効果(出力)を「契機[条件]/効果」で矢印に併記する。 複数の状態をグループ化して表す場合もある。

シーケンス図

複数のオブジェクトが相互にやり取りをしながら処理が進むようなものを記述するためのものがシーケンス図。 上部の長方形にクラス/オブジェクトを示し、その下に時系列の処理の流れの線(Life Line)を描く。 オブジェクトがアクティブな状態は、縦長の長方形で示し、そのLife Line間を、やり取り(メッセージ)の線で相互に結ぶ。 メッセージは、相手側からの返答を待つような同期メッセージは、黒塗り三角矢印で示す。 返答を待たない非同期メッセージは矢印で示し、返答は破線で示す。

コミュニケーション図

クラスやオブジェクトの間の処理とその応答(相互作用)と関連の両方を表現する図。

UMLと構造図

UMLの構造図の書き方の説明。 詳しくは、参考ページのUML入門などが、分かりやすい。

クラス図

クラス図は、構造図の中の基本的な図で、 枠の中に、上段:クラス名、中段:属性(要素)、下段:メソッド(関数)を記載する。 属性やメソッドの可視性を示す場合は、”-“:private、”+”:public、”#”:protected 可視性に応じて、”+-#”などを記載する。

関連

クラスが他のクラスと関係がある場合には、その関係の意味に応じて、直線や矢印で結ぶ。
(a)関連:単純に関係がある場合、
(b)集約:部品として持つが、弱い結びつき。関係先が消滅しても別に存在可能。
(c)コンポジション:部品として持つが強い結びつき。関係先と一緒に消滅。
(d)依存:依存関係にあるだけ
(e)派生:派生・継承した関係
(f)実現: Javaでのinterfaceによる多重継承

上図の例では、乗り物クラスVehicleから自動車がCarが派生し、 自動車は、エンジン(Engine)を部品として持つ。エンジンは車体と一緒に廃棄なら、コンポジションで実装する。
自動車は、同じく車輪(Wheel)を4つ持つが、自動車を廃棄してもタイヤは別に使うかもしれないので、集約で実装する。 集約で実装する場合は、C++などであれば、ポインタで部品を持ち、部品の廃棄(delete)は、別に行うことになる。

is-a 、has-a の関係

前の課題でのFigureクラスで、Color 情報をどう扱うべきかで、悩んだ場合と同じように、 クラスの設計を行う場合には、部品として持つのか、継承として機能を持つのか悩む場合がある。 この場合には、“is-a”の関係“has-a”の関係で考えると、部品なのか継承なのか判断しやすい。

たとえば、上の乗り物(Vehicle)クラスと、車(Car)のクラスは、”Car is-a Vehicle” といえるので、is-a の関係。 “Car is-a Engine”と表現すると、おかしいことが判る。 車(Car)とエンジン(Engine)のクラスは、”Car has-a Engine”といえるので、has-a の関係となる。 このことから、CarはVehicleからの派生であり、Carの属性としてEngineを部品として持つ設計となる。

オブジェクト図

クラス図だけで表現すると、複雑なクラス関係では、イメージが分かりづらい場合がでてくる。 この場合、具体的な値を図に書き込んだオブジェクトで表現すると、説明がしやすい場合がある。 このように具体的な値で記述するクラス図は、オブジェクト図と言う。 書き方としては、クラス名の下に下線を引き、中段の属性の所には具体的な値を書き込んで示す。

その他の構成図

その他の構成図としては、コンポーネント図(物理的な構成要素から、システムの構造を表現する図)、 配置図(ハードウェアとアプリケーションの関係を図示したもの)、パッケージ図(パッケージ同士の関係をグループ化した図) なども用いる。

UMLの概要

巨大なプロジェクトでプログラムを作成する場合、設計の考え方を図で示すことは、直感的な理解となるため重要であり、このために UML がある。以下にその考え方と記述方法を説明していく。

プログラムの考え方の説明

今まで、プログラムを人に説明する場合には、初心者向けの方式としてフローチャートを使うのが一般的であろう。しかし、フローチャートは四角の枠の中に説明を書ききれないことがあり、使い勝手が悪い。他には、PAD と呼ばれる記述法もある。この方法は、一連の処理を表す縦棒の横に、処理を表す旗を並べるようなイメージで記載する。

しかし、これらの記法は、手順を記載するためのものであり、オブジェクト指向のようなデータ構造を説明するための図が必要となってきた。

UML記法が生まれるまで

巨大なプロジェクトでプログラムを作る場合、対象となるシステムを表現する場合、オブジェクト指向分析(Object Oriented Analysis)オブジェクト指向設計(Object Oriented Design)とよばれるソフトウェア開発方法が重要となる。(総称して OOAD – Object Oriented Analysis and Design)

これらの開発方法をとる場合、(1)自分自身で考えを整理したり(2)グループで設計を検討したり(3)ユーザに仕様を説明したりといった作業が行われる。この時に、自分自身あるいはチームメンバーあるいはクライアントに直感的に図を用いて説明する。この時の図の書き方を標準化したもの、UML であり、(a)処理の流れを説明するための振る舞い図(フローチャートやPAD)と、(b)データ構造を説明するための構造図を用いる。

UMLは、ランボーによるOMT(Object Modeling Technique どちらかというとOOA中心)と、 ヤコブソンによるオブジェクト指向ソフトウェア工学(OOSE)を元に1990年頃に 発生し、ブーチのBooch法(どちらかというとOOD中心)の考えをまとめ、 UML(Unified Modeling Language)としてでてきた。

UMLでよく使われる図を列記すると、以下の物が挙げられる。

  • 構造図
    • クラス図
    • コンポーネント図
    • 配置図
    • オブジェクト図
    • パッケージ図
  • 振る舞い図
    • アクティビティ図
    • ユースケース図
    • ステートチャート図(状態遷移図)
    • 相互作用図
    • シーケンス図
    • コミュニケーション図(コラボレーション図)

図形と仮想関数の継承方法

純粋仮想基底クラスと図形の課題の基本形

課題で取り組んでいるプログラムは、純粋仮想基底クラスFigureと、そこから派生させたクラスと仮想関数で絵を書いている。このような派生の関係を以下のような図で表現する。

class Figure {
public:
   virtual void draw( int x , int y ) = 0 ;
} ;

class FigureBox : public Figure {
private:
   int width , height ;
public:
   FigureBox( int w , int h ) : width( w ) , height( h ) {}
   virtual void draw( int x , int y ) {
      // 四角を描く処理
   }
} ;

class FigureCircle : public Figure {
private:
   int radius ;
public:
   FigureCircle( int r ) : radius( r ) {}
   virtual void draw( int x , int w ) {
      // 円を描く処理
   }
} ;

色付き図形を派生する方法

課題では、上記プログラムを活用して、色付き図形のクラスを作ることを目的とするが、その実装方法には色々な方法がある。

単純なやり方は、FigureBox と同じように、Figure から FigureColorBox を派生させる方法だろう。

class FigureColorBox : public Figure {
private:
   int width , height , color ;
public:
   FigureColorBox( int w , int h , int c )
   : width( w ) , height( h ) , color( c ) {}
   virtual void draw( int x , int y ) {
      // 色を変える処理
      // 四角を描く処理 ... FIgureBox と同じ処理
   }
} ;

この方法は単純だけど、四角を描く処理を書くため無駄であり、FIgureBox と処理を共通化できればプログラムを書く手間を減らせるはず。

処理を共通化するなら派生すればいい

class FigureColorBox : public FigureBox {
private:
   int color ;  // 幅と高さの記述が無い
public:
   FigureColorBox( int w , int h , int c )
   : FigureBox( w , h ) , color( c ) {}
   virtual void draw( int x , int y ) {
      // 色を変える処理
      FigureBox::draw( x , y ) ; // 親クラスの処理を継承
   }
} ;

同じような色の処理を追加したクラスが沢山ある場合

上記の、FigureBox から FigureColorBox を派生させた場合は、四角を描く処理が継承により共通化ができた。

しかし、同じように FigureCircle から FigureColorCircle を派生させる…といったクラスを沢山作る場合は、色を変える処理を何度も使うことになるが、処理の共有することができない。こういった場合は、以下のような方法がある。

class Color {
private:
   int color ;
public:
   Color( int c ) : color( c ) {}
   void set_color() {
      // 色を変える処理
   }
} ;
class FigureColorBox : public FigureBox , public Color {
public:                // 多重継承のキモ
   FigureColorBox( int w , int h , int c )
   : FigureBox( w , h ) , Color( c ) {}
   virtual void draw( int x , int y ) {
      Color::set_color() ;       // Colorクラスを使って色を設定
      FigureBox::draw( x , y ) ; // FigureBoxクラスで形を描く
   }
} ;

多重継承

上記の FigureColorBox のように、親クラスとして、FigureBox と Color のように複数のクラスをもつ継承は、多重継承と呼ばれる。

ただし、多重継承は後に示すような曖昧さや、実装の際の手間を考えると、多重継承は必ずしも便利な機能ではない。このため、オブジェクト指向のプログラミング言語によっては、多重継承機能が使えない。

Java では、多重継承が使えない代わりに、interface 機能が使えたりする。

ダイヤモンド型の継承と曖昧さ

C++ のような言語での多重継承が問題となる事例として、ダイヤモンド型の継承が挙げられる。

例えば、動物クラスから鳥クラス・哺乳類クラスを派生して、鳥クラスからニワトリを派生して、哺乳類クラスから人間を派生するというのは、進化の過程を踏まえると、自然な派生と考えられる。

しかし、鳥がクラスメンバーとして羽と足を持ち、哺乳類が手と足を持つとしたとき、鳥のくちばしを持ち卵を生むカモノハシを派生する場合には、どうすれば良いのだろうか?しかし、これを鳥と哺乳類を親クラスとした多重継承で実装をすると、手と羽と足が4本のカモノハシができてしまう。(お前はドラゴンかッ!)

また、大元の動物クラスがインスタンスを持つ場合、このような多重継承をすると、同じ名前のインスタンスを2つ持つことになる。この場合、C++では仮想継承というメカニズムを使うことができる。

class Animal {
private:
   char  name[10] ;
} ;
class Bird : public virtual Animal {
} ;
class Mammalian : public virtual Animal {
} ;
class Psyduck : public Bird , public Mammalian {
   // このクラスは、name は1つだけ。 
} ;

また、動物が動くためのmove()というメソッドを持つとした場合、鳥は羽で飛び、哺乳類は足で移動する処理となるだろう。しかし、多重継承のカモノハシは、鳥の羽で動くメソッドmove()と、哺乳類の足で動くメソッドmove()を持つことになり、カモノハシに動け…と命令した場合、どちらのメソッドmove() を使うのだろうか?

こういった、使いにくさ・実装時の手間・処理の曖昧さを考慮したうえで、多重継承のメカニズムが使えるオブジェクト指向プログラム言語は少ない。

仮想関数

仮想関数

前回の派生したプログラムで継承の説明をしたが、以下のようなプログラムでは、Student 型が混在した family[] の配列でも、Person へのポインタに「格下げ」されて保存されているため、
family[i]->print() では、Student 型でも Person型で表示処理が行われる。

class Student : public Person {
   :
   void print() {
      Person::print() ;                    // 名前と年齢を表示
      printf( " %s %d¥n" , dep , grade ) ; // 所属と学年を表示
   }
} ;
void main() {
   Person saitoh( "t-saitoh" , 53 ) ;
   saitoh.print() ; // t-saitoh 53

   Student mitsu( "mitsuki" , 18 , "E" , 4 ) ;
   Student ayuka( "ayuka" , 16 , "EI" , 2 ) ;
   mitsu.print() ; // mitsuki 18 / E 4   名前,年齢,所属,学年を表示
   ayuka.print() ; // ayuka 16 / EI 2    名前,年齢,所属,学年を表示

   Person* family[] = {
      &saitoh , &mitsu , &ayuka ,  // 配列の中に、Personへのポインタと
   } ;                             // Studentへのポインタが混在している
                                   // 派生クラスのポインタは、
                                   // 基底クラスのポインタとしても扱える
   for( int i = 0 ; i < 3 ; i++ )
      family[ i ]->print() ;       // t-saitoh 53/mitsuki 18/ayuka 16
}                                  //  が表示される。

しかし、Student型とPerson型の機能を活かせないだろうか?

仮想関数

このような場合、オブジェクト指向では、仮想関数の機能が便利である。

class Person {
   :
   virtual void print() {
      printf( "%s %d\n" , name , age ) ;
   }
} ;
class Student : public Person {
   :
   virtual void print() {
      Person::print() ;                    // 名前と年齢を表示
      printf( " %s %d¥n" , dep , grade ) ; // 所属と学年を表示
   }
} ;
void main() {
   Person saitoh( "t-saitoh" , 53 ) ;
   saitoh.print() ; // t-saitoh 53

   Student mitsu( "mitsuki" , 18 , "E" , 4 ) ;
   Student ayuka( "ayuka" , 16 , "EI" , 2 ) ;
   mitsu.print() ; // mitsuki 18 / E 4   名前,年齢,所属,学年を表示
   ayuka.print() ; // ayuka 16 / EI 2    名前,年齢,所属,学年を表示

   Person* family[] = {
      &saitoh , &mitsu , &ayuka ,
   } ;
   for( int i = 0 ; i < 3 ; i++ )
      family[ i ]->print() ;       // t-saitoh 53/mitsuki 18,E 4/ayuka 16,EI 2
}

仮想関数が宣言されると、基底クラスの中に「型情報(PersonなのかStudentなのか)」が自動的に埋め込まれる。仮想関数を呼び出すと、型情報を使って、Person::print()を呼び出すか、Student::print()を呼び出すか、を選んでくれる。

仮想関数が生まれた背景

仮想関数は、GUI のプログラム記述に向いていた。例えば、GUIシステムでは、画面にデータを表示するための基本型として、座標と幅,高さの情報を持つ Window 型がある。

また、画面のアイコンは、Window型に、表示する絵の画像を追加した派生クラス WindowIcon 型、画面のテキストは、Windows型に、表示するテキストやフォント情報を持った、WindowText といった型のようなものとする。そうなると仮想関数の概念が無いと、display() を呼び出すためには、派生型の種類が大量ならばデータの型に応じた if 文を大量に書き並べないといけないし、データ型を区別するための情報(下記の例ならばtype)に、型毎に異なるマジックナンバーを埋め込む処理を自分で書かないといけない。

class Window {  // 概念を説明するための例にすぎない
private:
    int x , y , w , h ;
    int type ; // 型を区別するための情報
public:
    void display() {
        // 表示する処理
    }
} ;
class WindowIcon : public Window {
private:
    Image  img ;
public:
    void display() {
        // 画像を表示する処理
    }
} ;
class WindowText : public Window {
private:
    Font  font ;
    char* text ;
public:
    void display() {
        // テキストを表示する処理
    }
} ;
void main() {
    WindowIcon wi( アイコンのコンストラクタ ) ;
    WindowText wt( テキストのコンストラクタ ) ;
    Window* wins[] = {
        &wi , &wt , ...
    } ;
    for( int i = 0 ; i < 配列すべて ; i++ ) {
        if ( wins[i]->type が アイコンならば )
            wins[i]->display() ; // アイコンを表示する
        else if ( wins[ i ]->type が テキストならば )
            wins[i]->display() ; // テキストを表示する。
        else if ....
        :
    }
}

関数ポインタ

では、仮想関数はどのようなテクニックを用いて実装されているのだろうか?

これには、関数ポインタが用いられる。

int add( int x , int y ) {
    return x + y ;
}
int mul( int x , int y ) {
    return x * y ;
}
int main() {
    int (*func)( int , int ) ;
    func = add ; // add() ではない
    printf( "%d¥n" , (*func)( 3 , 4 ) ) ; // 7
    func = mul ;
    printf( "%d¥n" , (*func)( 3 , 4 ) ) ; // 12
    return 0 ;
}

仮想関数を用いると、基底クラスにはクラス毎の仮想関数への「関数ポインタ」などの型情報を保存する場所が自動的に作られ、基底クラス・派生クラスが使われると、そのオブジェクト毎に型情報を初期化する処理が行われる。仮想関数が呼び出されると、関数ポインタにより、各型毎のメソッドが呼び出されるため、大量の if 文は不要となる。

純粋仮想基底クラス

// 純粋仮想基底クラス
class Object {
public:
   virtual void print() = 0 ; // 中身の無い純粋基底クラスを記述しない時の書き方。
} ;
// 整数データの派生クラス
class IntObject : public Object {
private:
   int data ;
public:
   IntObject( int x ) {
      data = x ;
   }
   virtual void print() {
      printf( "%d\n" , data ) ;
   }
} ;
// 文字列の派生クラス
class StringObject : public Object {
private:
   char data[ 100 ] ;
public:
   StringObject( const char* s ) {
      strcpy( data , s ) ;
   }
   virtual void print() {
      printf( "%s\n" , data ) ;
   }
} ;
// 実数の派生クラス
class DoubleObject : public Object {
private:
   double data ;
public:
   DoubleObject( double x ) {
      data = x ;
   }
   virtual void print() {
      printf( "%lf\n" , data ) ;
   }
} ;
// 動作確認
int main() {
   Object* data[3] = {
      new IntObject( 123 ) ,
      new StringObject( "abc" ) ,
      new DoubleObject( 1.23 ) ,
   } ;
   for( int i = 0 ; i < 3 ; i++ ) {
      data[i]->print() ;
   }
   return 0 ;
} ;

この書き方では、data[]には、整数、文字列、実数という異なるデータが入っているが、 Objectという純粋仮想基底クラスを通して、共通な型のように扱えるようになる。 そして、data[i]->print() では、各型の仮想関数が呼び出されるため、 「123 abc 1.23」 が表示される。

ここで、Object の様な一見すると中身が何もないクラスを宣言し、 このクラスから様々な派生クラスを用いるプログラムテクニックは、 広く利用され、Objectのような基底クラスは、純粋仮想基底クラスなどと呼ばれる。

派生と継承

隠ぺい化の次のステップとして、派生・継承を説明する。

派生を使わずに書くと…

元となるデータ構造(例えばPersonが名前と年齢)でプログラムを作っていて、 途中でその特殊パターンとして、所属と学年を加えた学生(Student)という データ構造を作るとする。

// 元となる構造体(Person)
struct Person {
   char name[ 20 ] ; // 名前
   int  age ;        // 年齢
} ;
// 初期化関数
void set_Person( struct Person* p ,
                 char s[] , int x ) {
   strcpy( p->name , s ) ;
   p->age = x ;
}
// 表示関数
void print_Person( struct Person* p ) {
   printf( "%s %d\n" , p->name , p->age ) ;
}
void main() {
   struct Person saitoh ;
   set_Person( &saitoh , "t-saitoh" , 50 ) ;
   print_Person( &saitoh ) ;
}

パターン1(そのまんま…)

上記のPersonに、所属と学年を加えるのであれば、以下の方法がある。 しかし以下パターン1は、要素名がname,ageという共通な部分があるようにみえるが、 プログラム上は、PersonとPersonStudent1は、まるっきり関係のない別の型にすぎない。

このため、元データと共通部分があっても、同じ処理を改めて書き直しになる。

// 元のデータに追加要素(パターン1)
struct PersonStudent1 {
   char name[ 20 ] ; // 名前
   int  age ;        // 年齢
   char dep[ 20 ] ;  // 所属
   int  grade ;      // 学年
} ;
void set_PersonStudent1( struct PersonStudent1* p ,
                         char s[] , int x ,
                         char d[] , int g ) {
   strcpy( p->name , s ) ; // 同じことを書いてる
   p->age = x ;
   strcpy( p->dep , d ) ;  // 追加分はしかたない
   p->grade = g ;
}
// 名前と年齢だけ表示
void print_PersonStudent1( struct PersonStudent1* p ) {
   // また同じ処理を書いてる
   printf( "%s %d\n" , p->name , p->age ) ;
}
void main() {
   struct PersonStudent1 yama1 ;
   set_PersonStudent1( &yama1 ,
                       "yama" , 22 , "PS" , 2 ) ;
   print_PersonStudent1( &yama1 ) ;
}

パターン2(元データの処理を少し使って…)

パターン1では、同じような処理を何度も書くことになり、面倒なので、 元データ用の関数をうまく使うように書いてみる。

// 元のデータに追加要素(パターン2)
struct PersonStudent2 {
   struct Person person ;
   char          dep[ 20 ] ;
   int           grade ;
} ;
void set_PersonStudent2( struct PersonStudent2* p ,
                         char s[] , int x ,
                         char d[] , int g ) {
   // Personの関数を部分的に使う
   set_Person( &(p->person) , s , x ) ;
   // 追加分はしかたない
   strcpy( p->dep , d ) ;
   p->grade = g ;
}
void print_PersonStudent2( struct PersonStudent2* p ) {
   // Personの関数を使う。
   print_Person( &p->person ) ;
}
void main() {
   struct PersonStudent2 yama2 ;
   set_PersonStudent2( &yama2 ,
                       "yama" , 22 , "PS" , 2 ) ;
   print_PersonStudent2( &yama2 ) ;
}

このパターン2であれば、元データ Person の処理をうまく使っているので、 プログラムの記述量を減らすことはできるようになった。

しかし、print_PersonStudent2() のような処理は、元データ構造が同じなのに、 いちいちプログラムを記述するのは面倒ではないか?

そこで、元データの処理を拡張し、処理の流用ができないであろうか?

基底クラスから派生クラスを作る

オブジェクト指向では、元データ(基底クラス)に新たな要素を加えたクラス(派生クラス)を 作ることを「派生」と呼ぶ。派生クラスを定義するときは、クラス名の後ろに、 「:」「public/protected/private」基底クラス名を書く。

// 基底クラス
class Person {
private:
   char name[ 20 ] ;
   int  age ;
public:
   Person( const char s[] , int x ) {
      strcpy( name , s ) ;
      age = x ;
   }
   void print() {
      printf( "%s %d\n" , name , age ) ;
   }
} ;
// 派生クラス
class Student : public Person {
private:
   char dep[ 20 ] ;
   int  grade ;
public:
   Student( const char s[] , int x ,
            const char d[] , int g )
            : Person( s , x ) // 基底クラスのコンストラクタ
   {
      strcpy( dep , d ) ;
      grade = g ;
   }
} ;
void main() {
   Person saitoh( "t-saitoh" , 50 ) ;
   saitoh.print() ;
   Student yama( "yama" , 22 , "PS" , 2 ) ;
   yama.print() ;
}

ここで注目すべき点は、main()の中で、Studentクラス”yama”に対し、yama.print() を呼び出しているが、パターン2であれば、print_PersonStudent2()に相当するプログラムを 記述していない。 しかし、この派生を使うと Person の print() が自動的に流用することができる。 これは、基底クラスのメソッドを「継承」しているから、 このように書け、名前と年齢「yama 22」が表示される。

さらに、Student の中に、以下のような Student 専用の新しい print()を記述してもよい。

class Student ...略... {
   ...略...
   void print() {
      Person::print() ;
      printf( "%s %d\n" , dep , grade ) ;
   }
} ;
void main() {
   ...略...
   Student yama( "yama" , 22 , "PS" , 2 ) ;
   yama.print() ;
}

この場合は、継承ではなく機能が上書き(オーバーライト)されるので、 「yama 22 / PS 2」が表示される。

派生クラスを作る際の後ろに記述した、public は、他にも protected , private を 記述できる。

public    だれもがアクセス可能。
protected であれば、派生クラスからアクセスが可能。
派生クラスであれば、通常は protected で使うのが一般的。
private   派生クラスでもアクセス不可。

仮想関数への伏線

上記のような派生したプログラムを記述した場合、以下のようなプログラムでは何が起こるであろうか?

class Student ... {
   :
   void print() {
      Person::print() ;                    // 名前と年齢を表示
      printf( " %s %d¥n" , dep , grade ) ; // 所属と学年を表示
   }
} ;
void main() {
   Person saitoh( "t-saitoh" , 53 ) ;
   saitoh.print() ; // t-saitoh 53

   Student mitsu( "mitsuki" , 18 , "E" , 4 ) ;
   Student ayuka( "ayuka" , 16 , "EI" , 2 ) ;
   mitsu.print() ; // mitsuki 18 / E 4   名前,年齢,所属,学年を表示
   ayuka.print() ; // ayuka 16 / EI 2    名前,年齢,所属,学年を表示

   Person* family[] = {
      &saitoh , &mitsu , &ayuka ,  // 配列の中に、Personへのポインタと
   } ;                             // Studentへのポインタが混在している
                                   // 派生クラスのポインタは、
                                   // 基底クラスのポインタとしても扱える
   for( int i = 0 ; i < 3 ; i++ )
      family[ i ]->print() ;       // t-saitoh 53/mitsuki 18/ayuka 16
}                                  //  が表示される。

複素数とクラス・隠蔽化の演習

コンストラクタやメソッドの書き方

前回のプログラムを、もう少しC++のオブジェクト指向っぽい書き方を導入してみる。この際に、分かりやすく記述するため、行数が長くなってきた時のための処理を考慮して、記述してみる。

#include <stdio.h>
#include <math.h>
class Complex {
private:
   double re ;
   double im ;
public:
   // 絶対座標系でも極座標系でも、実部・虚部・絶対値・偏角を扱うため
   inline double get_re() const { return re ; }
   inline double get_im() const { return im ; }
   inline double get_r()  const { return sqrt( re*re + im*im ) ; }
   inline double get_th() const { return atan2( im , re ) ; }

   // 極座標系なら、以下のような getterメソッド を書いておけば、
   //   座標系を気にせず処理がかける。
   // inline double get_re() const { return r * cos( th ) ; }
   // inline double get_r()  const { return r ; }

   // inline は、開いたサブルーチンで実装してくれるので処理速度が遅くならない
   // foo() const { ... } は、オブジェクトに副作用が無いことを示す。

   // コンストラクタのメンバー変数の初期化の書き方
   // Complex( ... )
   // : 要素1( 値1 ) , 要素2( 値2 ) ...
   // {  その他の初期化
   // }
   Complex()                       // コンストラクタ
   : re( 0.0 ) , im( 0.0 ) {}
   Complex( double r , double i )  // コンストラクタ
   : re( r ) , im( i ) {}

   // メソッドのプロトタイプ宣言
   //  メソッドの中身が長くなる場合は、
   //  名前と引数だけを宣言しておく
   void print() ;
   void add( Complex ) ;
   void mul( Complex ) ;
} ; // ←しつこいけどセミコロン忘れないでね。

// クラス宣言の外にメソッドの定義を書く
//   メソッドの中身の記述 「::」をクラス限定子と呼ぶ
void Complex::print() {
   printf( "%lf + j%lf¥n" , get_re() , get_im() ) ;
}
void Complex::add( Complex z ) {
   re = re + z.re ;
   im = im + z.im ;
}
void Complex::mul( Complex z ) {
   double r = re * z.re - im * z.im ;
   double i = re * z.im + im * z.re ;
   re = r ;
   im = i ;
}

int main() {
   Complex a( 1.0 , 2.0 ) ;
   Complex b( 2.0 , 4.0 ) ;
   a.add( b ) ;
   a.print() ;
   return 0 ;
}

参考資料

レポート1(複素数の加減乗除)

授業中に示した上記のプログラムをベースに、 記載されていない減算・除算のプログラムを作成し、レポートを作成する。 レポートには、下記のものを記載すること。

  • プログラムリスト
  • プログラムへの説明
  • 動作確認の結果
  • プログラムより理解できること。
    実際にプログラムを書いてみて分かった問題点など…

コンストラクタと複素数クラスと隠蔽化

コンストラクタ

プログラミングでは、データの初期化忘れによる間違いもよく発生する。これを防ぐために、C++ のクラスでは、コンストラクタ(構築子)がある。データ構造の初期化専用の関数。

// コンストラクタ
#include <stdio.h>
#include <string.h>

class Person {
private:
   char name[ 20 ] ;
   int  phone ;
public:
   void print() {
      printf( "%s %d¥n" , name , phone ) ;
   }
   Person() {                             // コンストラクタ(A)
      name[0] = '¥0' ; // 空文字列
      phone = 0 ;
   }
   Person( const char str[] , int tel ) { // コンストラクタ(B)
      strcpy( name , str ) ;
      phone = tel ;
   }
   ~Person() {                            // デストラクタ
      print() ; // 内容の表示
   }
} ;
int main() {
   Person saitoh( "t-saitoh" , 621111 ) ; // (A)で初期化
   Person tomoko() ;                      // (B)で初期化
   saitoh.print() ;  // "t-saitoh 621111" の表示
   tomoko.print() ;  // " 0" の表示

   return 0 ;        // この時点で saitoh.~Person()
                     // tomoko.~Person() が自動的に
}                    // 呼び出される。

コンストラクタと反対に、デストラクタは、データが不要となった時に自動的に呼び出される関数。

複素数クラスの例

隠蔽化と基本的なオブジェクト指向の練習課題として、複素数クラスをあげる。ここでは、複素数の加算・乗算を例に説明をするので、減算・除算などの処理を記述することで、クラスの扱いに慣れてもらう。

直交座標系

#include <stdio.h>
#include <math.h>
class Complex {
private:
   double re ; // 実部
   double im ; // 虚部
public:
   void print() {
      printf( "%lf + j%lf¥n" , re , im ) ;
   }
   Complex( double r , double i ) {
      re = r ;
      im = i ;
   }
   Complex() {
      re = im = 0.0 ;
   }
   void add( Complex z ) {
      re = re + z.re ;
      im = im + z.im ;
   }
   void mul( Complex z ) {
      double r = re * z.re - im * z.im ;
      double i = re * z.im + im * z.re ;
      re = r ;
      im = i ;
   }
} ; // ←何度も繰り返すけど、ここのセミコロン忘れないでね
int main() {
   Complex a( 1.0 , 2.0 ) ;
   Complex b( 2.0 , 3.0 ) ;
   a.print() ;
   a.add( b ) ;
   a.print() ;
   a.mul( b ) ;
   a.print() ;
}

極座標系

上記の直交座標系の Complex クラスは、加減算の関数は単純だけど、乗除算の関数を書く時には面倒になってくる。この場合、極座標系でプログラムを書いたほうが判りやすいかもしれない。

class Complex {
private:
   double r ;  // 絶対値 r
   double th ; // 偏角   θ
public:
   void print() {
      printf( "%lf ∠ %lf¥n" , r , th / 3.14159265 * 180.0 ) ;
   }
   Complex() {
      r = th = 0.0 ;
   }
   Complex( double x , double y ) {
      r  = sqrt( x*x + y*y ) ;
      th = atan2( y , x ) ;    // 象限を考慮したatan()
   }
   void add( Complex z ) {
      ; // 自分で考えて
   }
   void mul( Complex z ) { // 極座標系での乗算は
      r  = r  * z.r  ;     //  絶対値の積
      th = th + z.th ;     //  偏角の和
   }
} ; // ←しつこく繰り返すけど、セミコロン忘れないでね(^_^;

このように、プログラムを開発していると、当初は直交座標系でプログラムを記述していたが、途中で極座標系の方がプログラムが書きやすいという局面となるかもしれない。しかし、オブジェクト指向による隠蔽化を正しく行っていれば、利用者に影響なく「データ構造」や「その手続き(メソッド)」を書換えることも可能となる。

このように、プログラムをさらに良いものとなるべく書換えることは、オブジェクト指向ではリファクタリングと呼ぶ。

システム

最新の投稿(電子情報)

アーカイブ

カテゴリー