执行 std::min(0.0, 1.0) 和 std::max(0.0, 1.0) 会产生未定义的行为

Do std::min(0.0, 1.0) and std::max(0.0, 1.0) yield undefined behavior?

本文关键字:std 未定义 min 执行 max      更新时间:2023-10-16

这个问题很清楚。下面给出了我认为这些表达式可能会产生未定义行为的原因。我想知道我的推理是对还是错,为什么。

简读

(IEEE 754)double不是Cpp17LessThanCompal,因为<由于NaN,它不是一个严格的弱排序关系。因此,违反了std::min<double>std::max<double>的要求元素。

长读

所有参考都遵循 n4800。24.7.8 中给出了std::minstd::max的规格:


template<class T> constexpr const T& min(const T& a, const T& b);template<class T> constexpr const T& max(const T& a, const T& b);
要求:[...]T型应为Cpp17LessThanComp(表24)。

表24定义了Cpp17LessThanCompable,并说:

要求:<是严格的弱排序关系 (24.7)

第 24.7/4 节定义了严格的弱排序。特别是,对于<,它指出"如果我们将equiv(a, b)定义为!(a < b) && !(b < a)那么equiv(a, b) && equiv(b, c)意味着equiv(a, c)"。

现在,根据IEEE 754equiv(0.0, NaN) == trueequiv(NaN, 1.0) == trueequiv(0.0, 1.0) == false我们得出结论,<是一个严格的弱排序。因此,(IEEE 754)double不是Cpp17LessThanCompable,这违反了std::minstd::max的要求条款。

最后,15.5.4.11/1 说:

违反函数的Requires:元素中指定的任何前提条件会导致未定义的行为 [...]。

更新 1:

