【问题标题】:Pointer arithmetic across subobject boundaries跨子对象边界的指针算法
【发布时间】:2013-03-05 06:53:30
【问题描述】:

以下代码(跨子对象边界执行指针算术)是否对其编译的类型 T(在 C++11 中为 does not not necessarily have to be POD)或其任何子集具有明确定义的行为?

#include <cassert>
#include <cstddef>

template<typename T>
struct Base
{
    // ensure alignment
    union
    {
        T initial;
        char begin;
    };
};

template<typename T, size_t N>
struct Derived : public Base<T>
{
    T rest[N - 1];
    char end;
};

int main()
{
    Derived<float, 10> d;
    assert(&d.rest[9] - &d.initial == 10);
    assert(&d.end - &d.begin == sizeof(float) * 10);
    return 0;
}

LLVM 在内部向量类型的实现中使用了上述技术的变体,该内部向量类型经过优化,最初将堆栈用于小型数组,但一旦超过初始容量,就会切换到堆分配的缓冲区。 (从这个例子中并不清楚这样做的原因,但显然是为了减少模板代码膨胀;如果你看一下code,这会更清楚。)

注意:在任何人抱怨之前,这并不是他们正在做的事情,可能他们的方法比我在这里给出的更符合标准,但我想问一下一般情况。

显然,它在实践中有效,但我很好奇标准中是否有任何内容可以保证这种情况。我倾向于拒绝,因为 N3242/expr.add

当两个指向同一个数组对象的元素的指针相减时,结果是两个数组元素的下标之差……此外,如果表达式P指向数组对象的一个​​元素或一个过去的最后一个元素 一个数组对象,表达式 Q 指向同一数组对象的最后一个元素,表达式 ((Q)+1)-(P) 与 ((Q)-(P))+1 具有相同的值,并且-((P)-((Q)+1)),如果表达式 P 指向数组对象的最后一个元素后一个,则其值为 0,即使表达式 (Q)+1 不指向某个元素数组对象。 ...除非两个指针都指向同一个数组对象的元素,或者指向数组对象的最后一个元素,否则行为是未定义的。

但理论上,上述引用的中间部分,结合类布局和对齐保证,可能允许以下(次要)调整有效:

#include <cassert>
#include <cstddef>

template<typename T>
struct Base
{
    T initial[1];
};

template<typename T, size_t N>
struct Derived : public Base<T>
{
    T rest[N - 1];
};

int main()
{
    Derived<float, 10> d;
    assert(&d.rest[9] - &d.rest[0] == 9);
    assert(&d.rest[0] == &d.initial[1]);
    assert(&d.rest[0] - &d.initial[0] == 1);
    return 0;
}

结合有关union 布局、与char * 之间的可转换性等各种其他规定,可以说原始代码也有效。 (主要问题是上面给出的指针算术定义缺乏传递性。)

有人知道吗? N3242/expr.add 似乎明确指出,指针必须属于同一个“数组对象”才能对其进行定义,但 可能 假设其他保证的情况在标准中,当组合在一起时,在这种情况下可能无论如何都需要一个定义,以保持逻辑上的自洽。 (我不赌它,但我认为它至少是可以想象的。)

编辑:@MatthieuM 提出了这个类不是标准布局的反对意见,因此可能不能保证在基子对象和派生的第一个成员之间不包含任何填充,即使两者与alignof(T) 对齐。我不确定这是多么真实,但这会引发以下变体问题:

  • 如果继承被删除,这是否可以保证工作?

  • 即使&amp;d.end - &amp;d.begin == sizeof(float) * 10 没有,&amp;d.end - &amp;d.begin &gt;= sizeof(float) * 10 是否也能得到保证?

LAST EDIT @ArneMertz 主张非常仔细地阅读 N3242/expr.add(是的,我知道我正在阅读草稿,但已经足够接近了) ,但是如果删除交换线,标准是否真的暗示以下具有未定义的行为? (与上述相同的类定义)

int main()
{
    Derived<float, 10> d;
    bool aligned;
    float * p = &d.initial[0], * q = &d.rest[0];

    ++p;
    if((aligned = (p == q)))
    {
        std::swap(p, q); // does it matter if this line is removed?
        *++p = 1.0;
    }

    assert(!aligned || d.rest[1] == 1.0);

    return 0;
}

另外,如果==不够强,如果我们利用std::less在指针上形成一个全序,并将上面的条件更改为:

    if((aligned = (!std::less<float *>()(p, q) && !std::less<float *>()(q, p))))

根据严格阅读标准,假设两个相等指针指向同一个数组对象的代码真的被破坏了吗?

编辑抱歉,只想再添加一个示例,以消除标准布局问题:

#include <cassert>
#include <cstddef>
#include <utility>
#include <functional>

// standard layout
struct Base
{
    float initial[1];
    float rest[9];
};

int main()
{
    Base b;
    bool aligned;
    float * p = &b.initial[0], * q = &b.rest[0];

    ++p;
    if((aligned = (p == q)))
    {
        std::swap(p, q); // does it matter if this line is removed?
        *++p = 1.0;
        q = &b.rest[1];
        // std::swap(p, q); // does it matter if this line is added?
        p -= 2; // is this UB?
    }
    assert(!aligned || b.rest[1] == 1.0);
    assert(p == &b.initial[0]);

    return 0;
}

