[技術講座]
私自身今までいくつかのプログラミング言語を使ってきましたが、C++ が他のオブジェクト指向言語と比べて難しいのは、やはりメモリ管理をプログラマが自分でしなければいけない点だと思います。例えば、
Person* person = new Person();
と生成したオブジェクトは、使い終わったら次のように削除しなければなりません。
delete person;
生成してすぐ削除するなら簡単なのですが、実際にはよくよく注意しないと、削除し忘れたり、同じオブジェクトを2度削除してしまうというエラーが発生します。このノートでは、オブジェクトを「値オブジェクト」と「参照オブジェクト」というカテゴリに分け、詳細設計の段階で注意すべき点を整理しておきたいと思います。
対象読者としては、ある程度オブジェクトのメモリイメージが理解できる人を想定しています。また、参考文献にあげた「Effective C++」で書かれている内容は、詳しく説明しませんので、まだ読まれていない方は併せてご参照いただければと思います。
まず言葉の定義ですが、次のような特徴をもったオブジェクトを「値オブジェクト」といいます。
int や double などの基本データ型と同じセマンティックスで使用される。
日付や時間、お金、数量などは一般に「値オブジェクト」です。標準ライブラリでは、string や vector、map などの STL コンテナがこのカテゴリに入ります。
「名前(Name)」クラスを例に考えましょう。ソースコードは以下のようになります。
#ifndef Name_h
#define Name_h
#include <iostream>
class Name
{
public:
// デフォルトコンストラクタ.
Name();
// 引数を1つとるコンストラクタ.
explicit Name(const char* value);
// コピーコンストラクタ.
Name(const Name& other);
// デストラクタ.
~Name();
// 代入演算子.
Name& operator=(const Name& other);
// アクセッサ.
const char* getValue() const;
void setValue(const char* value);
// 等価演算子.
bool operator==(const Name& other) const;
bool operator!=(const Name& other) const;
private:
char* m_value;
};
// 出力演算子.
std::ostream& operator<<(std::ostream& lhs, const Name& rhs);
#endif /* !Name_h */
#include "Name.h"
#include <cstring>
using namespace std;
// デフォルトコンストラクタ.
Name::Name()
: m_value(0)
{
}
// 引数を1つとるコンストラクタ.
Name::Name(const char* value)
: m_value(0)
{
if (value != 0) {
m_value = new char[strlen(value) + 1];
strcpy(m_value, value);
}
}
// コピーコンストラクタ.
Name::Name(const Name& other)
: m_value(0)
{
if (other.m_value != 0) {
m_value = new char[strlen(other.m_value) + 1];
strcpy(m_value, other.m_value);
}
}
// デストラクタ.
Name::~Name()
{
delete[] m_value;
}
// 代入演算子.
Name& Name::operator=(const Name& other)
{
if (this != &other) {
setValue(other.m_value);
}
return *this;
}
// アクセッサ.
const char* Name::getValue() const
{
return m_value;
}
void Name::setValue(const char* value)
{
if (m_value == value) return;
delete[] m_value;
if (value == 0) {
m_value = 0;
} else {
m_value = new char[strlen(value) + 1];
strcpy(m_value, value);
}
}
// 等価演算子.
bool Name::operator==(const Name& other) const
{
if (m_value == other.m_value) {
return true;
}
if (m_value == 0 || other.m_value == 0) {
return false;
}
return strcmp(m_value, other.m_value) == 0;
}
bool Name::operator!=(const Name& other) const
{
return !(*this == other);
}
// 出力演算子.
ostream& operator<<(ostream& lhs, const Name& rhs)
{
const char* value = rhs.getValue();
lhs << (value == 0 ? "(none)" : value);
return lhs;
}
設計のポイントを確認していきます。
名前クラスの場合、コンストラクタの中で、new でメモリを動的に確保しているので、デストラクタで解放する必要があります。(「Effective C++」6項参照)
「コピーを作ることができる」「基本データ型と同じセマンティックス」ということから、次のような使われ方が想定されます。
Name name1("Kudou Shinichi");
Name name2("Mouri Ran");
// オブジェクトのコピー.
Name name3(name1);
// オブジェクトの代入.
name3 = name2;
ソースコードにコピーコンストラクタと代入演算子が明示的に定義されていない場合、C++ のコンパイラは次のようなコードを勝手に生成します。
Name::Name(const Name& other)
: m_value(other.m_value)
{
}
Name& Name::operator=(const Name& other)
{
m_value = other.m_value;
return *this;
}
クラス内でメモリを動的に確保していなければこれで充分なのですが、メモリを動的に確保している場合、このままだとどこからも指されていないメモリや、複数箇所から指されているメモリができ、エラーが発生してしまいます。したがって、コピーコンストラクタと代入演算子を明示的に定義する必要があります。(「Effective C++」11項参照)
このクラスのインスタンスを次のように生成した場合、
Name name1("Kudou Shinichi");
Name name2("Kudou Shinichi");
2つのインスタンスは実体は異なりますが、同じ値をもっているので同じオブジェクトと見なすことができます。名前クラスの場合、getValue() でとってきた値を比較しても良いですが、上のコードのように、等価演算子を定義しておくとオブジェクト同士を直接比較することができます。
if (name1 == name2) {
// do something...
}
なお、等価演算子は、クラスの外で次のような形式で定義することもできます。
bool operator==(const Name& lhs, const Name& rhs);
bool operator!=(const Name& rhs, const Name& rhs);
const の指定を厳密に行う。
データ値の保証をするために const Name として使えるよう、状態を変更しないメンバ関数にはシグニチャの後に const を指定します。(「Effective C++」21項参照)
ポリモフィズム的な使用を想定していないので、メモリ効率とパフォーマンスを重視し、すべての関数は非仮想関数( virtual をつけない)とします。
値オブジェクトに限りませんが、引数を1つとるコンストラクタは通常 explicit をつけて暗黙の型変換を禁止し、以下のようなコードが通るのを防止します。
Name name;
// explicit がないとこれが通る.
name = "Edogawa Conan";
また、出力演算子( operator<< )も定義しておくと、次のように使えて便利でしょう。
cout << name << endl;
続いて「値オブジェクト」を利用する側の注意点を確認しておきます。
「値オブジェクト」の宣言と初期化は以下のように行います。
Name name1;
Name name2("Kudou Shinichi");
まれにポインタで宣言することがあるかもしれませんが、ポリモフィズム的な使用を想定して設計されていないので、子クラスのインスタンスを親クラスのポインタで受けてはいけません。
class ExtendedName : public Name ...
// これをやってはいけない.
Name* name = new ExtendedName("Edogawa Conan");
// こう宣言する.
ExtendedName name1("Edogawa Conan");
ExtendedName* name2 = new ExtendedName("Edogawa Conan");
const 参照で渡す。次のように「値オブジェクト」を関数へ参照やポインタで渡すと、関数側で呼び出し側の変数を変更できてしまい、「値オブジェクト」にとって重要な「値」が保証されなくなってしまいます。
// これをやってはいけない.
void useAsParam(Name& name)
{
// 呼び出し側の変数 name を変更できてしまう.
name = "Mouri Kogorou";
}
基本型と同じセマンティックスという原則なので、
// これでも良いが...
void useAsParam(Name name)
{
// ここで見える name は呼び出し側とは別の実体.
}
このように宣言しても良いのですが、オブジェクトの無駄なコピーが発生するため、通常は以下のように const 参照で渡します。
// この形式がベター.
void useAsParam(const Name& name)
{
// name の状態は変更できない.
}
このようにすると、関数内では name の状態を読み出すことはできますが、変更することはできなくなります。(「Effective C++」22項参照)
「値オブジェクト」を戻り値として使用する場合は、値で返します。(「Effective C++」23項参照)
Name useAsReturn()
{
Name name;
// do something...
return name;
}
他のクラスのメンバ変数として使用する場合は、通常次の形式になります。
class Client
{
public:
// アクセッサ.
Name getName() const;
void setName(const Name& value);
private:
// メンバ変数.
Name m_name;
};
次のような特徴をもったオブジェクトを「参照オブジェクト」といいます。
分析段階で抽出される、いわゆる普通の「オブジェクト」のことです。標準ライブラリでは、iostream がこのカテゴリに入ります(このノートとは多少使い方が異なりますが)。
「人(Person)」クラスを例に考えましょう。ソースコードは以下のようになります。
#ifndef Person_h
#define Person_h
#include <iostream>
class Person
{
public:
// デフォルトコンストラクタ.
Person();
// 引数を1つとるコンストラクタ.
explicit Person(const char* name);
// virtual なデストラクタ.
virtual ~Person();
// アクセッサ.
const char* getName() const;
void setName(const char* value);
// 内容を出力する.
virtual void print(std::ostream& out) const;
private:
// コピーコンストラクタは無効に.
Person(const Person& other);
// 代入演算子も無効に.
Person& operator=(const Person& other);
private:
char* m_name;
};
// 出力演算子.
std::ostream& operator<<(std::ostream& lhs, const Person& rhs);
std::ostream& operator<<(std::ostream& lhs, const Person* rhs);
#endif /* !Person_h */
#include "Person.h"
#include <cstring>
using namespace std;
// デフォルトコンストラクタ.
Person::Person()
: m_name(0)
{
}
// 引数を1つとるコンストラクタ.
Person::Person(const char* name)
: m_name(0)
{
if (name != 0) {
m_name = new char[strlen(name) + 1];
strcpy(m_name, name);
}
}
// デストラクタ.
Person::~Person()
{
delete[] m_name;
}
// アクセッサ.
const char* Person::getName() const
{
return m_name;
}
void Person::setName(const char* value)
{
if (m_name == value) return;
delete[] m_name;
if (value == 0) {
m_name = 0;
} else {
m_name = new char[strlen(value) + 1];
strcpy(m_name, value);
}
}
// 内容を出力する.
void Person::print(ostream& out) const
{
if (m_name == 0) {
out << "Person";
} else {
out << "Person(" << m_name << ")";
}
}
// 出力演算子.
ostream& operator<<(ostream& lhs, const Person& rhs)
{
rhs.print(lhs);
return lhs;
}
ostream& operator<<(ostream& lhs, const Person* rhs)
{
if (rhs == 0) {
lhs << "(null)";
} else {
rhs->print(lhs);
}
return lhs;
}
設計のポイントを確認していきます。
必ず具象クラスのデストラクタが呼ばれるように、デストラクタは仮想関数(virtual をつける)として定義します。メモリを動的に確保している場合は、もちろんデストラクタで解放します。(「Effective C++」14項参照)
private で宣言のみ行う。
コピーコンストラクタと代入演算子は private で宣言のみ行い、オブジェクトのコピーを禁止します。(「Effective C++」11項参照)
インスタンス自身が重要な意味を持っていますので、等価演算子は定義せずに、オブジェクトの識別はアドレスを直接用いて行います。
const 指定は、あまりこだわらなくても良い。
Getter などお決まりのメンバ関数以外は、const かどうかあまりこだわらなくても良いでしょう。
必要ならばメンバ関数の宣言に virtual をつけて仮想関数にします。
引数を1つとるコンストラクタへの explicit の指定は値オブジェクトのときと同じです。出力演算子は、コード例のように仮想関数を使って実装すると良いでしょう。
続いて「参照オブジェクト」を利用する側の注意点を確認しておきます。
「参照オブジェクト」の宣言と初期化は通常以下のように行います。
Person* person = new Person("Kudou Shinichi");
使用後は削除するのを忘れないようにしましょう。
delete person;
子クラスのインスタンスを親クラスのポインタで受けてもかまいません。
class ExtendedPerson : public Person ...
// こうしてもよい.
Person* person = new ExtendedPerson("Edogawa Conan");
// 親クラスのポインタ経由で削除可能.
delete person;
むしろ、インタフェースと実装を積極的に分離することで、システムの開発効率、保守性、再利用性を高めることができます。
関数の中でオブジェクトの状態を変更する場合も多いと思いますので、通常 const をつけずにポインタで渡します。
void useAsParam(Person* person)
{
// do something...
}
メンバ変数の Getter や Factory などを除いて、戻り値として使用することはあまりないでしょう。
他のクラスのメンバ変数として使用する場合は、通常次の形式になります。
class Client
{
public:
// アクセッサ.
Person* getPerson() const;
void setPerson(Person* value);
private:
// メンバ変数.
Person* m_person;
};
Client クラスが Person のライフサイクルを管理している場合は、メモリのエラーが起こらないように Setter の実装に注意してください。必要がなければ Setter は用意しない方が良いでしょう。
こういう言葉は実際にはないのですが、便宜上以下のようなオブジェクトを「参照型値オブジェクト」と仮に呼ぶことにします。
ここまでは値オブジェクトと同様なのですが、
という点が値オブジェクトとは異なります。XML の DOM など Composite なデータ構造を作りたい場合などに使用します。
先程の「名前(Name)」クラスを例に考えましょう。ソースコードは以下のようになります。
#ifndef Object_h
#define Object_h
#include <cstddef>
#include <iostream>
class Object
{
public:
// デフォルトコンストラクタ.
Object();
// virtual なデストラクタ.
virtual ~Object();
// オブジェクトを複製する.
virtual Object* clone() const;
// 等価なオブジェクトか?
virtual bool equals(const Object& other) const;
// 内容を出力する.
virtual void print(std::ostream& out) const;
// メモリ管理演算子.
static void* operator new(std::size_t size);
static void operator delete(void* p);
protected:
// コピーコンストラクタは子クラスのみ使用可.
Object(const Object& other);
private:
// 代入演算子は無効に.
Object& operator=(const Object& other);
};
// 等価演算子.
bool operator==(const Object& lhs, const Object& rhs);
bool operator!=(const Object& lhs, const Object& rhs);
// 出力演算子.
std::ostream& operator<<(std::ostream& lhs, const Object& rhs);
std::ostream& operator<<(std::ostream& lhs, const Object* rhs);
#endif /* !Object_h */
#include "Object.h"
using namespace std;
// デフォルトコンストラクタ.
Object::Object() {}
// コピーコンストラクタ.
Object::Object(const Object& other) {}
// デストラクタ.
Object::~Object() {}
// オブジェクトを複製する.
Object* Object::clone() const
{
return 0;
}
// 等価なオブジェクトか?
bool Object::equals(const Object& other) const
{
return (this == &other);
}
// 内容を出力する.
void Object::print(ostream& out) const
{
out << "Object";
}
// メモリ管理演算子.
void* Object::operator new(size_t size)
{
// 実装省略.
}
void Object::operator delete(void* p)
{
// 実装省略.
}
// 等価演算子.
bool operator==(const Object& lhs, const Object& rhs)
{
return lhs.equals(rhs);
}
bool operator!=(const Object& lhs, const Object& rhs)
{
return !(lhs.equals(rhs));
}
// 出力演算子.
ostream& operator<<(ostream& lhs, const Object& rhs)
{
rhs.print(lhs);
return lhs;
}
ostream& operator<<(ostream& lhs, const Object* rhs)
{
if (rhs == 0) {
lhs << "(null)";
} else {
rhs->print(lhs);
}
return lhs;
}
#ifndef Name_h
#define Name_h
#include "Object.h"
class Name : public Object
{
public:
// デフォルトコンストラクタ.
Name();
// 引数を1つとるコンストラクタ.
explicit Name(const char* value);
// virtual なデストラクタ.
virtual ~Name();
// アクセッサ.
const char* getValue() const;
void setValue(const char* value);
// オブジェクトを複製する.
virtual Name* clone() const;
// 等価なオブジェクトか?
virtual bool equals(const Object& other) const;
// 内容を出力する.
virtual void print(std::ostream& out) const;
protected:
// コピーコンストラクタは子クラスのみ使用可.
Name(const Name& other);
private:
// 代入演算子は無効に.
Name& operator=(const Name& other);
private:
char* m_value;
};
#endif /* !Name_h */
#include "Name.h"
#include <cstring>
using namespace std;
// デフォルトコンストラクタ.
Name::Name()
: m_value(0)
{
}
// 引数を1つとるコンストラクタ.
Name::Name(const char* value)
: m_value(0)
{
if (value != 0) {
m_value = new char[strlen(value) + 1];
strcpy(m_value, value);
}
}
// コピーコンストラクタ.
Name::Name(const Name& other)
: Object(other), m_value(0)
{
if (other.m_value != 0) {
m_value = new char[strlen(other.m_value) + 1];
strcpy(m_value, other.m_value);
}
}
// デストラクタ.
Name::~Name()
{
delete[] m_value;
}
// アクセッサ.
const char* Name::getValue() const
{
return m_value;
}
void Name::setValue(const char* value)
{
if (m_value == value) return;
delete[] m_value;
if (value == 0) {
m_value = 0;
} else {
m_value = new char[strlen(value) + 1];
strcpy(m_value, value);
}
}
// オブジェクトを複製する.
Name* Name::clone() const
{
return new Name(*this);
}
// 等価なオブジェクトか?
bool Name::equals(const Object& other) const
{
if (this == &other) {
return true;
}
const Name* name = dynamic_cast<const Name*>(&other);
if (name == 0) {
return false;
}
if (m_value == name->m_value) {
return true;
}
if (m_value == 0 || name->m_value == 0) {
return false;
}
return strcmp(m_value, name->m_value) == 0;
}
// 内容を出力する.
void Name::print(ostream& out) const
{
out << (m_value == 0 ? "(none)" : m_value);
}
設計のポイントを確認していきます。
参照オブジェクトと同じように、デストラクタは仮想関数(virtual をつける)として定義します。
protected で定義する。
コピーコンストラクタは protected で定義し、clone() からのみ使用可能にします。
clone() を定義する。
オブジェクトの複製は、適切な具象クラスの複製を作るために、仮想メンバ関数 clone() を定義して行います。
Object* object = new Name("Kudou Shinichi");
// Name オブジェクトとして複製される.
Object* clone = object->clone();
なお、子クラス Name で clone() の戻り値を Object* から Name* に変更(共変; covariant)していますが、こうすると複製時にダウンキャストしなくてすみます。
Name* name = new Name("Mouri Ran");
// ダウンキャストの必要がない.
Name* clone = name->clone();
古いコンパイラでこのコードが通らない場合は、以下のようにしておきます。
class Name : public Object
{
public:
// 戻り値を Object* に戻す.
virtual Object* clone() const;
// 代わりにこれを用意する.
Name* cloneName() const;
// ...
};
Object* Name::clone() const
{
return new Name(*this);
}
Name* Name::cloneName() const
{
return static_cast<Name*>(this->clone());
}
private で宣言のみ行う。
代入演算子は private で宣言のみ行い、オブジェクトの代入を禁止します。
実行するまで実際の型がわからないので、コード例のように仮想関数を使って実装します。
const の指定を厳密に行う。
値オブジェクトと同じように、状態を変更しないメンバ関数にはシグニチャの後に const を指定します。
小さなオブジェクトを頻繁に new、delete することが多くなるので、パフォーマンスやメモリフラグメンテーションが問題になるようなら、基底クラス Object でメモリ管理演算子(operator new、operator delete)を定義します。
引数を1つとるコンストラクタへの explicit の指定、出力演算子の実装は、参照オブジェクトのときと同じです。
最後に「参照型値オブジェクト」を利用する場合の注意点を確認しておきます。
「参照型値オブジェクト」の宣言と初期化は以下のように行います。
Name name1("Kudou Shinichi");
Name* name2 = new Name("Mouri Ran");
子クラスのインスタンスを親クラスのポインタで受けることもできます。
// こうしてもよい.
Object* object = new Name("Edogawa Conan");
clone() を用いて行う。
設計のポイントで書いたように、コピーコンストラクタや代入演算子ではなく、clone() を用いてオブジェクトを複製します。
auto_ptr を使用する。
ローカル変数をポインタで宣言する場合は、削除し忘れや例外発生時の対応を考慮し、auto_ptr を使用した方がよいでしょう。
auto_ptr<Name> name2(new Name("Mouri Ran"));
auto_ptr<Object> object(new Name("Edogawa Conan"));
const 参照で渡す。
値オブジェクトと同じように、関数へは const 参照で渡します。実行時の型がわからないので、値渡しをしてはいけません。
// こうする.
void useAsParam(const Object& object)
{
// object の状態は変更できない.
}
// これはダメ.
// Name を渡すとコンパイルエラーになるはず.
void useAsParam(Object object)
{
// do something...
}
実行時の型がわからないので、戻り値を値で返すことができません。しかたがないのでポインタで返しますが、オブジェクトのライフサイクルの管理の責任がどちらにあるかはっきりさせる必要があります。
// この場合は呼び出し側で削除する必要がある.
Object* useAsReturn()
{
Object* object;
object = new Name("Haibara Ai");
return object;
}
// 呼び出し側のコード.
auto_ptr<Object> result(useAsReturn());
他のクラスのメンバ変数として使用する場合は、通常次の形式になります。
class Client
{
public:
// デストラクタ.
~Client() {}
// アクセッサ.
Object* getValue() const;
void setValue(Object* value);
void setValue(const Object& value);
private:
// メンバ変数.
Object* m_value;
};
Object* getValue() const
{
return m_value;
}
void setValue(Object* value)
{
m_value = value;
}
void setValue(const Object& value)
{
m_value = value.clone();
}
Getter は内部状態を返していますので、長期的に値を保持したい場合は呼び出し側で複製を作った方がよいでしょう。
Client client;
auto_ptr<Object> result(client.getValue()->clone());
ポインタ版の setValue() は、呼び出し側で新しいオブジェクトを new してセットし、const 参照版の setValue() では、Setter 側で複製を作ります。
Client client;
// ポインタ渡し.
client.setValue(new Name("Kojima Genta"));
Name name("Tuburaya Mitsuhiko");
// const 参照渡し.
client.setValue(name);
以上、主にメモリ管理の観点から、C++ によるシステムの詳細設計を行う上での注意点を整理してみました。リファレンスとしてより充実した内容にしていきたいと思いますので、ご意見・ご質問等ありましたらどうぞお寄せください。
| © 2002 OGIS-RI Co., Ltd. |
|