ATLAS日本基礎ネットワーク C++トレーニングコース

第五回 クラスの継承

ここでの目標

目次

1.クラスの継承

クラスというものが、プログラムの問題領域の中で振る舞うオブジェクト〜物体を規定する型であると言うことを説明しました。振る舞いは主にメソッドすなわちメンバー関数で規定されており、利用者はそのクラスのオブジェクトを生成し、そのメソッドを起動する(呼び出す)ことで仕事をするというのが作業パターンとなっています。

例として3次元の図形処理プログラムを考えてみましょう。まず、直方体を実装してみましょう。

class Vector3D;    //前方宣言(forward declaration) 3次元ベクトルを想定
class GC;    //同じく前方宣言。グラフィックスコンテキストを想定

class Box {
public :
    Box( double w, double h, double d );
    ~Box( );
    void move( Vector3D & pos );
    void rotate( Vector3D & axis, double angle );
    void draw( GC & gc );
private:
    double m_width;
    double m_height;
    double m_depth;
    Vector3D m_position;
    Vector3D m_axis;
    double m_rotation_angle;
};

コンストラクタBox()は引数を持っていて、箱の寸法を初期化するようです。メソッドとしては移動(move)や回転(rotate)が出来、さらに描画(draw)が可能なことがわかります。

同様に円柱Columnを作りましょう。

class Vector3D;
class GC;

class Column {
public:
    Column( double length, double radius );
    ~Column( );
    void move( Vector3D & pos );
    void rotate( Vector3D & axis, double angle );
    void draw( GC & gc );
private:
    double m_length;
    double m_radius;
    Vector3D m_position;
    Vector3D m_axis;
    double m_rotation_angle;
};

BoxColumnは非常に良く似たクラスになっています。メンバー関数にもメンバーデータにも共通なものがあります。実装についても考えてみましょう。moveメソッドの可能な実装としては

void Box::move( Vector3D & pos ) {
    m_position = pos;    //Vector3Dクラスでは代入演算子が定義されているとします。
}

たぶん、Column::moveも全く同じ定義になるのではないでしょうか。さらにrotate

void Box::rotate( Vector3D & axis, double angle ) {
    m_axis = axis;
    m_rotation_angle = angle;
}

これもColumn::rotateと同じ定義が使えます。まず経済的コーディングの観点から、同じことを二度実装するのはもったいない。共通部分moverotatem_positionm_axism_rotation_angleを集めて一度だけ記述すればよいと言うようにできないかと考えるのは自然です。

一方プログラミングモデルとしてこれらの共通部分が何を表しているかを考えてみましょう。BoxColumnも図形であるのだから、移動したり回転したり出来る。moverotateBoxColumn固有の性質ではなく、「図形」一般が持つ性質ではないか。逆に「図形」とは「移動できるもの」「回転できるもの」ということで定義できるのではないか。「箱」や「円柱」より「図形」はより抽象的な「もの」として扱えるのではないか。

クラスの継承はこのような考えから生まれてきました。

1.1.派生クラスの定義

まず、より抽象的なクラスを定義します。それを基底クラス(親クラス)として、そのクラスを継承し、派生クラス(子クラス)を作ります。今の例ではShapeクラスを基底クラスとすることにしましょう。

class Shape {
public:
    void move( Vector3D & pos );
    void rotate( Vector3D & axis, double angle );
protected:
    Vector3D m_position;
    Vector3D m_axis;
    double m_rotation_angle;
};

アクセス制御のラベルにprotected:というものが出てきました。privateですと、このクラスを継承した子クラスはそのメンバーにアクセスできません。protectedとすることで、公開メンバーではないメンバーを継承クラス内部で参照可能になります。もちろんprotectedメンバーは外部からは直接アクセスすることは出来ません。

このShapeクラスを元にしてBoxクラスを作りましょう。

class Box : public Shape {
public:
    Box( double width, double height, double depth );
    ~Box( );
    void draw( GC & gc );
private:
    double m_width; 
    double m_heidht;
    double m_depth;
};

クラス定義の最初のところで、このBoxクラスがShapeクラスをパブリックに継承していると宣言します。