【问题讨论】:

  • 我不敢相信 C++ 标签中有很好的问题。 +1。
  • 可能是Union element alignment的副本,但我不确定
  • @StephenLin:我必须承认我觉得这段代码很可疑;我怀疑标准中是否有任何保证不能在基础对象和派生对象的第一个属性之间插入填充(即使那会很愚蠢......)
  • @StephenLin:我怀疑你也可以用不同的数组来做到这一点。再次因为可能的填充问题。例如,想象一下通过在每个数组周围留下“红色区域”来检测越界访问来检测构建。并且标准非常清楚,理论上,两个不同的对象意味着未定义的行为(我相信其余的近/远指针及其不同的地址空间)。
  • 零长度数组无效。

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


【解决方案1】:

更新:这个答案起初遗漏了一些信息,因此导致错误的结论。

在您的示例中,initialrest 显然是不同的(数组)对象,因此将指向 initial(或其元素)的指针与指向 rest(或其元素)的指针进行比较是

  • UB,如果您使用指针的差异。 (§5.7,6)
  • 未指定,如果您使用关系运算符 (§5.9,2)
  • == 定义良好(所以第二次剪断很好,见下文)

第一个 sn-p:

在第一个 sn-p 中建立差异是未定义的行为,对于您提供的报价(§5.7,6):

除非两个指针都指向同一个数组对象的元素,或者 超过数组对象的最后一个元素,行为未定义。

澄清第一个示例代码的UB部分:

//first example
int main()
{
    Derived<float, 10> d;
    assert(&d.rest[9] - &d.initial == 10);            //!!! UB !!!
    assert(&d.end - &d.begin == sizeof(float) * 10);  //!!! UB !!! (*)
    return 0;
}

标有(*) 的行很有趣:d.begind.end 不是同一个数组的元素,因此运算结果为UB。尽管您可以reinterpret_cast&lt;char*&gt;(&amp;d) 并将它们的地址都包含在结果数组中,但这仍然存在。但由于该数组是dall 的表示,因此不能将其视为对dparts 的访问。因此,虽然该操作可能会正常工作并在任何人梦寐以求的实现中给出预期结果,但它仍然是 UB - 作为定义问题。

第二次sn-p:

这实际上是定义良好的行为,但实现定义的结果:

int main()
{
    Derived<float, 10> d;
    assert(&d.rest[9] - &d.rest[0] == 9);
    assert(&d.rest[0] == &d.initial[1]);         //(!)
    assert(&d.initial[1] - &d.initial[0] == 1);
    return 0;
}

标有(!) 的行不是 ub,但其结果是实现定义,因为填充、对齐和提到的工具可能会起作用。 但是如果该断言成立,您可以像使用一个数组一样使用两个对象部分

你会知道rest[0] 会紧跟在内存中的initial[0] 之后。 乍一看,你不能轻易使用等式:

  • initial[1] 将指向 initial 的末尾,取消引用它是 UB。
  • rest[-1] 明显越界。

但进入§3.9.2,3

如果 T 类型的对象位于地址 A,则 cv T* 类型的指针其值为 据说地址A 指向该对象,无论该值是如何获得的。 [注:例如, 数组末尾的地址(5.7)将被视为指向不相关的对象 可能位于该地址的数组元素类型。

所以如果&amp;initial[1] == &amp;rest[0],它将是二进制的,就好像只有一个数组一样,一切都会好的。

您可以遍历两个数组,因为您可以在边界处应用一些“指针上下文切换”。因此,对于您的最后一个 sn-p:不需要 swap

但是,有一些注意事项:rest[-1] 是 UB,initial[2] 也是如此,因为 §5.7,5

如果指针操作数和结果都指向同一个数组对象的元素,或者一个过去 数组对象的最后一个元素,评估不应产生溢出; 否则,行为是 未定义

(强调我的)。那么这两者如何结合在一起呢?

  • “好路径”:&amp;initial[1] 没问题,因为 &amp;initial[1] == &amp;rest[0] 您可以使用该地址并继续增加指针以访问 rest 的其他元素,因为 §3.9.2,3
  • “Bad path”:initial[2]*(initial + 2),但由于 §5.7,5,initial +2 已经是 UB,您永远无法在此处使用 §3.9.2,3。

一起:你必须在边界停下来,稍作休息以检查地址是否相等,然后你可以继续前进。

【讨论】:

  • 我相信你,但这意味着它不可能实现std::memset 或类似的东西然后不调用UB,不管对象是否是标准布局?您必须使用提供的函数作为原语,否则?
  • (顺便说一句,我的编辑被拒绝了,但我认为您的意思是 float * 不是 int *
  • 如果您澄清您是否认为我编辑的问题中的最后一个示例是 UB,我会接受这一点。 (从技术上讲,这可能只是好奇你的想法。)
  • @StephenLin:memset 依赖于reinterpret_cast&lt;char&gt; anything 的可能性和 POD 的属性(或标准布局类型,不太确定 atm)。不具备这些属性的 Memsetting 对象确实是 UB。 - 我更正了 poitner 类型 - 不知道您的编辑为什么被拒绝。我会在我的回答中评论最后一次编辑。
  • 很好,很想知道你的想法。此外,由于继承,显然这不是现在的标准布局,但如果将其删除,那么我认为。那么&amp;begin&amp;end 会不会是两个指向具有明确布局的数组的指针?否则,如何通过char * 指针在标准布局对象之间进行按位复制安全?
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-07-31
  • 2014-01-02
  • 2013-01-25
  • 1970-01-01
相关资源
最近更新 更多