C/C++の「ポインタ」とは
C/C++ の「ポインタ」は、難しいとか、実は簡単だとか、色々言われます。
ポインタについては規格に書かれています。この記事は、ポインタをより正確にイメージするため、規格に私の解釈を追加したものです。区別のため、C++23 草案 (N4950) の内容には節番号を付け、私の解釈は 斜体 で書きます。
私の解釈は以下の記事の影響を受けています。
ポインタとは、IDとオフセットの組
まず、ヌル以外のポインタは、以下の ID とオフセットの組 です。
- ID:新しいオブジェクトが作られるたびに振られる、ユニークな値。
- オフセット:オブジェクト先頭からのバイト数。
詳しく話していきます。
ID の割り振り
変数を定義するとオブジェクトが作られ (6.7.2)、各オブジェクトに固有の ID が割り振られます。たとえば、
int x, arr[10];
と書くと整数 x
と配列 arr
が作られるので、x
と arr
にそれぞれ ID が振られます。ユニークなので、x
の ID と arr
の ID は異なります。
ただし、ID が振られるのは変数として定義した x
と arr
だけで、要素 arr[0]
arr[1]
… arr[9]
に固有の ID は振られません。構造体でも同様で、
struct { int x, y; } foo;
と書けば foo
に ID が振られますが、foo.x
と foo.y
には振られません。
arr[5]
や foo.x
のように要素やメンバーとして含まれるオブジェクトをサブオブジェクトといい、そうでない arr
や foo
を完全なオブジェクトといいます (6.7.2)。ID は、完全なオブジェクトだけに振られます。
new
を用いてオブジェクトを作っても、同様に ID が割り振られます。
int *ptr = new int;
ptr = new int;
new
を書くたびに新しいオブジェクトが作られ、異なる ID が振られます。ここでも、振られる ID は 1 度の new
につき 1 つだけで、サブオブジェクトには振られません。
オフセット
完全なオブジェクトは、正のサイズを持ちます。サイズは sizeof
演算子で得られます。
int arr[10];
std::cout << sizeof(arr) << std::endl;
私の環境では int
が 4 バイトなので、40 と出力されました(以下 int
を 4 バイトとして話を進めます)。
この 40 バイトのうちどこか 1 箇所(末尾の直後も可)を指すのが、ポインタです。完全なオブジェクトの中で位置の指定に使う値を、オフセットと呼びます。
たとえばポインタが「arr
の ID」と「オフセット 0」の組み合わせなら、arr
の 0 バイト目、つまり先頭を指します。これは arr
自体で表されます。
一方ポインタが「arr
の ID」と「オフセット 20」の組み合わせなら、arr
の 20 バイト目、つまり(0 始まりで)5 番目の要素を指します。これは、足し算 arr + 5
で得られます。
「末尾の直後も可」というのは、オフセットが 40 でもよいという意味です。arr
は 40 バイトしかないので、40 バイト目は存在しない 10 番目の要素ですが、特別に arr
末尾の直後のみ許されています。もちろん 41 バイト目や -1 バイト目はなく、オフセットは常に 0 以上 40 以下の値です。
実際に書くとこうなります。
int arr[10];
int *p = arr; // 「arr の ID」と「オフセット 0」
int *q = p + 5; // 「arr の ID」と「オフセット 20」
int *r = q + 5; // 「arr の ID」と「オフセット 40」
arr + 11
や arr - 1
を計算しようとすると違法(未定義動作)なので (7.6.6)、負のオフセットや 40 を超えるオフセットについて考慮する必要はありません。
配列以外でも同様です。
int x; // サイズ 4
int *p = &x; // 「x の ID」と「オフセット 0」
int *q = p + 1; // 「x の ID」と「オフセット 4」
struct { int x, y; } foo; // サイズ 8(とします)
int *p = &foo.x; // 「foo の ID」と「オフセット 0」
int *q = &foo.y; // 「foo の ID」と「オフセット 4」
ヌルポインタ
どのポインタとも異なる、ヌルポインタ nullptr
という特別なポインタがあります。
足し算 nullptr + 0
ができることから、サイズ 0 の仮想的なオブジェクトの ID とオフセット 0 の組と考えても良いですが、そうすると nullptr
は完全なオブジェクトの末尾の直後を指すことにもなり、以下で都合が悪いので、ID とオフセットの組という形ではない特別なポインタ ということにしておきます。
==
!=
による比較
ポインタ同士は ==
!=
で比較できます。ここに未定義動作はありません (7.6.10)。
原則として、2 つのポインタが等しいというのは、ID とオフセットがともに等しいという意味です。
ただし例外があって、以下を全て満たす場合は結果が未規定です。
- ID が異なる。
- 片方のオフセットが 0。
- もう片方のオフセットがサイズと同じ(つまり末尾の直後)。
なぜこのような例外があるのでしょうか?それは最適化のためです。
ID とオフセットを別々に扱うと、各操作につき大変コストがかかるので、コンパイラは ID とオフセットをまとめて 1 つの整数値で表すように翻訳します。この過程で、ある完全オブジェクトの末尾の直後を指すポインタと、別の完全オブジェクトの先頭を指すポインタが、同じ整数値に対応してしまうことがあります。そこで規格は、プログラマーに対し「末尾の直後と先頭を比較しても結果に期待するな」と言うことで、コンパイラがより効率的なプログラムを生成することを許したのです。
サイズが 0 のオブジェクトが基本的に作れないのも、このためでしょう。
また、nullptr == nullptr
は常に真で、nullptr == (nullptr以外)
は常に偽です。コンパイラは、好きな整数値を nullptr
に割り当てておき、どのオブジェクトとも被らないようにすれば OK です。
<
>
<=
>=
による比較
ポインタ同士の大小は <
>
<=
>=
で比較できますが、基本的には同じ ID のときにオフセットの大小を比較するものです。
同じ配列中の 2 つの要素を指すポインタを比較すれば、ID が同じなので、添字が小さい方が小さく、添字が大きい方が大きくなります。また同じ構造体の 2 つのメンバーを指すポインタを比較すれば、ID が同じなので、前の方が小さく、後の方が大きくなります(ここでいう前後は、構造体を定義するときに宣言した順番です)。
一方、ID が異なる 2 つのポインタの大小比較については、矛盾が生じない(たとえば p < q
と q < r
と r < p
は同時に成り立たない)ことが保証されていますが、その具体的な結果は未規定です。nullptr
とそれ以外の大小比較も未規定です。
オブジェクトの生存期間
上で挙げたような変数宣言や new
でオブジェクトが作られると、オブジェクトの生存期間(ライフタイム)が始まります (6.7.3)。
変数のスコープを抜けたり delete
を呼んだりしてオブジェクトが破棄されると、生存期間は終了します。
上で話した、==
!=
<
>
<=
>=
によるポインタ同士の演算は、たぶん生存期間中のみ有効……だと思いますが、自信ありません。以下の記述がそうだと思います。
6.7.3 Lifetime [basic.life]
4. The properties ascribed to objects and references throughout this document apply for a given object or reference only during its lifetime.
整数とのキャスト
ポインタと整数の間でキャストができますが、0
が nullptr
になることを除いて処理系定義です (7.6.1.10)。
しかし、size_t
が十分に大きいとして、 int x;
に対して (int *)(size_t)&x
が元の値 &x
に戻ってくることは保証されています。おかげで、XOR linked list が実装できます。
先頭で紹介した記事 "Pointers Are Complicated, or: What's in a Byte?" ではポインタと整数のキャストを未解決の問題点と指摘していますが、処理系定義なら別に良いんじゃないかと個人的には思います。
Discussion
自分で最近考えていたことと重なる部分もあり、楽しく読ませてもらいました!
せっかくなので、いくつかの観点でコメントを書かせていただきます。
ポインタを ID とインデックスに分けるアイディアについて
ご存知かもしれませんが、N2311 などで Pointer Provenance というかなり近いアイディアがアカデミアと規格側の両方に提唱されていて、C 言語については形式的な意味論が作られていたりします。
ただし、C++ の (特にクラスに対する new/delete を含めた) Pointer Provenance については十分な議論はされていないと思うので、おそらく掘ってみると未解決問題の宝庫だと思います。
具体例かも?
最近個人的に気になったところだと、[class.ctor]/2 で example に出されている例との整合性が怪しい気がします。
例示されているコードに基けば、
&cobj
とthis
の比較結果は未規定としたくなる気がしますが、このあたりは特にポインタの比較に関する部分では言及されていません。生存期間外の領域へのポインタについて
生存期間外の領域を指すポインタについては、
void*
として扱う限り well-defined という規定があります。[basic.life]/6これがそのまま値の評価まで
void*
として扱って良いと書いているかは自信がありませんが、void*
同士の比較について特別な規定は見当たらないのでそのように思うと、[expr.eq]/3.3 と [expr.rel]/4.3 の規定により、比較の結果は未規定で良いのではないかと思います。またこの場合でも、partial ordering (記事中の「矛盾を生じない」?) は適用される気がします。XOR Linked List の正当化について
XOR Linked List の実装にあたっては、ポインタから変換された
size_t
型 (実際にはuintptr_t
型) の値に対してビット演算が行われるので、「int x;
に対して(int *)(size_t)&x
が元の値&x
に戻ってくる」だけでは不十分なように思えます。これについては前に記事を書いたので参考になれば幸いです。(Safely-Derived Pointer という古い概念を取り回していて、本当に記事の内容が正しいかは若干怪しいです)
ありがとうございます!!大変助かります。
Pointer provenance はアドレスにその provenance(この記事で言う ID)の情報が付加されたものというイメージだったので、そもそもアドレスのイメージを忘れて最初から ID とオフセットの組と考えればシンプルで良いのではないか、という考えでした。が、いただいた N2311 を見ると ID とアドレスの組なので、結局同じ感じでしたね……。
また、いただいた [class.ctor] の例の
this == &cobj
は、規格通りなら「cobj
のコンストラクタ内では true、他のコンストラクタ内では false」と読めて、確かに嫌ですね。存じ上げませんでした。ただ、これだと
としたとき
v1 == v2
が false になる必要があると思うのですが、いかがでしょうか。"A pointer converted to an integer of sufficient size (if any such exists on the implementation) and back to the same pointer type will have its original value" (7.6.1.10 https://timsong-cpp.github.io/cppwp/n4950/expr.reinterpret.cast#5) ですが、たしかに途中で演算を挟んでいいとは書かれていませんね……。
Safely-derived pointer、C++20 にはありますが、C++23 から消えたのでしょうか?キャストの事情が残っているのに何故消してもよいと判断されたのかよく分かりません。ちょっと難しそうな話ですね。
めっっっちゃ目が滑っていて、[expr.eq]/3.3 の "unequal" を "unspcified" と見間違えていたのに気がつきました…… ご指摘いただいてありがとうございます。
[basic.life]/6 には生存期間外の
void*
として扱って良い条件としてとあるので、もしとがさんの出した例でメモリ領域の再利用が行われた場合には、v1
(およびp1
) は正しくv2
(およびp2
) と等しい領域へのポインタとして扱われるのではないかと思います。詳しくは [basic.life]/8 に規定があります。(今回はp1
とp2
は正しく transparently replaceable でもある)実用的には、実際にメモリ領域の再利用が行われたかをチェックするために、逆に
v1 == v2
の値を見れば良い。とも読めると思います。→嘘です。領域が release されているので多分
v1
とp1
の利用自体が well-defined でないというのが正しいです消えたはずです。キャストの事情はおそらく認識されていないか元々それを意図したものではないので無視されていて、したがって C++23 では
intptr_t
に対する演算は基本的には保証がない (故に XOR Linked List も未保証) となると思います。ありがとうございます。難しい…… [basic.life] をもっときちんと読もうと思います
XOR linked list についてですが、たとえば
int x;
に対してはキャストして戻しているだけなので
p == &x
は真ですよね。でははどうでしょうか、これも格納の仕方が違うだけでキャストして戻しているだけに見えます。では整数専用の特殊なコンテナに格納し、その中でビット演算が行われていたらどうなのでしょうか。
規格の記述が正確に読み取れていないだけかもしれませんが、何もせずキャストする場合と、演算して元の値に戻してキャストする場合の区別がよく分からずにいます。
規格の文面だと分かりにくいですが、一般的に C++ の値の操作に関する規定は、構文上の話ではなく意味論的な話をしていることが多いです。
そのため、とがさんの例だと
std::vector
に一回格納していても行われている操作は (ほぼ) 同一なのでp == &x
は真だと思います。同様に、整数専用の特殊なコンテナなどを使ってビット演算を行なった場合には、普通にビット演算をした場合と同じことになるはずです。
演算して元の値に戻っていれば OK じゃないのか?みたいな話で言うと、以下の例で
p == a
となる保証がないのと同様の話だと思います。なるほど、理解が足りていませんでした。
ありがとうございます。
Safely-derived pointer の定義はC++11 GC最小サポート(N2670) で導入された概念でしたが、C++23 GCサポート廃止(P2186R2) で削除されていますね。
XOR Linked Listが合法(well-defined)か否かという議論は StackOverflow でも見つかりますが、言語仕様解釈としての明確な答えは出ていないようにも思えます。
「整数値を経由したポインタ値の解釈」は、 azaika さんが言及されている "Pointer Provenance(ポインタの来歴)" として C言語では2001年頃に問題提起され 延々と議論が続いています。C言語では ISO/IEC TS 6010 A provenance-aware memory object model for C(WG14 DTS)で PNVI-ae-udi (PNVI exposed-address user-disambiguation) モデル として結論を出す方向で進んでいるようです。C++言語もC言語の解釈に準ずる方向で 検討されている ので、おそらく同じ結論を出すものと思われます。
PNVI-ae-udi モデルについては onihusubeさんによる解説記事 も参考になります。同モデルに基づくと XOR Linked List も(晴れて)合法になるはずです。
ISO/IEC TS 6010の目的は、序文(Introduction)にある通り「現行Cコンパイラの要請とCコード資産の間を調停(reconciles)する」ことであり、仕様追加や変更が行われるものではありません。言語仕様の曖昧さ排除を目指したものですから、通常のCプログラマには影響はまずないと思われます。
"Pointer Provenance" と関連する話題として、C++/WG21では生存期間外オブジェクトを指す "Pointer Zap"(P2414) の取り扱いも議論されています(こちらも onihusubeさん解説記事 をご参考に)。C/WG14では最新規格C23にて 生存期間外オブジェクトを指すポインタ定義を微調整(N2861) しています。
一般のアプリ開発者は他言語の参照が
わかりやすいのでそれで十分かと
思います、
組み込みではドライバー開発でLSIの
直アドレスのアクセス等があるので
避けられませんが、そろそろc/c++から
rustへの移行時期かとおもいます。
rustもunsafeを宣言するだけでLSI等の
アクセスに改善があるわけでは無いですが
通常部分は安全になってます、
c/c++はダングリングポインタがあるので
怖いです、数百万行のシステムで発生するとツールを使っても調査が大変です。
rustはunsafe部分に局所化できるのでらくです、ジュニアエンジニアにも部分依頼しやすいです。
組み込みでもドライバー開発やOSポーティング限定の話しになりますね。