Кажется, задача вычисления абсолютного значения (или модуля) числа совершенно тривиальна. Если число отрицательно, давайте сменим знак. Иначе оставим как есть. На Java это будет выглядеть примерно так:


public static double abs(double value) {
  if (value < 0) {
    return -value;
  }
  return value;
}

Вроде бы это слишком просто даже для вопроса на собеседовании на позицию джуна. Есть ли тут подводные камни?


Вспомним, что в стандарте IEEE-754 вообще и в Java в частности есть два нуля: +0.0 и -0.0. Это такие братья-близнецы, их очень легко смешать и перепутать, но вообще-то они разные. Разница проявляется не только в текстовом представлении, но и в результате выполнения некоторых операций. Например, если поделить единицу на +0.0 и -0.0, то мы получим кардинально разные ответы: +Infinity и -Infinity, отличие между которыми уже сложно игнорировать. Однако, например, в операциях сравнения +0.0 и -0.0 неразличимы. Поэтому реализация выше не убирает минус у -0.0. Это может привести к неожиданным результатам. Например:


double x = -0.0;
if (1 / abs(x) < 0) {
  System.out.println("oops");
}

Казалось бы, обратное к модулю x число не может быть отрицательным, какое бы ни было x. Но в данном случае может. Если у вас есть садистские наклонности, попросите джуна на собеседовании написать метод abs. Когда же он выдаст код вроде того что в начале статьи, можете спросить, выполнится ли при каком-нибудь x условие 1 / abs(x) < 0. После таких собеседований про вашу компанию будут ходить легенды.


Ну ладно, проблему мы нашли. А как её исправить? Наивно добавить if (value < 0 || value == -0.0) не получится, потому что +0.0 == -0.0. В итоге мы сделаем ещё хуже: теперь будет выдаваться -0.0 для положительного нуля на входе. Чтобы надёжно отличить отрицательный нуль, есть метод Double.compare:


public static double abs(double value) {
  if (value < 0 || Double.compare(value, -0.0) == 0) {
    return -value;
  }
  return value;
}

Это работает. Но метод становится ужасно медленным для такой тривиальной операции. Double.compare устроен не так уж просто, нам потребуется пара дополнительных сравнений для положительного числа, три сравнения для -0.0 и целых четыре сравнения для +0.0. Если посмотреть на реализацию Double.compare, можно понять, что нам нужна только часть связанная с doubleToLongBits. Этот метод реинтерпретирует битовое представление double-числа как битовое представление long-числа (и там, и там восемь байт). А со сравнением целых чисел никаких сюрпризов нет. Поэтому можно упростить так:


private static final long MINUS_ZERO_LONG_BITS =
  Double.doubleToLongBits(-0.0);

public static double abs(double value) {
  if (value < 0 ||
      Double.doubleToLongBits(value) == MINUS_ZERO_LONG_BITS) {
    return -value;
  }
  return value;
}

Однако, оказывается, doubleToLongBits тоже не совсем тривиален, потому что он канонизирует NaN'ы. Есть много способов закодировать not-a-number в виде double, но только один из них канонический. Эти разные NaN'ы совсем-совсем близнецы, их не отличишь ни сравнением через Double.compare, никакой операцией, ни строковым представлением. Но в памяти компьютера они выглядят по-разному. Чтобы не было сюрпризов, doubleToLongBits приводит любой NaN к каноническому виду, который записывается в long как 0x7ff8000000000000L. Конечно, это лишние проверки, которые нам здесь тоже не нужны.


Что же делать? Оказывается, можно использовать doubleToRawLongBits, который никаких умностей с NaN'ами не делает и возвращает всё как есть:


private static final long MINUS_ZERO_LONG_BITS =
  Double.doubleToRawLongBits(-0.0);

public static double abs(double value) {
  if (value < 0 ||
      Double.doubleToRawLongBits(value) == MINUS_ZERO_LONG_BITS) {
    return -value;
  }
  return value;
}

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


Ладно, у нас осталось два ветвления для всех положительных чисел и нулей. Всё равно кажется, что много. Мы знаем, что ветвления — это плохо, если branch predictor не угадает, они могут очень дорого стоить. Можно ли сделать меньше? Оказывается, можно любой нуль превратить в положительный, если вычесть его из 0.0:


System.out.println(0.0-(-0.0)); // 0.0
System.out.println(0.0-(+0.0)); // 0.0

Таким образом, можно написать:


public static double abs(double value) {
  if (value == 0) {
    return 0.0 - value;
  }
  if (value < 0) {
    return -value;
  }
  return value;
}

