ポインタはアドレスを保持し、アドレスは数値です。ですから演算ができそうだと考えるのは自然です。実際にできますが、ただの整数演算とまったく同じではありません。C はポインタの演算を、指している対象のサイズで必ずスケールします。これによって「1 を足す」が、はるかに有用な「次の要素へ移動する」になるのです。
このたった 1 つの設計判断のおかげで、C ではポインタで配列を歩くことが、添字で歩くのと同じくらい自然になっています。
1 を足すと 1 バイトではなく 1 オブジェクト分進む
ポインタを取り、1 を足して、両方のアドレスを表示してみましょう。
int * は 4 バイト、double * は 8 バイト、char * は 1 バイト跳びます。どれも、その型の次のオブジェクトが始まるはずの位置にぴたりと着地します。ルールは単純です。
ptr + n == (ptr の中のアドレス) + n * sizeof(*ptr)
まさにこれが、ポインタに型がある理由です。型の付いていない裸のアドレスは、「次」がどれだけ先なのかを知りようがありません。
ポインタで配列を歩く
ここからが見返りです。配列の要素は連続したメモリに並んでいるので、ポインタを 1 歩ずつ進めれば順番に訪れられます。
どちらのループも同じ 5 つの数値を表示します。2 つ目では scores が先頭要素へのポインタに減衰し、p++ が int 1 つ分進み、*p がそこにある要素を読みます。メモリ上に並べるとこうです。
scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
| 88 | 92 | 75 | 60 | 100 |
+---------+---------+---------+---------+---------+
^ ^ ^
p p+1 scores+5
(開始) (4 バイト後) (末尾の 1 つ先)
2 つの書き方の関係は厳密で、覚える価値があります。scores[i] は *(scores + i) として定義されています。詳しくは ポインタと配列 を参照してください。
インクリメント、デクリメント、そして *p++ の罠
++ と -- も同じスケーリングでポインタに働きます。
誰もが混乱する式が *p++ です。後置 ++ は * より強く結び付くので、「現在の p を取り、p を進め、それから古い値を参照外しする」という意味になります。
3 つの式、3 つの異なる意味です。
| 式 | 実行後のポインタ | 生み出される値 |
|---|---|---|
*p++ | 進む | 移動前の要素 |
*++p | 進む | 移動後の要素 |
(*p)++ | 変わらない | 古い値。そして要素がインクリメントされる |
*p++ は C の慣用句で、文字列やバッファのコピーはこの形で書かれます。ただし、それ以外の意味を狙うときはかっこを書きましょう。
ポインタ同士の引き算
あるポインタから別のポインタを引くと、バイト数ではなく両者の間の要素数が得られます。
結果の型は ptrdiff_t で、<stddef.h> にある符号付き整数型です。%td で表示します。異なる 2 つの配列を指すポインタ同士の引き算は未定義の結果になります。標準はこの演算を 1 つのオブジェクトの内部でしか定義していません。
ポインタから整数を引くこともできますが(p - 3 は 3 要素分戻ります)、ポインタ同士を足すことはできません。「2 つのアドレスの和」は何も指し示さないからです。
ポインタの比較
同じ配列を指すポインタ同士では関係演算子が使え、その意味はメモリ配置から想像されるとおりです。p < q は、p がより前の要素を指しているときに真になります。
front < back がこのループの終了条件のすべてであり、両方のポインタが 1 つの配列の中にとどまっているので正しく動きます。== と != も、ループを終端マーカーと比べる自然な方法です。そこから、これらすべてを合法にしているルールへ話が移ります。
「末尾の 1 つ先」のルール
C は、配列の最後の要素の1 つ先の位置を指すポインタを作ることを明示的に認めています。計算しても、保存しても、比較に使ってもかまいません。しかし参照外しはできません。
int arr[4];
arr[0] arr[1] arr[2] arr[3] (要素なし)
+------+ +------+ +------+ +------+ +- - - -+
| | | | | | | | | |
+------+ +------+ +------+ +------+ +- - - -+
^ ^
arr arr + 4
使用してよい 作成と比較はしてよい
参照外しは決して許されない
このルールこそが、標準的なループの形を有効にしています。
for (int *p = arr; p != arr + n; p++) {
/* ... */
}
最後の反復で p は arr + n になり、比較が偽になってループを抜けます。その位置を読むことは一度もありません。
それでも未定義のままのことが 2 つあります。末尾の2 つ先を指すポインタや、先頭の1 つ手前を指すポインタを作るのは、たとえ決して参照外ししなくても未定義動作です。
int *bad1 = arr + n + 1; // 未定義 - 行き過ぎ
int *bad2 = arr - 1; // 未定義 - 先頭より前
2 つ目は実務で効いてきます。for (int *p = arr + n - 1; p >= arr; p--) と書いた逆順ループは、最後のデクリメントで arr - 1 を計算します。よくあるコンパイラではどれでも動きますが、それでも形式上は未定義です。きれいな逆順ループはこれを避けます。
void ポインタと、できない演算
void * は型の付いていないアドレスを保持します。malloc や qsort が汎用でいられるのはこのおかげです。要素サイズがないので、void * に対するポインタ演算は標準では認められていません。まず具体的な型(バイト単位の操作なら char *)にキャストしてください。
void *v = buffer;
// v + 1; // 標準 C ではない - スケールするサイズがない
char *b = v;
b + 1; // 問題なし - 1 バイト前へ
GCC と Clang は拡張として void * の演算を char * と同じに扱って受け付けるので、それに頼ったコードは、より厳格な環境でビルドされる日までコンパイルが通り続けます。指摘してほしいなら -std=c17 -pedantic を付けてコンパイルしましょう。
これで何が手に入るのか
ポインタ演算は、たまに持ち出す最適化のテクニックではありません。配列の添字付け、文字列の処理、そして標準ライブラリのあらゆるバッファ走査の土台にある仕組みそのものです。strlen は終端の '\0' まで進めたポインタから先頭を引いたものですし、memcpy は歩調を合わせて進む 2 つのポインタです。
次は、配列とポインタの同値性が実際にどう働くのかを見ましょう。初めて配列を関数に渡すとき誰もが引っかかる sizeof の罠も含めて、ポインタと配列 で扱います。
よくある質問
C で ptr + 1 は何をしますか?
ポインタを 1 バイトではなく 1 オブジェクト分だけ前へ進めます。典型的なマシンの int * なら 4 バイト、double * なら 8 バイトです。コンパイラが sizeof(*ptr) を掛けてくれるので、ポインタの型が重要になるのです。
C でポインタ同士を引き算できますか?
両方が同じ配列を指しているときはできます。end - start は両者の間にある要素の個数を返し、型は ptrdiff_t です(%td で表示します)。無関係なオブジェクトを指すポインタ同士の引き算は未定義動作です。
配列の末尾の 1 つ先を指すのは合法ですか?
合法です。最後の要素の 1 つ先を指すポインタを作ることは明示的に許されており、for (int *p = arr; p != arr + n; p++) のようなループが成り立つのはそのためです。そのポインタを計算して比較することはできますが、参照外しは未定義動作です。
なぜ *p++ と (*p)++ は同じではないのですか?
*p++ はポインタをインクリメントし、古い値のほうを参照外しします。つまり現在の要素を読んでから前へ進みます。(*p)++ はポインタをそのままにして、指している値をインクリメントします。後置 ++ は * より強く結び付くので、意味を変えているのはかっこです。