C++をはじめて一年くらいたちました


C++をはじめてちょうど一年くらいです。
大分慣れてきた感はありますが、
未だに基本的な部分がちゃんと理解できてないようなバグをたまに出すので、
確認していきます。

ポインタ

まず大前提として。

class Hoge {
public:
  int member_;
  double value_;
  void Func() { return; }
};

// 宣言と初期化
Hoge* pointer = new Hoge();

// アロー演算子(->)でメンバにアクセス
pointer->member_ = 3;

Hoge型のインスタンスを使用するためにはメモリ上のどこかに保存しなきゃなりません。
とりあえず

void SomeFunction() {
  Hoge hoge;
  hoge.member_ = 3;
}

みたいにしてもhogeはスタック領域に作られるので使えますが、これだとhogeはスコープから抜けると消滅するのでスコープ内でしか使えません。

new Hoge()

とするとヒープ領域の"どこか"にHogeクラスのインスタンス用の領域が確保されて使用することができるようになります。

ただ、このままではその"どこか"がどこなのかわかりません。

Hoge *pointer = new Hoge();

とすると、new Hoge()で確保した場所がどこであるかという情報(アドレス)がポインタ変数pointerに代入されます。
このポインタ変数を使用してnew Hoge()で確保した領域にアクセスすることが出来ます(≒インスタンスにアクセスすることが出来ます)。

pointer->member_;

このとき起こり得るバグとしては

Hoge *pointer;
pointer->member_; // 実行時エラー ポインタが初期化されていない

対策

Hoge *pointer = new Hoge(); // 出来るだけポインタの宣言と同時に初期化する

出来ないときは...

Hoge *pointer = nullptr; // 明示的にnullptrを代入してやら無いとだめだよ

if(pointer != nullptr)
  pointer->member;
Hoge *pointer = new Hoge();
delete pointer;
delete pointer; // 実行時エラー すでに開放された領域をさらに開放しようとした

対策

Hoge *pointer = new Hoge();

if(pointer != nullptr) {
  delete pointer;
  pointer = nullptr;     // deleteしたらnullptrを入れる
}

if(pointer != nullptr) { // deleteした領域をさらにdeleteしないようにする
  delete pointer;
  pointer = nullptr;
}
Hoge *pointer = new Hoge();
delete pointer;

pointer->member_; // 実行時エラー  開放した領域にアクセスしようとした

対策

Hoge *pointer = new Hoge();
if(pointer != nullptr) {
  delete pointer;
  pointer = nullptr;
}

if(pointer != nullptr) 
  pointer->member_;

ということで、アクセス/開放できないポインタはnullptrを代入しておくのがよさそうです。
よさそうですが、機械的にこう書けばいいわけじゃなく、もっと大事なのは

if(pointer != nullptr) {
  // ... 
} else {
  // ここ!
}

のelseの方で、たとえば

int some_parameter;
if(pointer != nullptr) {
  // pointerの指すオブジェクトの値でsome_parameterの値を更新する必要がある!
  some_parameter = pointer->member_; 
} 

SomeFunction(some_parameter);

みたいな書き方しちゃうと更新されるべきsome_parameterの値が更新されないままSomeFunction()が呼ばれてしまって原因が分かりづらいバグの原因になったりします。
pointerがnullptrだったときの適切な処理をelseに書いておく必要があります。

あと、僕が一番最初にしたひどい勘違いは
・ポインタ変数を宣言したときはnullptrが入ってる <= 入っていない!!
・deleteしたポインタ変数にはnullptrが入っている <= 入っていない!!
です。

コンストラクタ / デストラクタ

初期化や終了処理にコンストラクタやデストラクタが使えます。

class Hoge {
public:
  Hoge(); // コンストラクタ
  ~Hoge();// デストラクタ
  int member_;  
  Fuga *fuga_;
};

Hoge::Hoge()
  : member_(3) // メンバ変数の初期化とかをする
  , fuga_(new Fuga())
{
}

Hoge::~Hoge() { 
  delete fuga_; // 終了処理とかをする
}
int main() {
  Hoge* hoge1 = new Hoge(); // new されるとコンストラクタが呼ばれる
  Hoge hoge2; // プリミティブ型以外は宣言と同時に初期化されるのでコンストラクタが呼ばれる

  delete hoge1; // delete されるとデストラクタが呼ばれる
} // hoge2がスコープから外れるのでデストラクタが呼ばれる

コンストラクタに引数を与えることもできます。

class Hoge {
public:
  Hoge(int i) : member_(i) {}
  //...
};

