2.6 イディオムとデザインパターン
「イディオム」も「デザインパターン」も,プログラミングを行ううえでの決まり文句のようなものだという点で同じです.あえて分類することも可能でしょうが,ここでは両者を特に区別することなく,比較的使いやすいもの絞って紹介することにします.
2.6.1 Pimplイディオム
C++では多くの場合,クラスの定義はヘッダファイルに記述することになります.しかし,ヘッダファイルには本来インターフェースのみを記述すべきであり,実装の詳細をヘッダファイルに記述すると,とたんにコンパイル時の依存関係が複雑になってしまいます.また,アクセス指定によって隠蔽するだけではなく,文字どおり見えなくしたい場合でも,ヘッダファイルに記述している以上,どうしても見えてしまいます.
これらの問題を解決するには次のようにします.
【a.h】
// a.h
class A
{
public:
A();
~A();
void func();
…
private:
struct AImpl* pimpl;
};
【a.cpp】
// a.cpp
struct A::AImpl
{
void func()
{
…
}
…
};
A::A()
: pimpl(new AImpl)
{
}
A::~A()
{
delete pimpl;
}
void A::func()
{
pimpl->func();
}
…
上記のコードでは,実装の詳細はすべてA::AImpl構造体に閉じ込めてしまい,Aクラスはそのポインタを保持するだけにしています.こうすることで,実装の詳細はヘッダファイルであるa.hから完全に分離することができます.ここではあまり詳しく解説しませんが,Pimplイディオムを使用することで例外安全保障が容易になるなど,多くのメリットがあります.
しかし,ここでも問題になるのが,A::AImpl構造体の動的な割り付けです.業務系のアプリケーション開発であればこれで問題ないのですが,組込み開発とは必ずしも相性が良いとはいえないのかもしれません.A::AImpl構造体へのポインタをAの外部から与える,あるいはA::AImpl構造体の割り付けに固定長アロケータを用いるなどの工夫をしたほうがよいでしょう.
なお,Pimplイディオムという名前は,上記のコードでも使用しているpimplというデータメンバーの名前に由来しています.pimplの“impl”は「Implementation」の意味であり,「p」はポインタを表す接頭辞です.
2.6.2 RAIIイディオム
「RAII」というのは「Resource Acquisition Is Initialization」の略で,リソースの確保は初期化時に行うというイディオムです.具体的には,クラスが管理するリソースはコンストラクタで確保を行い,デストラクタでその解放を行うというものです.ここでは,その典型的な応用例であるスマートポインタを紹介することにしましょう.
オブジェクトを動的に割り付けると,どうしても解放忘れによるリークや,二重解放による不具合が発生しがちです.この問題を解決するのがスマートポインタ(気の利いたポインタ)です.
まずは簡単な例から見ていきましょう.
template <typename T>
class scoped_ptr
{
public:
explicit scoped_ptr(T* p_obj = 0)
: ptr(p_obj)
{
}
~scoped_ptr()
{
delete ptr;
}
T* operator->() const
{
return ptr;
}
T& operator*() const {
return *ptr;
}
…
private:
scoped_ptr(const scoped_ptr<T>&);
scoped_ptr<T>& operator=(const scoped_ptr<T>&);
T* ptr;
};
このクラスは,コンストラクタでnew演算子で生成されたオブジェクトへのポインタを受け取り,デストラクタでdelete演算子を実行してそのオブジェクトを解体します.
文章で説明するより,コードを示したほうが使い方がよくわかることでしょう.
{
scoped_ptr<A> pa(new A);
pa->f(); // ← operator->を使用
(*pa).g(); // ← operator*を使用
// ここでscoped_ptr<A>のデストラクタによって,
//管理しているオブジェクトを自動的に解体する
}
->演算子は二項演算子ですが,多重定義するときは,原則としてポインタを返す単項演算子のように定義します.->演算子と*演算子が定義されていることで,ポインタと同じように扱うことができます.一方で,++演算子や--演算子を定義していませんので,誤ってポインタの参照先がずれてしまうようなこともありません.
このscoped_ptrは,同じブロックの中で生成と解体を行う場合にはたいへん便利です.ただし,delete[]ではなくdeleteを使っている関係上,配列には利用できませんので注意が必要です.
配列の場合は,下記のような専用のスマートポインタを用意するとよいで しょう.
template <typename T>
class scoped_array
{
public:
explicit scoped_array(T* p_obj = 0)
: ptr(p_obj)
{
}
~scoped_ptr()
{
delete[] ptr;
}
T& operator[](size_t pos) const
{
return ptr[pos];
}
…
private:
scoped_array(const scoped_array<T>&);
scoped_array<T>& operator=(const scoped_array<T>&);
T* ptr;
};
ところで,scoped_ptrはコピーを行うことができませんので,関数の引数として渡したり,他のクラスやデータメンバーや配列の要素になることができません.これでは不便なのでコピーを行えるようにしたいところですが,ポインタを持ち回りすると所有権が不明瞭になってしまいます.
この問題を回避するには,通常は,次のように参照カウンタを使用します.
template <typename T>
class counted_ptr{
public:
counted_ptr()
: ptr(0), pcount(0)
{
}
explicit counted_ptr(T* p_obj)
: ptr(p_obj), pcount(new size_t(1))
{
}
counted_ptr(const counted_ptr<T>& src)
: ptr(src.ptr), pcount(src.pcount)
{
if (pcount != 0)
{
++*pcount;
}
}
counted_ptr<T>& operator=(const counted_ptr<T>& src)
{
if (src.pcount != 0)
{
++*src.pcount;
}
if (pcount != 0 && --*pcount < 1)
{
delete ptr;
delete pcount;
}
ptr = src.ptr;
pcount = src.pcount;
return *this;
}
~counted_ptr()
{
if (pcount != 0 && --*pcount < 1)
{
delete ptr;
delete pcount;
}
}
T* operator->() const
{
return ptr;
}
T& operator*() const
{
return *ptr;
}
…
private:
T* ptr;
size_t* pcount; // ← 参照カウンタへのポインタ
};
counted_ptrクラステンプレートは,T型のオブジェクト以外に参照カウンタを管理しています.コピーコンストラクタやコピー代入演算子では*pcountをインクリメントし,そのオブジェクトを参照しているcounted_ptrの数を保持します.デストラクタでは*pcountをデクリメントし,参照しているcounted_ptrが無くなったときに管理しているオブジェクトを解体します.
こうした参照カウンタ方式のスマートポインタが最も汎用性の高いものとなります.しかし,参照カウンタを格納するための変数を別途動的に割り付けなければならないという問題もあります.また,上記の例では反映していませんが,マルチタスク環境では参照カウンタの増減時に排他制御が必要になり,コピー時のオーバーヘッドがばかになりません.そのため,なんでもかんでも参照カウンタ方式を使うのではなく,適材適所を心掛ける必要があります.
2.6.3 NVIイディオム
「NVI」というのは「Non Virtual Interface」の略です.簡単にいうと,仮想関数を公開しないようにするという意味です.どういうことかというと,下記のように,仮想関数を直接公開するのではなく,いったん非仮想の公開メンバー関数を介して呼び出すようにしようというものです.
class A
{
public:
void func(int arg)
{
do_func(arg); // ← 仮想関数を間接的に呼び出す
}
private:
virtual void do_func(int arg);
};
こんなことして何になるのかと思われるかもしれませんが,よく考えてみるといろいろメリットが見えてきます.真っ先に思い付くメリットは,事前処理と事後処理を非公開メンバー関数で行うことで,仮想関数を上書きするたびに,同じことを記述する手間を省く(そして,うっかり記述を忘れたり,間違ったりするミスを防ぐ)ことができます.事前処理と事後処理には,関数の呼び出しトレースや,エラーチェック,排他制御など,いろいろなものが考えられます.デメリットとしては,ちょっと記述が面倒になることぐらいです.しかし,それも基底クラスで一度面倒になるだけで,派生クラスを定義するのよりは,むしろ記述が簡単になります.
NVIイディオムが実際のプログラムでどのように使われているかを示すための例としては,標準ライブラリのstd::locale::facetからの派生クラス群があります.組込み開発はもちろん,それ以外の開発現場でも,このファセットを扱う機会は少ない気がしますが,これを機会に一度ヘッダをのぞいてみてください.
NVIイディオムの有用性を示すために,NVIイディオムを使うことで予防できる「つまらないミス」を紹介します.C++もCと同じく,プログラマーを信用するスタンスの言語であるからには「つまらないミス」をするほうが悪いのですが,予防線を張っておくことでミスを防ぐことが重要なのは間違いありません.
まずは,意外に知られていないことなのですが,仮想関数に省略時実引数を使用することによるミスです.
#include <stdio.h>
class A
{
public:
virtual void f(int arg = 1)
{
printf("A::f(%d)\n", arg);
}
};
class B : public A
{
public:
virtual void f(int arg = 2) // ← 基底クラスとは異なる省略時実引数
{
printf("B::f(%d)\n", arg);
}
};
int main()
{
B b;
A* p = &b;
b.f(); // ← 省略時実引数として,2が渡される
p->f(); // ← 省略時実引数として,1が渡される
}
このコードの実行結果は次のようになります.
B::f(2)
B::f(1)
省略時実引数は,たとえ仮想関数の場合でも,静的な型情報に基づいて決定されます.結果として,同じ実体に対する同じ仮想関数の呼び出しであるにもかかわらず,実行結果が異なるという,不思議な現象が起こります.
仮に,Bクラスの省略時実引数を記述しなければ,b.f();という記述はコンパイルエラーになってしまうので,仮想関数に省略時実引数を指定する場合には,それを上書きする場合にも,つねに同じ値を省略時実引数として指定しなければなりません.現実問題として,そんな面倒で管理しにくいことはやっていられません.NVIイディオムを使えば,この問題は一気に解消します.
#include <stdio.h>
class A
{
public:
void f(int arg = 1) // ← 省略時実引数は公開関数だけに設定する
{
do_f(arg);
}
private:
virtual void do_f(int arg)
{
printf("A::f(%d)\n", arg);
}
};
class B : public A
{
private:
virtual void do_f(int arg)
{
printf("B::f(%d)\n", arg);
}
};
次に,「うっかり別関数」のミスです.これもコードを示したほうが話が早いでしょう.
#include <stdio.h>
class A
{
public:
virtual void f(size_t arg)
{
printf("A::f(%d)\n", arg);
}
};
class B : public A
{
public:
virtual void f(int arg) // ← A::fとは仮引数の型が異なる
{
printf("B::f(%d)\n", arg);
}
};
int main()
{
B b;
A* p = &b;
b.f(0); // ← B::fB::fが呼び出される
p->f(0); // ← A::fA::fが呼び出される
}
先ほどのコードとよく似ているので,注意してください.このコードを実行すると,次のようになります.仮想関数のはずなのに,Aクラスのポインタを介して呼び出した場合には,Bクラスのfではなく,Aクラスのfが呼び出されてしまいます.
B::f(0)
A::f(0)
それもそのはずで,基底クラスAで定義された仮想関数fの引数はsize_t型なのに,派生クラスBの仮想関数fの引数はint型になっています.これでは,仮想関数の上書きではなく,別関数になってしまいます.
このような「うっかり別関数」によるミスは,Bクラスの単体テストを行っただけでは検出漏れを起こすことも多く,後になってバグが発生して,いろいろと悩ませてくれます.こうしたミスも,NVIイディオムを使っていれば,必ず非仮想関数を介して仮想関数が呼ばれるので,単体テストでバグを検出できる可能性が飛躍的に高まります.
もう1つは,基底クラスの仮想関数を外部から呼び出してしまうミスです.このミスは,比較的プログラミングスキルの高い技術者が,強引に目先の問題を解決する際にやってしまいがちなミスです.つまり,次のように,明示的に基底クラスの有効範囲を指定することで,基底クラスの仮想関数を呼び出すことができてしまうわけです.
B b;
b.A::f();
仮想関数というのは,オブジェクトの動的な型に応じて,適切なものが呼び出されてこそ正しく機能することができます.基底クラスとはいえ,異なるクラスの仮想関数が外部から呼び出されてただで済むことはまれです.こうしたミス(というか,かなり故意に近いですが……)に対する予防にも,NVIイディオムは非常に有効です.
もっとも,どんなにNVIイディオムを駆使していても,悪意を持ったプログラマーにはさすがに歯が立ちません.というのも,悪意を持ったプログラマー(あるいは,C++のことをよく知らずに,目先の問題の解決を優先するプログラマー)の多くは,いくらNVIイディオムを使っていても,躊躇なくアクセス指定子の記述場所を移動してしまうからです.
ここからはNVIイディオムの応用になります.クラスが何らかのリソース群を管理するような場合,特定のリソースを指定するのに,外部からはID番号や名前(文字列)で指定して,それを内部的にリソースを管理する構造体などへのポインタに変換するといった設計がよくあると思います.
class A
{
public:
virtual void func(int id)
{
resource* p_res = get_resource(id);
// p_resを使った何らかの処理
}
…
private:
struct resource { …… };
resource* get_resouce(int id);
resource res_[256];
};
このような場合,funcが仮想関数だと,上書きするたびに,ID番号からresourceへのポインタを変換する必要があり,(この程度であればたいしたことはありませんが)それなりに面倒です.
そこで,次のようにすることで,派生クラスで仮想関数を上書きするのがかなり楽になります.
class A
{
public:
void func(int id)
{
do_func(get_resource(id)); // ← get_resourceの呼び出しはここでのみ行う
}
…
private:
struct resource { …… };
resource* get_resouce(int id);
virtual void do_func(resoource* p_res) { …… }
resource res_[256];
};
このように,公開された非仮想メンバー関数では利用者にとって便利なように,非公開または限定公開の仮想関数では実装者にとって便利なように,引数や返却値の持ち方を決定することができます.
次に,多重定義に関するミスです.やろうとしていることは同じだけれども,引数の与え方を少し変えたいためにメンバー関数を多重定義することはよくあります.たとえば,次のようにです.
class window
{
public:
void move_resize(int left, int top, int right, int bottom);
void move_resize(const point& left_top, const point& right_bottom);
void move_rezize(const point& left_top, int width, int height);
void move_resize(const rect& region);
…
};
このように,GUIにおけるウィンドウの移動とサイズ変更を同時に行う関数では,座標や幅/高さの指定方法を複数用意したいことでしょう.しかし,これらをすべて仮想関数にしてしまうと,派生クラスで上書きするのがたいへんです.また,どれか1つだけを仮想関数にして,他の関数からその仮想関数を呼び出す方法もありますが,どれが仮想関数なのか一見しただけではわかりにくく,混乱のもとになります.こうした場合にも,限定公開または非公開の仮想関数を,公開された非仮想メンバー関数から呼び出すことで,セマンティクスが同じ処理を1カ所に集め,しかも,見た目にも非常にわかりやすい構造にすることができます.
このように,仮想関数を非公開または限定公開にすることは,いろいろとメリットが大きいと思います.特別な事情がないかぎり,仮想関数は公開しないほうがよさそうです.
2.6.4 Iteratorパターン
Iteratorは,「反復子」という訳語が当てられているように,配列や線形リストなど,データ列の各要素に順番にアクセスするためのものを意味しています.Cでは,配列の要素を順番にアクセスするには,下記のように,ポインタを使用することが多いと思います.
int array[100];
int *p;
for (p = &array[0]; p < &array[100]; p++)
…
ここで,*pとすれば現在の要素にアクセスできますし,p++または++pとすれば,参照先を次の要素に移動することができます.その意味で,上記の例におけるpも一種の反復子といえます.
また,線形リストであれば,次のようになることでしょう.
struct node_t
{
int vaue;
struct node_t* next;
};
struct node_t* top;
for (struct node_t *p = top; p != NULL; p = p->next)
…
この場合には,p->valueが現在の要素にアクセスする方法であり,p = p->nextが次の要素に移動する方法となります.
このように,データ構造によって,同じことをやろうとしても操作の方法が異なります.Iteratorパターンでは,これらに共通のインターフェースを与えることによって,データ列であればどんなものであっても同じように扱えるようにしようというデザインパターンです.具体的には,下記のようにすれば,currentおよびnextを使って,配列でも線形リストでもまったく同じように扱うことができるようになります.
class iterator_base
{
public:
virtual int& current() = 0; // ← 現在の要素を参照する
virtual void next() = 0; // ← 参照位置を次の要素に移動する
};
class array_iterator : public iterator_base // ← 配列に対する反復子クラス
{
public:
explicit array_iterator(int* array)
: p(array)
{ }
virtual int& current()
{
return *p;
}
virtual void next()
{
++p;
}
private:
int* p;
};
class list_iterator : public iterator_base // ← 線形リストに対する反復子クラス
{
public:
explixit list_iterator(node_t* node)
: p(node)
{
}
virtual int& current()
{
return p->value;
}
virtual void next()
{
p = p->next;
}
private:
node_t* p;
};
それでは,上記を踏まえたうえでもうひとひねりしてみましょう.上記は動的な多相性を使った例ですが,多くの場合は静的な多相性で十分ですし,そのほうがいろいろと細かなことができるようになります.また,Javaなどとは異なり,C++には演算子を多重定義できる言語機能が備わっているので,それを活かさない手はありません.
たとえば,次のクラステンプレートを定義したとしましょう.
template <typename T>
class list_iterator
{
public:
explixit list_iterator(node_t* node)
: p(node)
{
}
T& operator*() // ← *演算子
{
return p->value;
}
list_iterator<T>& operator++() // ← 前置形式の++演算子
{
p = p->next;
return *this;
}
list_iterator<T> operator++(int) // ← 後置形式の++演算子
{
list_iterator<T> temp(*this);
++*this;
return temp;
}
bool operator==(const list_iterator<T>& rhs) const
{
return p == rhs.p;
}
bool operator!=(const list_iterator<T>& rhs) const
{
return p != rhs.p;
}
private: node_t* p;
};
そうすると,次のような使い方ができるようになります.
int main()
{
for (list_iterator<int> iter = list_iterator<int>(top);
iter != list_iterator<int>(0); ++iter)
{
int x = *iter;
…
}
…
return 0;
}
おわかりでしょうか? この例では,線形リストであるにもかかわらず,*iterで要素を参照することができ,++iterで次の要素に移動することができるようになったのです.これはポインタの操作方法とまったく同じです.list_iteratorクラステンプレートの定義を見ると,++演算子を2つ定義しています.1つは引数なし,もう1つはint型の引数を受け取るようになっています.これは,ややC++の仕様の汚いところなのかもしれませんが,引数なしの定義が前置形式の++演算子を,int型の引数を取る定義が後置形式の++演算子を意味しています.後置形式のint型の引数は,前置形式の定義と区別するだけのものであり,実際には何にも使われることはありません.--演算子の場合も同様の方法で前置形式と後置形式を区別します.
さて,それではポインタと同じように使える反復子を最大限に活用するための方法を紹介することにします.
template <typename Iterator>
void for_each(Iterator first, Iterator last, void (*f)(int))
{
while (first != last)
{
(*f)(*first);
++first;
}
}
void func(int arg)
{
printf("%d\n", arg);
}
int main()
{
for_each(&array[0], &array[100], &func);
for_each(list_iterator<int>(top), list_iterator<int>(0), &func);
return 0;
}
いかがでしょうか? 上記のコードに登場するfor_each関数テンプレートのように,反復子の型をテンプレート引数にすれば,どんなデータ列に対しても通用する非常に汎用性の高い関数を作ることができるようになります.しかも,オーバーヘッドは限りなくゼロに近くなります.
実は,この手法はC++の標準ライブラリの中ではごく一般的に利用されています.
2.6.5 Singletonパターン
Singletonパターンは,プログラム全体でそのクラスのオブジェクトがただ1つしか生成されないことを保証するデザインパターンです.1つしかないハードウェアリソースを管理するクラスなど,利用価値はいろいろあります.Singletonパターンそのものについては,いろいろな書籍で解説されていますし,Webで検索しても簡単に見つけることができるので,ここでは,Singletonパターンの実装方法に絞って解説することにします.
Singletonパターンを実装するうえでのポイントは,コンストラクタとデストラクタを非公開(private)にすることにあります.そして,ただ1つだけ存在するオブジェクトへのポインタまたは参照を取得するための静的メンバー関数を用意する必要があります.
class singleton
{
public:
static singleton* get_instance()
{
static singleton obj;
return &obj;
}
…
private:
singleton();
~singleton();
singleton(const singleton&);
singleton& operator=(const singleton&);
};
忘れてはならないのが,コピーコンストラクタとコピー代入演算子を禁止しておくことです.また,上記のコードではsingletonのオブジェクトをget_instance関数の中で定義しています.こうすることで,コンストラクタとデストラクタが非公開(private)であっても,問題なくオブジェクトの生成と解体が可能になります.さらに,関数の中で定義された静的記憶域期間のオブジェクトは,その定義箇所に実行パスが差しかかった時点で動的初期化されるので,非局所オブジェクトのコンストラクタからsingleton::get_instance関数が呼び出された場合でも,問題なくsingletonオブジェクトにアクセスすることができるようになります.
しかし,この実装方法にも1つだけ問題点があります.それは,プログラムの終了時にget_instance関数内のobjがいつまで生存できるかが予測しにくいということです.静的記憶域期間を持つどんなオブジェクトのデストラクタからでもsingletonオブジェクトにアクセスできるようにするには,get_instance関数内のobjは一番最後に解体されなければなりません.ところが,実際にはそのような要求を満たす方法は一般的には存在しません.
こうした問題を解決するには,いっそのことget_instance関数内のobjが解体される機会を奪ってしまう方法が考えられます.オブジェクトが解体されることがないので,singletonクラスのデストラクタでは重要な処理は行うべきではありません.オブジェクトが解体される機会を奪うには,次のような方法が考えられます.
static singleton* get_instance()
{
static char buffer[sizeof(singleton)]; // ← 境界調整には配慮していない
static singleton* ptr = 0;
if (ptr == 0)
{
ptr = new(buffer) singleton;
}
return ptr;
}
このように「生成位置指定の構文」を用いれば,明示的にシングルトンオブジェクトのデストラクタを呼び出さないかぎり,解体されることはなくなります.ただし,オブジェクトの内部でリソースの獲得を行っているような場合には,リソースリークが起こることになるので要注意です.