C++と色々

主にC++やプログラムに関する記事を投稿します。

C++でクラスに外の参照を持つ方法

気になったので考察してみました。

結論

std::shared_ptr 以外で参照を持つのは寿命管理が難しい

動機

オブジェクトが参照型である言語の場合、普通にクラスに別オブジェクトを持った場合それは参照になります。例えばJavaだと

class A {
    private int value;
    public A(int value) {
        this.value = value;
    }
    public int get() {
        return value;
    }
    public void set(int value) {
        this.value = value;
    }
}

class B {
    private A a;
    public B(A a) {
        this.a = a;
    }
    public void print() {
        System.out.println(a.get());
    }
}

class Program {
    public static void main(String[] args) {
        A a = new A(10);
        B b = new B(a);
        b.print(); // 10
        a.set(42);
        b.print(); // 42
    }
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

Bが所有しているAは参照であり、main関数でAを操作するとBの所有するAの状態が変わることがわかります。これと同じことをC++で行う場合、どうすれば良いでしょうか?というのが動機になります。

C++には参照型があります。型名に & をつけたものですね。また、ポインタも参照を持つと言えるでしょう。そこで以下の方法が考えられます。参照を所有したい型を Tとします。

  • T& で持つ
  • T* で持つ
  • std::reference_wrapper<T> で持つ
  • std::optional<T&> で持つ(できないです。理由は後述)
  • std::shared_ptr<T> で持つ
  • std::unique_ptr<T> で持つ(できないです。理由は後述)

それぞれ見ていきます。

T& で持つ

そもそもC++には参照型があります。

int a = 10;
int& refA = a; // refAはaへの参照になる
refA++;
std::cout << a << std::endl; // 11

先程のJavaの例をこれで実装してみます

#include <iostream>

class A {
    int value;
public:
    A(int value) : value{value}
    {}
    
    int get() const {
        return value;
    }
    
