6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C++】初心者のためのクラス設計基礎② ~constの伝播~

Last updated at Posted at 2021-03-05

はじめに

 前回の続きです。前回同様、超初心者向けです。今回はメンバ関数のconstの付け方に関しての話です。割と短めの記事になると思っていましたが、そんなことはありませんでした。

前回までのあらすじ

 前回はカプセル化について話しました。メンバ変数をprivateにすることで、"常識的な"範疇でクラスを利用する限り、バグが起きないようにするというのがカプセル化の目的でした。

#include <cassert>

class Array {
private:
  int length;       // 要素数
  int* array_ptr;   // 配列のポインタ
public:
  void allocate(int len) {         // メモリ確保
    length = len:
    array_ptr = new int[length];
  }
  void clear() {                   // メモリ解放
    delete[] array_ptr;
    length = 0;
  }
  int size() { return length; }    // 要素数の取得
  int& nth_elem(int n) {           // n番目の要素へのアクセス
    assert(n >= 0 && n < length);  // 配列外参照を避けるためのassert
    return array_ptr[n];
  }  
};

 前回例として挙げたコードを見ると、カプセル化されていてなおかつallocateclearで処理がまとまっているおかげで、lengtharray_ptrが指す先の配列の要素数が (newが例外を出さなければ) 常に一致しますね。

クラス設計とconstの伝播

 前回に引き続き配列のクラスを設計しながら、constの付け方を解説していこうかと思います。constは初心者が割と躓きやすいポイントの一つだと思いますが、慣れればそこまで難しくありません。また、割とかっちりとしたルールに則って機械的にconstをつけるかどうか判別できます。そういう意味では「クラス設計」というより「C++の書き方」に近い気もします。

動機

 一般的に大きいオブジェクトのコピーというのは時間がかかります。コピーを避けるために、関数で引数を値渡しではなく参照渡しを使うのはよくある手段です。コンパイラの最適化次第で変わりますが、参照渡しをすることで高々ポインタ一つのコピー程度の時間コストに抑えられます。まだコピーコンストラクタやコピー代入の話をしていないので、Arrayのコピーは不完全で低コストのままですが、一旦それは置いておいて、Arrayのコピーを避けるために参照でArrayを関数に渡すことを考えましょう。例としてArrayの最後の要素を返す関数backを考えましょう。

// int back(Array arr);  // 値渡しは避ける
int back(Array& arr) {   // 参照渡し
  int len = arr.size();
  assert(len > 0);
  return arr.nth_elem(len - 1);
}

int main() {
  Array array;

  // arrayの初期化 
  array.allocate(42);
  for (int i = 0; i < array.size(); i++) 
    array.nth_elem(i) = i;

  // backに参照で渡す
  back(array);
  return 0; 
}

 何ら問題ないように思えます。さて、ここでさらにmain関数の中でarrayの初期化をするのをやめて、どこかの関数に処理をまとめましょう。

int back(Array& arr) { /* 略 */ }

Array init_array() {
  Array array;
  array.allocate(42);
  for (int i = 0; i < array.size(); i++)
    array.nth_elem(i) = i;
  return array;
}

int main() {
  back(init_array());    // error!!!
  return 0;
}

 するとどうでしょう。なんとこれはコンパイルエラーになります。まあこれはまだ直接の動機とは言えませんが、間接的な動機といえるでしょう。前置きがあまり長すぎてもあれなのでこのくらいにしておきます。今回はこれを解決するためにconst参照やメンバ関数のconstの話をします。

constの規則

 まずconstとは何かから解説していきましょう。

普通のconst

 constは定数を表すもので、型につけると初期化時以外に書き換えできなくなります。読み取りはできます。

int i = 0;
i = 42;      // 書き換えOK

const int ci = 0;
// ci = 42;  // error: ciへの書き込み
i = ci;      // OK: ciの値の読み取り

int const ic = 0;  // これもconst intと同義。どちらでも可。こちらの方が初心者向き?

 簡単ですね。例ではintにつけましたがdoublelong等基本的には何にでも付きます。

ポインタのconst

 ポインタにもconstがあります。こちらはつける位置で意味が変わって、もう少し複雑です。

int i = 0;
&i;   // int*型
const int ci = 0;
&ci;  // const int*型

