Flat Leon Works

アプリやゲームを作ってます。

【C++アイデア】ラムダ式で発生しがちな不正な参照(ダングリングポインタ)を回避する

C++11から導入されたラムダ式はとても便利な機能ですが、その使われ方から不正な参照いわゆるダングリングポインタが発生しやすい機能でもあります。今回はその回避策を考えてみます。

なぜ不正な参照(ダングリングポインタ)が発生しやすいのか

ラムダ式変数をキャプチャした上で、その呼び出しを遅延できるからです。C++のポインタや参照は、その参照先オブジェクトの寿命が切れることで簡単に不正な参照(ダングリングポインタ)となってしまします。ラムダ式は呼び出しをいくらでも遅延できるため、その間にキャプチャしたポインタや参照の指す先のオブジェクトの寿命が切れてしまい、不正な参照が発生しやすくなっています。

また、ラムダ式内では見た目上はキャプチャした変数のスコープ内かのように見えてしまうのも、不正な参照が発生しやすい原因かもしれません。

{
    int a = 0;
    std::function<void()> func = [&](){
        a = 10;
        // ↑このaはちゃんと寿命内のスコープにいるように見えるが、
        // このラムダ式が呼び出されたときには寿命外のスコープにいるかもしれない
    };
}

不正な参照の発生例

#include <stdio.h>
#include <functional>
class A
{
    int m_ID = 777;
public:
    void Print( const char* p_mes ) const
    {
        printf( "[ID:%d]%s\n", m_ID, p_mes );
    }
};
int main()
{
    std::function<void()> func;
 
    {
        A* pA = new A;
 
        func = [=](){
            if ( pA == nullptr ){ return; }
            pA->Print( "Lambda" );
        };
 
        // pAインスタンス削除
        delete pA; pA = nullptr;
    }
 
    func(); // 不正な参照が行われる
 
    return 0;
}
  • A型インスタンスへのポインタであるpAラムダ式でコピーキャプチャ([=])し、std::functionに格納
  • そのstd::functionを呼び出す前にA型インスタンスを削除。pAにもnullptrをセット
  • しかし、コピーキャプチャなのでキャプチャされたpAnullptrにはならず
  • std::functionを呼び出し、そのまま不正にアクセスしてしまう

この例では、キャプチャ元のpAnullptrをセットしたものの、コピーキャプチャ([=])なためキャプチャの方のpAには反映されずそのまま不正アクセスになっています。それではコピーキャプチャではなく、参照キャプチャ([&])にすれば不正なアクセスを回避できるのでしょうか。答えはNOです。たしかに、参照キャプチャにすればキャプチャ元のpAnullptrをセットすることで、キャプチャの方のpAnullptrになります。しかし、そのあとのstd::functionを呼び出すときには、キャプチャの方のpAの指す先ではなく、キャプチャの方のpA自体が不正な参照になってしまいます。なぜなら、キャプチャ元のpAの寿命が切れているからです。

このように、ポインタや参照キャプチャは参照元の破棄(寿命切れ)を検知できないため、容易に不正なアクセスになってしまいます。

弱参照による回避策

先ほどの不正な参照の発生例であった、ポインタの不正なアクセス、つまりダングリングポインタについては弱参照で簡単に回避できます。弱参照といえばstd::weak_ptrですが、いろいろ面倒臭さがあり個人的に好きではないので今回は自作の弱参照クラスを利用します。弱参照クラスの実装については当サイトのこちらの記事をご覧ください -> 「【C++】弱参照クラスを自作する

#include "WeakPtr.h"
#include <stdio.h>
#include <functional>
class A
{
    DEF_WEAK_CONTROLLER( A ); // WeakPtrを作れるようにするための仕込み
    int m_ID = 777;
public:
    void Print( const char* p_mes ) const
    {
        printf( "[ID:%d]%s\n", m_ID, p_mes );
    }
};
int main()
{
    std::function<void()> func;

    {
        A* pA = new A;

        auto weakA = pA->GetWeakPtr();
        func = [=](){
            if ( weakA.IsNull() ){ return; }
            pA->Print( "Lambda" );
        };

        // pAインスタンス削除
        delete pA; pA = nullptr;
    }

    func(); // 弱参照により不正な参照が回避される

    return 0;
}

変更点

  • Aクラスから弱参照を生成できるように仕込み(DEF_WEAK_CONTROLLER)
  • ラムダ式の前に弱参照を変数として用意しておき、ラムダ式でキャプチャ
  • ラムダ式内ではキャプチャした弱参照を利用してオブジェクトの生存確認

クラス側に弱参照のための仕込みが必要ですが、それを含めても3行のコードの追加で不正な参照の回避ができるようになりました。かなりお手軽ではないでしょうか。

ラムダ式の前に弱参照変数を用意している部分は、C++14以降なら初期化キャプチャを利用できます。

        func = [=,weakA=pA->GetWeakPtr()](){
            if ( weakA.IsNull() ){ return; }
            pA->Print( "Lambda" );
        };

強参照(std::shared_ptr)による回避策