パブリックの継承とは、親クラスのアクセス制御をそのまま引き継ぐ継承です。publicメンバーはpublicに、protectedメンバーはそのままprotectedになります。protectedに継承すると、publicなメンバーがprotectedとして扱われ、protectedメンバーはprivateとして扱われるようになります。通常はpublicな継承を使います。

クラスメンバーは、親クラスShapeに含まれないものを記述します。コンストラクタは箱の寸法を決めます。drawメンバー関数は現在のパラメータに従って箱を描こうとするでしょう。

同様にColumnクラスも

class Column : public Shape {
public:
    Column( double length, double radius );
    ~Column( );
    void draw( GC & gc );
private:
    double m_length;
    double m_radius;
};

これも同様です。drawというBoxと同じメソッドを持っていますが、こちらのdrawは円柱を書こうとするもので、実装は明らかにBoxとは異なります。

実習1.

作業場所を用意しておきましょう。

cd ~/tutorial/cplusplus
mkdir l5
cd l5

図形の例を実習しても良いのですが描画をしたりする実装が簡単ではないので別の例を考えてみます。端末に文字を表示するアプリケーションを考えてみます。例えば端末に-を並べて横線を引きましょう。そのためのクラスを考えます。Lineクラスとしましょう。Line.hを書きます。

#ifndef __LINE_H
#define __LINE_H
#include <iostream>

class Line {
public:
    Line( int plength );
    void draw( std::ostream & os );
private:
    int m_length;
};
#endif

実装はLine.cxxです。

#include "Line.h"

Line::Line( int plength ) : m_length( plength ) { }

void Line::draw( std::ostream & os ) {
    for( int i = 0; i < m_length; i ++ )
        os << '-';
}

テストのためにmain関数を書きます。main.cxxとしましょう。

#include "Line.h"

main( ) {
    using namespace std;

    Line l(40);

    l.draw( cout );
}

走らせます。分割コンパイルです。

g++ -c Line.cxx
g++ -c main.cxx
g++ main.o Line.o
./a.out
------------------------------

改行されないので少しかっこうわるいです。LineNewクラスを加えます。LineNew.h

#ifndef __LINENEW_H
#define __LINENEW_H
#include <iostream>

class LineNew {
public:
    LineNew( int plength );
    void draw( std::ostream & os ); 
private:
    int m_length;
};
#endif

実装はお任せします。LineNew.cxxを作ってください。Line.cxxとほぼ同じで、単に最後に改行するだけです。main.cxx

#include "Line.h"
#include "LineNew.h"

main( ) {
    using namespace std;

    Line l(40);
    LineNew ln(20);

    l.draw( cout );
    ln.draw( cout );
}

実行します。

g++ -c LineNew.cxx
g++ -c main.cxx
g++ main.o Line.o LineNew.o
./a.out
------------------------------

今度はちゃんと改行されたでしょうか。

非常にシンプルな例ですが、std::ostreamに出力できるものという共通性が見て取れます。これら二つのクラスLineLineNewの基底クラスとしてDrawerクラスを考えます。Drawer.h

#ifndef __DRAWER_H
#define __DRAWER_H
#include <iostream>

class Drawer {
public:
    void draw( std::ostream & os );
};

#endif

実装を一応しておきましょう。

#include "Drawer.h"

void Drawer::draw( std::ostream & os ) { }

書くべきものは持たないので何もしません。LineLineNewを継承させましょう。Line.h

#ifndef __LINE_H
#define __LINE_H
#include "Drawer.h"

class Line : public Drawer {
public:
    Line( int plength );
    void draw( std::ostream & os );
private:
    int m_length;
};

#endif

Line.cxxの方は特に変更は必要ありません。同様にLineNew.hを書き換えてください。main.cxxもそのままにしておきましょう。でもヘッダーファイルの内容が書き換わっているので全部コンパイルし直します。

g++ -c Drawer.cxx
g++ -c Line.cxx
g++ -c LineNew.cxx
g++ -c main.cxx
g++ main.o Drawer.o Line.o LineNew.o
./a.out
------------------------------

継承のありがたみは今ひとつわかりませんが、問題なく動くことは確認できました。

2.メンバー

