Может ли сравнение равенства чисел с плавающей точкой вводить в заблуждение младших разработчиков, даже если в моем случае ошибки округления не происходит?


31

Например, я хочу показать список кнопок с 0,0,5, ... 5, которые переходят на каждые 0,5. Для этого я использую цикл for, и у кнопки STANDARD_LINE другой цвет:

var MAX=5.0;
var DIFF=0.5
var STANDARD_LINE=1.5;

for(var i=0;i<=MAX;i=i+DIFF){
    button.text=i+'';
    if(i==STANDARD_LINE){
      button.color='red';
    }
}

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

var MAX=10;
var STANDARD_LINE=3;

for(var i=0;i<=MAX;i++){
    button.text=i/2.0+'';
    if(i==STANDARD_LINE/2.0){
      button.color='red';
    }
}

С одной стороны, оригинальный код более прост и понятен мне. Но есть одна вещь, которую я рассматриваю: я == STANDARD_LINE вводит в заблуждение младших товарищей по команде? Это скрывает тот факт, что числа с плавающей запятой могут иметь ошибки округления? Прочитав комментарии к этому посту:

/programming/33646148/is-hardcode-float-precise-if-it-can-be-represented-by-binary-format-in-ieee-754

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


23
Поведение этих двух списков кодов не эквивалентно. 3 / 2.0 - это 1,5, но iво втором списке будут только целые числа. Попробуйте удалить второй /2.0.
candied_orange

27
Если вам абсолютно необходимо сравнить два FP на равенство (что не требуется, как другие указали в своих прекрасных ответах, поскольку вы можете просто использовать сравнение счетчиков циклов с целыми числами), но если вы это сделали, то комментария должно быть достаточно. Лично я работаю с IEEE FP в течение длительного времени, и я все еще был бы озадачен, если бы увидел, скажем, прямое сравнение SPFP без каких-либо комментариев или чего-либо еще. Это просто очень деликатный код - стоит комментировать хотя бы каждый раз ИМХО.

14
Независимо от того, что вы выберете, это один из тех случаев, когда комментарий, объясняющий, как и почему абсолютно необходим. Более поздний разработчик может даже не рассмотреть тонкости без комментариев, чтобы привлечь их внимание. Кроме того, меня сильно отвлекает тот факт, что buttonнигде в вашей петле ничего не меняется. Как получить доступ к списку кнопок? Через индекс в массив или какой-то другой механизм? Если это по индексу доступа к массиву, это еще один аргумент в пользу переключения на целые числа.
jpmc26

9
Напишите этот код. Пока кто-то не подумает, что 0,6 будет лучшим шагом и просто изменит эту константу.
Тоо

11
«... вводить в заблуждение младших разработчиков» Вы также будете вводить в заблуждение старших разработчиков. Несмотря на количество мыслей, которые вы вложили в это, они будут считать, что вы не знали, что вы делаете, и, скорее всего, в любом случае измените его на целочисленную версию.
GrandOpener

Ответы:


116

Я бы всегда избегал последовательных операций с плавающей точкой, если модель, которую я вычисляю, не требует их. Арифметика с плавающей запятой неинтуитивна для большинства и является основным источником ошибок. И говорить о случаях, когда это вызывает ошибки из тех, где это не так, - еще более тонкое различие!

Следовательно, использование чисел с плавающей запятой в качестве счетчиков циклов является дефектом, ожидающим возникновения, и, по крайней мере, потребует жирного комментария, объясняющего, почему здесь допустимо использовать 0,5, и что это зависит от конкретного числового значения. На этом этапе переписывание кода, чтобы избежать счетчиков с плавающей запятой, вероятно, будет более читабельным вариантом. И читабельность рядом с правильностью в иерархии профессиональных требований.


48
Мне нравится "дефект, ожидающий случиться". Конечно, это может сработать сейчас , но легкий ветерок от кого-то, проходящего мимо, сломает его.
AakashM

10
Например, предположим, что требования изменяются так, что вместо 11 кнопок с одинаковым интервалом от 0 до 5 со «стандартной линией» на 4-й кнопке у вас есть 16 кнопок с равным интервалом от 0 до 5 со «стандартной линией» на 6-й кнопка. Поэтому тот, кто унаследовал этот код от вас, изменится с 0,5 на 1,0 / 3,0 и с 1,5 на 5,0 / 3,0. Что происходит потом?
Дэвид К

