std::min(0.0, 1.0)和std::max(0.0, 1.0)会导致未定义的行为吗?

52
这个问题很清楚。下面是我认为这些表达式可能导致未定义行为的原因。我想知道我的推理是对还是错,以及为什么。
(IEEE 754) double不是Cpp17LessThanComparable,因为由于NaN<不是严格的弱序关系。因此,违反了std::min<double>std::max<double>Requires元素。
所有引用都遵循n4800。24.7.8中给出了std::minstd::max的规范:
template constexpr const T& min(const T& a, const T& b); template constexpr const T& max(const T& a, const T& b); 要求:[...]类型T应该是Cpp17LessThanComparable(表24)。
表24定义了Cpp17LessThanComparable并表示:
要求:<是一个严格的弱序关系(24.7)
第24.7/4节定义了严格弱序。特别地,对于<,它指出:“如果我们将equiv(a,b)定义为!(a < b) && !(b < a),则equiv(a,b) && equiv(b,c)意味着equiv(a,c)”。根据IEEE 754,现在有equiv(0.0,NaN) == trueequiv(NaN,1.0) == true,但是equiv(0.0,1.0) == false,因此我们得出结论:<不是严格弱序。因此(IEEE 754)的double不是Cpp17LessThanComparable,这违反了std::minstd::maxRequires条款。
最后,15.5.4.11/1说:
任何函数的前提条件都会导致未定义行为[...]。
更新1: 问题的重点并不在于争论std::min(0.0,1.0)是否未定义,以及当程序评估此表达式时可能发生什么。 它返回0.0。 期。 (我从未怀疑过。)
重点是展示标准的一个(可能的)缺陷。在追求精度的可赞任务中,标准经常使用数学术语,弱严格排序只是其中的一个例子。在这些情况下,必须使用数学精度和推理。
例如,看看维基百科关于strict weak ordering的定义。它包含四个要点,每一个要点都以“对于S中的每个x[...]”开始。没有一个说“对于使算法有意义的S中的某些值x”(什么算法?)。此外,std::min的规范清楚地说明“T应为Cpp17LessThanComparable”,这意味着<小于号>是T上的一种弱严格排序。因此,T扮演了维基百科页面中的S集合的角色,当考虑T的值的整体时,四个要点必须成立。
显然,NaN与其他双精度值非常不同,但它们仍然是可能的值。我没有在标准中发现任何(相当大的1719页的标准,因此出现了这个问题和language-lawyer标签)数学上导致结论:只要不涉及NaNs,std::min可以使用doubles。
实际上,可以说NaN是好的,而其他双精度浮点数才是问题所在!事实上,要记住有几个可能的NaN双精度值(其中2^52-1个,每个都携带不同的有效负载)。考虑包含所有这些值和一个“正常”双精度浮点数(比如42.0)的集合S。用符号表示为S={42.0, NaN_1,...,NaN_n}。结果表明,在S上<是严格弱排序(证明留给读者)。C++委员会在指定std::min时是否考虑了这组值呢?他们的意思是“请勿使用任何其他值,否则将破坏严格弱排序,std::min的行为将是未定义的”?我敢打赌他们没有,但我更愿意在标准中阅读这一点,而不是推测“某些值”的含义。
更新2: 将std::min的声明(上文)与clamp 24.7.9的声明进行对比:
template constexpr const T& clamp(const T& v, const T& lo, const T& hi); Requires: The value of lo shall be no greater than hi. For the first form, type T shall be Cpp17LessThanComparable (Table 24). [Note : If NaN is avoided, T can be a floating-point type. — end note]
在这里,我们清楚地看到了一些内容,它说“std::clamp 可以处理双精度浮点数,只要不涉及 NaN。” 我正在寻找与 std::min 相同类型的句子。
值得注意的是,巴里在他的post中提到的段落 [structure.requirements]/8。显然,这是在 C++17 之后添加的(来自P0898R0):
“本文档中定义的任何概念所需的操作不必是总函数;也就是说,对于某些所需操作的参数,可能导致所需语义无法满足。[例如:严格全序概念(17.5.4)的所需 < 运算符在操作 NaN 时不满足该概念的语义要求。——结束示例]这不影响类型是否满足该概念。”
这是明确尝试解决我在这里提出的问题,但是针对概念的上下文(正如巴里指出的,Cpp17LessThanComparable 不是一个概念)。此外,在我看来,这段话也缺乏精确性。