Зачем так сложно, спросите вы. Ведь можно просто вернуть 0.0 в первом условии. Кроме того, у нас всё равно два сравнения. Однако можно заметить, что для обычных отрицательных чисел 0.0 - value и просто -value дают одинаковый результат. Поэтому первые две ветки легко схлопнуть в одну:


public static double abs(double value) {
  if (value <= 0) {
    return 0.0 - value;
  }
  return value;
}

Отлично, у нас теперь всегда одна ветка. Победа? Но как насчёт сделать всегда ноль веток? Возможно ли это?


Если посмотреть на представление числа double в стандарте IEEE-754, можно заметить, что знак — это просто старший бит. Соответственно, нам нужно просто безусловно сбросить этот старший бит. Остальная часть числа при выполнении этой операции не меняется. В этом плане дробные числа даже проще целых, где отрицательные превращаются в положительные через двоичное дополнение. Сбросить старший бит можно через операцию & с правильной маской. Но для этого надо интерпретировать дробное число как целое (и мы уже знаем как это сделать), а потом интерпретировать назад (для этого есть longBitsToDouble, и он тоже практически бесплатный):


public static double abs(double value) {
  return Double.longBitsToDouble(
    Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL);
}

Этот способ действительно не содержит ветвлений, и профилирование показывает, что пропускная способность метода при определённых условиях увеличивается процентов на 10%. Предыдущая реализация с одним ветвлением была в стандартной библиотеке Java с незапамятных времён, а вот в грядущей Java 18 уже закоммитили улучшенную версию.


В ряде случаев, впрочем, эти улучшения ничего не значат, потому что JIT-компилятор может использовать соответствующую ассемблерную инструкцию при её наличии и полностью проигнорировать Java-код. Например, на платформе ARM используется инструкция VABS. Так что пользы тут мало. Но всё равно интересная статья получилась!