8
Да, меня не устраивает мысль о том, что замена того, что кажется произвольным числом (настолько «нормальным», каким может быть число), на другое произвольное число (которое кажется одинаково «нормальным») фактически вносит дефект.
Александр - Восстановить Монику

7
@ Александр: правильно, вам нужен комментарий, который сказал DIFF must be an exactly-representable double that evenly divides STANDARD_LINE. Если вы не хотите писать этот комментарий (и полагаетесь на то, что все будущие разработчики достаточно хорошо разбираются в двоичной64 IEEE754, чтобы понять его), не пишите код таким образом. т.е. не пишите код таким образом. Тем более, что это, вероятно, даже не более эффективно: сложение FP имеет большую задержку, чем сложение целых чисел, и это зависимость, переносимая циклами. Кроме того, компиляторы (даже JIT-компиляторы?), Вероятно, лучше делают циклы с целочисленными счетчиками.
Питер Кордес

39

Как правило, циклы должны быть написаны так, чтобы думать о том, чтобы что-то делать n раз. Если вы используете индексы с плавающей запятой, это больше не вопрос о том, чтобы делать что-то n раз, а скорее о выполнении до тех пор, пока не будет выполнено условие. Если это условие оказывается очень похожим на то, i<nчто ожидают многие программисты, тогда код, по-видимому, выполняет одно, а фактически другое, что может быть легко неверно истолковано программистами, скиммирующими код.

Это несколько субъективно, но, по моему скромному мнению, если вы можете переписать цикл, чтобы использовать целочисленный индекс для цикла фиксированное число раз, вы должны сделать это. Поэтому рассмотрим следующую альтернативу:

var DIFF=0.5;                           // pixel increment
var MAX=Math.floor(5.0/DIFF);           // 5.0 is max pixel width
var STANDARD_LINE=Math.floor(1.5/DIFF); // 1.5 is pixel width

for(var i=0;i<=MAX;i++){
    button.text=(i*DIFF)+'';
    if(i==STANDARD_LINE){
      button.color='red';
    }
}

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


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

1
@ilkkachu Правда. Я думал, что если вы устанавливаете 5.0 в качестве максимального количества пикселей, то при округлении вы бы предпочли быть на нижней стороне этого 5.0, а не чуть больше. 5.0 будет эффективно максимум. Хотя округление может быть предпочтительнее в зависимости от того, что вам нужно сделать. В любом случае, это не имеет большого значения, если деление все равно создает целое число.
Нил

4
Я категорически не согласен. Лучший способ остановить цикл - это условие, которое наиболее естественно выражает бизнес-логику. Если бизнес-логика заключается в том, что вам нужно 11 кнопок, цикл должен остановиться на итерации 11. Если бизнес-логика заключается в том, что кнопки находятся на расстоянии 0,5, пока строка не заполнится, цикл должен остановиться, когда строка заполнится. Существуют и другие соображения, которые могут подтолкнуть выбор к тому или иному механизму, но при отсутствии этих соображений выбрать механизм, наиболее близкий к бизнес-требованиям.
Восстановить Монику

Ваше объяснение было бы совершенно правильным для Java / C ++ / рубин / Python / ... Но Javascript не целые числа, так iи STANDARD_LINEтолько выглядят как целые числа. Там нет никакого принуждения вообще, и DIFF, MAXи STANDARD_LINEвсе просто Numberс. Numbers, используемые в качестве целых чисел, должны быть безопасны ниже 2**53, хотя они все еще являются числами с плавающей точкой.
Эрик Думинил

@EricDuminil Да, но это половина. Другая половина - читабельность. Я упоминаю это как основную причину сделать это таким образом, а не для оптимизации.
Нил

20

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

Ваш код «знает», что доступная ширина линии в точности кратна 0,5 от 0 до 5,0. Должно ли это? Похоже, это решение пользовательского интерфейса, которое может легко измениться (например, может быть, вы хотите, чтобы промежутки между доступными значениями ширины увеличивались по мере увеличения ширины. 0,25, 0,5, 0,75, 1,0, 1,5, 2,0, 2,5, 3,0, 4,0, 5,0 или что-то).

