|
本サイトは移転しました。旧アドレスからのリダイレクトは2025年03月31日(月)まで有効です。
|
🛈 | ✖ |
C++の構文である"参照"を説明する。プログラミング一般を語る文脈の"参照"ではない。
ウィキペディア(日本語版)にその項目は欠落する。
参照とは、最広範には、名詞あるいは代名詞とその対象(哲学の定義するオブジェクト)との関係と解釈される。プログラミング一般の文脈でも、多くは識別子(プログラミング言語の定義する名前)とその対象(プログラミング言語の定義するオブジェクト)との関係として、先の定義の範疇に収まる。Pythonの参照セマンティクスはその典型だが、C++の値セマンティクスも関係を1:1に限定する参照と言えなくもない。C++は複数の識別子から単一のオブジェクトを参照するための構文として、C言語より受け継ぐポインタとC++で加えられた参照を用意する。このように"参照"は文脈に依存して少なくとも三つの定義(最広範、プログラミング一般、C++構文)が存在し、本サイトは特に区別せず混用する。本記事はC++構文の"参照"を主要なテーマとして扱う。
本記事においてオブジェクトはC++規格定義(JTC1/SC22/WG21 N4659 4.5/p1)とする。他記事のほとんどで用いる"クラスとインスタンスの総称"ではないことに留意いただきたい。C++構文の参照はオブジェクトまたは関数にバインドされる(11.3.2/p5)。
参照はポインタ(*)と同じように型を構成し、左辺値参照(&)と右辺値参照(&&)がある(N4659 11.3.2/p2)。サンプルコードを実行すればナイーブなあなたは右辺値参照型の怪しいオーバーロード選択を知るだろう。
左辺値参照(lvalue reference)はC++創世記から存在するが(Bjarne Stroustrup, The Design and Evolution of C++, Reading, Addison-Wesley, 1994; Reading, Addison-Wesley, 1998, pp.85-87)右辺値参照(rvalue reference)はC++11で規格に加えられた。参照(reference)は両者の総称で、必ず初期化を伴い(11.6.3/p3)指定された対象は変更できず(p2)、つまり参照は対象にバインドされる。左辺値参照と右辺値参照は初期化の過程のみが異なる(p5)。
式とは演算子(operator)と被演算子(operand)のシーケンスで計算を定める(N4659 8/p1)。演算子オーバーロードは実態が関数コールなので議論に含めない(p2-3)。かつては代入演算子(=)の左側に現れる式(expression)を左辺値式(lvalue expression)、右側に現れる式を右辺値式(rvalue expression)と称したが、C++11以降はより細かく分類して値カテゴリ(value category)と呼ぶ(6.10/p1)。式は型(多くの場合、計算結果の型と同じ)に加えて値カテゴリで特徴付けられ、例えば"整数型の汎左辺値式"と呼ぶ。値カテゴリの日本語名はそれなりに同意できるものを選んだつもりだが、期限切れ値式はかなり苦しい。
以下は厳密性を重視してビットフィールド(12.2.4)を含むが、これは無視して構わない。
汎左辺値式は参照を結果とする(6.10/p2)。汎左辺値式が演算子の純右辺値式を要求する被演算子に現れると、左辺値から右辺値へ変換(lvalue-to-rvalue)などの標準変換(standard conversion)(7.1、7.2、7.3)で純右辺値式に変換される(8/p9)。
純右辺値式は結果(result)を値として実行した文脈に置く(6.10/p2)。結果オブジェクト(result object)は純右辺値式が初期化したオブジェクトで、ある演算子の被演算子の計算に用いられる純右辺値式はそれを持たない。純右辺値式が演算子の汎左辺値式を要求する被演算子に現れると、一時実体化変換(temporary materialization conversion)(7.4)が一時オブジェクト(temporary object)を結果オブジェクトとして初期化して、期限切れ値式とする(8/p10)。
期限切れ値式は以下のいずれかとする(8/p7)。一般に名前のある右辺値参照は左辺値式であり、名前の無い右辺値参照は期限切れ値式であり、関数の場合は名前の有無にかかわらず左辺値式である。
式は、それ自体が式である部分式で構成される(4.6/p9-13)。最小単位はリテラル(8.1.1)や変数(8.1.4.1/p1)といった一次式(primary expression)(8.1)で、丸括弧による括弧式(8.1.3)もまた内包する式の結果に置き換えられた一次式である。一次式でない式は単一のオーバーロードされていない演算子とその被演算子で構成される。被演算子は主に部分式であるが、特定の被演算子に現れる波括弧による初期化リスト(braced-init-list)(11.6.4/p1)は部分式でなく、その要素に式があればそれが部分式である(4.6/p9)。一次式/演算子は規格で40項目を数え(8.1-19)、自らと被演算子を必要に応じて定義する。規格は一次式に属さない項目のいくつかを"式"として定義するが、本サイトはそれらも"演算子"として定義されていると見なす(8/p3)。この見解はいくつかの議論を残し、例えばnew式(new expression)(8.3.4)をnew演算子(new operator)と呼べばoperator new(21.6.2)との関係が演算子/演算子オーバーロード関数となるという誤解を与えよう。それでも規格は三箇所(8.3.4/p4、18.4/p11、29.3/p1)でnew operatorを用い、教科書も"The name of the function the new operator calls to allocate memory is operator new."と語る(Scott Meyers, More Effective C++, Reading, Addison-Wesley, 1996, p.38)。
Xへの参照型を返す式は、X型の左辺値式あるいは期限切れ値式に修正される(8/p5)。これを根拠に本サイトは式が参照型となることはないと考えるが、これはスコット・メイヤーズの見解と異なる。いずれにせよ、式が参照を結果とするかどうかは型でなく値カテゴリで判断する。識別子(名前)を持つ変数はそれ自体が左辺値式となるid式(8.1.4.1/p1)なので、X型、Xの左辺値参照型、Xの右辺値参照型の変数全てがX型の左辺値式である。
C++におけるコピー(N4659 15.8.1/p1、15.8.2/p1)はC++創世記から存在し(Bjarne Stroustrup, The Design and Evolution of C++, Reading, Addison-Wesley, 1994; Reading, Addison-Wesley, 1998, pp.239-241)、オブジェクトを複製する。例えばX型オブジェクトxとyでX y=x;(コピー構築(初期化))あるいはy=x;(コピー代入)はxをyにコピーし、値セマンティクスで両者は同値の別オブジェクトである。値セマンティクスを1:1に限定する参照と解釈すれば、それぞれが同値の別オブジェクトを1:1参照する。それではC++11で規格に加えられたムーブ(15.8.1/p2、15.8.2/p2) とは何か。
C++11がC++0xと呼ばれていた昔、サイト作成者は参照と左辺値は等価と固く信じて、購読していたニュースグループで右辺値参照とムーブの存在を知り衝撃を受ける。内容を知らずにX y=std::move(x);(ムーブ構築(初期化))あるいはy=std::move(x);(ムーブ代入)はコンパイラが怪しい魔法を振るってxが1:1参照していたオブジェクトをyの1:1参照に変更するものと誤解して、その誤解はC++11登場後も長く解消しなかった。これがムーブの目指すセマンティクス(ムーブセマンティクス)である事は間違いないが、C言語のメモリモデルを継承するC++コンパイラはそんな魔法を振るえない。このセマンティクスを実現するにはXをクラス型としてクラスメンバに実装オブジェクトへのポインタ等による(C++構文でない)参照を所有して、ムーブでその所有をxからyへ移動する。pimplイディオムはその実装例を示した。C++標準ライブラリの供給するクラスあるいはクラステンプレートの多くはムーブできるが、これらもライブラリがその様に実装しているからである。
このようにC++11以降の構築/代入はコピーであるかムーブであるかのどちらかで、その選択はコンストラクタ/代入演算子のオーバーロード解決(16.3.1.6/p1)による。C++03以前にC++標準ライブラリが供給した悪名高きスマートポインタstd::auto_ptr(20.5.4.3.1/p1.1)はコピーコンストラクタ/代入演算子を ムーブセマンティクスで実装したが、これが問題含みなのは明らかでサイト作成者も何度かひどい目に遭ったものだ。
標準的なコンストラクタ/代入演算子のコピー/ムーブオーバーロードをまとめる。規格は各仮引数型のcv修飾(無し、const、volatile、const volatileのいずれか)は任意で、コピー代入演算子の仮引数型は非参照でも良く、オーバーロード選択する値カテゴリはそういった仮引数型で異なり、さらにX&&は右辺値式の他に関数左辺値式も選択するが、これらは例外と見なして表から除外した。より詳しくはpimplイディオムで説明している。
関数 | オーバーロード | オーバーロード選択する値カテゴリ |
---|---|---|
コピーコンストラクタ | X(const X&) | 左辺値式、右辺値式 |
コピー代入演算子 | operator=(const X&) | |
ムーブコンストラクタ | X(X&&) | 右辺値式 |
ムーブ代入演算子 | operator=(X&&) |
明示宣言しない場合、コピーコンストラクタ/代入演算子はムーブコンストラクタ/代入演算子の全てが明示宣言されなければ=defaultで暗黙宣言され、さもなくば=deleteで暗黙宣言される(15.8.1/p6、15.8.2/p2)。明示宣言しない場合、ムーブコンストラクタ/代入演算子はデストラクタ、コピーコンストラクタ/代入演算子、ムーブコンストラクタ/代入演算子の全てが明示宣言されなければ=defaultで暗黙宣言され、さもなくば宣言されない(15.8.1/p8、15.8.2/p4)。=defaultのコンストラクタ/代入演算子はサブオブジェクト(基底クラス、メンバ変数)をコピー構築/代入あるいはムーブ構築/代入する(15.8.1/p14、15.8.2/p12)。コンストラクタ/代入演算子がコピー/ムーブの両方を持つ場合の右辺値式のオーバーロード解決はconst修飾変換の要否による(16.3.3.1.4/p2、16.3.3.1./p3)が、標準的にはconstを持たずムーブを選択する。特に純右辺値の生成する一時オブジェクトはconstでない(15.2/p1.1)。標準的な場合のデストラクタ/コピー/ムーブの明示宣言有無におけるオーバーロードをまとめる。関数にチェック(✔)があれば明示宣言あり、なければ明示宣言なし、横棒(—)はどちらでも可とする。言うまでもなく選択された明示宣言が=deleteであれば失敗する。
値カテゴリ | ~X() | X(const X&) | operator=(const X&) | X(X&&) | operator=(X&&) | コンストラクタ選択 | 代入演算子選択 |
---|---|---|---|---|---|---|---|
左辺値式 | — | — | — | X(const X&) | operator=(const X&) | ||
— | ✔ | 少なくとも一つが✔ | X(const X&) | 失敗 | |||
— | ✔ | 少なくとも一つが✔ | 失敗 | operator=(const X&) | |||
— | ✔ | ✔ | 少なくとも一つが✔ | X(const X&) | operator=(const X&) | ||
右辺値式 | X(X&&) | operator=(X&&) | |||||
少なくとも一つが✔ | X(const X&) | operator=(const X&) | |||||
— | — | ✔ | X(X&&) | 失敗 | |||
— | — | ✔ | ✔ | X(X&&) | operator=(const X&) | ||
— | — | ✔ | 失敗 | operator=(X&&) | |||
— | ✔ | — | ✔ | X(const X&) | operator=(X&&) |
クラス型以外はムーブセマンティクスを持たない。クラス型もあなた(あるいはライブラリ)がムーブコンストラクタ/代入演算子を実装しない限り、ムーブセマンティクスを持たない。ムーブセマンティクスを持たないオブジェクトのムーブはコピーと同じになる。コピー/ムーブセマンティクスをあなた自身が実装する場合、デストラクタとコピー/ムーブのコンストラクタ/代入演算子はリソース(例えば実装オブジェクトへの参照として保持するポインタ)管理を矛盾なく行う必要があり、オーバーロードに課せられた制約はこれをある程度強制する。ただしC++03以前との互換性(例えばstd::auto_ptrのコピー)から不完全で、pimplイディオムに示したルール・オブ・スリー/ファイブ/ゼロといった規範に従う事が推奨される。
ムーブセマンティクスを実装するクラスのオブジェクトからムーブすれば、そのオブジェクトは"抜け殻"となる。右辺値式は純右辺値式あるいは期限切れ値式であるが、純右辺値式は一時オブジェクトを生成して期限切れ値式として参照される。純右辺値式を起源とする期限切れ値式の参照するオブジェクトは識別子(名前)を持たず、抜け殻になったとしても問題ない。一方、id式などの左辺値式を右辺値参照にキャストすれば期限切れ値式になり、std::move関数テンプレート(23.2.5/p5-6)はそういったキャストを行う。C++標準ライブラリ<utility>が定義するstd::moveのmingw-w64実装を示す。
左辺値式を起源とする期限切れ値式の参照するオブジェクトは識別子を持ち、抜け殻となった後も参照できる。ムーブセマンティクスを持つスマートポインタstd::unique_ptrで例示する。
抜け殻となったu1を逆参照すれば実行時エラーとなるのはstd::auto_ptrと同じだが、責任はstd::move(u1)したあなたにある。強調したいのはstd::move(u1)が単なるキャストに過ぎないことで、ムーブセマンティクスはオブジェクトのムーブコンストラクタ/代入演算子が実装する。右辺値参照とは、結局はムーブセマンティクスをオーバーロード解決で実現するための糖衣構文(syntactic sugar)であり、冒頭サンプルコードに示したint&& irr=(int&&)i;は左辺値参照と変わらず右辺値参照としての意味がない。スマートポインタの抜け殻は保持するポインタをnullptrとするのが自然であるが、一般には抜け殻がどうであるかは実装の勝手で、新たな値が代入されるまで抜け殻は利用するべきでない。
本サイトは右辺値参照をムーブセマンティクスをオーバーロード解決で実現する構文と整理した。原初のC++設計者は参照(左辺値参照)を演算子オーバーロードの効率化を目的とする構文として加えた(The Design and Evolution of C++, pp.85-86)。どちらも関数コールの文脈として総括できて、つまりは参照型仮引数の初期化(バインド)である(N4659 15.2/p6.1)。呼び出し側が純右辺値式を実引数に与える場合、それぞれが一時オブジェクトを期限切れ値式として生成してバインドする。
関数コールの文脈における仮引数初期化は変数初期化の一つと定義されるので、仮引数でない参照型変数も一時オブジェクトにバインドできる(11.6.3/p5.2)が、その用法に意味はあるだろうか。代表的な用法は一時オブジェクトの延命(15.2/p6)で、サンプルコードでa1は一時オブジェクトから別オブジェクトをコピー構築するが、a2とa3は一時オブジェクトへバインドしてコピー不要として、つまりa2/a3の方が効率的と主張する。
実際にサンプルコードを実行すればa1でもコピーコンストラクタがコールされていないことを確認するだろう。C++14以前でこれはコンパイラ最適化に依存したが、C++17以降はコピー省略が保証され(11.6/p17.6.1)何となればA(const A&)=delete;としてもコンパイルできる。つまりa2/a3のa1に対する優位性は無い。一方で一時オブジェクトへのバインドは不適格となる場合があり、コンパイラは(少なくともC++23以前は)これをエラーとしないので注意が必要になる。次のサンプルコードはT型の変数v1、v2、c.v_を一時オブジェクトで初期化して、TがAであればコピー構築、const A&あるいはA&&であればバインドされる。それぞれの寿命が変数に一致していればデストラクタは"*** test<T> returned ***"出力の後にコールされる。
TがAであれば変数とオブジェクトの寿命は一致する。Tがconst A&あるいはA&&であればv1は一致するものの、v2とc.v_にバインドされたオブジェクトは変数寿命まで延命しない。v2はコールした関数内の一時オブジェクトへのバインド(15.2/p6.2)で、c.v_はクラスメンバのコンストラクタへ与えた一時オブジェクトへのバインドで(15.6.2/p8)、どちらも規格は不適格と定義する。規格であるからそれ以上の議論は不要だが次のように考えれば理解の足しになるかもしれない。変数バインドされる一時オブジェクトのメモリ領域は実装次第だが、それが生成されたスコープに対応するスタックフレームと考える。関数内の一時オブジェクト寿命は関数評価終了まで、リターンでスタックフレームは破棄されて参照は無効になる。コンストラクタ実引数の一時オブジェクト寿命はコンストラクタ評価終了まで、コンストラクタのスタックフレームに属してリターンで破棄されて参照は無効になる。いずれも不適格であるがコンパイラはエラーとして検知しない。なお関数コールの文脈であれば一時オブジェクトの寿命とそれにバインドする仮引数の寿命は必ず一致して(15.2/p6.1)、不適格となる余地はない。
そもそもコピーコストを無視できないオブジェクトはポインタで扱うのが王道で、さもなくばムーブセマンティクスを実装すべきだろう。関数コールの文脈以外で一時オブジェクトへのバインドをサイト作成者は全く支持しないが、少なくともよほどの例外とすべきと思う。
関数f1の実引数に与えたオブジェクトを、f1がさらに別関数f2の実引数に与えてコールする場合、f2が参照型仮引数で受けなくてもf1は参照型で受けて余分なコピーを省く。f1は左辺値式/右辺値式をf2へ転送して必要であればクラス型オブジェクトの初期化におけるコピー/ムーブセマンティクスを選択し、つまり値カテゴリも"転送"する。初期化されるクラス型オブジェクトはf2の参照型でない仮引数が一般的だが、参照型仮引数で受けてローカル変数やクラス変数などを初期化する場合やf2自身がコンストラクタである場合も想定される。f2がオーバーロードされていて、f1がf2オーバーロードによらず規則的な場合、f2オーバーロードの全てにf1オーバーロードを書くのはいかにも面倒で、これらを値カテゴリ転送を含めて関数テンプレート化する手法を完全転送(perfect forwarding)と呼ぶ。まずは関数テンプレートを用いない場合を例示する。
f1仮引数が左辺値参照型(例えばA& a)であれば左辺値式(a)でf2実引数に渡し、右辺値参照型(例えばA&& a)であれば右辺値式(std::move(a))で渡す。なお多くの場合に両者はconst A&で一括できるがA&/A&&オーバーロードとしたほうが広く一般性を維持する。f1を完全転送で書き換えれば仮引数の数が異なる関数/関数テンプレートのオーバーロードになる。完全転送の構文は後に説明する。
テンプレート仮引数パック(N4659 17.5.3/p1)は関数/関数テンプレートのオーバーロードを単一の関数テンプレートにまとめる。これを完全転送の基本公式として覚えてしまえば良い。
完全転送を実現する構文を説明する。f1(T&& t)で例示するとして、tへ与える実引数はA型左辺値式あるいはA型右辺値式とする。tはいずれもid式で左辺値式なので、与えた実引数の左辺値式/右辺値式に従いA&/A&&でキャストしてf2に渡す。ところが通常の関数テンプレート実引数推定から左辺値式/右辺値式を推定する手段が無く、参照修飾子(ref-qualifier)&&の特殊な構文として転送参照(forwarding reference)が導入された(17.8.2.1/p3)。関数テンプレート仮引数Tで関数仮引数型がT&&の場合に限り転送参照となり、渡した実引数がA型左辺値式であればTはA&型、A型右辺値式であればA型と推定する。つまり冒頭サンプルコードのようにT&&の見かけは右辺値参照だが左辺値式も受ける。なお関数仮引数型がT&、const T&、const T&&の場合は転送参照とならない。このように推定されたTに参照修飾子&&を加えれば、TがA&型であれば参照折りたたみ(reference collapsing)でA&型となり(11.3.2/p6)、TがA型であればA&&型となる。
C++標準ライブラリ<utility>が定義するstd::forward関数テンプレート(23.2.5/p2-4)のmingw-w64実装を示すが、関数仮引数はTから参照を除いた型の左辺値参照型と右辺値参照型のオーバーロードとする。
f1(T&& t)はstd::forwardのテンプレート実引数に転送参照から得るT、関数実引数に左辺値式tを与える。f1に与えた左辺値式/右辺値式に従いTはA&/Aであるが、std::forwardのオーバーロード選択は必ず左辺値参照型(A&)でf1への左辺値式/右辺値式は関係しない。このように完全転送は通常ならstd::forwardの左辺値参照型オーバーロードを選択し、右辺値参照型(A&&)を選択するのは転送するオブジェクトを何らかの理由で右辺値式で与える例外と言える。std::forwardはtをT&&でキャストし、つまりf1に与える左辺値式/右辺値式に従いA&/A&&でキャストする。f1はstd::forwardの結果をf2に渡し、つまりf1に与えた左辺値式/右辺値式をf2へ転送する。