継承クラスは、自分が継承した親クラスのメンバーと自分自身のメンバーを持つことになります。

2.1.アクセス制御

既に見てきたように、継承を考慮して、アクセス制御はpublicprotectedprivateという3種のカテゴリーに分類されることになります。

publicは公開メンバーを表します。ATLASではメンバーデータの公開を禁止していますので、基本的にメソッドのみになります。

protectedは継承クラスにアクセスを許す非公開メンバーです。

privateは継承クラスにアクセスを許さない非公開メンバーです。例えば基底クラスの中で厳重に管理されており、子クラスのメソッドから不注意に操作されたくないメンバーデータはprivateに設定します。

ATLASでは、クラス定義の中ではまずpublic、続いてprotected、最後にprivateメンバーを宣言するように規則を決めています。

2.2.初期化の順番

継承クラスのコンストラクタは親クラスのコンストラクタを明示的に呼び出すことが出来ます。コンストラクタの関数定義において

Box::Box( double width, double height, double depth ) : Shape( ), m_width( width ),
        m_height( height ), m_depth( depth ) {
...
}

というようにコンストラクタShape()を呼び出しています。今の場合、明示しなくてもデフォルトコンストラクタであるShape()は自動的に呼び出されますが、例えば引数を持ったコンストラクタを呼び出すときには明示が必要になります。

実行順序にも注意しましょう。まず最初に親クラスのコンストラクタShape()が呼び出され、続いてメンバーデータの初期化子が実行されます。最後に{}が実行されることになります。

2.3.メンバーの遮蔽

子クラスで、親クラスと同じ名前のメンバーを定義することが出来ます。この場合、親クラスのメンバーは遮蔽され、見えなくなります。

実習2.

先ほどの実習のmain関数を見てみましょう。

main( ) {
    using namespace std;

    Line l(40);
    LineNew ln(20);

    l.draw( cout );
    ln.draw( cout );
}

これを実行した結果からわかるようにl.drawln.drawは継承クラスLineLineNewのクラスのdrawメソッドが呼ばれています。基底クラスDrawerdrawメンバー関数は遮蔽されていて使われません。

    Drawer d;
    d.draw( cout );

main関数の適当な場所に加えて実行しましょう。これは実際にはなにもしない(正しく)基底クラスDrawerdraw メソッドが呼ばれます。

3.仮想継承

BoxColumndrawという名前のメソッドを持っています。これはmoverotateメソッドと違って、実装は異なります。ですが、図形が描画できるものという概念から言えばShapeのメソッドと考えたくなります。そのために仮想継承という仕組みを導入します。

3.1.仮想メンバー関数

仮想継承とは、メソッドを仮想的virtualと宣言することで、基底クラスで仕様を決めるが、その実装は派生クラスごとに行えるようにします。

class Shape {
...
public:
virtual void draw( GC & gc );
...
};

これを継承する側は

class Box : public Shape {
...
public:
virtual void draw( GC & gc );
...
};

Shape::drawメソッドはたぶん何もしないまま制御が帰ってくるでしょう。一方、Box::drawの方は箱の絵を描くことになるでしょう。

この例では派生クラスの定義でもvirtual宣言をしています。親クラスで一度virtualと宣言されたメソッドはその子クラスでも一貫してvirtualとして扱われるため、子クラス側ではvirtual宣言はしなくても良いのですが、ATLASの規則としては明示することを要求しています。

では、このメソッドをどのように使えば良いかというと

Shape * drawables[ 10 ];
...
drawables[ 0 ] = new Box( 1.0, 2.0, 3.0 );
drawables[ 1 ] = new Column( 2.0, 3.0 );
drawables[ 2 ] = new Sphire( 1.5 );
...
for( int i = 0; i < 10; i ++ )
    drawables[ i ] -> draw( gc );
...

Shape型へのポインター配列drawablesをまず定義しました。そしてその要素にShape型を継承したいろいろなクラスのオブジェクトを生成して加えます。forループの中ではdrawablesのおのおのの要素についてdrawメソッドが呼ばれています。Shapeクラスはdrawメソッドを持っています。それ故、構文上正しい表現になっています。しかし、実際に呼び出されるのはShapedrawメソッドではなく、個別の子クラスのオブジェクトのdrawメソッドです。