问题的重点不是争辩说std::min(0.0, 1.0)是未定义的,当程序计算此表达式时,任何事情都可能发生。它返回0.0.时期。(我从来没有怀疑过。

关键是要显示标准的(可能的)缺陷。在对精度的追求中,该标准经常使用数学术语,弱严格排序只是一个例子。在这些场合,数学精度和推理必须一路走来。

例如,看看维基百科对严格弱排序的定义。它包含四个项目符号点,每个项目符号点都以"对于每个 x [...]在S..."。他们都没有说"对于 S 中的某些值 x 对算法有意义"(什么算法?此外,std::min的规范明确表示"T应为Cpp17LessThanComparable",这意味着<是对T的严格弱排序。因此,T在维基百科的页面中扮演着集合S的角色,当完全考虑T的值时,四个要点必须成立。

显然,NaN 与其他双重值完全不同,但它们仍然是可能的值。我在标准(相当大,1719 页,因此这个问题和语言律师标签)中没有看到任何在数学上得出的结论,即只要 NaN 不参与,std::min对双打很好。

实际上,人们可以争辩说 NaN 很好,其他双打是问题所在!事实上,回想一下,有几个可能的 NaN 双精度值(2^52 - 其中 1 个,每个值携带不同的有效载荷)。考虑包含所有这些值和一个"正常"双精度值的集合 S,例如 42.0。在符号中,S = { 42.0, NaN_1, ..., NaN_n }。事实证明,<是 S 上的严格弱排序(证明留给读者)。C++委员会在具体说明std::min时是否想到了这套价值观,如"请不要使用任何其他值,否则严格的弱秩序被破坏,std::min的行为是不确定的"?我敢打赌不是,但我宁愿在标准中阅读这一点,而不是推测"某些值"的含义。

更新 2:

std::min声明(上文)与clamp24.7.9 的声明进行对比:

template<class T> constexpr const T& clamp(const T& v, const T& lo, const T& hi);
要求:lo的值不得大于hi。对于第一个表单,键入 T应为Cpp17低于可比性(表24)。 [...]
[注意:如果避免NaN,T 可以是浮点类型。

在这里,我们清楚地看到一些东西说"只要不涉及 NaNstd::clamp就可以使用双打。我正在寻找与std::min相同类型的句子.

值得注意的是Barry在他的帖子中提到的段落[structure.requirements]/8。 显然,这是在C++17之后添加的,来自P0898R0):

本文档中定义的任何概念的必需操作不必是总函数;也就是说,必需操作的某些参数可能会导致无法满足所需的语义。[示例:在 NaN 上操作时,StrictTotallyOrdered概念 (17.5.4) 所需的<运算符不符合该概念的语义要求。 ] 这不会影响类型是否满足概念。

这显然是试图解决我在这里提出的问题,但在概念的背景下(正如Barry所指出的,Cpp17LessThanCompare不是一个概念)。此外,恕我直言,这一段也缺乏精确性。

在新的[概念.平等]中,在一个稍微不同的上下文中,我们有:

如果给定相等的输入,则表达式的结果相等,则该表达式是相等的。表达式的输入是表达式的操作数集。表达式的输出是表达式的结果以及表达式修改的所有操作数。

并非所有输入值都需要对给定表达式有效;例如,对于整数ab,表达式a / b0b时没有明确定义。这并不排除a / b平等的表达。表达式的是一组输入值,需要为其明确定义表达式。

虽然表达式域的概念在整个标准中没有完全表达,但这是唯一合理的意图:语法要求是类型的属性,语义要求是实际值的属性。

更一般地说,我们还有[结构要求]/8:

本文档中定义的任何概念的必需操作不必是总函数;也就是说,必需操作的某些参数可能会导致无法满足所需的语义。[示例StrictTotallyOrdered概念([concept.stricttotallyordered])所需的<运算符在NaN上操作时不符合该概念的语义要求。这不会影响类型是否满足概念。

这特指概念,而不是像Cpp17LessThanComparable这样的命名需求,但这是理解库如何工作的正确精神。


Cpp17LessThanComparable给出语义要求时

<是一个严格的弱排序关系 (24.7)

违反此规定的唯一方法是提供一对违反严格弱排序要求的值。对于像double这样的类型,那将是NaNmin(1.0, NaN)是未定义的行为 - 我们违反了算法的语义要求。但是对于没有NaN的浮点数,<是一个严格的弱排序 - 所以这很好......你可以使用minmaxsort,随心所欲。

展望未来,当我们开始编写使用operator<=>的算法时,这种域的概念是表达ConvertibleTo<decltype(x <=> y), weak_ordering>的句法要求是错误要求的原因之一。x <=> ypartial_ordering很好,它只是看到一对x <=> ypartial_ordering::unordered不是的值(至少我们可以通过[[ assert: (x <=> y) != partial_ordering::unordered ]];进行诊断)

免责声明:我不知道完整的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++标准定义的,并明确表示它可能"在翻译或程序执行期间以环境特征的记录方式运行">

为什么你不应该在双打上使用std::min

因为我意识到下面的长阅读部分可能会很无聊,所以这里有一个比较中 NaNs 风险的玩具示例(我什至不尝试排序算法......

#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;
}

当我使用 Apple LLVM 版本 10.0.0 (clang-1000.10.44.4) 在我的 macbookpro 上进行编译时(我进行精确计算,因为这是未定义的行为,因此理论上这可能会在其他编译器上产生不同的结果)我得到:

$ 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

这意味着与您可能假设的相反,当涉及 NaN 时,std::min是不对称的,甚至-0.0.而且 NaN 不会传播。简短故事:这确实在以前的项目中引起了我的一些痛苦,我必须实现自己的min函数才能按照项目规范的要求在两侧正确传播 NaN。因为双打std::min没有定义

IEEE 754:

正如你所注意到的,IEEE 754浮点数(或ISO/IEC/IEEE 60559:2011-06,这是C11标准使用的规范,见下文,它或多或少地复制了C语言IEEE754)没有严格的弱排序,因为NaN违反了不可比性的传递性(维基百科页面的第四点)

有趣的是,IEE754规范已在2008年进行了修订(现在称为IEEE-754-2008),其中包括一个总排序功能。事实上,C++17和C11都没有实施IEE754-2008,而是实施ISO/IEC/IEEE 60559:2011-06

。但谁知道呢?也许将来会改变。

长读:

首先,让我们首先回顾一下您链接的同一标准草案中未定义的行为实际上是什么(重点是我的):

本文档未强加的未定义行为行为 要求

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

没有"屈服"未定义的行为这样的事情。它只是C++标准中未定义的东西。这可能意味着您可以使用它并获得正确的结果,风险自负(例如通过执行std::min(0.0, 1.0);或者,如果您发现编译器对浮点数非常小心,则可能会引发警告甚至编译错误!

关于子集...你说:

我在标准中没有看到任何内容(它很大,1719 页, 因此这个问题和语言律师标签)那 数学上得出的结论是,std::min 可以 双打,前提是 NaN 不参与。

我自己也没有读过标准,但从您发布的部分来看,标准似乎已经说这很好。我的意思是,如果您构造一个新的类型 T来包装不包括 NaNs 的双精度值,那么应用于新类型的template<class T> constexpr const T& min(const T& a, const T& b);定义将具有定义的行为,并且行为与您对最小函数的期望完全相同。

我们还可以查看double运算<的标准定义,该定义在浮点类型的第 25.8节数学函数中定义,其中说不是很有帮助:

分类/比较函数的行为与 C 相同 具有 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格式等

唯一可能的(不仅仅是合理的)解释是方程适用于函数范围内的值; 也就是说,算法中实际使用的值

您可能会想到定义一组值的类型,但对于 UDT,这无论如何都没有意义。你对范围是一个类型的每个可能值的解释显然是荒谬的。

这在这里没有问题。

在浮点类型的值不能超过类型允许的精度的实现中,可能存在一个非常严重的问题,因为浮点类型的数学值的整个想法失去了所有意义,因为编译器可能决定更改浮点类型的值以随时删除精度。事实上,在这种情况下无法定义语义。任何这样的实现都会被破坏,任何程序都可能只是偶然运行。

编辑:

类型不定义算法的一组值。对于具有未在任何代码中正式指定的内部不变量的用户数据类型,这一点很明显。

可在任何容器中使用的值集,算法(容器内部对元素使用算法)...是该容器或算法的特定使用的属性。这些库组件没有共享它们的元素:如果你有两个set<fraction>S1 和 S2,它们的元素不会被另一个使用:S1 将比较 S1 中的元素,S2 将比较 S2 中的元素。这两个集合存在于不同的"宇宙"中,它们的逻辑属性是孤立的。每个不变量都独立成立;如果您在 S2 中插入一个元素 x2,该元素在 S1中不小于或大于 x1(因此被认为是等效的),您不希望在 S1 中的 x1 位置找到 x2!容器之间不可能共享数据结构,元素也不能在算法之间共享(算法不能具有模板类型的静态变量,因为它具有意外的生存期)。

有时标准是一个谜语,你必须找到正确的解释(最合理,最有用,最有可能是有意的);如果委员会成员被要求澄清一个问题,他们将确定最X的解释(X =合理,有用......),即使它与之前的确切措辞相矛盾,所以当文本晦涩难懂或给出疯狂的结论时, 您不妨跳过字面阅读并跳到最有用的。

这里唯一的解决方案是模板化库组件的每次使用都是独立的,并且方程只需要在使用期间成立。

您不希望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的域中。

因此,内置或类类型在这里统一处理。库实现不能假定值集上的任何内容,即使使用内置类型实例化也是如此。

相关文章: