24
26

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 5 years have passed since last update.

かなり初心者Advent Calendar 2017

Day 18

【C++】メンバにポインタを持つクラスの注意点(2重解放、コピーコンストラクタ、代入演算子)

Last updated at Posted at 2017-12-17

C++最初のつまずき

きっと常識ですが、こういうのを書くアドベントカレンダーだと思うので。。

C++初めてまだ2ヶ月くらいの超初心者ですが、最初に2重解放に結構苦しみました。
このページでは2重解放が起こる例(あくまで一例ですが)と、その解決方法を解説します。
(同じようなこと書いてある記事たくさんあると思います)

私自身C++初心者なので、間違い等ありましたらご指摘よろしくお願い致します。

スマートポインタ?なにそr

落ちるコード

以下のようなコードを用意しました。
ポインタ変数をメンバに持つクラスNumを定義しています。
このクラスはコンストラクタでメモリ領域を動的確保、デストラクタで解放しています。
このコードは実行するとdouble free or corruption(2重解放)で落ちます。

sample1.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }
    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

void dump(Num n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    return(0);
}

何がいけなかったのか

C++ではコピーに関する動作を特に何も定義しない場合、デフォルトのコピーコンストラクタが呼ばれます。(コピーコンストラクタの説明は後ほど)
このデフォルトのコピーコンストラクタにより、dump関数に値が渡るときに、Numのメンバ変数がそのままコピーされます。

valはポインタなのでメモリのアドレスが入っています、これもそのままコピーされます。
ポインタの値がそのままコピーされるということは、つまり同じメモリアドレスを指すということです。

次に、dumpの引数であるnの寿命は、dump関数が終了するまでです。
dump関数が終了するときにnは解放されます。nはNumの実体ですから、このときにNumのデストラクタが呼ばれます。
Numのデストラクタでは、valの指す先のメモリ領域を解放します。

続いてdump関数が終了したので処理はmain関数に戻ります。
main関数の残りの処理はreturn、つまり終了です。
main関数のnという自動変数もこのタイミングで解放され、デストラクタが呼ばれます。
しかし、デストラクタ内の解放処理時にvalの指すメモリ領域はすでに、dump関数の終了時に解放されています。
ここで2重解放が発生し、プログラムが落ちたのです。

C/C++において、すでに解放された領域に対する再解放(2重解放)の動作は未定義です。

double_free1.png

落ちないにようにするには

落ちないコードにするだけなら、解決方法は2つあります。

1.dump関数の引数をポインタ・参照にする

例えば、以下のようなコードは問題なく動作します。

sample2.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }
    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

//Numのポインタで受ける
void dump(Num *n){
    printf("%d\n", n->getVal());
}

int main(void){
    Num n(3);
    dump(&n);

    return(0);
}
sample3.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }
    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

//参照で受け取る
void dump(Num &n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    return(0);
}

この場合、dump関数の終了時には、ポインタ変数・参照が破棄されます。
Numのオブジェクトが破棄されるわけではないので、デストラクタは呼ばれず、2重解放は発生しません。

double_free2_2.png

2.適切なコピーコンストラクタを定義する

先程、C++ではコピーに関する動作を特に何も定義しない場合、デフォルトのコピーコンストラクタが呼ばれると書きました。
コピーコンストラクタとは、関数への値渡しなどのオブジェクトのコピー時に呼ばれる関数のことです。
このコピーコンストラクタは自分で定義することができます。