ここで非常に重要な概念が導入されたことがわかります。例えば次のような関数を考えましょう。

void drawAll( Shape ** shapelist, int entries ) {
    GC gc;
    Shape ** sp = shapelist;
    for( int i = 0; i < entries; i ++ )
        * sp ++ -> draw( gc );
}

この関数はShape型が定義されさえすれば実装することが可能です。この関数内に限って言えば誰もShapeがどのように継承されたかを意識していません。Shape型という抽象的なクラスを用いてプログラムが書けてしまいます。もちろんこの関数が意味を持つのは、別のところでShapeを継承した様々なクラスが実装され、それらを詰め込んだshapelistが渡された時なのですが。

実習3.

実習サンプルDrawerに戻りましょう。Drawerdrawメソッドは上述の議論から当然仮想メンバーであるべきでしょう。Drawer.hを次のように修正します。

#ifndef __DRAWER_H
#define __DRAWER_H
#include <iostream>

class Drawer {
public:
    virtual void draw( std::ostream & os );
};

#endif

他は特に変更ありません。全体のコンパイルをやり直して実行してみましょう。

g++ -c Drawer.cxx
g++ -c Line.cxx
g++ -c LineNew.cxx
g++ -c main.cxx
g++ main.o Drawer.o Line.o LineNew.o
./a.out
------------------------------

そろそろこれを繰り返して書くのが面倒になってきました。コンパイル・リンクの手順を書いておいてそれを呼び出す方が楽です。Makefileというファイルを用意します。内容は次のようになります。

.SUFFIXES: .cxx

.cxx.o:
        g++ -c $<

OBJECTS = Drawer.o Line.o LineNew.o

l5.exe: main.o libl5.a
        g++ -o l5.exe main.o -L. -ll5

libl5.a: $(OBJECTS)
        ar -r libl5.a $(OBJECTS)

main.o: Drawer.h Line.h LineNew.h

Drawer.o: Drawer.h

Line.o: Drawer.h Line.h

LineNew.o: Drawer.h LineNew.h

ここで段下げ(インデント)は必ずタブキーを使って下げてください。空白だと正しく動きません。HTML表示の関係で上の記述は段下げが空白で書かれていますが、これをそのままコピペしたのではだめですのでご注意ください。

先にオブジェクトファイルを消しておきます。

rm *.o

では今用意したMakefileを使ってコンパイル・リンクをやりましょう。

make
g++ -c main.cxx
g++ -c Drawer.cxx
g++ -c Line.cxx
g++ -c LineNew.cxx
ar -r libl5.a Drawer.o Line.o LineNew.o
g++ -o l5.exe main.o -L. -ll5

makeもしくはgmakeと呼ばれるコマンドを使ってみました。makeでは

目的ファイル: 依存ファイル

という形でファイルの関係を書き、目的ファイルより依存ファイルの方が新しいとその次に書かれたコマンドを実行します。コマンドが書かれていない場合は目的ファイルのサフィックスから処理手順を決めます。目的ファイルが.oファイルで、サフィックスルールが決まっているものはその元になるファイルを探し、それがあればルールに従って処理をします。今の場合サフィックスルールは.cxx.o:という形で書かれておりその実体はg++ -c $<です。$<は入力ファイル(目的ファイルのサフィックス.oを取り、代わりにサフィックスルールに書かれたソースのサフィックス.cxxをつけたもの)を表します。以後、いずれかのファイルを書き直すとそれによって依存するファイルがすべて作り直されます。便利ですね。

さて、仮想メソッドを導入したメリットを確認しておきましょう。main.cxxを書き直します。

#include "Line.h"
#include "LineNew.h"

main( ) {
    using namespace std;

    Drawer * d[ 3 ];

    d[ 0 ] = new Line( 40 );
    d[ 1 ] = new LineNew( 20 );
    d[ 2 ] = new Drawer( );

    for( int i = 0; i < 3; i ++ )
        d[ i ] -> draw( cout );

}