int main() {
  Hoge hoge(3);
  hoge.member_; // <- これは 3
}

注意しなきゃならないのは、引数を一つとるときは

Hoge hoge(3);

なら、引数をとらないときは

Hoge hoge();

でいいんじゃね?と勘違いしてはならないということです。
以下のコードは、コンパイル時エラーとなります。

Hoge hoge();       // 引数なしのコンストラクタを呼び出したつも
hoge.member_ = 10; // ここでエラー

なんでこんなことになるかというと、コンパイラ

Hoge hoge();

int SomeFunction();

みたいな関数プロトタイプ宣言であると解釈してしまうからです。

SomeFunction.member_;

なんて書いてもだめなので上記の箇所でエラーが発生するわけですね。

new する場合はどっちでもいいのでややこしいですね

Hoge *hoge1 = new Hoge;   // これでもいいし、
Hoge *hoge2 = new Hoge(); // これでもOK

ついでに

Hoge hoge1 = Hoge;
Hoge hoge2 = Hoge(3);

みたいな書き方も出来ます。

コンストラクタを書かなかった場合でも、作成時やnew時に

Hoge::Hoge(){}

とおなじことが行われます。

継承を行った場合、継承元のコンストラクタを自動で呼び出します。

class BaseClass {
public:
  BaseClass() { std::cout << "BaseClass" << std::endl; }
};

class DerivedClass : public BaseClass { 
  DerivedClass() { std::cout << "DerivedClass" << std::endl; }
};

int main() {
  DerivedClass derived; 
  return 0;
}

結果

BaseClass
DerivedClass

ポインタなどの初期化が必要なメンバ変数は必ずコンストラクタで初期化しましょう。

コンストラクタに関してもう一つ。explicitについて。

一つだけ引数を取る(あるいは二つ目以降の引数にデフォルト引数が設定されている)コンストラクタを持つクラスは、
一つ目の引数の型から自動的にそのクラスに変換される可能性があります。

たとえば、

class Hoge{
public:
  Hoge(int i, int j = 3) : member_(i) {}
  int member_;
  int j_;
};

void func(Hoge hoge){
  std::cout << hoge.member_ << std::endl;
}

int main() {
  Hoge hoge = 3; // Hoge hoge = Hoge(3);と同じ
  std::cout << hoge.member_ << std::endl;

  func(6); // func(Hoge(6))と同じ
  return 0;
}

この機能のおかげで、

Hoge hoge_list[] = {1, 2, 3, 4};
for(int i = 0; i < 4; i++) 
  std::cout << hoge_list[i].member_ << std::endl;

みたいなことも出来るんですが、これをする必要が無いときは逆に変な間違いをしてしまう可能性も無いこともないです。

この暗黙の型変換を行わないためには、コンストラクタの宣言にexplicitをつければよいのです。

class Hoge {
  explicit Hoge(int n);
};

コピーコンストラクタ / 代入演算子

これら二つは良く似ています。

class Hoge {
public:
  Hoge(const Hoge& hoge);      // コピーコンストラクタ
  operator=(const Hoge& hoge); // 代入演算子
}

ややこしいのは

Hoge hoge1;
Hoge hoge2 = hoge1; // ここでは コピーコンストラクタが呼ばれる
Hoge hoge3;
hoge3 = hoge1;      // ここでは 代入演算子が呼ばれる

ですね。
コピーコンストラクタは

Hoge hoge1;
Hoge hoge2(hoge1); 

と呼ぶことも出来ます。

コピーコンストラクタと代入演算子は明示的に書かなくても、
各メンバ変数のコピーコンストラクタ/代入演算子を呼ぶだけの内容で自動的に作成されます。

が、明示的に書く場合は、コンストラクタを書かなきゃいけません。

コピーコンストラクタ/代入演算子で注意しなければいけないのは、

class Fuga {};

class Hoge {
public:
  Hoge() : pointer_(new Fuga) {}
  ~Hoge() { delete pointer_ }
private:
  Hoge* pointer_;
};

みたいな時です。

SomeFunction() {
  Hoge hoge1;
  Hoge hoge2(hoge1); // コピーコンストラクタが呼ばれる
}

このコードを実行すると、コピーコンストラクタが実行され終わると、
hoge1.pointer_にもhoge2.pointer_にも同じアドレスが入ることになります。

hoge1, hoge2がスコープを抜けるときに、それぞれのデストラクタが呼ばれ、
それぞれのメンバのpointer_が指す領域を開放しようとしますが、
それらが指す先は同じなので同一の領域を2回deleteしようとしたとして実行時エラーとなります。