強参照(std::shared_ptr)でも不正な参照は回避することができますが、以下の理由であまり好きではありません。

  • オブジェクトの延命は意図しないオブジェクトの生存につながり、バグやメモリリークの原因となる
  • オブジェクトの延命はメモリの断片化を引き起こしやすい
  • 循環参照によるメモリリークの危険がある

もちろん強参照が有効な手段の場合もあります。

この辺は「【C++】弱参照のすすめ」でも紹介しています。

プリミティブ型への参照(ポインタ)の不正アクセス対策

今回紹介した弱参照による回避策は、弱参照を生成できるクラスへの参照(ポインタ)にしか利用できません。なので例えば、int型ポインタのコピーキャプチャ、あるいint型の参照キャプチャなどでは、弱参照による対策が利用できません。

int型の参照キャプチャでの不正アクセスの例

#include <stdio.h>
int main()
{
    std::function<void()> func;
    {
        int value = 10;
        func = [&](){
            printf( "value = %d\n", value );
        };
    }
    func(); // この時点で、valueの寿命は尽きているので不正なアクセスになる
    return 0;
}

で、ちょっと考えてみたところ、弱参照(WeakPtr)を生成するためのクラスであるWeakPtrControllerを使えばそれなりに対策ができそうな気がしてきました。以下のような感じです。

#include <stdio.h>
#include "WeakPtr.h"
int main()
{
    std::function<void()> func;
    {
        int value = 10;
        WeakPtrController<int> weakCtrl{ &value };
        WeakPtr<int> weakValue = weakCtrl.GetWeakPtr();
        func = [&,weakValue](){
            if ( weakValue.IsNull() ){ return; }
            printf( "value = %d\n", value );
        };
    }
    func(); // valueと同じ寿命であるweakCtrlが破棄されることでweakValueの参照が無効化され、不正なアクセスが回避される
    return 0;
}

ポイント

  • WeakPtrController<int> weakCtrl{ &value };とすることで、プリミティブ型からもWeakPtrが作れるようになる
  • WeakPtrControllerは対象のプリミティブ型と同じ寿命になる場所に配置する
  • WeakPtrはコピーキャプチャにする

実際に試してみた所ちゃんと動作したようなのでいけそうです。ただ、WeakPtrはコピーキャプチャにしなければいけないところが忘れやすそうで危ないと思いました。WeakPtrをコピーキャプチャにするのを忘れて参照キャプチャにしてしまうと、WeakPtr自体の寿命切れによる不正なアクセスとなってしまいます。これはコンパイルエラーにならず、場合によってはまともに動作しているようにも見えてしまうのがやっかいです。

プリミティブ型への参照の不正アクセス対策として紹介してきましたが、この方法はプリミティブ型以外にもDEF_WEAK_CONTROLLERマクロによる弱参照の仕込みができないクラス、例えば標準ライブラリのクラスなどでも使えそうです。

#include <stdio.h>
#include <string>
#include "WeakPtr.h"
int main()
{
    std::function<void()> func;
    {
        std::string value = "aaa";
        WeakPtrController<std::string> weakCtrl{ &value };
        WeakPtr<std::string> weakValue = weakCtrl.GetWeakPtr();
        func = [&,weakValue](){
            if ( weakValue.IsNull() ){ return; }
            printf( "value = %s\n", value );
        };
    }
    func(); // valueと同じ寿命であるweakCtrlが破棄されることでweakValueの参照が無効化され、不正なアクセスが回避される
    return 0;
}

参照キャプチャは控えるべき

参照キャプチャはポインタのコピーキャプチャと比べても、更に罠の度合いが強い気がします。なので、できることなら参照キャプチャは極力避けてた方がいいのではないかと思います。参照キャプチャを使うとしても[&]によるデフォルトキャプチャは使うわず[&var]による個別のキャプチャ指定を使い、さらに先程のWeakPtrControllerを使った例のように参照先オブジェクトの寿命切れを検知できる仕組みを一緒に使うべきかもしれません。

参照キャプチャの利用方針をまとめると以下のとおりです。

  • 確実に安全だとわかる場合以外は参照キャプチャはなるべく使わない
  • どうしても必要な場合は、個別に参照キャプチャを行う([&]にしない)
  • 参照キャプチャを使う場合は寿命切れを検知できる仕組みを合わせて使う

先ほどの例もよく考えたら参照キャプチャする必要ありませんね。

#include <stdio.h>
#include "WeakPtr.h"
int main()
{
    std::function<void()> func;
    {
        int value = 10;
        WeakPtrController<int> weakCtrl{ &value };
        WeakPtr<int> weakValue = weakCtrl.GetWeakPtr();
        func = [=](){
            if ( weakValue.IsNull() ){ return; }
            printf( "value = %d\n", *weakValue );
        };
    }
    func(); // valueと同じ寿命であるweakCtrlが破棄されることでweakValueの参照が無効化され、不正なアクセスが回避される
    return 0;
}

まとめ

  • 弱参照を使おう
  • 参照キャプチャは控えよう