今度はDrawerクラスへのポインター配列dを定義し、中身をnewでクラスオブジェクトを生成して加えていきます。実際に描画をさせるところではDrawer型へのポインターを使ってdrawメソッドを呼び出していますが、それぞれ正しく継承クラスのdrawが呼び出されます。確認しましょう。

make
g++ -c main.cxx
g++ -o l5.exe main.o -L. -ll5

3.2.純粋仮想メソッド

上の例の、Shapeクラスのメンバー関数drawについてもう少し考えてみましょう。皆さんはもしこのメソッドが呼ばれたら何をしますか。「何もしないで返す。」という人もいるかもしれません。

void Shape::draw( GC & gc ) {
}

しかし、この関数が呼ばれると言うことは、Shape型を継承したクラスのオブジェクトでなくShape型のオブジェクトを作って呼んだ人がいると言うことを表します。これは本来正しい使い方ではないので、

void Shape::draw( GC & gc ) {
    std::cout << "あなたはShape型のオブジェクトを作っています。良くないよ!" << std::endl;
}

このメッセージを見た利用者はコードをチェックして、Shapeのオブジェクトがなぜ出来たか調べるでしょう。

もっと積極的に、Shapeのオブジェクトを文法的に作れなくしてしまう方がよい、必ず派生クラスを使うように利用者に強制する方法がある方が好ましい。そのために純粋仮想関数が導入されました。クラス定義の中で、

class Shape {
...
public:
    virtual void draw( GC & gc ) = 0;
...
};

なんと、関数を0で初期化してしまいます。この意味はShapeクラスの中ではdrawは実装しません、という宣言です。一つでもこのような純粋仮想関数を持つクラスは抽象クラスと呼ばれ、オブジェクトを作ることが禁止されます。必ず継承したクラスを用意して、そのクラスを使うことになります。

抽象クラスは大規模なフレームワークに利用者が開発した部品を組み込むのに非常に適した仕組みです。自分で開発した部品をフレームワークに組み込みたいプログラマーは、そのフレームワークが提供する抽象クラスをベースにして自分の部品を開発すればよいことになります。少なくとも、すべての純粋仮想関数を実装しさえすれば、そのフレームワークの中で使える部品になります。この意味で、このような抽象クラスをインターフェースと呼びます。

ATLASの解析ソフトウエアAthenaというのはそのようなフレームワークの一つです。Athenaの中で使われるアルゴリズムやサービスはすべてインターフェースクラスを継承して作ることになります。

実習4.

Drawerクラスのdrawメソッドを純粋仮想関数とします。

#ifndef __DRAWER_H
#define __DRAWER_H
#include <iostream>

class Drawer {
public:
    virtual void draw( std::ostream & os ) = 0;
};

#endif

これでgmakeをやると

gmake
g++ -c main.cxx
main.cxx: In function `int main()':
main.cxx:19: error: cannot allocate an object of type `Drawer'
main.cxx:19: error: because the following virtual functions are abstract:
Drawer.h:8: error: virtual void Drawer::draw(std::ostream&)
gmake: *** [Main.o] Error 1

Drawクラスのオブジェクトを生成している部分がエラーになります。必要な機能が実装されていないクラスは使ってはいけないとコンパイラーが警告しています。大規模かつ複雑な継承を行って作られたユーザのクラスが正しく動くことを保証するために重要な機能です。

3.3.仮想デストラクタ

コンストラクタは派生クラスのオブジェクトとして生成されるときは当然その派生クラスのコンストラクタが呼び出され、その実装の中で基底クラスのコンストラクタが呼び出されるのが普通です。一方、デストラクタには注意が必要です。

仮想的でないデストラクタは、それが宣言されたポインターの形により呼ばれ方が変わってきます。

Box * sp = new Box( 1.0, 2.0, 3.0 );
...
    delete sp;

この例ではBox型のポインターとしてspが宣言されているので、delete文で呼び出されるのはBoxのデストラクタBox::~Box( )です。正しい処理が期待されます。一方、

Shape * sp = new Box( 1.0, 2.0, 3.0 );
...
delete sp;

