web-dev-qa-db-ja.com

同じ配列の未定義の動作に関連しない2つのポインターの減算を行う根拠は何ですか?

C++ドラフト expr.add によると、同じ型のポインターを減算しても同じ配列に属していない場合、動作は未定義です(強調は私のものです)。

2つのポインター式PとQが減算されると、結果の型は実装定義の符号付き整数型になります。この型は、ヘッダー([support.types])でstd :: ptrdiff_­tとして定義されている型と同じでなければなりません。

  • PとQの両方がnullポインター値に評価される場合、結果は0になります。(5.2)
  • それ以外の場合、PとQがそれぞれ同じ配列オブジェクトxの要素x [i]とx [j]を指す場合、式P-Qの値はi-jになります。

  • それ以外の場合、動作は未定義です。[注:値i−jがタイプstd :: ptrdiff_tの表現可能な値の範囲にない場合、動作は未定義です。 —メモを終了]

たとえば、実装定義ではなく、そのような動作を未定義にする根拠は何ですか?

16

より学術的に話す:ポインターは数値ではありません。それらはポインタです。

システム上のポインタが、抽象的な種類のメモリ(おそらく仮想のプロセスごとのメモリ空間)内の場所のアドレスのような表現の数値表現として実装されていることは事実です。

しかし、C++はそれを気にしません。 C++は、ポインタを特定のオブジェクトへのポストイット、ブックマークとして考えることを求めています。数値のアドレス値は単なる副作用です。ポインタで意味のあるonly演算は、前方および後方を介してオブジェクトの配列。哲学的に意味のあるものは他にありません。

これはかなり難解で役に立たないように見えるかもしれませんが、実際には意図的で便利です。 C++は、制御を制御できない実用的で低レベルのコンピュータープロパティにさらなる意味を組み込むために、実装を制約したくありません。そして、それを行う理由はないので(なぜこれを行いたいのですか?)、結果は未定義であると表示されます。

実際には、減算が機能する場合があります。ただし、コンパイラは非常に複雑であり、可能な限り最速のコードを生成するために標準の規則をうまく利用しています。そのため、ルールを破ると、プログラムが奇妙なことをするように見えることがあります。コンパイラーが元の値と結果の両方が同じ配列を参照していると想定しているときにポインター算術演算がマングルされる場合でも、それほど驚かないでください。

コメントで一部指摘されているように、結果の値が何らかの意味を持つか、何らかの方法で使用できる場合を除いて、動作を定義しても意味がありません。

ポインタの来歴に関連する質問にC言語で回答するために行われた調査があり(C仕様への変更の表現を提案する意図があります)、質問の1つは次のとおりです。

オブジェクト間減算(ポインターまたは整数演算のいずれかを使用)によって2つの別々に割り当てられたオブジェクト間の使用可能なオフセットを作成し、最初のオフセットにオフセットを追加して2番目の使用可能なポインターを作成できますか? (ソース)

この研究の著者の結論は、次のタイトルの論文で発表されました: Exploring C Semantics and Pointer Provenance andに関してこの特定の質問の答えは:

オブジェクト間ポインター演算このセクションの最初の例は、2つの割り当て間のオフセットの推測(およびチェック)に依存していました。代わりに、ポインター減算でオフセットを計算するとどうなるでしょうか。以下のように、オブジェクト間を移動できますか?

// pointer_offset_from_ptr_subtraction_global_xy.c
#include <stdio.h>
#include <string.h>
#include <stddef.h>

int x=1, y=2;
int main() {
    int *p = &x;
    int *q = &y;
    ptrdiff_t offset = q - p;
    int *r = p + offset;
    if (memcmp(&r, &q, sizeof(r)) == 0) {
        *r = 11; // is this free of UB?
        printf("y=%d *q=%d *r=%d\n",y,*q,*r);
    }
}

ISO C11では、q-pはUBです(異なるオブジェクトへのポインター間のポインター減算として。これは、一部の抽象マシンの実行では、過去のものではありません)。 1つ以上の過去のポインタの構築を可能にするバリアントセマンティクスでは、*r=11アクセスがUBであるかどうかを選択する必要があります。 rはx割り当ての来歴を保持するため、基本的な来歴セマンティクスはそれを禁止しますが、そのアドレスはその範囲内にありません。 これはおそらく最も望ましいセマンティクスです:オブジェクト間ポインター演算を意図的に使用するイディオムの例はほとんど見つかりませんでした。エイリアスの分析と最適化にそれを与えることの自由は重要なようです

この調査はC++コミュニティによって取り上げられ、要約され、フィードバックのためにWG21(C++標準委員会)に送信されました。

概要の関連点

ポインターの違いは、来歴が同じで同じ配列内のポインターに対してのみ定義されます。

それで、彼らは今のところそれを未定義にしておくことに決めました。

C++標準委員会内に研究グループSG12があることに注意してください未定義の動作と脆弱性。このグループは、体系的なレビューを実施して、脆弱性のケースと未定義/未指定の動作を標準に分類し、一貫した一連の変更を推奨して動作を定義または指定します。このグループの進行状況を追跡して、現在未定義または指定されていない動作に将来変更があるかどうかを確認できます。

8
P.W

まず、コメントに記載されている この質問 を参照して、なぜ明確に定義されていないのかを確認してください。簡潔に与えられた答えは、一部の(現在は古風な)システムで使用されているセグメント化されたメモリモデルでは、任意のポインタ演算ができないということです。

たとえば、実装を定義する代わりに、そのような動作を未定義にする根拠は何ですか?

標準が未定義の動作として何かを指定するときはいつでも、それは通常、代わりに単に実装定義であるように指定されることができます。では、なぜ何かを未定義として指定するのでしょうか?

まあ、未定義の動作はより寛大です。特に、未定義の動作はないと想定することが許可されているため、コンパイラーは、想定が正しくなかった場合にプログラムを破壊する最適化を実行する場合があります。したがって、未定義の動作を指定する理由は最適化です。

2つのポインタを引数として取る関数fun(int* arr1, int* arr2)を考えてみましょう。これらのポインターは、同じ配列を指す場合とそうでない場合があります。関数が指定された配列(arr1 + n)の1つを反復処理し、各反復で各位置を他のポインタと比較して((arr1 + n) != arr2)等しいかどうかを確認するとします。たとえば、ポイントされたオブジェクトがオーバーライドされないようにするためです。

次のように関数を呼び出すとしましょう:fun(array1, array2)。コンパイラーは(array1 + n) != array2を認識しています。それ以外の場合の動作は未定義です。したがって、関数呼び出しがインラインで展開される場合、コンパイラーは、常に真である冗長チェック(arr1 + n) != arr2を削除できます。配列の境界を越えたポインター演算が適切に(または実装も)定義されている場合、(array1 + n) == array2は一部のnでtrueになる可能性があり、コンパイラーがnのすべての可能な値に対して(array1 + n) != array2が保持することを証明できない限り、これは不可能になる可能性があります。証明するのは難しい。


クラスのメンバー間のポインター演算は、セグメント化されたメモリモデルでも実装できます。サブアレイの境界を越えて繰り返す場合も同様です。これらが非常に役立つユースケースがありますが、技術的にはUBです。

これらのケースでのUBの議論は、UB最適化の可能性の増加です。これは十分な議論であることに必ずしも同意する必要はありません。

5
eerorika