【问题标题】:What is the rationale for limitations on pointer arithmetic or comparison?限制指针算术或比较的基本原理是什么?
【发布时间】:2018-05-16 22:09:59
【问题描述】:

在 C/C++ 中,仅当结果指针位于原始指向的 complete object 内时,才定义 addition or subtraction on pointer。而且,两个指针的comparison只有在两个指向的对象是唯一的完整对象的子对象时才能执行。

这些限制的原因是什么?

我认为分段内存模型(参见here §1.2.1)可能是原因之一,但由于编译器实际上可以定义所有指针的总顺序,正如answer 所展示的那样,我对此表示怀疑。

【问题讨论】:

  • @oliv 一个原因可能是更多的 UB 可以实现更多的优化;考虑 "int x[10];int* py = x + j;",这里编译器可以合法地对 j 的范围做出假设
  • @MassimilianoJanes 鉴于 C 的历史和使用 UB 进行优化,我认为优化潜力不太可能是 C 中任何 UB 的原因。
  • 在分段架构中,如果地址架构允许段地址方案重叠,pa - pb 将有多个正确答案。
  • @oliv 如果您(或任何人)能够产生一个 genuine 用例,这种放宽的要求将变得有用,那将会有所帮助;恕我直言,为了公平起见,这样的用例应该 1)适用于最新标准,2)应该有一个等效的、可移植的解决方案,3)应该依赖于任何进一步的未定义行为。只有这样,才能将此类用例与此类决策所导致的一般性损失进行有意义的比较。
  • @PasserBy 关于 std::less 和内置运算符,它们有很好的理由表现不同。为了效率,应该允许内置比较运算符使用架构自然比较指令,并且由于分段寻址或多个地址空间的原因,可能无法以这种方式实现指针的总顺序。对于 std::less,总顺序比效率更重要,可以做额外的工作来处理内存段和地址空间。

标签: c++ c pointers language-lawyer pointer-arithmetic


【解决方案1】:

原因是为了保持生成合理代码的可能性。这适用于具有平坦内存模型的系统以及具有更复杂内存模型的系统。如果您禁止(不是很有用)极端情况,例如在数组中添加或减去,并要求对象之间指针的总顺序,您可以在生成的代码中跳过很多开销。

标准施加的限制允许编译器对指针算法做出假设并使用它来提高代码质量。它涵盖了在编译器中而不是在运行时静态计算事物以及选择要使用的指令和寻址模式。例如,考虑一个具有两个指针p1p2 的程序。如果编译器可以得出它们指向不同的数据对象,那么它可以安全地假设任何基于p1 的无操作将永远影响p2 指向的对象。这允许编译器根据p1 重新排序加载和存储,而不考虑基于p2 的加载和存储,反之亦然。

【讨论】:

  • 使用平面内存模型,我相信没有开销。在分段内存模型上,由于编译器在编译时就知道每个指针属于哪个段,它们可以为同一段上的指针生成最优代码,而对不属于同一段的指针生成次优代码。你能在“大量开销”上进行开发吗?
  • @oliv 这通常不是真的,即使使用平面内存模型,ub 规则也允许编译器对指针算法做出假设,从而提高代码质量。它与在编译器中而不是在运行时静态计算事物以及选择哪些指令有关。
  • @Johan:你能举个例子吗?
  • @geza:举个简单的例子,考虑int foo[10],bar[10],x; ... if (bar[0]) foo[x]++; return bar[0];。编译器是否必须在增加foo[x] 后重新加载bar[0] 以允许foo+x == bar 的可能性?
  • 但是编译器会假设这两个表达式在几乎所有情况下都可能有别名。考虑一个将两个指向同一类型的指针作为参数的函数,如果您不希望编译器为上面的表达式起别名,则需要声明一个“限制”。编译器可以推断这两个表达式没有混叠的唯一情况是编译器可以推断出这些指针的原始数组,即在这些表达式的直接上下文中声明数组时。所以这条规则只是没有帮助!
【解决方案2】:

有些架构中程序空间和数据空间是分开的,并且不可能减去两个任意指针。指向函数或 const 静态数据的指针将位于与普通变量完全不同的地址空间中。

