【问题标题】:Does C have an equivalent of std::less from C++?C 是否与 C++ 中的 std::less 等效?
【发布时间】:2020-02-07 20:25:54
【问题描述】:

pq 是指向不同对象/数组的指针时,我最近回答了一个关于在C 中执行p < q 的未定义行为的问题。这让我想到:在这种情况下,C++ 具有与< 相同(未定义)的行为,但也提供了标准库模板std::less,当可以比较指针时,它保证返回与< 相同的东西,并在他们不能时返回一些一致的顺序。

C 是否提供了具有类似功能的功能,可以安全地比较任意指针(到相同类型)?我尝试查看 C11 标准并没有找到任何东西,但我在 C 方面的经验比在 C++ 方面小几个数量级,所以我很容易错过一些东西。

【问题讨论】:

标签: c pointers undefined-behavior memory-model memory-segmentation


【解决方案1】:

在使用平面内存模型(基本上所有内容)的实现中,强制转换为 uintptr_t 即可。

(但请参阅Should pointer comparisons be signed or unsigned in 64-bit x86? 讨论是否应将指针视为带符号的,包括在 C 中的 UB 对象之外形成指针的问题。)

但具有非平面内存模型的系统确实存在,考虑它们可以帮助解释当前情况,例如 C++ 对 <std::less 有不同的规范。


< 指向在 C 中是 UB(或至少在某些 C++ 修订版中未指定)的单独对象的指针的部分观点是允许奇怪的机器,包括非平面内存模型。

一个众所周知的例子是 x86-16 实模式,其中指针是段:偏移,通过(segment << 4) + offset 形成一个 20 位线性地址。同一个线性地址可以用多个不同的 seg:off 组合来表示。

C++ std::less 在奇怪的 ISA 上的指针可能需要很昂贵,例如在 x86-16 上“规范化”一个段:偏移量,使其偏移量 可移植 方法来实现这一点。 规范化uintptr_t(或指针对象的对象表示)所需的操作是特定于实现的。

但即使在 C++ std::less 必须昂贵的系统上,< 也不必如此。例如,假设一个对象适合一个段内的“大”内存模型,< 可以只比较偏移部分,甚至不用考虑段部分。 (同一对象内的指针将具有相同的段,否则它是 C 中的 UB。C++17 更改为仅“未指定”,这可能仍然允许跳过规范化并仅比较偏移量。)这是假设所有指向任何部分的指针一个对象总是使用相同的seg 值,从不规范化。这是您期望 ABI 对“大”内存模型而不是“巨大”内存模型所要求的。 (见discussion in comments)。

(例如,这种内存模型的最大对象大小可能为 64kiB,但最大总地址空间更大,可以容纳许多此类最大大小的对象。ISO C 允许实现对对象大小进行限制,即低于size_t 可以表示的最大值(无符号)SIZE_MAX。例如,即使在平面内存模型系统上,GNU C 也将最大对象大小限制为PTRDIFF_MAX,因此大小计算可以忽略有符号溢出。)参见this answer和 cmets 中的讨论。

如果你想允许大于一个段的对象,你需要一个“巨大”的内存模型,它必须担心在执行 p++ 循环遍历数组时或在执行索引 / 时溢出指针的偏移部分指针算术。这会导致任何地方的代码变慢,但可能意味着p < q 碰巧适用于指向不同对象的指针,因为针对“巨大”内存模型的实现通常会选择始终保持所有指针标准化。请参阅What are near, far and huge pointers? - 一些用于 x86 实模式的真正 C 编译器确实有一个选项来编译“巨大”模型,其中所有指针默认为“巨大”,除非另有声明。

x86 实模式分段并不是唯一可能的非平面内存模型,它只是一个有用的具体示例,用于说明 C/C++ 实现如何处理它。在现实生活中,实现扩展了 ISO C 与 farnear 指针的概念,允许程序员选择何时可以摆脱仅存储/传递 16 位偏移量部分,相对于一些常见的数据段.

但是纯 ISO C 实现必须在小型内存模型(除了具有 16 位指针的相同 64kiB 中的代码之外的所有内容)或大型或巨大的所有指针都是 32 位之间进行选择。一些循环可以通过仅增加偏移部分来优化,但指针对象不能优化为更小。


如果您知道任何给定实现的神奇操作是什么,您就可以用纯 C 实现它。问题是不同的系统使用不同的寻址方式,并且任何可移植宏都没有参数化细节。

也许不是:它可能涉及从特殊的段表或其他东西中查找某些东西,例如像 x86 保护模式而不是实模式,其中地址的段部分是索引,而不是要左移的值。您可以在保护模式下设置部分重叠的段,并且地址的段选择器部分甚至不一定按照与相应段基地址相同的顺序排列。如果 GDT 和/或 LDT 未映射到进程中的可读页面,则在 x86 保护模式下从 seg:off 指针获取线性地址可能涉及系统调用。

(当然,x86 的主流操作系统使用平面内存模型,因此段基数始终为 0(使用 fsgs 段的线程本地存储除外),并且只有 32 位或 64 位“offset”部分用作指针。)

您可以为各种特定平台手动添加代码,例如默认情况下假设为平面,或 #ifdef 检测 x86 实模式并将 uintptr_t 拆分为 16 位的两半 seg -= off>>4; off &= 0xf; 然后将这些部分组合回一个 32 位数字。