// ポインタ自体も中身 (ポインタの指す先) も書き換え可能
int* ip1 = &i;      // OK: int* -> int*
// int* ip2 = &ci;  // error: const int* -> int* へ変換できない。中身のconstを外せない
ip1 = nullptr;      // OK: ポインタ自体の書き換え可能
*ip1 = 42;          // コンパイルはOK (nullptr参照でバグる)

// ポインタ自体がconst
int* const ipc1 = &i;     // OK: int* -> int* const
// int* const ipc2 = &ci; // error: 中身のconstを外せない
// ipc1 = nullptr;        // error: ポインタ自体を書き換えられない
*ipc1 = 42;               // OK: 中身は書き換え可能

// ポインタの中身がconst
const int* cip1 = &i;  // OK: int* -> const int* へ変換可能。中身にconstをつけるのはOK
const int* cip2 = &ci; // OK: const int* -> const int*
cip1 = nullptr;        // OK: ポインタ自体の書き換え可能
// *cip1 = 42;         // error: 中身はconst intなので書き換えられない

// ポインタ自体も中身もconst
const int* const cipc1 = &i;  // OK: 中身にconstをつけるのはOK
const int* const cipc2 = &ci; // OK: const int* -> const int* const
// cipc1 = nullptr;           // error: ポインタ自体を書き換えられない
// *cipc = 42;                // error: 中身も書き換えられない

int const* icp = nullptr;   // const int* と同義
int const* const icpc = nullptr;  // const int* const と同義

 ポインタのconstに関して重要なのは、一番外側 (ポインタ自体) のconstは自由につけ外しできることと、const int*からint*には変換できないけれど、int*からconst int*に変換できることですね。const int*は中身が書き換えできないことを保証するポインタであり、int*は中身の書き換えができるポインタなので、書き換えできないはずのものが書き換えられてしまうと困りますが、書き換えできるものを書き換えなくても困らないということですね。あと、const int* constは何もできないじゃんと思うかもしれませんが、値の読み取りはできるので注意してください。
 2段以上のポインタ (つまりポインタのポインタやさらにそのポインタ) のconstの規則はもっと複雑ですが、とりあえず置いておきます。というより僕も多分わかりませんが、わからなくてもそんなに困った経験もありません。

参照のconst

 さて、ポインタにconstがあるならば参照にもconstもあります。参照はポインタと違って宣言時に指す先が決まって、途中で変更できないことに注意してください。ある意味ポインタ自体がconstなポインタに近いといえますね。

int i = 0;
const int ci = 0;

// int& は中身が書き換えられる参照
int& ir1 = i;          // OK: int -> int& は変換可能
// int& ir2 = ci;      // error: const int -> int& に変換できない
ir1 = 42;              // OK: i が書き換わる

// const int& は中身が書き換えられない参照
const int& cir1 = i;   // OK: int -> const int& は変換可能
const int& cir2 = ci;  // OK: const int -> const int& は変換可能
// cir1 = 42;          // error: 書き換え不能

int const& icr = i;    // const int& と同義

 ここまではポインタとほぼ同じで、書き換えできないはずのものが書き換えられてしまうと困りますが、書き換えできるものを書き換えなくても困らないという考えを適用できます。ただ、参照/const参照にはもう少し違いがあります。それを理解するには右辺値と左辺値を理解する必要があります。右辺値と左辺値は、大雑把に言うとどこかに代入したりしないとすぐに消えるか否かで分けられます。すぐに消える方が右辺値です。代入しないと消えてしまうので代入式の右辺に来ることが多いからそう呼ばれています。逆にすぐには消えないのが左辺値です。左辺値は代入式の右辺にも左辺にも来ますが、右辺値と対になるものとして左辺値と呼ばれることが多いです。
 あるいはアドレスを取れるか否か、とも考えられます。右辺値はアドレスを取ろうとするとコンパイルエラーになります。

int func() { return 42; }