即使您任意提供不同地址空间之间的排名,diff_t 类型也有可能需要更大的大小。并且比较或减去两个指针的过程会非常复杂。在专为速度而设计的语言中,这不是一个好主意。

【讨论】:

  • 感谢您,我明白指针无法超出其地址空间。但是为什么只允许在数组边界内进行算术运算,为什么不允许在所有地址空间上进行算术运算呢?
  • @Oliv 该标准没有任何地址空间的概念。它甚至不假设存在堆栈和堆,即使每个人都有它们。以有意义的方式指定限制的唯一方法是根据对象或数组来执行。
  • 谢谢您,这正是我阅读您的回答后所期望的:)。
  • 注意:程序和数据空间是分开的,例如:Harvard architecture.
【解决方案3】:

你只证明了限制可以被移除 - 但错过了它会带来成本(在内存和代码方面) - 这与 C 的目标背道而驰。

具体来说,差异需要有一个类型,即 ptrdiff_t,并且可以假设它类似于 size_t。

在分段内存模型中,您(通常)间接限制了对象的大小 - 假设:What's the real size of `size_t`, `uintptr_t`, `intptr_t` and `ptrdiff_t` type on 16-bit systems using segmented addressing mode? 中的答案是正确的。

因此,至少对于差异而言,消除该限制不仅会添加额外的指令以确保总订单 - 对于不重要的极端情况(如其他答案),而且还会为差异等花费双倍的内存量。

C 被设计为更加简约,而不是强制编译器在这种情况下花费内存和代码。 (在那些日子里,内存限制更重要。)

显然还有其他好处——比如在混合来自不同数组的指针时检测错误的可能性。类似地,在 C++ 中未定义两个不同容器的混合迭代器(有一些小例外) - 并且一些调试实现会检测到此类错误。

【讨论】:

  • 所以由于类型不同而存在限制。但是为什么要限制原来指向指针的完整对象的大小呢?
  • @Oliv 因为有些系统可以有不同种类或不同的内存段,其中指向一种内存的指针看起来与其他类型的内存完全不同,并且不存在一个好的方法来比较指向不同类型内存的指针 - 至少在不增加每种指针比较的巨大成本的情况下(即每次比较都需要首先确定它是哪种内存,然后将这些信息与所有指针一起携带)。因此,并非所有系统都提供平面地址空间,其中内存地址是一个简单的唯一数字。
  • @nos 以某种方式携带信息,因为没有它,就不可能取消引用指针。例如,声明为void f(int* p) 的函数在任何情况下都应该能够解除对p 的引用。所以int* 必须保存所有信息(地址空间、段、相对地址)。或者,远大的指针有不同的类型?
  • 不要忘记存储内存 (en.wikipedia.org/wiki/Bank_switching)...当 C 被发明时,存储架构很流行。不同银行中的指针根本无法以任何理智的方式相互比较。我见过银行页面小到 512(两字节)字(C 永远不会在这种情况下工作)和大到 8K(两字节)字,它们确实支持编译器。可能有更大的银行,但我从未使用过它们。
  • @Oliv,远和巨大的指针是不同的类型(或不同的类型前缀或......)。对我来说,比较它们类似于比较 map::iterator 和 vector::iterator (不仅是在来自不同向量的迭代器之间)。在这种情况下,可以定义一个总顺序,但在超过 99.99% 的情况下,这是一个错误,检测出来会更有帮助。
【解决方案4】:

基本原理是某些架构具有分段内存,指向不同对象的指针可能指向不同的内存段。那么这两个指针之间的差异就不一定是有意义的了。

这一直追溯到标准 C 之前。C 的基本原理没有明确提及这一点,但它暗示了这就是原因,如果我们查看它在哪里解释了为什么使用负数组索引未定义的基本原理行为(C99 基本原理 5.10 6.5.6,强调我的):

另一方面,在 p-1 的情况下,整个对象必须在 p 遍历的对象数组,因此从数组底部运行的递减循环可能会失败。 此限制允许分段架构,例如,将对象放置在 可寻址内存范围。