5
在多大程度上,IEEE754浮点数满足"小于可比较"的条件? - kmdreko
3
“重复”并没有说明所涉及的代码是否存在未定义行为。 - M.M
1
找到了一篇讨论此主题及其对排序等事物影响的论文:C++中的比较 - godel9
@godel9 谢谢,这确实很有用。虽然这篇论文并没有明确回答上面的问题,但它确实给出了线索,表明答案是肯定的(即行为未定义)。 - Cassio Neri
1
这个问题关注IEEE浮点数似乎不太相关于实际问题,而且占用了很多篇幅,这是非常不利的。可以像这样做(显然这不是一个严格的弱序,并且不需要讨论NaN或引用其他标准来确定)。 - Barry
显示剩余8条评论
3个回答

13
在新的[concepts.equality]中,稍有不同的情况下,我们有:
引用: 表达式是等式保持的,如果给定相等的输入,则表达式结果相等。表达式的输入是表达式操作数的集合。表达式的输出是表达式的结果和所有由表达式修改的操作数。 并非所有输入值都需要对于给定的表达式有效;例如,对于整数a和b,当b为0时,表达式a / b未定义。这并不排除表达式a / b是等式保持的。表达式的域是表达式必须定义良好的输入值的集合。
虽然这种表达式域的概念没有完全体现在标准中,但这是唯一合理的意图:语法要求是类型的属性,语义要求是实际值的属性。
更一般地,我们还有[structure.requirements]/8
该文档中定义的任何概念所需的操作不一定是总函数;也就是说,某些传递给所需操作的参数可能导致所需的语义无法满足。【例如:在StrictTotallyOrdered概念([concept.stricttotallyordered])的所需的<运算符在处理NaN时未满足该概念的语义要求。 — 示例结束】这不影响类型是否满足该概念的要求。
这特别指涉到概念,而不是像Cpp17LessThanComparable之类的命名要求,但这是理解库如何工作的正确精神。
Cpp17LessThanComparable给出了语义要求时,<是一个严格的弱序关系(24.7)。唯一违反它的方法是提供一对违反严格弱序要求的值。对于像double这样的类型,那将是NaNmin(1.0, NaN)是未定义行为 - 我们正在违反算法的语义要求。但对于没有NaN的浮点数,<是一个严格的弱序关系,所以没事...你可以使用minmaxsort,任何你喜欢的东西。
在我们开始编写使用operator<=>的算法时,这个域的概念是有一个原因的,即表达ConvertibleTo<decltype(x <=> y), weak_ordering>的句法要求是错误的要求。让x <=> y成为partial_ordering是可以的,只是看到一对使得x <=> ypartial_ordering::unordered的值是不可以的(至少我们可以通过[[ assert: (x <=> y) != partial_ordering::unordered ]];进行诊断)。

+1 这个回答非常准确地捕捉到了标准的意图和方向(请注意新段落[structure.requirements]/8)。然而,就目前情况而言(C++17),我认为DainsDwarf的回答更正确。(当然,我可能错了。)关于你在他们的帖子中发布的评论所提出的问题,我的初步想法是“这很明显”。鉴于我们正在进行的整个讨论,我不再认为它是显而易见的。我能说的最好的话是“我不知道但我相信是这样的”,这并不好。我知道的是,在这个问题上的精度变得越来越重要。 - Cassio Neri
抱歉:在我进行第二次更新之前,我没有意识到您提到了[structure.requirements]/8。我进行了编辑以给予应有的荣誉。 - Cassio Neri

7

免责声明:我不了解完整的C++标准,但我研究过浮点数的相关内容。我对IEEE 754-2008浮点数和C++有一定了解。