sample4.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }

    //コピーコンストラクタ
    //新たにメモリを動的確保してから同じ値を持たせる
    Num(const Num &num){
        printf("copy constructor is called\n");
        this->val = new int(num.getVal());
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

//値渡し
void dump(Num n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    return(0);
}

dump関数にNumのオブジェクトが渡る際に、値渡し(コピー)が発生しコピーコンストラクタが呼ばれます。
コピーコンストラクタ内で、そのオブジェクト用のメモリ領域を新たに確保し、同じ値を入れています。
dump関数が終了時にコピー先のオブジェクトが破棄されても、
valの指すアドレスは異なるので、元のメモリ領域は解放されず、問題なく動作します。
double_free3_2.png

これなら少なくとも落ちないコードになりました。

コピーコンストラクタ、代入演算子オーバーロード

コピーコンストラクタに触れたらやはり、代入演算子のオーバーロードにも触れておくべきでしょう。
オーバーロード(多重定義)とは、戻り値や引数が異なる同名の演算子や関数(メソッド)を複数定義することです。
C++では関数だけでなく、演算子もオーバーロードすることが可能です。

これは2重解放で落ちるコードです。

sample5.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }

    //コピーコンストラクタ
    //新たにメモリを動的確保してから同じ値を持たせる
    Num(const Num &num){
        printf("copy constructor is called\n");
        this->val = new int(num.getVal());
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

void dump(Num n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    Num m(1);
    //変数mにnを代入
    m = n;

    return(0);
}

新たに宣言した変数mにnを代入しています。
この時はコピーコンストラクタが呼ばれず、オブジェクトのメンバ変数などはそのままコピーされます。
main関数が終了するときに、m,nそれぞれデストラクタが呼ばれますが、
それぞれ持っているvalの指すアドレスが同じなので2重解放になります。

この問題は代入演算子をオーバーロードすることで解決できます。

sample6.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;
public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }

    //コピーコンストラクタ
    //新たにメモリを動的確保してから同じ値を持たせる
    Num(const Num &num){
        printf("copy constructor is called\n");
        this->val = new int(num.getVal());
    }

    //代入演算子のオーバーロード
    //引数は同型の参照を取り、戻り値も同型の参照
    Num &operator=(const Num &num){
        printf("oerator \"=\" is called\n");

        //自分自身が渡された場合は処理不要
        if(this != &num){
            this->val = new int(num.getVal());
        }

        //戻りは自分の参照
        return(*this);
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

void dump(Num n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    Num m(1);
    //変数mにnを代入
    m = n;
    dump(m);

    return(0);
}

代入演算子は代入演算子による代入が発生したときに呼ばれます。
代入演算子は右辺と左辺で同じオブジェクトを指定することもできる(n = nのように)ため、自分自身の場合は新しく動的確保しないという分岐を入れています。

どう書くべきなのか

これは未だに自分も悩んでいるところでして。。
個人的にはコピーコンストラクタ・代入演算子(オーバーロード)は必ず明示的に定義するべきだと思っています。

最初に関数にポインタ・参照渡しをする例を示しましたが、
このようなコードは、あとに説明したコピーコンストラクタ・代入演算子(オーバーロード)が定義されていても動作します。
今回の例では「どちらも同時に採用できる」ということです。

そもそも関数へのデータの渡し方と、コピーコンストラクタは別議論ではないでしょうか。

  • 関数へのデータの渡し方は速度だったり、副作用だったりを考慮して決めるべき
  • そこで決まったデータの渡し方について、コピーコンストラクタ・代入演算子が安全な・適切な渡し方を定義する

2重解放の危険性がある場合、「後者は値渡しできないように」定義するべきだと思います。
具体的には、コピーコンストラクタ・代入演算子自体をprivateにすればコンパイル時に弾くことができます。

sample7.cpp
#include <cstdio>

//数値を格納するクラス
class Num{
private:
    int *val;

    //コピーコンストラクタ
    Num(const Num &num);

    //代入演算子のオーバーロード
    Num &operator=(const Num &num);

public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

void dump(Num n){
    printf("%d\n", n.getVal());
}

int main(void){
    Num n(3);
    dump(n);

    Num m(1);
    //変数mにnを代入
    m = n;
    dump(m);

    return(0);
}
$ g++ -o sample7 sample7.cpp
sample7.cpp: In function ‘int main()’:
sample7.cpp:9:5: error: ‘Num::Num(const Num&)’ is private
     Num(const Num &num);
     ^
sample7.cpp:36:11: error: within this context
     dump(n);
           ^
sample7.cpp:30:6: note:   initializing argument 1 of ‘void dump(Num)’
 void dump(Num n){
      ^
sample7.cpp:12:10: error: ‘Num& Num::operator=(const Num&)’ is private
     Num &operator=(const Num &num);
          ^
sample7.cpp:40:7: error: within this context
     m = n;
       ^
sample7.cpp:9:5: error: ‘Num::Num(const Num&)’ is private
     Num(const Num &num);
     ^
sample7.cpp:41:11: error: within this context
     dump(m);
           ^
sample7.cpp:30:6: note:   initializing argument 1 of ‘void dump(Num)’
 void dump(Num n){
      ^

このように、そのクラスを「安全に扱う・危険なことはできない」ことを意識したコードにすると良いと思います。

ところで、クラスのメンバにポインタを持つの場合、必ずデストラクタで解放しなければならないというわけではありません。
例えば、そのクラスの外で確保した領域をそのクラスにポインタで渡し、クラス内はメンバのポインタで保持、
その領域の管理(確保・解放)はそのクラスの責任の範囲外とすれば、デストラクタで破棄する必要はありません。
この場合は、確保した領域の解放の責任はクラスの外に出せますが、そこのメモリ管理をしっかりしないとメモリリークを引き起こします。

書きながら思いましたが、結局ケースバイケースです。

vectorなどのコンテナ型も要注意

vectorも暗黙的にコピーコンストラクタが呼ばれるので注意です。
こんなことをすると落ちます。

sample8.cpp
#include <cstdio>
#include <vector>

//数値を格納するクラス
class Num{
private:
    int *val;

public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

int main(void){

    std::vector<Num> vec;
    Num n(3);

    vec.push_back(n);
    vec.pop_back();

    return(0);
}

vectorはコピーコンストラクタを呼んでコピーを格納します。
vector::pop_back()で要素の削除時にデストラクタが呼ばれます。
main関数の終了時にnのデストラクタが呼ばれますが、すでにn.valの指すメモリは解放されており、2重解放で落ちます。

これも、コピーコンストラクタを定義すると落ちなくなります。

#include <cstdio>
#include <vector>

//数値を格納するクラス
class Num{
private:
    int *val;

public:
    Num(int val){
        //動的確保
        this->val = new int(val);
    }
    
    //コピーコンストラクタ
    Num(const Num &num){
        printf("copy constructor is called\n");
        this->val = new int(num.getVal());
    }

    ~Num(){
        //解放
        delete val;
    }

    int getVal() const{
        return(*val);
    }
};

int main(void){

    std::vector<Num> vec;
    Num n(3);

    vec.push_back(n);
    vec.pop_back();

    return(0);
}

#まとめ
どう動くかがわかるとごくごく当たり前の動作です。
C++は書きながら考えることが多く、こんがらがってきますね。。
その1行で自分が何をやりたいのか、何をやっているのかをよく理解しないといけないです。

ただ、こういう低水準さがあるからこそ効率的なコードが書けるというのがC/C++の良さだとは思うので仲良くしたいです。
デバッガないと今頃死んでる。

24
26
1

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
24
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?