【讨论】:

  • 差不多了,那么为什么如果他们允许p-1(不允许其服从)分段架构将不允许将对象放置在可寻址内存范围的开头?
  • @Oliv • 我在推测,但可能是因为递减 C008:0000 变为 C008:FFF0 (对于一个需要 16 个字节的数组),并且环绕会很棘手处理(即,需要额外的代码,速度较慢)。分段架构仍然存在吗?
  • @Eljay:在 8086 实模式编译器上,从 0xC008:0 中减去 1 将产生 0xC008:0xFFFF。奇怪的是,这种行为实际上非常有用,因为这意味着大于 32K 的对象可以使用 16 位算术来处理。指向 50,000 字节对象的指针将具有小于 15536​​ 的段部分。如果尝试向其添加 40,000,则该值将通过整数环绕转换为 -25536,但将 -25536 添加到其段部分的指针小于 15536​​ 将增加 40000 到段部分。
【解决方案5】:

由于 C 标准打算涵盖大多数处理器架构,因此它也应该涵盖以下一种: 想象一个架构(我知道一个,但不会命名),其中指针不仅仅是普通数字,而且就像结构或“描述符”。这样的结构包含有关它指向的对象的信息(它的虚拟地址和大小)以及其中的偏移量。添加或减去指针会产生一个新结构,其中仅调整了偏移字段;硬件禁止生成偏移量大于对象大小的结构。还有其他限制(例如如何生成初始描述符或修改它的其他方法),但它们与主题无关。

【讨论】:

    【解决方案6】:

    在大多数情况下,Stanadrd 将操作归类为调用未定义行为,原因是:

    1. 在某些平台上定义行为可能会很昂贵。如果代码尝试执行超出对象边界的指针运算,分段架构可能会表现得很奇怪,并且一些编译器可能会通过测试 q-p 的符号来评估 p > q

    2. 在某些类型的编程中,定义行为是无用的。许多类型的代码都可以正常运行,而无需依赖超出标准给出的指针加法、减法或关系比较的形式。

    3. 为各种目的编写编译器的人应该能够识别用于此类目的的高质量编译器的行为应可预测的情况,并在适当时处理此类情况,无论标准是否强制他们这样做。

    #1 和#2 都非常低,而#3 被认为是“给我”。尽管编译器编写者通过寻找破坏代码的方法来炫耀他们的聪明已经成为一种时尚,这些代码的行为是由旨在用于低级编程的质量实现定义的,但我不认为标准的作者期望编译器编写者能够感知到巨大的要求行为可预测的操作与期望几乎所有质量实现行为相同的行为之间的差异,但在这些行为中,可以想象让一些神秘的实现做其他事情可能有用。

    【讨论】:

      【解决方案7】:

      我想通过颠倒问题来回答这个问题。与其问为什么不允许指针加法和大多数算术运算,不如说为什么指针只允许对一个整数进行加法或减法、前后递增和递减以及指向同一数组的指针的比较(或减法)?它与算术运算的逻辑结果有关。 将整数 n 添加/减去指针 p 可以得到当前指向元素的第 n 个元素的地址,无论是正向还是反向。同样,减去指向同一个数组的 p1 和 p2 可以得到两个指针之间的元素数。 指针算术运算的定义与其指向的变量类型一致的事实(或设计)是真正的天才之举。除了允许的操作之外的任何操作都违反编程或哲学逻辑推理,因此是不允许的。

      【讨论】:

      • 实际上,我的问题源于这样一个事实,即这些规则不允许实现有效的向量或没有 UB 的菌落。不幸的是,我们也使用指针来识别内存位置。
      • @oliv 在这种情况下你所说的“殖民地”是什么意思?
      • @zwol 就像 boost 或 plf 之一。
      • @oliv 我不知道那是什么,搜索也没有提出来。您所谈论的内容有具体的网址吗?
      • @zwol 确实 google 需要这 3 个词来查找相关结果(提升 cpp 殖民地),它需要了解您正在“编码器”上下文中进行搜索。
      猜你喜欢
      • 2016-04-25
      • 2018-02-07
      • 1970-01-01
      • 2021-11-03
      • 2022-10-15
      • 2011-04-25
      • 2013-02-05
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多