Комментарии (102)


  1. FGV
    16.08.2021 09:48
    +3

    Например, если поделить единицу на +0.0 и -0.0, то мы получим кардинально разные ответы: +Infinity и -Infinity, отличие между которыми уже сложно игнорировать.

    А смысл деления на 0 какой? Собственно как только получили инф или нан дальше уже можно не считать, т.к. или ошибка входных данных или кривая математика.


    1. Torvald3d
      16.08.2021 11:57
      +13

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


      1. INSTE
        16.08.2021 13:44
        +1

        В посте все же явно не шейдер на жаве пишут.


        1. funny_falcon
          16.08.2021 15:04
          +4

          Я встречал пример с построением графика. Система с поддержкой -0.0 рисовала правильный график, а без поддержки - с артефактами. И как раз из-за подобного деления на переменную, стремящуюся к нулю (и в какой-то момент становящейся +0 или -0), где-то внутри формулы.

          К сожалению, давно это было, ссылку не отыщу.


        1. Makeman
          18.08.2021 01:07

          Поведение double стандартизировано, поэтому рассмотренные подходы, скорее всего, будут справедливы и для множества других языков программирования.


          1. INSTE
            18.08.2021 11:19

            double в java гарантированно хранится в ieee754, это в спеке так описано? Если да, то вопросов нет.


            1. tagir_valeev Автор
              18.08.2021 12:39

              Как хранится - это личное дело виртуальной машины. В спеке описано поведение. Поведение соответствует IEEE-754 с некоторыми упрощениями (например, никаких signalling NaN нету).


      1. FGV
        17.08.2021 22:33

        ноль может получиться в результате вычислений.

        дык а не проще для этого случая проверку предусмотреть? а то иначе придется предусматривать проверку после деления на инф :)


        1. Torvald3d
          20.08.2021 10:44

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


  1. netricks
    16.08.2021 09:54
    +12

    Суть проблемы понятна. А почему бы просто не возвращать положительный ноль, если x==0?


    1. tagir_valeev Автор
      16.08.2021 10:11
      +4

      Хороший вариант! Но отдельное условие для нуля всё равно будет.


    1. tagir_valeev Автор
      16.08.2021 12:38
      +1

      Немного поправил статью, упомянул такую возможность. Спасибо!


  1. AxisPod
    16.08.2021 10:18
    +1

    Math.signum(value) * value

    Не?


    1. tagir_valeev Автор
      16.08.2021 12:35
      +1

      signum - тоже нетривиальная операция с условиями. Да и умножение не на константу. Заметно медленнее будет скорее всего. Но я не проверял!


  1. torbasow
    16.08.2021 10:34
    +43

    Пишем языки высокого уровня.
    Обнаруживаем, что они работают как-то не так.
    Оперируем битами вручную.


    1. Kanut
      16.08.2021 11:09

      Как будто это применимо только к языкам высокого уровня :)


      1. torbasow
        17.08.2021 06:35

        С ними нагляднее.


    1. static_cast
      16.08.2021 11:15
      +13

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


      1. ainoneko
        16.08.2021 18:42
        +1

        к тривиальной для любого сишника реализации.
        Причём то, что реализация работает и для
        Float.NEGATIVE_INFINITY
        (я проверял), вообще говоря, не обязано было получиться (если не знать, как там внутри всё устроено).
        (Напоминает историю (нагуглить не удалось), когда в советское время отдел программистов переходил с ассемблера на ЯВУ (не Джаву ^_^).
        Почти у всех (кроме одного) эффективность программ упала.
        Причина успеха единственного — он представлял, в какие ассемблерные инструкции транслировалась программа )


      1. GospodinKolhoznik
        16.08.2021 20:18
        +1

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

        А на джаве действительно проще, код будет работать хоть на суперкомпьютере, хоть на микроконтроллере.


        1. Miiko
          16.08.2021 20:50
          +3

          На джаве действительно проще - ни на суперкомпьютере, ни на микроконтроллере она работать просто не будет ;).

          Чудес-то не бывает - если количество бит в "родном" double другое, чем прописано в стандарте языка, то эффективно реализовать этот тип на данной архитектуре будет в принципе невозможно.


    1. novoselov
      16.08.2021 11:27

      В java можно сделать intrinsic функцию и писать там что хочешь.


      1. tagir_valeev Автор
        16.08.2021 12:36
        +5

        Стоит уточнить, что авторы виртуальной машины могут это сделать. Обычный пользователь JVM не имеет такой роскоши :-)


    1. pda0
      16.08.2021 14:40
      +3

      Добро пожаловать в закон дырявой абстракции…


  1. SlFed
    16.08.2021 10:43
    +1

    А если так :

    double x = -0.0;
    x= x-1+1;

    После этого разве x не равен +0.0 ?


    1. ksbes
      16.08.2021 10:47

      Даже не оптимизатор, а просто компилятор эту строку выкинет.


      1. tagir_valeev Автор
        16.08.2021 12:32
        +2

        Кажется, не выкинет.


      1. ShadowTheAge
        16.08.2021 16:17
        +9

        Не имеет права выкидывать. Только если в компиляторе стоит опция типа fast math (она никогда не стоит по умолчанию). Потому что floating point math не ассоциативна и не дистрибутивна.


        1. encyclopedist
          16.08.2021 23:17
          +3

          В компиляторе Интел fast math включен по умолчанию.


    1. tagir_valeev Автор
      16.08.2021 12:34
      +1

      Вариант интересный, но на низком уровне может быть гораздо более сложный. Вряд ли это будет быстрее, чем `0.0 - x`.


    1. ShadowTheAge
      16.08.2021 13:21
      +10

      Это операция с потерей данных.
      попробуйте:
      double x = 1e-200
      x=x-1+1


    1. AlexMih
      16.08.2021 14:41
      +7

      Вспомнилось...

      "...В 60-е - 70-е годы, когда компьютеры были большими, каждая линейка компьютеров имела свою программную реализацию вычислений с плавающей запятой, свои форматы представления чисел, точность, представимые диапазоны и правила округления. Соответственно, чудеса, вроде описанных в "Неочевидных особенностях вещественных чисел", были у каждого свои. По воспоминаниям старожилов, на некоторых машинах число могло выглядеть отличным от нуля в операциях сравнения и сложения, но быть чистым нулем при умножении и делении. Чтобы без страха поделить на такое число, его следовало умножить на 1.0 и лишь потом сравнить с нулем. А другие машины могли выдать ошибку переполнения при умножении на 1.0 вполне нормального числа. Были такие малюсенькие числа (но не нули), которые давали переполнение при делении на самих себя. В программах были обычными шаманские вставки вроде X = (X + X) - X. Соответственно, одна и та же программа, даже написанная на стандартном FORTRAN'е, могла давать разные результаты на разных машинах..."

      (с) Загадки округления


  1. Dima_Sharihin
    16.08.2021 11:10
    +5

    Сначала пишут на жабе, а потом выжимают проценты из тривиальных операций.

    Как хорошо замечено в конце статьи, все современные FPU имеют аппаратную инструкцию взятия модуля, которая просто сбрасывает бит знака для IEEE-754 представления.


    1. tagir_valeev Автор
      16.08.2021 12:37
      +10

      Так джава - очень высокопроизводительный язык! В ней всегда выжимают проценты.


  1. dolovar
    16.08.2021 11:12
    +7

    в операциях сравнения +0.0 и -0.0 неразличимы
    System.out.println(-0.0-(+0.0)); // -0.0
    if (value <= 0) {
        return -0.0 - value;
    }
    Отлично, у нас теперь всегда одна ветка. Победа?
    Если на входе будет +0.0, то будет ли на выходе победа?


    1. tagir_valeev Автор
      16.08.2021 12:31
      +1

      О, спасибо! Я действительно немножко оплошал и неправильно объяснил. Поправил статью.


  1. alexdoublesmile
    16.08.2021 11:17

    я теперь мечтаю попасть на собеседование к Тагиру


  1. speshuric
    16.08.2021 11:26
    +11

    Всегда, когда вижу числа с плавающей точкой (double/float) в задачах отличных от физики, начинаю паниковать и включается режим "сапёра" с тщательным анализом граничных случаев и максимальной изоляции этого кода. Если вижу double/float в финансовых приложениях, "дёргаю стоп-кран" и предлагаю избавиться от него. Если вижу битовые манипуляции с такими типами, то паникую еще больше (но, к сожалению, если уж пришлось прийти к битовым манипуляциям над флоатами, то это не от хорошей жизни и обычно оправдано).

    95% разработчиков легко допускает ошибки в работе с этими типами на уровне "обычных" операций (сравнить на равенство, сложить-вычесть в неправильном порядке, применить ассоциативность/коммутативность/дистрибутивность не думая, преобразования в другие типы и т.п.). Не меньше 80% разработчиков не напишут корректно даже элементарного метода Гаусса с первой попытки.

    Причём не стоит думать, что "ну я -то точно легко справлюсь". До нас в этой ловушке побывали и разработчики процессоров, и разработчики ОС, и разработчики Excel, и разработчики стандартных библиотек.

    PS: а статья @lany, как всегда, хороша тем, что заставляет подумать.

    PPS: угадайте, что меня напрягает в JavaScript :)


    1. JustDont
      16.08.2021 11:50
      -1

      В JS есть прекрасная целочисленная математика. Если только вы сами собственными руками не возьмете float.


    1. sergeyns
      16.08.2021 12:35

      вижу double/float в финансовых приложениях

      Хм, а как вы от них избавляетесь? Очень часто невозможно предсказать какую минимальную дробность могут принимать значения. При расчете какой-нибудь себестоимоимости запросто может быть важен 4-5-6 знак после запятой.. Вводить сразу дробность в 10е-9 ? Тогда вам может int64 не хватить...


      1. Beholder
        16.08.2021 12:55
        +5

        BigDecimal есть, в котором разрядностью можно управлять явно.


      1. speshuric
        16.08.2021 13:48
        +7

        Полный ответ и в статью не поместится, не то что в комментарий. Потому и "стоп-кран". Дальше надо смотреть на конкретную задачу и требования. Где-то перейти в BigDecimal, где-то изолировать плавающую точку, где-то изменить исходную задачу. При расчёте себестоимости в ядре вполне может быть (чаще всего должна быть) СЛАУ, которую, наверное, можно считать в double и изолировать, "но это не точно". Но оставлять незащищенным числовой тип данных в котором нельзя написать if (a==b) (а кто-нибудь так напишет) страшновато.


      1. Tschumin
        16.08.2021 14:15

        согласен.

        Вариант, если просто поступать так - разделяя, например, центы и доллары на целочисленные разделы и работая в BigInteger? насколько я помню BigDecimal тоже проблему не снимают....


        1. AlexMih
          16.08.2021 15:48

          А сколько будет стоить капля спирта в вашей системе?

          0 долларов 0 центов? Накапайте мне ведерко...


          1. suns
            17.08.2021 04:28

            К слову, у некоторых банков есть ровно такая проблема при конвертации валют, позволяющая получить профит от конвертации маленьких сумм

            Решается все просто - либо делают изначально высокие лимиты, либо мониторят


            1. noittom
              17.08.2021 21:03
              +1

              По хорошему, всего есть округление до ближайшего пипа в пользу банка


      1. noittom
        17.08.2021 21:04

        Можно не обращать внимания пока идут внутренние вычисления, а на выходи форматировать.

        Ну и непримитивные типы это мусор (память)


    1. Bakuard
      16.08.2021 14:56
      +5

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

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


      1. speshuric
        16.08.2021 17:42
        +1

        Да, но с другой стороны, это базовая (модельная) задача. Реальные задачи зачастую гораздо больше и сложнее.


  1. Beholder
    16.08.2021 13:00

    Ну а просто посмотреть, как сделано в JDK, который оптимизируют и шлифуют уже много-много лет?


        @HotSpotIntrinsicCandidate
        public static double abs(double a) {
            return (a <= 0.0D) ? 0.0D - a : a;
        }

    Причём аннотация вот эта означает, что реально в машинном коде может быть вставлено что-то другое, та же операция с битами или инструкция FPU (fabs).


    1. tagir_valeev Автор
      16.08.2021 13:25
      +11

      То есть вы думаете, что я сослался на пулл-реквест, где этот код изменён на более свежий, но не глянул, что было до этого? :-)


    1. tagir_valeev Автор
      16.08.2021 13:26

      Аннотированных так методов, кстати, в разы больше, чем настоящих интринсиков. Поэтому доверять этой аннотации нельзя, надо смотреть конкретно в исходники JVM (для C2 - opto/library_call.cpp)


  1. gorilych
    16.08.2021 14:35
    +1

    это собеседование по Java (в которой надо просто использовать библиотечный метод, а не заниматься ерундой) или на знание стандарта IEEE?


  1. Amomum
    16.08.2021 14:57
    +4

    Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL); я бы записал как Double.doubleToRawLongBits(value) & ~(1L<<63)); ибо так лучше видно, что это 63 бит (ну и так немножко короче).


  1. orionll
    16.08.2021 16:04
    +1

    Интересно, что в джавадоке метода реализация без ветвления упомянута, причём ещё с Java 9

    As implied by the above, one valid implementation of this method is given by the expression below
    which computes a double with the same exponent and significand as the argument but with a
    guaranteed zero sign bit indicating a positive value:
    Double.longBitsToDouble((Double.doubleToRawLongBits(a)<<1)>>>1)

    Странно, что только в Java 18 её догадались скопировать из джавадока в сам метод )))


    1. tagir_valeev Автор
      16.08.2021 18:16
      +1

      Там не всё так просто. Когда этот тикет появился, с интринсиками дела обстояли туго, и всякие doubleToRawLongBits были реально медленными, поэтому такое изменение не имело смысла. Потом времена изменились.


      1. static_cast
        16.08.2021 19:17

        А там разве не что-то типа return *((long*)(&a)) внутри?


        1. tagir_valeev Автор
          16.08.2021 19:19
          +1

          Java вообще не так работает. В JNI методе можно оно и так, только на сам JNI оверхед будет не меньше сотни наносекунд.


  1. IBAH_II
    16.08.2021 16:50
    -1

    inline float absF(float a) { return (*(((unsigned long*)(&a))))&0x7FFFFFF;} // :)


  1. maxim_ge
    16.08.2021 17:38

    Math.abs не избавляет от чудес:


            double a = Math.abs(0. / 0.);
            double b = a;
            if ( a != b ) {
                System.out.println("oops");
            }

    Понятно, что Math.abs() тут для отвода глаз, но тем не менее. Проблема не в том, что велосипедный abs() сработал некорректно, а в том, что поделили на ноль. На ноль делить нельзя, даже на -0., и это надо проверять перед делением:


            double x = -0.0;
            if ( x == 0. ) {
                System.out.println("oops, x == 0.");
            }


    1. ainoneko
      16.08.2021 18:56

      Проблема не в том, что велосипедный abs() сработал некорректно, а в том, что поделили на ноль. На ноль делить нельзя, даже на -0.,
      По-моему, проблема (если она есть) тут в том, что «не-число» (в данном случае это должно быть «любое число», так как 0*икс == 0 при любом «обычном» икс) не равно «не-числу», что логично (как и NULL в SQL).
      (А делить на ноль в Java можно, и результат даже получается достаточно разумным.)


      1. maxim_ge
        16.08.2021 19:28

        (А делить на ноль в Java можно, и результат даже получается достаточно разумным.)

        Не всегда:


                int x = 0;
                int y = 0;
                System.out.println("x / y = " + (x / y));

        В целом, согласен, есть определенный смысл в -/+ Infinity для вычислений с плавающей точкой, но мне пока не выпал случай этот смысл сознательно использовать.


        Получить исключение от целочисленного деления на ноль гораздо более реально.


  1. diakin
    16.08.2021 18:26

    public static double abs(double value) {
      if (value+1 < 1) {
        return -value;
      }
      return value;
    }

    Не проканает?


    1. flx0
      16.08.2021 20:37
      +1

      Нет.
      -1e-100 + 1 == 1


      1. MacIn
        16.08.2021 23:38

        Это из-за приведения? А с 1.0?


        1. flx0
          17.08.2021 00:06
          +1

          Нет, это из-за формата, в котором хранится floating point. Оно не просто так называется числом с плавающей точкой. Оно состоит из мантиссы и порядка (и их знаковых бит) в виде
          M * 2^E
          Так, в 64-битном double под мантиссу M отведено 52 бита и 10 под порядок E.
          Чтобы сложить два числа, их мантиссы нужно выровнять битовым сдвигом так чтобы точка оказалась в одном и том же месте. Если порядки этих чисел различаются на 52 и больше, то при сдвиге мантиссы меньшего из них какой-либо информации об ее значении в двоичном представлении числа просто не останется. Если разница меньше, то потеряется часть точности.


          1. MacIn
            17.08.2021 02:03

            Ах да, я не задумался о том, что сдвиг такой большой.
            Спасибо.


  1. usa_habro_user
    16.08.2021 18:35
    +2

    Вспомним, что в стандарте IEEE-754 вообще и в Java в частности есть два нуля: +0.0 и -0.0.

    А в чем вообще физический/практический смысл наличия положительного и отрицательного нулей в Java?


    1. Punk_Joker
      16.08.2021 19:17
      +3

      Это особенность не Java как таковой. А формата кодирования чисел с плавающей запятой, которому следуют большинство ЯП.


      1. usa_habro_user
        16.08.2021 19:31
        -3

        Я сомневаюсь, что приведенный в начале статьи пример с "-infinity" и "+infinity", также будет работать для C# или C++.
        И что именно в "формате кодирования чисел с плавающей" мешает существованию одного единственного нуля? Проясните, пожалуйста (я без издевки спрашиваю, если что).


        1. johndow
          16.08.2021 19:51

          Полагаю то, что для знака выделен 1 бит и всё (0, infinity, NaN хоть для него знак и не имеет смысла) может быть либо отрицательным либо положительным.


        1. Makeman
          18.08.2021 01:55

          В C# и C++ работает так же, поскольку поведение double стандартизировано и в конечном счёте сводится к одним и тем же арифметическим инструкциям процессора, какой бы язык мы ни использовали.

          Насколько сам понимаю, в формате с плавающей запятой под знак числа выделен отдельный бит, то есть отрицательный ноль получается сам собой из положительного путём инвертирования знакового бита. В целочисленной же арифметике отрицательные числа записываются в дополнительном коде (как такового знакового бита нет), из-за чего само собой выходит, что +0 и -0 побитово эквивалентны, если же инвертировать у +0 условный "знаковый" бит, то получится уже не -0, а минимально возможное отрицательное число, например, -128 для типа byte.


  1. TakashiNord
    16.08.2021 20:19
    -1

    а может так, сойдет?

    tolerance = 0.00000001

    EQ_ge { s t } { return ( s > (t - tolerance) ) }

    Abs { v } {

    if EQ_ge { v 0.0 } { return v }

    return ((-1)*v)

    }

    :)


    1. tagir_valeev Автор
      18.08.2021 12:43
      +2

      Нет, не сойдёт.


  1. KvanTTT
    16.08.2021 20:37

    Интересно, а в .NET как модуль вычисляется?


    1. AnarchyMob
      17.08.2021 23:17

      1. KvanTTT
        19.08.2021 11:50
        +2

        Ага, а в ассемблере они раскрываются вот в такое (x64):


        C.Abs(Double)
            L0000: vzeroupper
            L0003: vmovsd xmm0, [C.Abs(Double)]
            L000b: vandps xmm0, xmm0, xmm1
            L000f: ret

        Видимо vandps и обнуляет знаковый бит.


        На x86 используются команды FPU:


        C.Abs(Double)
            L0000: fld qword [esp+0x4]
            L0004: fabs
            L0006: ret 0x8

        По ссылке доступны функции для целых и вещественных типов. Что удивительно — ассемблерный код для первых содержит больше инструкций, чем для вторых.


  1. shybovycha
    17.08.2021 03:16
    -1

    Напомнило факториал на хаскеле.

    Не спорю, раз в столько-то там лет оно может и будет иметь какое-нибудь значение в проекте, но подозреваю, что в большинстве случаев #define abs(x) x < 0 ? -x : x будет точно так же эффективно в плане перформанса и многократно эффективнее в плане читабельности.


    1. tagir_valeev Автор
      18.08.2021 12:43
      +2

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


  1. alex103
    17.08.2021 07:31
    +6

    (шутка)

    • А давайте придумаем числа, чтобы посчитать сколько у нас яблок..

    • А давайте придумаем такое число 0, когда у нас нет ни одного яблока..

    • А давайте придумаем отрицательные числа, чтобы посчитать какого количества яблок у нас нету..

    • .... а давайте придумаем такое число -0, чтобы указать что у нас нету ни одного яблока, которого у нас нету..

    • О! А давайте придумаем функцию Abs() ...


  1. Xao_Fan-Tzilin
    17.08.2021 09:35

    Хмм, а разве нельзя представить число (хоть 0, хоть -0.0, хоть +0.0) в строковом виде, а там сделать проверку наличия символа "минус" (или как он там будет называться - дефис, тире или ещё как), при нахождении его удалить и полученную строку снова конвертировать в числовой вид и дальше уже оперировать с ним..?

    Мнение непросвещённого, так что не обессудьте.


    1. tagir_valeev Автор
      17.08.2021 09:37
      +3

      Это ужасно медленно. Невообразимо медленно. Может сотни наносекунд занять.


      1. Xao_Fan-Tzilin
        17.08.2021 09:48

        Но по идее правильно? На Луа такое прокатывает.

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


        1. tagir_valeev Автор
          18.08.2021 12:41
          +5

          Ну это из разряда проверять истинность булевой переменной через String.valueOf(myFlag).length() == 4. Работает, конечно, вопросов нет. Может есть языки, где такой подход идиоматичен.


  1. key08rus
    17.08.2021 12:14
    +1

    public static double abs(double value) {
    return Double.longBitsToDouble(
    Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL);
    }

    Если без «магии» и платформонезависимо, то, наверное, лучше просто copySign()
    public static double abs(double value) {
    return Math.copySign(value,1);
    }


    1. tagir_valeev Автор
      17.08.2021 12:26

      Так это решение абсолютно платформонезависимо! А copySign внутри сделает то же самое. Возможно, JIT даже докрутит ваш вариант до моего.


      1. key08rus
        17.08.2021 12:32

        Глянул в исходники Math, там действительно похоже. Только не конкретная маска используется, а (DoubleConsts.SIGN_BIT_MASK). Подозреваю, что эта константа точно равна 0x7fffffffffffffffL (я так то не знаток java, я эмбед-сишник, поэтому с типами ооочень осторожен)
        UPD:

        public static double copySign(double magnitude, double sign) {
                return Double.longBitsToDouble((Double.doubleToRawLongBits(sign) &
                                                (DoubleConsts.SIGN_BIT_MASK)) |
                                               (Double.doubleToRawLongBits(magnitude) &
                                                (DoubleConsts.EXP_BIT_MASK |
                                                 DoubleConsts.SIGNIF_BIT_MASK)));
            }


        Так что вместо 0x7fffffffffffffffL можно (нужно) подставить
        (DoubleConsts.EXP_BIT_MASK | DoubleConsts.SIGNIF_BIT_MASK) и JIT докрутит до конкретного значения (=0x7fffffffffffffffL)
        и итоговая функция будет
        public static double abs(double value) {
                return Double.longBitsToDouble(Double.doubleToRawLongBits(value) &
                                                (DoubleConsts.EXP_BIT_MASK |
         DoubleConsts.SIGNIF_BIT_MASK));
            }


        1. tagir_valeev Автор
          17.08.2021 14:09

          (DoubleConsts.EXP_BIT_MASK | DoubleConsts.SIGNIF_BIT_MASK) докрутит javac. Это по стандарту константа времени компиляции, она уже в байткод ляжет в виде конкретного числа. А вот сделать инлайнинг, подставить параметр sign и вычислить (Double.doubleToRawLongBits(sign) & (DoubleConsts.SIGN_BIT_MASK) до константы 0 и удалить - уже задача JIT-компилятора (вполне посильная).


  1. MKMatriX
    17.08.2021 20:45
    -1

    Я понимаю что js не джава и т.д. но можно x<<1>>1 (обнулить двумя сдвигами первый бит), ну или хотя бы x & MAX_POSITIVE_DOUBLE, а если у вас есть поинтеры, то можно вообще по поинтеру переписать первый бит.

    Или в джаве с этими способами туго?


    1. INSTE
      18.08.2021 11:47

      signed integer overflow это норм в JS?


      1. MKMatriX
        18.08.2021 13:46

        Просто сдвиг происходит в окне памяти переменной, этот бит не уйдет куда-то в overflow. Конечно для языков с нормальным управлением памятью это минус, но в js это нужно крайне редко. Вроде sharedArrayBuffer для этого есть. В общем в js уже даже null pointer (x is undefined) редко встречается. Скриптовый язык)


        1. INSTE
          18.08.2021 22:23

          Что такое «окно памяти переменной»?


          1. MKMatriX
            19.08.2021 09:37

            Область оперативки под эту значение этой переменной, очевидно что в js напрямую управлять поинтерами и давать доступ к физической памяти нельзя, (т.е. с поинтерами чуть-чуть можно). И нельзя ни при каких условиях выходить за рамки виртуалки под вкладку. В противном случае js из одной вкладки браузера мог бы, теоретически, читать оперативку всего компа. Впрочем зачастую он это и может, например если не стоят патчи от spectre и meltdown и их аналогов для amd. В таком грустном мире живем)


            1. INSTE
              19.08.2021 11:22

              А сколько бит выделено под эту переменую? Насколько ее можно сдвинуть влево?


              1. MKMatriX
                19.08.2021 11:46

                Я подозреваю бит под значение, ибо переменная в js это не только ее значение, грубо говоря все переменные в js это enum т.е. занимают весьма много места изначально вне зависимости от типа.

                Сдвигать можно насколько угодно, просто лишние биты удалятся, а вместо недостающих вставится 0. Это ожидаемое поведение ибо в js нельзя просто так поставить переменные в памяти рядом (можно). По размерам... Не знаю насколько js стандартизирован, поэтому теоретически размеры переменных в разных браузерах/системах/ноде могут быть разными, но если верить https://exploringjs.com/impatient-js/ch_numbers.html то числа с точкой это 64 бита, и 53 для инта, только инт нифига не инт)) Т.е. есть еще Bigints которые не ограниченны. Но сдвигать можно все равно на любое число, просто свдиг больше размера числа его обнулит. Также сдвиг приводит число с точно к инту, типа здраствуйте магическая константа из дума) Т.е. ```123.123>>0 === 123 //true```


    1. mayorovp
      19.08.2021 17:10

      Ваше решение даже в js работать не будет, потому что это решение для целых, а надо для вещественных.


      1. MKMatriX
        20.08.2021 02:19
        -1

        Руководствовался больше названием) Да в js битовые операции числам с точкой недоступны. Когда-то писал себе утилитку чтобы их хотя бы посмотреть в битовом виде. Однако в js -0 это всегда int XD поэтому в принципе всей этой мороки не будет) Тем более я не решение предлагал, а спрашивал будет ли это работать?)

        Т.е. изначально мой вопрос можно было сформулировать как: "А почему нельзя просто обнулить первый бит"?

        Ведь это не требует ветвлений, или даже чтения переменной до конца. Основная проблема это NaN которые тоже Double. Впрочем в конце статьи уже есть это решение и про NaN ни слова, видимо NaN в этом бите не нуждается.


        1. mayorovp
          20.08.2021 11:54
          +2

          Однако в js -0 это всегда int XD поэтому в принципе всей этой мороки не будет)

          Да ладно?


          > const abs = x => x < 0 ? -x : x;
          < undefined
          > 1 / abs(-0)
          < -Infinity


          1. MKMatriX
            21.08.2021 15:30
            -1

            Эм, я про мороку с тем, что эти способы в js с даблом не сработают да и перейдут сразу в Math.abs)

            Правда если верить https://galactic.ink/journal/2011/11/bitwise-gems-and-other-optimizations/ то ваше решение все равно будет работать быстрее хоть и не будет работать для -0. Хотя глядя на 1 / (-0 >> 0) === Infinity (тоже самое с parseInt и parseFloat для -0) можно решить что -0 в js не обязательно int.


            1. mayorovp
              21.08.2021 17:52

              -0 в js не обязательно int

              Вообще-то в js нет никаких int, есть только number.
              И есть операции, которые работают с number как c целым числом — в частности, сдвиги.


              1. MKMatriX
                22.08.2021 00:02

                Я не про тип в js, а про то как число хранится в памяти. Ибо целые, большие целые и с точкой хранятся по разному. Да и большие целые формально в js не number. Плюс через sharedArrayBuffer типы чуть более явные. Но его в js не часто используют, разве что игры оптимизировать.


                1. mayorovp
                  22.08.2021 00:39

                  А какая разница как оно там хранится в памяти? Это всего лишь деталь реализации, но если спецификация JS требует различать -0 и +0 — они будут различаться, иначе это баг и он рано или поздно будет исправлен.