Ваш код «знает», что все доступные значения ширины строки имеют «хорошие» представления как в виде чисел с плавающей точкой, так и в виде десятичных дробей. Это также кажется чем-то, что может измениться. (Вы можете захотеть 0,1, 0,2, 0,3, ... в какой-то момент.)

Ваш код «знает», что текст, который нужно надеть на кнопки, - это просто то, во что превращает эти значения с плавающей точкой Javascript. Это также кажется чем-то, что может измениться. (Например, возможно, однажды вы захотите ширину, например, 1/3, которую вы, вероятно, не захотите отображать как 0.33333333333333 или что-то в этом роде. Или, возможно, вы захотите увидеть «1.0» вместо «1» для согласованности с «1.5» .)

Все это кажется мне проявлением единой слабости, которая является своего рода смешением слоев. Эти числа с плавающей точкой являются частью внутренней логики программного обеспечения. Текст, отображаемый на кнопках, является частью пользовательского интерфейса. Они должны быть более отдельными, чем в коде здесь. Понятия типа "какой из них по умолчанию должен быть выделен?" вопросы пользовательского интерфейса, и они, вероятно, не должны быть привязаны к этим значениям с плавающей точкой. И ваш цикл здесь действительно (или, по крайней мере, должен быть) циклом над кнопками , а не по ширине линии . Написанный таким образом, искушение использовать переменную цикла, принимающую нецелые значения, исчезает: вы просто используете последовательные целые числа или цикл for ... in / for ....

Мне кажется, что большинство случаев, когда можно испытать соблазн зацикливаться на нецелых числах, таковы: существуют другие причины, совершенно не связанные с числовыми проблемами, почему код должен быть организован по-другому. (Не во всех случаях; я могу представить, что некоторые математические алгоритмы могут быть наиболее аккуратно выражены в терминах цикла над нецелыми значениями.)


8

Один запах кода использует плавающие циклы, как это.

Зацикливание может быть выполнено многими способами, но в 99,9% случаев вы должны придерживаться шага, равного 1, иначе наверняка возникнет путаница не только у младших разработчиков.


Я не согласен, я думаю, что целые кратные 1 не сбивают с толку в цикле for. Я бы не посчитал это запахом кода. Только дроби.
CodeMonkey

3

Да, вы хотите избежать этого.

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

Используйте числа с плавающей запятой именно там, где они уместны, например, при выполнении реальных математических вычислений там, где они вам нужны (например, тригонометрия, построение графиков функций и т. Д.), И будьте очень осторожны при выполнении последовательных операций. Равенство прямо Знание того, какой именно набор чисел является точным по стандартам IEEE, очень загадочно, и я бы никогда не зависел от этого.

В вашем случае, будет , по Murphys закона, наступит момент , когда руководство хочет , чтобы вы не имеете 0,0, 0,5, 1,0 ... 0,0 , но, 0,4, 0,8 ... или что - то; вы вы будете немедленно BORKED, и ваш младший программист (или себя) будет отлаживать долго и упорно , пока не найдете проблему.

В вашем конкретном коде у меня действительно была бы целочисленная переменная цикла. Она представляет собой iкнопку th, а не текущий номер.

И я бы, вероятно, ради большей ясности, не писал, i/2но i*0.5это совершенно ясно дает понять, что происходит.

var BUTTONS=11;
var STANDARD_LINE=3;

for(var i=0; i<BUTTONS; i++) {
    button.text = (i*0.5)+'';
    if (i==STANDARD_LINE) {
      button.color='red';
    }
}