【讨论】:

  • 如果段不相等为什么会是UB?
  • @Acorn: 反过来说;固定的。指向同一对象的指针将具有相同的段,否则为 UB。
  • 但是你为什么认为它是UB呢? (倒逻辑与否,其实我也没注意到)
  • p < q 是 C 中的 UB,如果它们指向不同的对象,不是吗?我知道p - q 是。
  • @Acorn:无论如何,我看不到在没有 UB 的程序中会生成别名(不同的 seg:off,相同的线性地址)的机制。因此,编译器不必竭尽全力避免这种情况。对对象的每次访问都使用该对象的 seg 值和 >= 该对象开始的段内的偏移量。 C 使得 UB 可以在指向不同对象的指针之间做很多事情,包括像tmp = a-b 这样的东西,然后用b[tmp] 访问a[0]。这个关于分段指针别名的讨论是一个很好的例子,说明了为什么这种设计选择是有意义的。
【解决方案2】:

once tried to find a way around this 确实找到了一个适用于重叠对象的解决方案,并且在大多数其他情况下假设编译器执行“通常”的事情。

您可以首先在How to implement memmove in standard C without an intermediate copy? 中实施建议,然后如果这不起作用,则转换为uintptruintptr_tunsigned long long 的包装类型,具体取决于uintptr_t 是否可用)并获取一个最有可能准确的结果(尽管它可能并不重要):

#include <stdint.h>
#ifndef UINTPTR_MAX
typedef unsigned long long uintptr;
#else
typedef uintptr_t uintptr;
#endif

int pcmp(const void *p1, const void *p2, size_t len)
{
    const unsigned char *s1 = p1;
    const unsigned char *s2 = p2;
    size_t l;

    /* Check for overlap */
    for( l = 0; l < len; l++ )
    {
        if( s1 + l == s2 || s1 + l == s2 + len - 1 )
        {
            /* The two objects overlap, so we're allowed to
               use comparison operators. */
            if(s1 > s2)
                return 1;
            else if (s1 < s2)
                return -1;
            else
                return 0;
        }
    }

    /* No overlap so the result probably won't really matter.
       Cast the result to `uintptr` and hope the compiler
       does the "usual" thing */
    if((uintptr)s1 > (uintptr)s2)
        return 1;
    else if ((uintptr)s1 < (uintptr)s2)
        return -1;
    else
        return 0;
}

【讨论】:

    【解决方案3】:

    C 是否提供了具有类似功能的功能,可以安全地比较任意指针。

    没有


    首先让我们只考虑对象指针函数指针带来了另外一组问题。

    2 个指针p1, p2 可以有不同的编码并指向同一个地址,所以p1 == p2 即使memcmp(&amp;p1, &amp;p2, sizeof p1) 不为0。这样的架构很少见。

    然而,将这些指针转换为uintptr_t 不需要导致(uintptr_t)p1 != (uinptr_t)p2 的相同整数结果。

    (uintptr_t)p1 &lt; (uinptr_t)p2 本身是合法的代码,可能无法提供所希望的功能。


    如果代码确实需要比较不相关的指针,请形成一个辅助函数less(const void *p1, const void *p2) 并在那里执行特定于平台的代码。

    也许:

    // return -1,0,1 for <,==,> 
    int ptrcmp(const void *c1, const void *c1) {
      // Equivalence test works on all platforms
      if (c1 == c2) {
        return 0;
      }
      // At this point, we know pointers are not equivalent.
      #ifdef UINTPTR_MAX
        uintptr_t u1 = (uintptr_t)c1;
        uintptr_t u2 = (uintptr_t)c2;
        // Below code "works" in that the computation is legal,
        //   but does it function as desired?
        // Likely, but strange systems lurk out in the wild. 
        // Check implementation before using
        #if tbd
          return (u1 > u2) - (u1 < u2);
        #else
          #error TBD code
        #endif
      #else
        #error TBD code
      #endif 
    }
    

    【讨论】:

      【解决方案4】:

      当一个动作调用“未定义的行为”时,C 标准明确地允许实现“以环境的记录方式特征”运行。在编写标准时,每个人都很明显,在具有平面内存模型的平台上进行低级编程的实现应该在处理任意指针之间的关系运算符时准确地做到这一点。同样显而易见的是,针对指针比较的自然方式永远不会产生副作用的平台的实现应该以没有副作用的方式执行任意指针之间的比较。

      程序员可能在指针之间执行关系运算符的一般情况有以下三种:

      1. 永远不会比较指向不相关对象的指针。

      2. 代码可以在结果重要的情况下比较对象内的指针,或者在结果不重要的情况下比较不相关的对象之间的指针。一个简单的例子是一个操作,它可以按升序或降序对可能重叠的数组段进行操作。在对象重叠的情况下,升序或降序的选择很重要,但在对不相关对象中的数组段进行操作时,任何一种顺序都同样有效。

      3. 代码依赖于产生与指针相等性一致的传递顺序的比较。

      第三种用法很少出现在特定于平台的代码之外,这些代码要么知道关系运算符可以简单地工作,要么知道特定于平台的替代方案。第二种用法可能出现在应该主要是可移植的代码中,但是几乎所有的实现都可以像第一种那样便宜地支持第二种用法,而且他们没有理由不这样做。唯一应该有理由关心是否定义了第二种用法的人是为此类比较昂贵的平台编写编译器或寻求确保其程序与此类平台兼容的人。这样的人会比委员会更好地判断维护“无副作用”保证的利弊,因此委员会将问题悬而未决。

      可以肯定的是,编译器没有理由不有效地处理构造这一事实并不能保证“免费聪明的编译器”不会以标准为借口不这样做,但是原因C 标准没有定义“less”运算符,因为委员会预计“

      【讨论】:

        猜你喜欢
        • 2011-01-28
        • 2010-12-04
        • 1970-01-01
        • 2021-04-30
        • 2010-12-03
        • 2017-01-01
        • 2010-09-10
        • 2023-03-30
        • 1970-01-01
        相关资源
        最近更新 更多