int main() {
  int i;       // 左辺値。i はmain関数の終わりまで生き延びる
  int* p = &i; // 左辺値はアドレスを取れる
  *p;          // 左辺値。この行が終わっても *p は消えない

  42;          // 右辺値。この行が終わると 42 という値の情報は消える
  i + 1;       // 右辺値。この行が終わると i + 1 の計算結果は消える
  func();      // 右辺値。戻り値はこの行が終わると消える
  // &42;      // error: 右辺値はアドレスを取れない

  int& ir1 = i;          // OK: 左辺値はOK
  // int& ir2 = 42;      // error: 右辺値はダメ

  const int& cir1 = i;   // OK: 左辺値はOK
  const int& cir2 = 42;  // OK: 右辺値もOK。右辺値はmain関数の終わりまで生き延びられるようになる
  
  return 0;
}

 上の通り普通のconst無しの参照は左辺値しか取れません。そのため左辺値参照とも呼ばれます。実は右辺値のための参照もありますが、それはまた次回以降にしましょう。それに対して、const参照は左辺値も右辺値も取れます。

constの伝播

 さて、やっと本題に入れます。動機で挙げた例がなぜコンパイルエラーになるのかもうわかりましたね。

int back(Array& arr) { /* 略 */ }
Array init_array() { /* 略 */ }

int main() {
  back(init_array());    // error!!!
  return 0;
}

 backの引数は左辺値参照ですが、渡している引数は右辺値です。だからコンパイルエラーになってしまうのです。コンパイルエラーを回避する一つの手段としては、

int main() {
  Array array = init_array();
  back(array);
  return 0;
}

 このように左辺値を用意すればいいですね。ですが、できれば直接関数の戻り値を渡したいので、backの引数をconst参照にしてみましょう。

int back(const Array& arr) {
  int len = arr.size();
  assert(len > 0);
  return arr.nth_elem(len - 1);
}

 backの中身を見てみると、arrの要素数と、最後の要素を読み取っているだけで、arrに対する書き換えはなさそうです。そのため、constにしても問題はなさそうに見えますが、、、これは残念ながらコンパイルエラーが出ます。なぜかというと、実はconst Array*からArray*への隠れたキャストが存在するからです。中身のconstは外せないんでしたね。いったいどこでそんなキャストをしているか疑問に思うかもしれませんが、メンバ関数の呼び出しでそれが行われています。クラスのメンバ関数は呼び出し時に暗黙の引数としてthisポインタを渡します。これは何かというと、クラスオブジェクト自体のポインタです。そして、メンバをメンバ関数内で使用するときはこのthisポインタを経由して呼び出されます。thisポインタは省略できるので一見わかりませんが。

class Array {
  /* 略 */
  int size() {      // 暗黙の引数: Array* this
    return length;  // this->length と同義。thisは省略可
  }
  int& nth_elem(int n) {           // 暗黙の引数: Array* this
    assert(n >= 0 && n < length);  // n < this->length と同義
    return array_ptr[n];           // this->array_ptr[n] と同義
  }
};

int main() {
  Array arr;
  arr.size();  // 暗黙のうちに Array* this = &arr という引数が渡されている。

  const Array& arrref = arr;  // OK: 中身にconstはつけられる
  // arrref.size();   // error: Array* this = &arrref というのは const Array* -> Array* への変換
  return 0;
}

 さてこのままでは困るので、暗黙の引数をArray*ではなくconst Array*に変えたいわけですが、これは簡単にできて、メンバ関数のシグネチャの後にconstをつければいいだけです。sizeでは読み取りだけ行っているので、constをつけるだけでいいですが、nth_elemでは、返り値を読み取る場合と、返り値に書き込む場合の2種類の使い方が考えられるんでしたね。そこで、暗黙の引数がconst Array*の時には読み取りのみ、Array*の時には読み取りと書き込みの両方ができるようにするのが自然でしょう。つまり、nth_elemはオーバーロードします。読み取り用の方は参照ではなく値で返します。これによって戻り値が左辺値ではなくなるので書き換えできなくなります。

class Array {
  /* 略 */
  int size() const {  // constをつける。暗黙の引数: const Array* this
    return length;
  }
  // 読み取りのみ
  int nth_elem(int n) const {   // 暗黙の引数: const Array* this
    assert(n >= 0 && n < length);
    return array_ptr[n];
  }
  // 読み取り & 書き込み
  int& nth_elem(int n) {        // 暗黙の引数: Array* this
    assert(n >= 0 && n < length);
    return array_ptr[n];
  }
};

 これで、晴れてコンパイルが通るようになります。めでたしめでたし。ちなみに非constのArrayからは両方のnth_elemを呼び出せる (Array*Array*にもconst Array*にも変換できる) わけですが、その場合はconst無しの方が優先されます。基本的にconstは、クラスオブジェクトから情報を読み取るだけならつけて、クラス内部が書き換わる可能性がある場合はつけないのが鉄則です。

