【问题标题】:Is incrementing a pointer to a 0-sized dynamic array undefined?是否递增指向 0 大小的动态数组的指针未定义?
【发布时间】:2019-11-03 21:55:34
【问题描述】:

AFAIK,虽然我们不能创建大小为 0 的静态内存数组,但我们可以使用动态数组来实现:

int a[0]{}; // Compile-time error
int* p = new int[0]; // Is well-defined

正如我所读到的,p 就像一个过去结束元素。我可以打印p指向的地址。

if(p)
    cout << p << endl;
  • 虽然我确信我们不能取消引用该指针(过去最后一个元素),因为我们不能使用迭代器(过去最后一个元素),但我不确定是否增加该指针 p?未定义的行为 (UB) 与迭代器类似吗?

    p++; // UB?
    

【问题讨论】:

  • UB "...任何其他情况(即,尝试生成一个未指向同一数组的元素或未指向末尾元素的指针)都会调用未定义的行为。 ..." 来自:en.cppreference.com/w/cpp/language/operator_arithmetic
  • 嗯,这类似于std::vector,其中包含 0 项。 begin() 已经等于 end(),所以你不能增加一个指向开头的迭代器。
  • @PeterMortensen 我认为您的编辑改变了最后一句话的含义(“我确定 -> 我不确定为什么”),请您仔细检查一下吗?
  • @PeterMortensen:你编辑的最后一段变得不太可读了。

标签: c++ pointers undefined-behavior dynamic-arrays


【解决方案1】:

允许指向数组元素的指针指向一个有效元素,或者指向末尾的一个元素。如果您以一种超过结尾的方式递增指针,则行为未定义。

对于您的 0 大小的数组,p 已经指向末尾的一个,因此不允许递增。

请参阅 C++17 8.7/4 关于 + 运算符(++ 具有相同的限制):

如果表达式P 指向具有n 个元素的数组对象x 的元素x[i],则表达式P + JJ + P(其中J 的值是j)指向(可能是假设的)元素 x[i+j] 如果 0≤i+j≤n;否则,行为未定义。

【讨论】:

  • 所以x[i]x[i + j] 相同的唯一情况是ij 的值都为0?
  • @RamiYen x[i] 是与 x[i+j] 相同的元素,如果 j==0
  • 呃,我讨厌 C++ 语义的“暮光之城”......不过 +1。
  • @einpoklum-reinstateMonica:真的没有暮光之城。即使对于 N=0 的情况,也只是 C++ 保持一致。对于 N 个元素的数组,有 N+1 个有效指针值,因为您可以指向数组后面。这意味着您可以从数组的开头开始并将指针递增 N 次以到达结尾。
  • @MaximEgorushkin 我的回答是关于语言当前允许的内容。关于您希望它允许的讨论是题外话。
【解决方案2】:

我想你已经有了答案;如果你看得更深一点:你说过增加一个结束迭代器是 UB 因此:这个答案是什么是迭代器?

迭代器只是一个对象,它有一个指针,递增迭代器实际上是递增它所拥有的指针。因此,在许多方面,迭代器都是根据指​​针来处理的。

int arr[] = {0,1,2,3,4,5,6,7,8,9};

int *p = arr; // p 指向 arr 中的第一个元素

++p; // p 指向 arr[1]

正如我们可以使用迭代器来遍历向量中的元素一样,我们也可以使用指针来遍历数组中的元素。当然,为此,我们需要获取指向第一个元素和最后一个元素的指针。正如我们刚刚看到的,我们可以通过使用数组本身或通过获取第一个元素的地址来获得指向第一个元素的指针。我们可以通过使用数组的另一个特殊属性来获得一个结束指针。我们可以将不存在的元素的地址放在数组的最后一个元素之后:

int *e = &arr[10]; // 指针刚刚超过 arr 中的最后一个元素

这里我们使用下标操作符来索引一个不存在的元素; arr 有 10 个元素,因此 arr 中的最后一个元素位于索引位置 9。我们对这个元素唯一能做的就是获取它的地址,我们这样做是为了初始化 e。与非终端迭代器(第 3.4.1 节,第 106 页)一样,终端指针并不指向元素。因此,我们不能取消引用或增加一个未结束的指针。

这是来自 Lipmann 的 C++ 入门 5 版。

所以是UB不要这样做。

