Приводят ли std :: min (0.0, 1.0) и std :: max (0.0, 1.0) к неопределенному поведению? - программирование

Приводят ли std :: min (0.0, 1.0) и std :: max (0.0, 1.0) к неопределенному поведению?

Вопрос довольно понятен. Ниже приводится причина, по которой я думаю, что эти выражения могут привести к неопределенному поведению. Я хотел бы знать, правильны ли мои рассуждения или нет и почему.

Краткое чтение:

(IEEE 754) double не является Cpp17LessThanComparable, поскольку < не является строгим отношением слабого порядка из-за NaN. Поэтому элементы std::min<double> в std::min<double> и std::max<double> нарушаются.

Долго читал:

Все ссылки следуют за n4800. Спецификации std::min и std::max приведены в 24.7.8:

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 должен быть 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) == true, equiv(NaN, 1.0) == true equiv(0.0, 1.0) == false мы заключаем, что < не является строгим слабым порядком. Таким образом, (IEEE 754) double не является Cpp17LessThanComparable, что является нарушением условий Требований std::min и std::max.

Наконец, 15.5.4.11/1 говорит:

Нарушение любых предварительных условий, указанных в функции Требуется: элемент приводит к неопределенному поведению [...].

Обновление 1:

Суть вопроса не в том, чтобы утверждать, что std::min(0.0, 1.0) не определен, и что-либо может произойти, когда программа оценивает это выражение. Возвращает 0.0. Период. (Я никогда не сомневался в этом.)

Смысл в том, чтобы показать (возможный) дефект стандарта. В похвальном стремлении к точности Стандарт часто использует математическую терминологию, и слабый строгий порядок является лишь одним примером. В этих случаях математическая точность и рассуждение должны идти до конца.

Посмотрите, например, определение Википедии о строгом слабом порядке. Он содержит четыре маркера, и каждый из них начинается с "Для каждого x [...] в S...". Никто из них не говорит "Для некоторых значений x в S, которые имеют смысл для алгоритма" (Какой алгоритм?). Кроме того, спецификация std::min ясна в том, что " T должно быть Cpp17LessThanComparable", что влечет за собой то, что < является строгим слабым порядком на T Следовательно, T играет роль множества S на странице Википедии, и четыре маркера должны сохраняться, когда значения T рассматриваются полностью.

Очевидно, что NaN - совершенно разные звери от других двойных значений, но они все еще являются возможными значениями. Я не вижу ничего в Стандарте (который довольно большой, 1719 страниц, и, следовательно, этот вопрос и тег language-lawyer), который математически приводит к выводу, что std::min подходит для удвоений при условии, что NaN не участвуют.

На самом деле, можно утверждать, что NaNs в порядке, и другие двойники являются проблемой! Действительно, напомним, что существует несколько возможных двойных значений NaN (2 ^ 52 - 1 из них, каждое из которых несет различную полезную нагрузку). Рассмотрим множество S, содержащее все эти значения и один "нормальный" дубль, скажем, 42.0. В символах S = {42.0, NaN_1,..., NaN_n}. Оказывается, что < строгий слабый порядок на S (доказательство оставлено читателю). Был ли этот набор значений, который имел в виду Комитет C++ при указании std::min например, "пожалуйста, не используйте никакие другие значения, иначе строгое слабое упорядочение нарушено и поведение std::min не определено"? Могу поспорить, что это не так, но я бы предпочел прочитать это в Стандарте, чем размышлять, что означают "некоторые значения".

Обновление 2:

Сравните декларацию std::min (выше) с clamp 24.7.9:

template<class T> constexpr const T& clamp(const T& v, const T& lo, const T& hi);
Требуется: значение lo не должно быть больше hi. Для первой формы тип T должен быть Cpp17LessThanComparable (Таблица 24). [...]
[Примечание: если избегать NaN, T может быть типом с плавающей запятой. - конец примечания]