    void set(int value) {
        this->value = value;
    }
};

class B {
    A& a;
public:
    B(A& a) : a{a}
    {} 
    void print() const {
        std::cout << a.get() << std::endl;
    }
};

int main() {
    A a{10};
    B b{a};
    b.print(); // 10
    a.set(42);
    b.print(); // 42
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

できていますね。ですが問題が3つあります。参照の再代入と参照の寿命とデフォルトのコピー/ムーブ代入演算子がdeletedになる、の3つです。

参照の再代入

Javaのサンプルコード以下のコードを追加します

class B {
    ....
    public void setA(A a) {
        this.a = a;
    }
    ....
}

このとき、Bは別のAオブジェクトを再代入できるようになります。

public static void main(String[] args) {
    A a = new A(10);
    B b = new B(a);
    b.print(); // 10
    A a2 = new A(20);
    b.setA(a2);
    b.print(); // 20
    System.out.println(a.get()); // 10
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

C++では以下のようになります

class B {
    ....
    void setA(A& a) {
        this->a = a;
    }
    ....
};

int main() {
    A a{10};
    B b{a};
    b.print(); // 10
    A a2{20};
    b.setA(a2);
    b.print(); // 20
    std::cout << a.get() << std::endl; // 20 (!)
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

Javaでは a.get() は10のままでしたが、 C++では a.get() は20になりました。実はC++では参照の再代入は出来ず、 B::setAB::a の値を代入で上書きしただけに過ぎません。

int a = 1;
int b = 2;
int& c = a;
c = b; // これはcの参照をaからbに変えるのではなく、cが参照しているaにbの値を代入しているだけ

C++の参照型は後から参照先オブジェクトを変えられない仕様となっています。またC++の参照型は必ず初期化する必要があり、 Javaで言うと「初期値はnullにしておいて後からオブジェクトを代入」に相当する操作はできません。

参照の寿命

JavaなどGCがある言語では基本的に参照がある限りオブジェクトはdeleteされません。しかしC++はオブジェクトの寿命をプログラマーが管理できます。そのため参照先が存在しない参照型を作れてしまいます。

int main() {
    // AとBの定義は前と同じ
    A* a = new A{10};
    B b{*a};
    b.print(); // 10
    delete a;
    b.print(); // アッ
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

2度目の b.print() 呼び出し時点で a は存在していないため B::a は無効な参照になっています(dangling referenceと言います)。これはコンパイルエラーになりませんし、もしかしたらコンパイラによってはdeleteをしてもdeleteする前の値がメモリに残っていて実行しても意図したような振る舞いをしてしまうかもしれません。wandboxの場合は0が表示されました。サンプルコードぐらいの大きさなら見ればわかりますが、大きなプログラムでこのミスをしてしまうと該当箇所通過時に即死するわけでもなく中途半端に動いてしまってエラーの特定が困難になる可能性があります。個人的にdangling pointer/reference起因のバグは特定が厄介なバグの1つだと思います*1

デフォルトのコピー/ムーブ代入演算子のdeleted指定

非staticの参照型メンバまたはconstメンバを持つクラスはデフォルトのコピー/ムーブ代入演算子がdetele指定になります。つまり先述のC++のAクラスとBクラスのサンプルコードのうち、Bクラスはデフォルトではコピー/ムーブ代入ができません

int main() {
    // AとBの定義は前と同じ
    A a{10};
    B b{a};
    B b2{a};
    b2 = b; // compile error. コピー代入演算子は消されている
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

d.hatena.ne.jp

先述したように参照型には参照先を変える代入が出来ませんし、const修飾は代入を禁ずるものであるため、これらに対してデフォルトの代入の振る舞いは決められないのは当然といえば当然の仕様だと思います。自分で明示的に実装すればコピー/ムーブ代入演算子を持つことが出来ます。

その他

うっかりミスで気をつけなければならないのは、メンバを参照型にするだけでなく、コンストラクタ引数など初期化・代入する時の関数の引数も参照型にするということです。

class B {
    A& refA;
public:
    B(A a) : refA{a} {}
};

など書いてしまうと、コンストラクタにコピーで渡された一時オブジェクトのaへの参照を持ってしまいます。コンストラクタを抜けた瞬間にaは寿命を迎え、 B::refA は即dangling referenceになります。気をつけましょう。

T* で持つ

T* もつ場合のサンプルコードは T& の時ととても良く似ています。

#include <iostream>

// class Aの実装は同じなので省略

class B {
    A* a;
public:
    B(A* a) : a{a}
    {} 
    void print() const {
        std::cout << a->get() << std::endl;
    }
};

int main() {
    A a{10};
    B b{&a};
    b.print(); // 10
    a.set(42);
    b.print(); // 42
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

T& と比べて2点違いがあります。1つはnullptrが代入できる点、 2つ目はデフォルトのコピー/ムーブ代入演算子がdeleteされないという点です。

1点目のnullptrが代入できるという点ですが、T& の時に述べた Javaで言うと「初期値はnullにしておいて後からオブジェクトを代入」に相当する操作はできません。ができます。オブジェクトを作った後に参照を持ちたい場合に有効です。

2点目ですが、ポインタは参照型メンバやconstメンバではないためデフォルトのコピー/ムーブ代入演算子が生成されます。ポインタは参照型と異なり参照先のオブジェクトを変えることができるため、ポインタの参照が変わります。

int a = 1;
int b = 2;
int* c = &a;
c = &b; // 参照先が変わる
*c = 3; // bが3になるaは影響なし

ポインタで持つ場合、ポインタで出来る操作は何でもできてしまうので気をつける必要があります。ポインタのインクリメントやdeleteなどです。このような操作はする方が悪いとは思いますが、してはいけないことはできないようにする仕組みになっているべきだと思います。そのため他の人がコードを読む時にこのポインタは何を表しているのか?をわかるようにコメントを書くべきでしょう。参照型なら参照を保持しているのだとわかりますが、ポインタだとただ参照を保持しているだけとはポインタを見ただけでは断定できません。配列の先頭を表しているかもしれませんし動的に確保されるメモリを表しているかもしれません。

実はポインタで参照を持つというのは、あるクラスが自身が生きている間だけ参照されることがわかっているクラスへ参照をもたせる場合に使われたりします。

例えばあるクラスのinner classでiteratorを定義し、ownerを参照する場合などです。あまりいいコードではないかもしれませんがサンプルコードです

#include <iostream>

class X {
    int a;
    int b;
    int c;

public:
    X(int a, int b, int c) : a{a}, b{b}, c{c} {}
    
    class iterator {
        X* owner;
        int i = 0;
    public:
        iterator(X* owner) : owner{owner} {}
        
        iterator& operator++() {
            if (i < 3) {
                ++i;
            }
            return *this;
        }
        
        int operator*() {
            if (i == 0) {
                return owner->a;
            } else if (i == 1) {
                return owner->b;
            } else {
                return owner->c;
            }
        }
        
        bool operator!=(iterator const&) const {
            return i < 3;
        }
    };
    
    iterator begin() {
        return iterator{this};
    };
    
    iterator end() {
        return iterator{nullptr};
    }
};

int main() {
    X x{7, 6, 5};
    for (auto const& it : x) {
        std::cout << it << std::endl;
    }
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

iteratorオブジェクトはXより長生きすることはありませんから、iteratorオブジェクトが行きているうちはXの寿命は問題ありません(beginの戻り値のほうがXより長生きする場合があったら指摘お願いします)。

既に書いたようにポインタで保つ場合、参照の意図で持っているということがコードではわかりづらくまたポインタに許されている操作が出来てしまう点が気になります。また参照先の寿命の問題は依然として存在します。次は std::reference_wrapper を考えてみます。

std::reference_wrapper<T> で持つ

std::reference_wrapper<T>C++11から入ったTの参照を持つクラスです。ほとんど参照型 ( T& ) と近い使用感になりますが、参照型と2点異なる点があります。1つはコピー・代入可能であるという点です。これにより std::reference_wreapper<T> を持つクラスは生の参照型のようにデフォルトのコピー/ムーブ代入演算子がdelete指定されるということがありません。2点目はコピー・代入した際に参照するオブジェクトを変更できる点です

#include <iostream>
#include <functional> // std::reference_wrapper

int main() {
    int a = 1;
    int b = 10;
    std::reference_wrapper<int> c = a; // auto c = std::ref(a); とも書ける。こちらの方が簡潔で好ましい
    c.get() = 5; // aに5を代入
    c = b; // 参照をaからbに変更
    c.get() = 50; // bに50を代入
    std::cout << a << std::endl; // 5
    std::cout << b << std::endl; // 55
}

std::reference_wrapperget メンバ関数で参照を取り出します。参照を要求される文脈では operator T&() が使えるため、自然に参照を扱うことが出来ます。

上記のサンプルコードを見るとできることはポインタと同等でシンタックスは参照型(取り出しにget使ってるのは異なりますが…)のように扱えることがわかると思います。ポインタと参照型の両者の良いとこ取りをした型だと思います。改善はされていますが、参照の寿命の問題は依然残ったままです。ポインタでもstd::reference_wrapperでも所持している参照のオブジェクトが生きているか知ることは出来ません。

あと細かい点として、追加のヘッダのインクルードが必要になります。 #include <functional> です。

std::reference_wrapper<T> の使い所としてはテンプレートに参照を渡したい場合に有効です。

reference_wrapper - cpprefjp C++日本語リファレンス のサンプルコードです

#include <iostream>
#include <functional>

void f(int& x)
{
  ++x;
}

template <class T>
void g(T x)
{
  f(x);
}

int main()
{
  int x = 3;

  // 関数テンプレートの型推論によって、xの型が非参照のintと見なされる
//g(x);

  // 関数テンプレートに変数を参照として渡す
  g(std::ref(x));

  std::cout << x << std::endl; // 4
}

std::optional<T&> で持つ

std::optional<T>C++17から入った、T型に無効値を追加する型です。T& の時に書いた 「初期値はnullにしておいて後からオブジェクトを代入」に相当する操作はできません。 について、ポインタと別のアプローチで解決できるのではと考えました。つまり std::optional<T&> は T&に std::nullopt を追加した型なので初期値は std::nullopt にして後から参照を代入できるのではないかと考えました。しかしこれはできませんでした。

Tの要件に参照型ではないこと、が存在するためです。よってstd::optionalで参照を持つことは出来ませんでした。

std::shared_ptr<T> で持つ

参照カウントによって寿命管理されたポインタです。動的メモリは std::vector<T> を、文字列は std::stringイテレーターは各コンテナの iterator で表されるため、実質 std::shared_ptr<T> が所有するポインタは参照用途になると思っています。 今までてきたアプローチ全てで問題になっていた参照先の寿命問題が解決します。参照の再代入やデフォルトの代入演算子の問題も起きません。std::shared_ptr<T> で所有する限り参照先があることが保証されます(生のポインタを取り出してdeleteする等の野蛮な行為をしていなければ)。

所有することで寿命をロックしたくない場合は std::weak_ptr<T> で保持できます。

std::shared_ptr<T> を使った例です

#include <iostream>
#include <memory>

// class Aは今までと同じ

class B {
    std::shared_ptr<A> a;
public:
    B(std::shared_ptr<A> a) : a{a}
    {} 
    void print() const {
        std::cout << a->get() << std::endl;
    }
};

int main() {
    auto a = std::make_shared<A>(10);
    B b{a};
    b.print(); // 10
    a->set(42);
    b.print(); // 42
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

std::shared_ptrで今までのアプローチで出てきた問題は解決しますが、2つ注意点があります。1つはそもそも参照したいオブジェクトがstd::shared_ptrで管理されている必要があるというところです。サンプルコードのmain関数で、 astd::make_shared で作られています。これが普通の変数だったり、生ポインタだった場合Bはstd::shared_ptrで参照を持つことが出来ません。

int main() {
    // AとBの定義は1つ前のshared_ptrサンプルと同じ
    A a; // shared_ptrではなく普通の変数として定義
    B b{/* shared_ptr<A>が求められているが、ここをどうする? */};
    ....
}

よって参照を所有するクラスの外側でstd::shared_ptrを使って変数を作成する必要があり、参照を持つクラスの中と外両方の協調が必要になります。最初からstd::shared_ptrを使うように設計してコードを書く必要があるでしょう。

2つ目は、std::shared_ptr<T> には T& T* std::reference_wrapper<T> と比べてオーバーヘッドがある点です。

std::shared_ptr<T> は参照計測のための参照カウンタを持っておりそれも管理しています。またTの動的なメモリ確保が行われます。std::shared_ptr<T> で参照を保つ場合は T& などと比べてこれらのオーバーヘッドがあることは理解して使用しましょう。個人的には参照の寿命管理問題が解決するのに必要なコストだと考えます。

std::unique_ptr<T> で持つ

std::unique_ptr<T> はTを所有しているオブジェクトは1つであることを保証するスマートポインタです。よって今回のサンプルコードで言うと、main関数で定義された ab のコンストラクタに渡した時点で a は無効になります。

#include <iostream>
#include <memory>

// class Aは今までと同じ

class B {
    std::unique_ptr<A> a;
public:
    B(std::unique_ptr<A> a) : a{std::move(a)}
    {} 
    void print() const {
        std::cout << a->get() << std::endl;
    }
};

int main() {
    auto a = std::make_unique<A>(10);
    B b{std::move(a)};
    b.print(); // 10
    // a->set(42); 無効になってるので参照してはいけない
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

なのでそもそも今回やりたかった外の変数の参照を持つことができません(渡した時点で外の変数は無効になるため)。そのため std::unique_ptr<T> は今回の用途には適していません。

念の為言うとstd::unique_ptr自体はとても素晴らしいものです。適切な用途で使えばとても役に立ちます。

結論

寿命を安全に外の参照を所有したい場合、 std::shared_ptr<T> で持つのが良いです。動的メモリ確保と参照カウンタのオーバーヘッドは理解しておく良いです。 std::shared_ptr<T> で参照を持つにはそもそも外のオブジェクトが std::shared_ptr<T> で作成されている必要があります。

外の参照の寿命が分かっている場合、 T&, T*std::reference_wrapper<T> も使えます。

ただし T& の制限や特徴を理解しておくべきです。T* で持つと T& より制限が緩くなりますが、緩い故に注意です。

std::reference_wrapper<T> は関数テンプレートに参照を渡すのに有効ですが、 参照を所有するのにも使えます。 追加のインクルードが必要です。参照の取り出しは get メンバ関数で行います。

std::optional<T&> はそもそも許されていません。 std::unique_ptr<T> は外の参照を持ちたいという今回の目的には合いません。

*1:dangling pointer/referenceを検出する技術が開発されています https://www.reddit.com/r/cpp/comments/9jnm4r/cppcon_2018_herb_sutter_thoughts_on_a_more/