【讨论】:

    【解决方案3】:

    从最严格的意义上说,这不是未定义的行为,而是实现定义的。因此,虽然如果您计划支持非主流架构是不可取的,但您可以可能会这样做。

    interjay 给出的标准报价很好,表示 UB,但在我看来它只是第二好的,因为它涉及指针-指针算法(有趣的是,一个明确是 UB,而另一个不是' t)。问题中有一段直接处理操作:

    [expr.post.incr] / [expr.pre.incr]
    操作数应为 [...] 或指向完全定义的对象类型的指针。

    哦,等一下,完全定义的对象类型?就这样?我的意思是,真的,type?所以你根本不需要对象?
    需要大量阅读才能真正找到其中的某些内容可能没有那么明确定义的提示。因为到目前为止,您似乎完全可以这样做,没有任何限制。

    [basic.compound] 3 声明了一个可能有什么类型的指针,而不是其他三个,你的操作结果显然会落入 3.4:invalid pointer
    然而,它并没有说你不允许有一个无效的指针。相反,它列出了一些非常常见的正常情况(例如存储持续时间结束),其中指针经常变为无效。所以这显然是允许发生的事情。确实:

    [basic.stc] 4
    通过无效指针值的间接传递以及将无效指针值传递给释放函数具有未定义的行为。对无效指针值的任何其他使用都具有实现定义的行为。

    我们在那里做“任何其他”,所以它不是未定义的行为,而是实现定义的,因此通常允许(除非实现明确说明不同的东西)。

    不幸的是,这不是故事的结局。虽然从这里开始最终结果不会再改变,但它会变得更加混乱,您搜索“指针”的时间越长:

    [basic.compound]
    对象指针类型的有效值表示内存中字节的地址或空指针。如果类型 T 的对象位于地址 A [...] 上,则称该对象指向该对象,无论该值是如何获得的
    [注意:例如,数组末尾的地址将被视为指向可能位于该地址的数组元素类型的不相关对象。 [...]]。

    读作:好吧,谁在乎!只要指针指向内存中的某处,我就很好?

    [basic.stc.dynamic.safety] 指针值是安全派生的指针 [blah blah]

    读作:好的,安全派生,随便什么。它没有解释这是什么,也没有说我真的需要它。安全衍生的见鬼。显然我仍然可以很好地使用非安全派生的指针。我猜想取消引用它们可能不是一个好主意,但拥有它们是完全可以允许的。它没有另外说。

    实现可能放宽了指针安全性,在这种情况下,指针值的有效性不取决于它是否是安全派生的指针值。

    哦,所以这可能并不重要,只是我的想法。但是等等……“可能不会”?这意味着,它也可以。我怎么知道?

    另外,一个实现可能具有严格的指针安全性,在这种情况下,不是安全派生的指针值的指针值是无效的指针值,除非引用的完整对象具有动态存储持续时间并且先前已被声明为可达

    等等,我什至可能需要在每个指针上调用declare_reachable()?我怎么知道?

    现在,您可以转换为intptr_t,这是定义明确的,提供安全派生指针的整数表示。当然,作为一个整数,它是完全合法且定义明确的,可以随意递增。
    是的,您可以将intptr_t 转换回一个指针,这也是定义明确的。只是,不是原始值,不再保证您拥有安全派生的指针(显然)。尽管如此,总而言之,按照标准的字面意思,虽然是由实现定义的,但这是 100% 合法的事情:

    [expr.reinterpret.cast] 5
    整数类型或枚举类型的值可以显式转换为指针。将指针转换为足够大小的整数 [...] 并返回相同的指针类型 [...] 原始值;指针和整数之间的映射是由实现定义的。

    捕获

    指针只是普通的整数,只是你碰巧将它们用作指针。哦,如果那是真的!
    不幸的是,存在这样的架构,其中 完全正确,并且仅仅生成一个无效指针(不取消引用它,只是将它放在指针寄存器中)会导致陷阱。

    这就是“定义实现”的基础。那个,以及随时增加指针的事实,如你所愿可能当然会导致溢出,这是标准不想处理的。应用程序地址空间的结尾可能与溢出的位置不一致,你甚至不知道特定架构上是否存在指针溢出之类的东西。总而言之,这是一场噩梦般的混乱,与可能的好处没有任何关系。

    另一方面,处理一个过去对象的情况很容易:实现必须简单地确保没有对象被分配,因此地址空间中的最后一个字节被占用。所以这是定义明确的,因为它是有用的,而且保证是微不足道的。

    【讨论】:

    • 你的逻辑有缺陷。 “所以你根本不需要对象?”通过专注于单一规则来误解标准。该规则与编译时间有关,无论您的程序是否格式正确。关于运行时间还有另一条规则。只有在运行时,你才能真正谈论某个地址处对象的存在。您的程序需要满足所有规则;编译时的编译时规则和运行时的运行时规则。
    • 你有类似的逻辑缺陷,“好吧,谁在乎!只要指针指向内存中的某个地方,我就很好?”。不,您必须遵守所有规则。关于“一个数组的结尾是另一个数组的开头”这种难懂的语言只是赋予了实现连续分配内存的权限;它不需要在分配之间保留可用空间。这确实意味着您的代码可能具有相同的值 A 既作为一个数组对象的结尾,又作为另一个数组对象的开始。
    • “陷阱”不能用“实现定义的”行为来描述。请注意,interjay 发现了对 + 运算符(++ 从中流出)的限制,这意味着在“one-after-the-end”之后的指向是未定义的。
    • @PeterCordes:请阅读basic.stc, paragraph 4。它说“间接[...]未定义的行为。无效指针值的任何其他用途具有实现定义的行为”。我并没有通过使用该术语来表示另一种含义来混淆人们。这是确切的措辞。这不是未定义的行为。
    • 您几乎不可能找到后增量的漏洞,但您没有引用后增量功能的完整部分。我现在不打算亲自调查。同意,如果有,那是无意的。无论如何,如果 ISO C++ 为平面内存模型定义了更多的东西,@MaximEgorushkin,那就太好了,还有其他原因(比如指针环绕)不允许任意东西。请参阅Should pointer comparisons be signed or unsigned in 64-bit x86?上的 cmets
    猜你喜欢
    • 2012-03-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-02-07
    • 2017-11-20
    相关资源
    最近更新 更多