Примечание: как указано в комментариях, JavaScript не имеет отдельного типа для целых чисел. Но целые числа до 15 цифр гарантированно будут точными / безопасными (см. Https://www.ecma-international.org/ecma-262/6.0/#sec-number.max_safe_integer ), следовательно, для таких аргументов (" более запутанный / подверженный ошибкам при работе с целыми или нецелыми числами ") это подходящим образом близко к наличию отдельного типа" по духу "; при ежедневном использовании (циклы, экранные координаты, индексы массивов и т. д.) сюрпризов с целыми числами, представленными в Numberвиде JavaScript, не будет.


Я бы изменил название КНОПКИ на что-то другое - в конце концов, есть 11 кнопок, а не 10. Может быть, FIRST_BUTTON = 0, LAST_BUTTON = 10, STANDARD_LINE_BUTTON = 3. Кроме этого, да, вот как вы должны это сделать.
gnasher729

Это правда, @EricDuminil, и я добавил немного об этом в ответ. Спасибо!
AnoE

1

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

function precisionRound(number, precision) {
  let factor = Math.pow(10, precision);
  return Math.round(number * factor) / factor;
}

var maxButtonValue = 5.0;
var buttonSpacing = 0.5;

let countEstimate = precisionRound(maxButtonValue / buttonSpacing, 5);
var buttonCount = Math.floor(countEstimate) + 1;

var highlightPosition = 3;
var highlightColor = 'red';

for (let i=0; i < buttonCount; i++) {
    let buttonValue = i / buttonSpacing;
    button.text = buttonValue.toString();
    if (i == highlightPosition) {
        button.color = highlightColor;
    }
}

Это может быть больше кода, но он также более читабелен и надежен.


0

Вы можете избежать всего этого, вычисляя отображаемое значение, а не используя счетчик цикла в качестве значения:

var MAX=5.0;
var DIFF=0.5
var STANDARD_LINE=1.5;

for(var i=0; (i*DIFF) < MAX ; i=i+1){
    var val = i * DIFF

    button.text=val+'';

    if(val==STANDARD_LINE){
      button.color='red';
    }
}

-1

Арифметика с плавающей точкой медленная, а целочисленная арифметика быстрая, поэтому, когда я использую число с плавающей запятой, я не буду использовать его без необходимости, где могут использоваться целые числа. Полезно всегда думать о числах с плавающей запятой, даже о константах, как о приблизительных, с некоторой небольшой ошибкой. Во время отладки очень полезно заменять собственные числа с плавающей запятой на объекты с плавающей запятой плюс / минус, где каждое число рассматривается как диапазон, а не точка. Таким образом вы обнаружите прогрессивные неточности роста после каждой арифметической операции. Таким образом, «1,5» следует понимать как «некоторое число от 1,45 до 1,55», а «1,50» следует понимать как «некоторое число от 1,495 до 1,505».


5
Разница в производительности между целыми числами и числами с плавающей запятой важна при написании кода на языке C для небольшого микропроцессора, но современные процессоры на основе x86 делают числа с плавающей запятой настолько быстрыми, что любое наказание легко затмевается из-за издержек использования динамического языка. В частности, разве Javascript на самом деле не представляет все числа как числа с плавающей точкой, используя полезную нагрузку NaN при необходимости?
оставил около

1
«Арифметика с плавающей точкой медленная, а целочисленная арифметика быстрая» - это исторический трюизм, который вы не должны сохранять по мере продвижения Евангелия. Чтобы добавить к тому, что сказал @leftaroundabout, не просто верно, что штраф будет почти неактуальным, вы можете обнаружить, что операции с плавающей запятой быстрее, чем их эквивалентные целочисленные операции, благодаря магии автовекторизации компиляторов и наборов команд, которые могут хрустить большое количество плавает в одном цикле. Для этого вопроса это не актуально, но основное предположение «целое число быстрее, чем число с плавающей запятой» уже давно не соответствует действительности.
Йерун Мостерт

1
@JeroenMostert SSE / AVX имеет векторизованные операции как для целых чисел, так и для чисел с плавающей запятой, и вы можете использовать меньшие целые числа (потому что биты не тратятся впустую на экспоненту), поэтому в принципе часто можно добиться большей производительности из высокооптимизированного целочисленного кода чем с поплавками. Но опять же, это не относится к большинству приложений и определенно не для этого вопроса.
оставил около

1
@leftaroundabout: Конечно. Я не говорил о том, какой из них определенно быстрее в любой конкретной ситуации, просто «я знаю, что FP медленный и целое число быстрое, поэтому я буду использовать целые числа, если это возможно», не является хорошей мотивацией даже до того, как вы решите Вопрос о том, нуждается ли вещь в том, что вы делаете, в оптимизации.
Йерун Мостерт
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.