是的,你说得对,这符合C++17标准的未定义行为。

简要说明:

标准没有说std::min(0.0,1.0);是未定义行为,它说constexpr const double& min(const double& a,const double& b);是未定义行为。这意味着,它并没有应用函数,而是该“函数声明本身”是未定义的。就像在数学上一样:在IEEE 754浮点数的“全部范围”内不可能有最小值函数,正如你所注意到的那样。

但未定义行为并不一定意味着崩溃或编译错误。它只是意味着它没有被C++标准定义,并且特别指出它可能“在环境特性的文档方式下,在翻译或程序执行期间行为”。

为什么不应该在double上使用std::min

因为我意识到下面这个冗长的阅读部分可能会让人感到无聊,所以这里提供一个玩具示例,展示NaN在比较中的风险(甚至不尝试排序算法...):
#include <iostream>
#include <cmath>
#include <algorithm>

int main(int, char**)
{
    double one = 1.0, zero = 0.0, nan = std::nan("");

    std::cout << "std::min(1.0, NaN) : " << std::min(one, nan) << std::endl;
    std::cout << "std::min(NaN, 1.0) : " << std::min(nan, one) << std::endl;

    std::cout << "std::min_element(1.0, 0.0, NaN) : " << std::min({one, zero, nan}) << std::endl;
    std::cout << "std::min_element(NaN, 1.0, 0.0) : " << std::min({nan, one, zero}) << std::endl;

    std::cout << "std::min(0.0, -0.0) : " << std::min(zero, -zero) << std::endl;
    std::cout << "std::min(-0.0, 0.0) : " << std::min(-zero, zero) << std::endl;
}

在我的MacBook Pro上使用Apple LLVM版本10.0.0(clang-1000.10.44.4)进行编译时(我强调一下,因为这是未定义的行为,所以在其他编译器上可能会有不同的结果),我得到:

$ g++ --std=c++17 ./test.cpp
$ ./a.out
std::min(1.0, NaN) : 1
std::min(NaN, 1.0) : nan
std::min_element(1.0, 0.0, NaN) : 0
std::min_element(NaN, 1.0, 0.0) : nan
std::min(0.0, -0.0) : 0
std::min(-0.0, 0.0) : -0

这意味着与您可能认为的相反,std::min在涉及NaNs或甚至-0.0时不是对称的。而NaNs不会传播。简而言之:在以前的一个项目中,这让我感到痛苦,因为我必须实现自己的min函数,以便正确地在两侧传播NaNs,正如项目规范所要求的那样。因为std::min在双精度上没有定义IEEE 754: 正如您所注意到的那样,IEEE 754浮点数(或ISO/IEC/IEEE 60559:2011-06,这是C11标准使用的规范,更多或少地复制了C语言的IEEE754)没有严格的弱序,因为NaN违反了无法比较的传递性维基百科页面的第四点)。
有趣的是,IEEE754规范在2008年进行了修订(现在称为IEEE-754-2008),其中包括总排序函数。事实是,C++17和C11都没有实现IEEE754-2008,而是ISO/IEC/IEEE 60559:2011-06。
但谁知道呢?也许将来会改变。
首先,让我们回顾一下未定义行为实际上是什么,从你链接的同一标准草案中(强调是我的)。

未定义行为 表示本文档没有强制规定的行为

[注1:当本文档省略任何明确的行为定义或程序使用错误构造或错误数据时,可能会出现未定义行为。允许的未定义行为范围从完全忽略情况并产生不可预测的结果,到在翻译或程序执行期间以环境特征的记录方式表现(无论是否发出诊断消息),到终止翻译或执行(附带诊断消息)。许多错误的程序结构不会引起未定义行为;它们需要被诊断。在本文档第4条到第14条(7.7)中明确定义为未定义的常量表达式评估从不显式展示行为。——结束注释]