constの伝播再考

 前回の記事に倣って今回も再考なんて章題をつけましたが、まああまり適切なタイトルでもないかもしれません。ここではconstの伝播の限界とconstの付け方でプログラマに判断が委ねられる部分の話をします。
 クラスにconstをつけると、メンバが全てconstになったかのように振る舞うわけですが、constなのは一番外側だけです。つまり何が言いたいかというと、メンバにポインタを持つ場合は中身までconstにならないということです。例としてArrayに適当な関数funcを追加してみましょう。

class Array {
  /* 略 */
public:
  int func() const {         // 暗黙の引数: const Array* this
    // length = 42;          // error: length は const int 扱いなので書き換えられない
    // array_ptr = nullptr;  // error: array_ptr は int* const 扱いなので書き換えられない
    array_ptr[0] = 42;       // OK: array_ptr は int* const 扱いなので (const int* constではないので) 書き換えOK
    return 42;
  }
};

 このfuncというメンバ関数はconst指定されていますが、クラス内部の情報 (array_ptr[0]) を書き換えてしまっていますね。これはconst教信者の大激怒を買うこと間違いなしです。

Array init_array() { /* 略 */ }

int main() {
  const Array array = init_array();  // const教信者ならこう書く可能性が高い
  array.func();   // constにしたはずなのにクラスの内部が書き換わった -> バグに繋がる
  return 0;
}

 まあconst教信者でなくともこれが原因でバグに遭遇する可能性は高いでしょう。そのため、基本的にはconstをつけたらクラスの中身は決して書き換えないというのを徹底しましょう。どうしても書き換えたいのなら、constを外すか、constのついてないバージョンをオーバーロードしてそこで書き換えるかのどちらかです。逆に書き換えない場合は必ずconstをつけましょう。つけないとクラスがconstだった時に呼び出せなくなります。
 で、ここからが重要なのですが、constがついているメンバ関数の内部で書き換えなければ何をしてもいいのかというと、そんなことはありません。戻り値も気を付けなければいけないのです。

class Array {
  /* 略 */
  int& nth_elem(int n) const {   // constでない左辺値参照を返す
    assert(n >= 0 && n < length);
    return array_ptr[n];
  }
};

int main() {
  const int i = 42;
  if (i = 42) {   // うっかり = と == を間違えたが、iが書き換え不能でコンパイルエラー
    // ...
  }
  // ...
  
  const Array array = init_array();
  if (array.nth_elem(42) = 42) {  // うっかり = と == を間違えたがコンパイルが通る。
    // ...
  }
  // ...
  return 0;
}

 上の例ではnth_elemはconstがついていて、なおかつconstでない左辺値参照を返しています。そしてif文の中で===を間違えているわけですが、通常はconstがついていればコンパイルエラーになるものの、const Arrayの方はコンパイルが通り、しかもarrayconst Arrayのはずなのに書き換わります。つまりバグの原因になります。const教に入信する理由の一つとして、if文の===のミスをコンパイルエラーで気付けるというものがありますが、それが無意味になってしまいます。まあ最近のコンパイラは賢いので警告を出してくれますが。
 ゆえに、constをつけたメンバ関数では、戻り値を通したクラス内部の書き換えも許してはいけません。メンバ関数にconstをつけたら、クラスの内部への参照やポインタを返すときは必ずconstをつけて返しましょう。それか参照をやめて、値で返しましょう。

終わりに

 今回はconstのルールとつけ方の話でした。慣れれば簡単ですが、慣れるまでは大変なイメージがあります。この記事を読んでくださった初心者の方はなかなか理解しきれない部分が多かったかもしれません。さて、次回は演算子オーバーロードのお話をしようかなと思っています。お楽しみに。

次回: 【C++】初心者のためのクラス設計基礎③ ~演算子オーバーロード~

おしまい

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?