Здесь мы ясно видим что-то, что говорит: " std::clamp хорошо с двойными числами при условии, что NaN не участвуют". Я искал предложение того же типа для std::min.

Стоит обратить внимание на пункт [structure.requirements]/8, который Барри упомянул в своем посте. По-видимому, это было добавлено после C++ 17 от P0898R0):

Требуемые операции любой концепции, определенной в этом документе, не обязательно должны быть полными функциями; то есть некоторые аргументы требуемой операции могут привести к тому, что требуемая семантика не будет удовлетворена. [Пример: требуемый оператор < концепции StrictTotallyOrdered (17.5.4) не соответствует семантическим требованиям этой концепции при работе с NaN. - конец примера] Это не влияет на то, удовлетворяет ли тип концепции.

Это явная попытка решить проблему, которую я поднимаю здесь, но в контексте концепций (и как указал Барри, Cpp17LessThanComparable не является концепцией). Кроме того, ИМХО этому пункту также не хватает точности.

4b9b3361

Ответ 1

Отказ от ответственности: я не знаю полного стандарта 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 для double:

Поскольку я понимаю, что следующий длинный раздел чтения может стать скучным, вот игрушечный пример риска, связанного с 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;
}

При компиляции на моем macbookpro с 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 не является симметричным, когда задействованы NaN, или даже -0.0. И NaNs не размножаются. Короткая история: Это вызвало у меня некоторую боль в предыдущем проекте, где я должен был реализовать свою собственную функцию min для правильного распространения NaN с обеих сторон, как того требовала спецификация проекта. Потому что std::min на double не определен !

IEEE 754:

Как вы заметили, числа с плавающей точкой IEEE 754 (или ISO/IEC/IEEE 60559: 2011 -0 6, что является нормой, используемой стандартом C11, см. Ниже, который более или менее копирует IEEE754 для языка C), не имеют строгий слабый порядок, потому что NaNs нарушает транзитивность несопоставимости (четвертый пункт страницы Википедии)

Самое интересное, что норма IEE754 была пересмотрена в 2008 году (теперь она называется IEEE-754-2008), которая включает в себя функцию полного упорядочения. Дело в том, что и C++ 17, и C11 не реализуют IEE754-2008, а скорее ISO/IEC/IEEE 60559: 2011 -0 6

Но кто знает? Может быть, это изменится в будущем.

Долго читал:

Во-первых, давайте начнем с напоминания о том, что на самом деле является неопределенным поведением, из того же стандартного черновика, который вы связали (выделение мое):

неопределенное поведение поведения, для которого этот документ не предъявляет никаких требований

[Примечание 1 к записи: Неопределенное поведение может ожидаться, когда в этом документе отсутствует какое-либо явное определение поведения или когда программа использует ошибочную конструкцию или ошибочные данные. Допустимое неопределенное поведение варьируется от полного игнорирования ситуации с непредсказуемыми результатами до поведения во время перевода или выполнения программы документированным образом, характерным для среды (с выдачей диагностического сообщения или без него), до прекращения перевода или выполнения (с выдачей диагностического сообщения). Многие ошибочные программные конструкции не порождают неопределенное поведение; они должны быть диагностированы. Оценка константного выражения никогда не демонстрирует поведение, явно указанное как неопределенное в пунктах с 4 по 14 данного документа (7.7). —Конечная записка]

Нет такой вещи, как "уступать" неопределенному поведению. Это просто то, что не определено в стандарте C++. Это может означать, что вы можете использовать его и получить правильный результат на свой страх и риск (например, путем выполнения std::min(0.0, 1.0); или это может вызвать предупреждение или даже ошибки компиляции, если вы найдете компилятор, который действительно осторожен с плавающей точкой) номера!

О подмножестве… Вы говорите:

Я не вижу ничего в Стандарте (который довольно большой, 1719 страниц, и, следовательно, этот вопрос и тег language-lawyer), который математически приводит к выводу, что std :: min подходит для удвоений при условии, что NaN не участвуют.