このように派生クラスであるBoxのオブジェクトがShape型へのポインターspによって指されています。例の最後でdelete文により、生成されたオブジェクトが破棄されます。このとき、呼び出されるデストラクタはspShape型へのポインターであることからShape::~Shape()になってしまいます。もし、Boxのコンストラクタの中でメモリー割付などがされていると、通常はBox::~Box()の中でメモリー解放がされているはずなので、このようにデストラクタが正しく呼び出されないとメモリーリークが生じます。

こういった事態を避けるために、デストラクタをvirtualだと宣言します。

class Shape {
...
public:
    virtual ~Shape( );
...
};

こうすることにより、後の例でspShapeへのポインターであっても、デストラクタが仮想的なために正しくBox::~Box()が呼び出されます。

仮想メンバーを持ったクラスのデストラクタは必ずvirutalとすることがATLASでは要求されています。

4.継承とスコープ

スコープについては既に説明しましたが、継承はスコープにどのような影響を与えるか見ていきましょう。スコープとは、ものの名前を探してゆく範囲の決め方でした。複文の中という非常に狭いものから大域的なものまでいろいろとあります。クラススコープはその中で比較的広いものです。

派生クラスは派生クラスとしてのスコープを持っています。基底クラスのメンバー関数やメンバーデータはそのまま派生クラスで使われますが、もし、同じ名前のメンバー関数やメンバーデータを派生クラス内で宣言すると、基底クラスのそれは遮蔽されてしまいます。仮想関数などはそれにあたり、派生クラス側のメソッドから仮想関数を呼び出すと、当然派生クラスのメソッドが使われます。

このとき、明示的に基底クラスのスコープを宣言することによって親クラスのメソッドを呼び出すことが出来ます。スコープ演算子と呼び、::で表します。

class DerivedClass : public BaseClass {
...
virtual void vfunc( int iarg );
...
};

例えばDerivedClassの中から、BaseClassvfuncを使いたいときは

BaseClass::vfunc( 1 );

この意味はBaseClassというクラススコープを使ってvfuncという名前を探しなさいと言うことになります。

5.多重継承

複数の親クラスを持った派生クラスを定義することが出来ます。これを多重継承と呼びます。クラス継承の例としてインターフェースとしての抽象クラスを見ました。ユーザはフレームワークに自分の部品を繋ぎこみたい。この場合、そのクラスのオブジェクトがいくつかの場面で使用されるとき、その場面ごとにインターフェースが必要になるが、オブジェクトとしては単一であり一貫した情報を保持しているという状況を考えます。

例を挙げてみましょう。Athenaの中でAlgorithmクラスを継承してユーザのアルゴリズムを書くことが出来ます。このアルゴリズムは開始時に初期化メソッドが呼ばれ、終了時に終了化メソッドが呼ばれ、イベントを処理するたびにユーザ実行メソッドが呼ばれます。一方で、このクラスは属性を設定することが出来、その属性はjobOptionsのスクリプトで与えることが出来ます。このことからAlgorithmクラスはathenaのステートモデルに従った振る舞いをするものとしてIAlgorithmクラスを継承するとともに、属性を設定できるものとしてIPropertyクラスを継承します。

class Algorithm : public IAlgorithm, public IProperty {...};

というように親クラスをコンマで区切って並べます。

5.1.多重継承のメンバー

多重継承をした場合、すべての親クラスのメンバーが引き継がれます。場合によっては同じ名前のメンバー関数やメンバーデータがないとも限りません。その場合、派生クラスの側ではどちらのものが呼ばれているのかを明示する必要があります。

class A {
...
public:
    void print( );
...
};
class B {
...
public:
    void print( );
....
};
class C : public A, public B {...};

ABprintと言うメソッドを持っています。

C cobj;        //class Cのオブジェクト
cobj.print( );    //どちらのprintだかわからない。
cobj.A::print( );
cobj.B::print( );

ここで出てきたA::というのはAというクラスのスコープを使うという演算子です。これについては前節で説明しました。

5.2.仮想継承

今仮に、Aというクラスを継承してBを定義し、また別にAを継承してCを作ったとします。

class A {
protected:
    int m_identifier;
};

Aはメンバーデータとしてm_identifierという変数を持っています。

class B : public A {...};

さらには

class C : public A {...};