この問題を解決するには、

1. コピーする際にアドレスをコピーするんじゃなくて、新たなオブジェクトを作成してそのアドレスをpointer_に渡す

Hoge::Hoge(const Hoge& hoge) {
  // ...
  this->pointer_ = new Fuga(*(hoge->pointer_));
  // ...
}

って方法があります。

あるいは

2. デストラクタでdeleteする際にnullptrチェックする

Hoge::~Hoge() {
  if(pointer_ != nullptr) {
    delete pointer_;
    pointer_ = nullptr;
  }
}

みたいなやり方もあります。1.はHogeに対して一つずつFugaが対応している場合、
2.は1つのFugaを複数のHogeが参照している場合に対応します。

Fugaをオブジェクト自体としてもつかポインタとして持つかの選択も含めて、やりたいことにあわせた方法を選択するべきですね。

参照

参照を使うと、関数中で引数を変更できます。
が、べつにポインタでも引数を変更できますよね。

void Func1(int* p) {
  *p = 3;
}

void Func2(int& p) {
  p = 5;
}

int main() {
  int i;
  Func1(&i);
  std::cout << i << std::endl;

  Func2(i);
  std::cout << i << std::endl;

  return 0;
}

この使用方法の場合、別にどっちでもいい気がします。
なんとなくだけど、どっちかといえば参照のほうが良いような気がします。

ポインタは 指す先(アドレス)と、指す先の値のどちらも変更することが出来ますが、
参照は、指す先の値しか変更することが出来ないからです。

つまり、

void Func1(int* p) {
  int j = 4;
  p = &j;
}

int main() {
  int i = 3;
  Func1(i);

  return 0;
}

みたいなコードがコンパイルできてしまうからです。

でも逆に、これが出来るからnullptrを結果として返せるとも考えられるので、そういう場合はポインタのほうがいいのかなと思います。

あと、上記コードは * の後ろにconstをつけるとちゃんとエラーになってはくれます。

void Func1(int* const p) {
  int j = 4;
  p = &j; // コンパイル時エラー
}

あと、参照の場合はポインタ演算が出来ないのでポインタ演算が必要ならポインタ、不要なら参照みたいなことも判断材料の一つではあると思います。

なんしか、挙動を正しく理解しておくことが肝要かなと感じます。

参照/ポインタのもう一つの重要な役割は、値渡しの際に生じてしまうコピーのコストを軽減することです。

関数にコンストラクタやデストラクタのコストが高い場合、関数呼び出しのたびに時間がかかってしまうのでパフォーマンスに問題が生じます。
そのオブジェクトの中身がみたいだけなのに、いちいちコンストラクタやデストラクタが呼ばれるのは無駄に思えますよね。

class Hoge {
 //...
};

void func(Hoge hoge) { // コピーコンストラクタによりhogeが作られる
  // hogeを使って何か処理をする
} // hogeのデストラクタが呼ばれる

int main() {
  Hoge hoge1;
  func(hoge1); 
}

しかし、参照渡し/ポインタ渡しをすればコピーコンストラクタ/デストラクタは実行されません。

void func1(Hoge* hoge) { // コピーコンストラクタは呼ばれない
  // hogeを使用する何らかの処理
} // デストラクタも呼ばれない

void func2(Hoge& hoge) { // コピーコンストラクタは呼ばれない
  // hogeを使用する何らかの処理
} // デストラクタも呼ばれない

int main() {
  Hoge hoge1;
  func1(hoge1);
  func2(hoge1);
}

ただし、これだと値を変更したくないのに誤って変更してしまう可能性があります。
 ⇒ constを正しく使いましょう

Hoge& hoge 
 ⇒ 指す先(アドレス)は 変更不可能  指す先の値は 変更可能

const Hoge& hoge
  ⇒  指す先(アドレス)は 変更不可能  指す先の値は 変更不可能

Hoge* hoge
  ⇒  指す先(アドレス)は 変更可能    指す先の値は 変更可能

const Hoge* hoge
  ⇒  指す先(アドレス)は 変更可能    指す先の値は 変更不可能

const Hoge* const hoge
  ⇒  指す先(アドレス)は 変更不可能  指す先の値は 変更不可能

とりあえず今日はここまで

微妙なところや分かりづらいところが多々あると思いますが、
こんな記事でも書いてる最中に勉強になったことがすごくたくさんありました。

入門レベルの人こそが、入門記事を書くべきなのかもしれないと思いました。