Я и сам не читал стандарт, но из той части, которую вы опубликовали, кажется, что стандарт уже говорит, что это нормально. Я имею в виду, если вы создаете новый тип T, который оборачивает двойные значения, исключая NaN, то определение template<class T> constexpr const T& min(const T& a, const T& b); применяется к вашему новому типу будет иметь определенное поведение, и вести себя точно так, как вы ожидаете от минимальной функции.

Мы также могли бы взглянуть на стандартное определение операции < on double, которое определено в разделе 25.8 Математические функции для типов с плавающей запятой, которое говорит, что не очень полезно:

Функции классификации/сравнения ведут себя так же, как макросы C с соответствующими именами, определенными в стандартной библиотеке C. Каждая функция перегружена для трех типов с плавающей точкой. Смотрите также: ISO C 7.12.3, 7.12.4

Что говорит стандарт C11? (Потому что я думаю, C++ 17 не использует C18)

Операторы отношений и равенства поддерживают обычные математические отношения между числовыми значениями. Для любой упорядоченной пары числовых значений верно одно из отношений - меньше, больше и равно - верно. Реляционные операторы могут вызывать недопустимое исключение с плавающей запятой, когда значения аргумента являются NaN. Для NaN и числового значения или для двух NaN только неупорядоченное отношение истинно .241)

Что касается использования нормы C11, то в соответствии с приложением F этой нормы:

В этом приложении указана поддержка языка Си для стандарта МЭК 60559 с плавающей точкой. Стандарт МЭК 60559 - это, в частности, двоичная арифметика с плавающей точкой для микропроцессорных систем, второе издание (МЭК 60559: 1989), ранее обозначенная МЭК 559: 1989 и как стандарт IEEE для двоичной арифметики с плавающей точкой (ANSI/IEEE 754−1985). Стандарт IEEE для радикально-независимой арифметики с плавающей точкой (ANSI/IEEE854-1987) обобщает бинарный стандарт для удаления зависимостей от оснований и длины слова. МЭК 60559, как правило, относится к стандарту с плавающей запятой, как в работе МЭК 60559, формате МЭК 60559 и т.д.

Ответ 2

В новом [concept.equality] в несколько ином контексте мы имеем:

Выражение сохраняет равенство, если при равных входных данных выражение приводит к равным выходным данным. Входные данные для выражения являются набором операндов выражения. Выходные данные выражения являются результатом выражения и всеми операндами, измененными выражением.

Не все входные значения должны быть действительными для данного выражения; например, для целых чисел a и b выражение a/b не является четко определенным, когда b равно 0. Это не исключает, что выражение a/b сохраняет равенство. Домен выражения - это набор входных значений, для которых выражение должно быть четко определено.

Хотя это понятие области выражения не полностью выражено во всем стандарте, это единственное разумное намерение: синтаксические требования - это свойства типа, семантические требования - это свойства фактических значений.

В целом, у нас также есть [struct.requirements]/8:

Требуемые операции любой концепции, определенной в этом документе, не обязательно должны быть полными функциями; то есть некоторые аргументы требуемой операции могут привести к тому, что требуемая семантика не будет удовлетворена. [Пример: обязательный < оператор для концепции StrictTotallyOrdered ([concept.stricttotallyordered]) не соответствует семантическим требованиям этой концепции при работе с NaN. - конец примера] Это не влияет на то, удовлетворяет ли тип концепции.

Это относится конкретно к концепциям, а не к именованным требованиям, таким как Cpp17LessThanComparable, но это правильный дух для понимания того, как библиотека предназначена для работы.


Когда Cpp17LessThanComparable дает семантическое требование, что

< строгое отношение слабого порядка (24.7)

Единственный способ этого нарушить - предоставить пару значений, которые нарушают требования строгого слабого порядка. Для типа, подобного double, это будет NaN. min(1.0, NaN) - неопределенное поведение - мы нарушаем семантические требования алгоритма. Но для чисел с плавающей запятой без NaN < - строгий слабый порядок - так что хорошо... вы можете использовать min, max, sort, все, что вам нравится.