BCAを基底クラスとしているのでそれぞれm_identifierという変数を持っています。さて、このBCの両方を継承したクラスDを作るとどうなるでしょうか。

class D : public B, public C {...};

元々はAから始まっているのでこういう関係を菱形継承と呼びます。継承のルールとして、それぞれの親クラスのメンバーを引き継ぐわけですから、B::A::m_identifierC::A::m_identifierの二つのメンバーデータが別々に確保されることになります。これはちょっと都合が悪い。元々m_identifierというのはオブジェクトの固有番号のつもりでつけており、Dクラスのオブジェクトも一つユニークなm_identifierを持つべきでしょう。クラスAは多重継承されても一つだけ引き継いでほしい。そういう場合に仮想継承を使います。

class B : virtual public A {...};
class C : virtual public A {...};

このようにvirtualに継承することにより、親の親が同一の場合メンバーは一つだけ引き継ぎます。

大規模なソフトウエアシステムでは多重継承を使うことにより合理的に階層を実現することが出来ます。その場合仮想継承は重要な機能です。この節の最初の例、Algorithmも実は

class Algorithm : virtual public IAlgorithm, virtual public IProperty {...};

として定義されています。

ATLASのルールとしてはメンバーデータを持ったクラスの多重継承を避けるようになっています。上述のような複雑な問題を避けるためです。多重継承して良いのは

抽象インターフェースクラスだけにすべきだと規定しています。

6.継承の設計

これまで見てきた例では、箱や、円柱などを分析し、そのより抽象的な表現である「型」にたどり着きました。どのように継承を設計すればよいか考えていきましょう。

プログラムの開発をするということは、まず実現したいタスクをどのように表現するかという検討から始まることになります。プログラミングモデルが必要です。問題領域を想定し、その空間の中で振る舞うオブジェクトを発見することが第一歩になります。

次の重要なステップはオブジェクトの関係を検討することです。オブジェクトの関係はおよそ次の三つに分類することが出来ます。

プログラムの中で同じ役回りをするオブジェクトを見つけることは継承関係の発見の手がかりになります。一般的な議論ではなくて、ある特定の問題の解法において、プログラム上の振る舞いとして共通性があるか。いろいろなオブジェクトを同じ椅子に座らせる意味があるか。

クラスの利用者としては、そのフレームワークの開発者がどのようなモデルでそのインターフェースを設計したかを理解することが重要です。AthenaのAlgorithmやServiceがどのように使われようとしているか見てください。

オブジェクト指向解析設計について一度勉強してみることをお薦めします。UML(Unified Modeling Language)と呼ばれる表記法(クラスを箱で表したり、所有関係(has a)、継承関係(is a)、利用関係(using)などを線で表したりするもの)もよく使われます。

7.名前空間

クラススコープを越えると次のスコープとしてはこれまでの知識だと大域と言うことになってしまいます。例えばクラス名は大域で探されることになりますが、非常に大規模なソフトウエアのシステムではこれはいささか都合が悪くなります。別々に開発された二つのパッケージを使おうとしたときに、その中に同じ名前のクラスが全く独立に定義されていると、もうコンパイルできません。

こういう事態を避けるために、以前はクラス名にパッケージの略称などを含ませるなどして名前の衝突を避けてきました。しかし、こういうことをするとクラス名が長くなり、プログラムも読みにくくなります。仮にそういう仕組みを置いてもまだ衝突の可能性は残されます。

7.1.名前空間の宣言

この問題を解決するために名前空間namespaceが導入されました。クラスや関数などを定義するときに名前空間を宣言します。

namespace MyNamespace {
class MyApp {
...    //メンバー定義など
};
}

この{}に囲まれた範囲で宣言や定義された名前(この例ではMyAppクラス)はすべてMyNamespaceという名前空間にいることになります。

7.2.名前空間の利用

このように名前空間はクラススコープの外側にスコープの範囲を設定します。一旦名前空間内に定義された名前は、その名前空間に属すため、それを使うためには何らかの方法で名前空間を指定することが必要になります。例えば上の例ではコンストラクタMyApp::MyApp()を指定するのに