“yielding”未定义行为是不存在的。这只是C++标准中未定义的东西。这意味着您可以自己承担风险使用它并得到正确的结果(例如通过执行std::min(0.0, 1.0);)。或者如果您找到一个真正关心浮点数的编译器,它可能会引发警告甚至编译错误!

关于子集......你说:
“我没有在标准中看到任何数学上的东西(标准相当庞大,有1719页,因此这个问题和语言律师标签),表明std :: min对于双精度浮点数是可以接受的,前提是不涉及NaN。”
我自己也没有阅读过标准,但从您发布的部分来看,标准似乎已经说明了这是可以接受的。我的意思是,如果您构造一个排除NaN的双精度浮点数的新类型T,那么应用于您的新类型的模板< class T> constexpr const T&min(const T&a,const T&b);将具有定义行为,并且与您从最小函数所期望的完全相同。
我们还可以查看标准定义的操作<double上,该操作在
25.8浮点类型的数学函数 中定义,其中说得并不是很有用:
分类/比较函数的行为与C标准库中定义相应名称的宏相同。每个函数都针对三种浮点类型进行了重载。另请参见:ISO C 7.12.3、7.12.4。
C11标准怎么说?(因为我猜C++17不使用C18)
关系和等式运算符支持数值之间的通常数学关系。对于任何有序数值对,恰好有一种关系——小于、大于和等于——成立。当参数值为NaN时,关系运算符可能引发“无效”的浮点异常。对于NaN和数值,或者两个NaN,只有无序关系是成立的。241)
至于C11使用的规范,它在该规范的附录F下。
这个附件规定了IEC 60559浮点标准的C语言支持。IEC 60559浮点标准是针对微处理器系统的二进制浮点算术,第二版(IEC 60559:1989),之前被称为IEC 559:1989和IEEE二进制浮点算术标准(ANSI/IEEE 754-1985)。IEEE无基数浮点算术标准(ANSI/IEEE854-1987)将二进制标准概括为不依赖于基数和字长的标准。IEC 60559通常指浮点标准,如IEC 60559操作、IEC 60559格式等。

1
这个答案对我有意义。有几点需要说明。1)我承认我的措辞不够精确,我知道在这种情况下UB的意思是std::min的声明未定义(而不是表达式std::min(0.0, 1.0))。但是我必须说,有了这个标题,问题更引人注目 :-) - Cassio Neri
1
@DainDwarf,浮点数没有完全排序并不是问题(实际上这也不是问题的重点,因为它似乎也不是特别相关)——我的问题更多地涉及到关于“实例化本身”被视为UB而不是其具体使用的特定断言。 - Barry
1
有许多函数在其所有输入的定义域上未被定义(或行为良好)。但是,某些部分未定义并不意味着它们在其他地方无法定义和有用! - Deduplicator
1
@Deduplicator:我认为这个答案声称,用不符合标准规定的类型实例化std::min会导致UB。这可能违反了标准,但在任何特定的库实现中都不会有UB。我认为std::min(a,b)的真正定义必须与(a<b) ? a : b完全等价。对于NaN,它具有明确定义的行为:如果任一输入为NaN,则比较为false,因此返回第二个操作数。(有趣的事实:该行为恰好与x86的minsd指令相匹配) - Peter Cordes
1
@PeterCordes 知道 std::fmin 很有用,而且可能比使用 std::min 更好,但它不会传播 NaN,而是返回非 NaN 数字。 - DainDwarf
显示剩余10条评论

