...

пятница, 30 марта 2018 г.

Пятничный JS: минус без минуса

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

В последнее время я немного занимаюсь преподаванием. С целью расширить ученику сознание я задал ему такую задачку:
Написать функцию sub(a, b), которая будет находить разность чисел a и b. Однако в тексте функции не должно быть символа "-".
Сейчас для любознательного читателя наступило время отложить чтение статьи и попытатся решить задачу самостоятельно. Поэтому, чтобы он случайно не увидел одно из решений, приведённых ниже, я вставлю картинку со снежинкой, которая не растает, пока часы двенадцать бьют.

image

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

Общие соображения


Самый простой и безглючный способ сделать вычитание без вычитания — это каким-то образом получить значение «минус единица», а затем написать:
return a + b * minusOne;


Если получить каким-то образом строку "-", можно элементарно превратить её в минус единицу:
let minusOne = (minusChar + 1) | 0;


Если мы захотим обойтись без этих маленьких трюков, нас ожидает боль. Доставят нам её, во-первых, специальные значения (Infinity, NaN), во-вторых, возможная потеря точности при менее тривиальных операциях над числами. Но это не значит, что нам не нужно пытаться. Всё, что нас не убивает, нас делает сильней.

Самое очевидное


Первый способ, который, по моему разумению, должен прийти в голову новичку — это использование Array#indexOf. Конечно, это не первая подходящая вещь, на которую можно наткнуться, если методично читать Флэнагана по порядку. Однако новичку не нужно читать Флэнагана по порядку, так он быстро утонет в обилии ненужной информации. Array#indexOf удачно сочетает в себе простоту и практическую полезность, потому я склонен полагать это самым очевидным решением.
function sub(a, b){
    let minusOne = [].indexOf(0);
    return a + b * minusOne;
}


Метод indexOf, как следует из его названия, возвращает индекс элемента в массиве. Если в массиве такой элемент отсутствует, возвращается специальное значение -1. Очень кстати.

Битовые операции


А это первое, что должно было прийти в голову какому-нибудь суровому сишнику. Например, так:
function sub(a, b){
    let minusOne = ~0;
    return a + b * minusOne;
}


Тильда в джаваскрипте символизирует побитовое отрицание. Из-за особенностей внутреннего представления отрицательных чисел побитовое отрицание нуля волшебным образом оказывается минус единицей. Кстати говоря, верно и обратное, из-за чего некоторые имеют привычку записывать условие вхождения элемента в массив следующим образом:
if(~arr.indexOf(elem)){ //...


Сейчас, с появлением Array#includes, этот хак становится менее актуальным.

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

let minusOne = 1 << 31 >> 31;


Math


А это первое, что должно приходить в голову математику. Методы глобального объекта Math предоставляют множество способов. Например:
function sub(a, b){
    let minusOne = Math.cos(Math.PI);
    return a + b * minusOne;
}


Или альтернативные способы:
 let minusOne = Math.log(1/Math.E);
//или даже так
minusOne = Math.sign(Number.NEGATIVE_INFINITY);


Кстати, способ с логарифмом даёт возможность вычитать числа «напрямую», без предварительного получения минус единицы:
function sub(a, b){
    return Math.log( Math.E ** a / Math.E ** b);
}


Впрочем, о проблемах такого подхода я уже писал в «общих соображениях».

Строки


Способов получить строку "-" много. Самый очевидный, пожалуй, этот:
function sub(a, b){
    let minusChar = String.fromCharCode(45);
    let minusOne = (minusChar + 1) | 0; 
    return a + b * minusOne;
}


Также можно воспользоваться замечательными возможностями Юникода провались они в ад:
let minusChar = "\u002d";


Кроме того, этот символ можно вытащить из строки, уже его содержащей. Например, так:
let minusChar = 0.5.toExponential()[2];
// 0.5.toExponential() == "5e-1"
minusChar = (new Date(0)).toISOString()[4].
//(new Date(0)).toISOString() == "1970-01-01T00:00:00.000Z"


Кстати говоря, если мы получили символ минуса, нам вовсе не обязательно получать минус единицу. Можно сделать следующим образом:
function sub(a, b){
    let minusChar = "\u002d";
    return eval("(" + a + ")" + minusChar + "(" + b + ")");
}


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

Когда приходит год молодой


И раз уж речь зашла о датах, вот ещё один способ получить минус единицу:
let minusOne = Date.UTC(1969, 11, 31, 23, 59, 59, 999);


Дело в том, что джаваскриптовые даты «под капотом» содержат т.н. Unix time — количество миллисекунд, прошедших с полуночи первого января 1970 года. Соответственно, тридцать первого декабря 1969 года, в 23:59:59 и 999 миллисекунд это значение равнялось в точности -1.

Не повторять дома


Напоследок приведу пару сложных и плохо работающих способов.
Если оба числа положительны, конечны и первое больше второго, можно воспользоваться делением с остатком.
function sub(a, b){
    let r = a % b;
    while(r + b < a){
        r += b;
    }
    return r;
}


Это будет работать за счёт того, что a == a % b + b * n, где n — некоторое целое число. Соответственно, a - b == a % b + b * (n - 1), а значит, прибавляя к остатку b, мы рано или поздно получим искомую величину.

Если хорошенько подумать, можно избавиться от цикла. Действительно, цикл проходит больше нуля итераций, только если b укладывается в a более одного раза. Этого можно избежать следующим образом:

function sub(a, b){
    return (a + a) % (a + b);
}


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

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

function sub(a, b){
    var d = 1; // дельта. то, что мы будем пытаться прибавить к b так, чтобы получилось не более чем a
    var r = 0; // наш будущий результат вычитания

//сначала находим d, превышающее разность.
    while(b + d < a){
        d *= 2;
    }
//далее последовательно прибавляем его к r, при необходимости уменьшая вдвое
    while(b + r < a){
        if(b + r + d > a){
            d /= 2;
        }else{
            r += d;
        }
    }
//в силу конечной точности представления чисел в js этот процесс когда-нибудь закончится
    return r;
}


Опять же, этот способ работает, только если a >= b, и если ни одно из чисел не является бесконечностью или NaN.

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

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий