はじめに
この記事はC++の初心者向けにクラス設計の基礎を解説するものです。超初心者向けなので注意してください。クラス設計の具体例として、配列のクラスを実際に設計しながらどういう設計がいい設計なのか考察していこうと思います。とりあえず第一弾としてカプセル化の話を取り上げようと思います。
クラス設計とカプセル化
この章では配列のクラスを設計しながら、カプセル化の考え方を解説します。
動機
動機というほどのものでもありませんが、配列が必要になるケースは多々あるでしょう。みなさんも当然new
やmalloc
等で動的確保した配列を使ったことはあるのではないでしょうか。それとも最近は初心者でもいきなりstd::vector
とかを使うのでしょうか。まあ、とりあえずnew
を使って配列を用意して使ってみましょう。
int main() {
int length = 42;
int* array_ptr = new int[length]; // 動的確保
for (int i = 0; i < length; i++) {
array_ptr[i] = i; // 適当に初期化
}
// ...
delete[] array_ptr; // 確保したメモリの解放
return 0;
}
ここで、配列の要素数は42個なわけですが、その情報はlength
に入っていてarray_ptr
からはわからないですね。しかし実際の配列はポインタarray_ptr
の指す先にあるわけで。というわけで、みなさん当然クラスでlength
とarray_ptr
をまとめたくなりますよね? え、ならない? ならなかったらこの記事はここで終わります。
クラスの導入
とりあえずクラスにまとめるだけまとめてみましょう。public:
のタグの意味を知らない人はいったん無視してください。後で解説します。
class Array { // 変数をまとめる
public:
int length;
int* array_ptr;
};
int main() {
Array array;
array.length = 42; // 要素数の設定
array.array_ptr = new int[array.length]; // メモリ確保
// ...
delete[] array.array_ptr;
}
本当にまとめただけなので、あまりありがたみは感じませんね。でもlength
とarray_ptr
が互いに関係のある変数であるということはわかりやすくなったのではないでしょうか?
メンバ関数の導入
ところで、今はArray
が一回しか使われていませんが、将来的にはいろんな場面で何度も使うかもしれませんよね? しかし、そのたびに毎回要素数の設定 (array.length = 42;
) とメモリの確保 (array.array_ptr = new int [array.length];
) をやるのは面倒ではありませんか? うっかり片方書き忘れてしまったとかになると、バグの原因にもなります。そこでこの処理をまとめたいと思います。
class Array {
public:
int length;
int* array_ptr;
void allocate(int len) { // 処理をまとめる
length = len; // 要素数の設定
array_ptr = new int[length]; // メモリの確保
}
};
int main() {
Array array;
array.allocate(42);
// ...
delete[] array.array_ptr;
return 0;
}
これで、Array::allocate
を呼び出せば確実に要素数の設定とメモリの確保が両方とも行われますね。
カプセル化
しかし、そうなると今度は今まで
array.length = 42;
array.array_ptr = new int[array.length];
とやっていたこの方法は、いっそできないようにしてしまった方が良さそうに思えませんか? うっかりどちらかの処理を忘れたり、誤って途中でlength
やarray_ptr
を変に書き換えたりすると、バグの原因にもなるでしょう。そこで、length
とarray_ptr
をクラス内以外では書き換えられないようにしましょう。
class Array {
private: // クラス外からは見えない
int length;
int* array_ptr;
public: // クラス外からも見える
void allocate(int len) { /* 略 */ }
};
int main() {
Array array;
array.allocate(42);
// array.length = 60; // error: privateメンバはアクセスできない
// ...
return 0;
}
private:
タグ以下の関数や変数はクラスの中からしかアクセスできません。そのため、メンバ変数をprivate:
タグ以下に入れることで、余計な書き換えから守ることができます。ただし読み取りもできなくなります。これをカプセル化と呼び、クラス設計の基本ともいえる概念となっています。
また、public:
タグ以下はクラスの外からでもアクセスできます。上の例だと、Array::allocate
はクラス外からもアクセスできます。クラス設計の基本的な考え方として、publicな変数や関数を"常識的に"使っている限りはどんな使い方をしてもバグが起きないというのが重要です。(この"常識的"がどこまでを含むのかはなかなか難しい)
ちなみにclass
はタグがない場合はデフォルトでprivateなので、上の例だとprivate:
タグは省略できます。また、struct
はデフォルトでpublicであること以外はclass
と全く同じものです。
必要なメンバ関数の追加
さて、length
とarray_ptr
をprivateにしたことによって、いろいろと問題が生じてしまっています。例えば、array_ptr
にアクセスできなくなったことで、メモリの解放ができなくなりましたね。他にも要素数の取得や、各要素へのアクセスもできなくなりました。そこで、必要なメンバ関数を追加していきましょう。
class Array {
private:
int length;
int* array_ptr;
public:
void allocate(int len) { /* 略 */ }
void clear() { // メモリ解放
delete[] array_ptr;
length = 0;
}
int size() { return length; } // 要素数の取得
int& nth_elem(int n) { return array_ptr[n]; } // n番目の要素へのアクセス
};
int main() {
Array array;
array.allocate(42);
for (int i = 0; i < array.size(); i++) {
array.nth_elem(i) = i;
}
// ...
array.clear();
return 0;
}
まだまだ不完全な部分がかなり多いですが、とりあえずこれで最低限の機能は使えるクラスにはなりましたね。各要素へのアクセスArray::nth_elem
は要素の参照を返すことで、読み取りだけでなく書き込みも可能にしています。この関数はlength以上か負の数の引数をとってしまうと配列外参照になってバグります。しかし、このクラスを"常識的に"使うのであれば、Array::allocate
で確保した範囲の外側にはアクセスしないと思っていいでしょう。なので、このままでもいいと思いますが、不安ならばコメントにそのことを明記したり、以下のようにassert文を入れるのも手です。
#include <cassert>
class Array {
/* 略 */
int& nth_elem(int n) {
assert(n >= 0 && n < length); // 条件を満たさなければプログラムが異常終了する
return array_ptr[n];
}
};
assert文はデバッグ時にのみ有効になり、リリースビルドでは通常消えます。デバッグするときにエラーが起きたら使い方を誤ったことに気付けて便利です。
残った問題 (解決は次回以降)
まだまだ大量の問題が残っています。例えば、メモリを再確保したい時、
int main() {
Array array;
array.allocate(42);
array.allocate(60); // 再確保
// ...
array.clear();
return 0;
}
これは"常識的な"使い方に見えますが、残念ながら初めに確保した42個の要素分のメモリが解放されません。"常識的な"使い方ではなかった、というよりも、クラスの設計の仕方が悪いと僕は思います。
もちろん一概にはクラス設計が悪いとは言えないでしょう。allocate
したら再確保する前に必ずclear
をするのが"常識"とするのなら、これは使い方が悪いということですし、実際高速化のためにこのクラスの利用者にその"常識"を求めるのもありでしょう。ですが、一般的に考えれば、allocate
を立て続けに書いてもバグらないでほしいものですし、そうなるとやはりクラス設計が悪いと思います。この問題は次回以降に解決しましょう。他にもコピー代入・コンストラクトの問題やconstの問題もあります。初心者向けということで今回はあえて演算子オーバーロードの話を出しませんでしたが、配列アクセスといえばやはりoperator[]
を使うべきでしょう。これらも次回以降解決していく予定です。
カプセル化再考
さて、今回のテーマのカプセル化について改めて考えてみましょう。ここまで読むと必ずカプセル化すればいいと思うかもしれませんが、そんなこともないという例を挙げておこうと思います。例えば、様々な国のデータを保持する時、
struct Country {
const char* const name; // 国名
long long population; // 人口
long long land_area; // 国土面積
long long GDP; // 国内総生産
double density() { // 人口密度
return 1. * population / land_area;
}
double GDP_per_person() { // 一人当たりのGDP
return 1. * GDP / population;
}
};
というようにカプセル化せずに書くのと、
struct Country {
private:
const char* const name; // 国名
long long population; // 人口
long long land_area; // 国土面積
long long GDP; // 国内総生産
public:
// 国名の読み取りと書き込み
const char* get_name() { return name; }
// 人口の読み取りと書き込み
long long get_popuplation() { return population; }
void set_population(long long pop) { population = pop; }
// 国土面積の読み取りと書き込み
long long get_land_area() { return land_area; }
// 国土面積が変わることはほとんどないだろうが...
void set_land_area(long long la) { land_area = la; }
// 国内総生産の読み取りと書き込み
long long get_GDP() { return GDP; }
void set_GDP(long long gdp) { return GDP = gdp; }
double density() { // 人口密度
return 1. * population / land_area;
}
double GDP_per_person() { // 一人当たりのGDP
return 1. * GDP / population;
}
// コンストラクタ
// 次回以降に解説するので知らない方は気にしないでください
Country(const char* country) : name(country) {}
};
とカプセル化して書くののどちらが良いでしょうか? 僕は圧倒的に前者の方が好みです。もちろん後者が好みという人も一定数いるでしょうし、別にそれはそれでいいと思います。ですがやはり、カプセル化はメンバ変数同士が足並みを揃えて動かないとバグる場面において役立つわけですから、今回の場合は人口が増減したからと言って国の名前が変わるわけではありませんし、国内総生産が増減しても国土面積までそれに応じて変化したりもしないので、カプセル化する意味はないでしょう。カプセル化するかどうかはその時々に応じて使い分けるのがいいと思います。
ちなみに余談ですが、カプセル化しない場合はstruct
を、カプセル化する場合はclass
を使うケースが多い気がしますね。単純な好みの問題ではありますが。
終わりに
かなり初心者に向けて書いたので、これだけでは実用的なクラス設計とは到底言えませんが、この記事で何となくカプセル化の意義を理解していただけたら幸いです。次回以降メモリの管理やconst thisポインタ、コンストラクタ、デストラクタ等々について触れられればと思います。とか言って、次をいつ書くかはわかりませんが。次回分も書きました。
次回: 【C++】初心者のためのクラス設計基礎② ~constの伝播~
おしまい