3
唯一可能的(不仅仅是有道理的)解释是方程适用于函数值的范围;也就是说,它们适用于算法中实际使用的值。
您可能会认为类型定义了一组值,但对于UDT来说,这毫无意义。把范围解释为类型的每个可能值就显然荒谬了。
在这里没有问题。
在实现中可能存在一个非常严重的问题,即浮点数的值只能具有由类型允许的精度,因为浮点数类型的数学值的整个概念都失去了意义,因为编译器可能随时决定更改浮点数类型的值以去除精度。实际上,在这种情况下无法定义任何语义。任何这样的实现都是有缺陷的,任何程序可能只是偶然工作。
编辑:
类型不为算法定义值集。对于内部不符合任何代码规范的用户数据类型,这一点显而易见。
可用于任何容器、算法(容器在其元素上内部使用算法)的值集是该容器或算法的特定用法的属性。这些库组件不共享其元素:如果你有两个set<fraction>S1和S2,他们的元素不会被另一个使用: S1将比较S1中的元素,S2将比较S2中的元素。这两个集合存在于不同的"宇宙"中,并且它们的逻辑属性是隔离的。每个元素独立地保持不变;如果你在S2中插入一个x2元素,它既不小于也不大于S1中的x1元素(因此被认为是等价的),你不希望在S1中找到x2取代x1的位置! 容器之间无法共享数据结构,算法之间的元素也无法共享(算法不能有静态变量作为模板类型,因为它会产生意外的生命周期)。
有时标准就像一道谜题,你必须找到正确的解释(最有可能的、最有用的、最可能是目的所在的);在委员会成员被要求澄清问题的情况下,他们将确定最X解释(X=plausible,useful...),即使它与先前的确切措辞相矛盾,因此当文本模糊或给出疯狂的结论时,你可能需要跳过文字字面上的阅读并转向最有用的阅读。
唯一的解决方案是每个模板化库组件的使用是独立的,方程式只需要在该使用期间保持一致。
您不希望vector<int*>无效,因为指针可以有无效值不能拷贝:只有使用这样的值才是非法的。
因此,
vector<int*> v;
v.push_back(new int);
vector<int*> v2 = v; // content must be valid
delete v[0];
v[0] = null; // during v[0] invocation (int*)(v[0]) has no valid value

这段文本的意思是,某个元素类型的所需属性在需要的短暂时间内是有效的,因此它是有效的。

在这种情况下,我们可以调用向量的成员函数,知道它的元素不遵守可分配概念,因为没有允许赋值,因为无异常保证不允许赋值:存储在v [0]中的值不能被v [0]使用,在vector<>::operator [] 中不允许对元素进行用户定义操作。

库组件只能在特定函数的说明中提到的值上使用特定操作;即使对于内置类型,它也不能以任何其他方式生成值:如果未在特定实例中插入或查找0,则特定的set<int,comp>实例可能不会将值与0相比较,因为0甚至可能不在comp的域中。

因此,在此处统一处理内置或类类型。即使使用内置类型进行实例化,库实现也不能假设值的集合中的任何内容。


2
这可能是唯一可能的解释,但似乎我们需要更改/添加一些单词,以使其与标准的措辞相容。这里可能需要进行编辑更改。 - Lightness Races in Orbit
2
你对范围的解释是每个类型的所有可能值,这显然是荒谬的。我认为相反,即考虑“算法中实际使用的值”的集合是“荒谬的”。那些值是什么?严格弱序的定义没有提到任何算法。请参见更新。 - Cassio Neri
2
@CassioNeri:C或C++标准的任何版本都没有始终采用足够精确的级别来使其成为完整的规范。相反,它们都依赖于实现来使用常识填补各种空白。然而,这里讨论的问题很棘手,因为人们可以争论一个“double”是否应该适合期望“Cpp17LessThanComparable”的模板,或者是否应该强制编译器尝试不同的模板。 - supercat
1
@supercat 确实,我并不持相反意见。但对我来说,C++委员会试图尽可能精确,并且他们也依赖用户对措辞的反馈。他们甚至有程序让任何人报告标准中可能发现的任何问题(包括措辞)。“完美是无法达到的,但如果我们追求完美,我们可以达到卓越。”(文斯·隆巴迪) - Cassio Neri
1
@curiousguy:如果某个实现声称适用于需要“高级汇编语言”的任务,并且其部分文档和/或标准描述了某些结构的行为,则即使标准的其他部分允许实现执行其他操作,它也应该按照所描述的方式处理它们。尽管标准不要求这种行为,但程序在执行此操作的实现上表现出有用的行为,这绝非“偶然”。 - supercat
显示剩余19条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接