namespace MyNamespace {
MyApp::MyApp( ) {
...
}
}

とするか、

using namespace MyNamespace;
MyApp::MyApp( ) {
...
}

とするか、

MyNamespace::MyApp::MyApp( ) {
...
}

とする必要があります。この例の場合、MyApp::MyApp()をどこから探すかが確定すれば良いわけです。

一方、mainの中でMyAppを作りたいときは、

main( ) {
    MyNamespace::MyApp myapp;
}

とか

main( ) {
using namespace MyNamespace;
    MyApp myapp;
}

とします。次の場合は都合が悪くなります。

namespace MyNamespace {
main( ) {
    MyApp myapp;
}

この場合、mainまでがMyNamespaceの中になければならなくなります。通常のmain関数ではなく、MyNamespaceでユーザが定義された別のmain関数となり、制御が最初にここに渡されることはありません。

これまでHello! World.の例でもすでに名前空間は扱ってきました。C++の入出力などはstdという名前空間内で定義されてきました。そのような新しい名前空間で定義を行っているヘッダーファイルには.hをつけないということで、以前から使われてきたiostream.hと区別しています。以前から使われてきたものは名前なし(匿名)名前空間にあると考えます。

匿名名前空間にあるものを参照したいときは

int errorcode = ::errno;

というように::(スコープ演算子)の前に名前を与えないことで匿名空間にあることを表します。

using namespaceは任意の場所で宣言できます。その有効範囲は通常の変数のスコープと同じです。関数内で宣言されたusing namespaceはその関数の末尾まで有効です。関数外で宣言された場合、例えば#include <iostream>の直後にusing namespace std;と書くなどは、ファイルスコープが適用されるので、そのファイルの末尾まで有効になります。ATLASではファイルスコープの使用をしないようにしています。コーディングルールとしてはそれが必要な最小の範囲で宣言します。最初の例、HelloWorldで、main関数の中でusing namespace std;を宣言したのは、それが必要な範囲がmain関数の中だけだからです。

複数の開発者が同じソースを編集する場合、ファイルスコープのように有効範囲が広いものを導入することは、同じソースの他の部分にまで影響を与えてしまう可能性があります。自分の責任のある部分だけで有効な宣言をすべきだという立場です。例えばファイル前半をAさんが、後半をBさんが書いていて、ある理由で、このファイルを二つに分けたとしましょう。前半にファイルスコープを宣言していてそれが後半に影響していると、分割されたファイルでファイルスコープの見直しをしなくてはいけなくなります。こういうことを避けるためにファイルスコープを使わないようにしています。

実習5.

名前空間を使ってみましょう。DrawerExampleという名前空間を使うことにします。全部のクラス定義、実装をnamespaceの中に入れます。例えばDrawer.h

#ifndef __DRAWER_H
#define __DRAWER_H
#include <iostream>

namespace DrawerExample {
class Drawer {
public:
    virtual void draw( std::ostream & os ) = 0;
};
}

#endif

クラス実装の方も

#include "Drawer.h"
namespace DrawerExample {
void Drawer::draw( std::ostream & os ) { }
}

他のクラスも同様にnamespace DrawerExample { }で取り囲んでください。また、main.cxx

#include "Line.h"
#include "LineNew.h"

main( ) {
    using namespace std;
    using namespace DrawerExample;

    Drawer * d[ 2 ];

    d[ 0 ] = new Line( 40 );
    d[ 1 ] = new LineNew( );

    for( int i = 0; i < 2; i ++ )
        d[ i ] -> draw( os );
}
実習6.

LineLineNewだけではおもしろくありません。例えばテキストメッセージを文字列で表示するStringクラス、整数の値を適当なフォーマットで表示するIValueクラス、同じ数値を文字'#'の数で棒グラフのように表示するIBarクラスなどを作ってみましょう。それらを組み合わせて、前回の実習で作成したMyHistogramを棒グラフで表示するサービスクラスHistogramDrawerクラスを作ってみましょう。継承関係をどうするか、メンバーデータをどうするか、他のオブジェクトの関係をどう実装するか、色々な設計が考えられます。

次回は例外処理について解説します。


前へ 上へ 次へ


2017年7月22日