В дальнейшем, когда мы начнем писать алгоритмы, в которых используется operator<=>, это понятие домена является одной из причин, по которой выражение синтаксического требования ConvertibleTo<decltype(x <=> y), weak_ordering> было бы неправильным требованием. Хорошо, если x <=> y быть partial_ordering, это просто видеть пару значений, для которых x <=> y является partial_ordering::unordered - нет (что, по крайней мере, мы могли бы диагностировать с помощью [[ assert: (x <=> y) != partial_ordering::unordered ]];)

Ответ 3

Единственно возможная (не просто правдоподобная) интерпретация состоит в том, что уравнения применяются к значениям в диапазоне функции; то есть к значениям, фактически используемым в алгоритмах.

Вы можете подумать о типе, определяющем набор значений, но для UDT это все равно не имеет смысла. Ваша интерпретация диапазона, представляющего собой каждое возможное значение типа, явно абсурдна.

Это не проблема здесь.

Это может существовать очень серьезная проблема в реализациях, где значение с плавающей запятой может иметь не более высокую точность, чем допускается типом, так как сама идея математического значения типа с плавающей запятой теряет всякий смысл, так как компилятор может решить измените значение типа с плавающей запятой, чтобы удалить точность в любое время. На самом деле семантика не может быть определена в этом случае. Любая такая реализация нарушена, и любая программа, вероятно, работает только случайно.

РЕДАКТИРОВАТЬ:

Тип не определяет набор значений для алгоритма. Это очевидно для пользовательских типов данных, которые имеют внутренние инварианты, которые формально не указаны ни в одном коде.

Набор значений, используемых в любом контейнере, алгоритме (контейнеры внутренне используют алгоритмы на элементах)... является свойством этого конкретного использования этого контейнера или алгоритма. Эти библиотечные компоненты не имеют общих элементов: если у вас есть два set<fraction> S1 и S2, их элементы не будут использоваться другими: S1 будет сравнивать элементы в S1, S2 будет сравнивать элементы в S2. Два набора существуют в разных "вселенных", и их логические свойства изолированы. Инварианты держатся для каждого независимо; если вы вставите в S2 элемент x2, который не меньше или больше, чем x1 в S1 (что считается эквивалентным), вы не ожидаете, что x2 будет найден в месте x1 в S1! Невозможно совместное использование структур данных между контейнерами, а элементы не могут быть разделены между алгоритмами (у которых не может быть статических переменных типа шаблона, так как это может иметь неожиданное время жизни).

Иногда стандарт представляет собой загадку, в которой вы должны найти правильное толкование (наиболее правдоподобное, наиболее полезное, наиболее вероятное предназначение); в случае, если членов комитета попросят прояснить вопрос, они остановятся на наиболее 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

допустимо, потому что требуемые свойства типа элемента действительны в течение небольшого периода времени, в котором они должны быть.

В этом случае мы можем вызвать функцию-член вектора, зная, что его элементы не уважают концепцию Assignable, потому что нет допустимого присваивания, поскольку гарантия без исключения не позволяет этого: значение, сохраненное в v[0] не может будет использоваться v[0], пользовательская операция над элементом, разрешенным в vector<>::operator[].

Компоненты библиотеки могут использовать только определенные операции, упомянутые в описании конкретной функции, для значений, используемых в этом вызове; даже для встроенного типа он не может создавать значения любым другим способом: конкретный экземпляр set<int,comp> может не сравнивать значения с 0, если 0 не вставлен или не найден в конкретном экземпляре, так как 0 может даже не быть в области comp.

Таким образом, встроенные или классовые типы обрабатываются здесь единообразно. Реализация библиотеки не может принимать что-либо на множестве значений, даже если она создается с помощью встроенных типов.