Недавно мне на глаза попалась одна статья на Хабре. В ней сравниваются C# и JavaScript. На мой взгляд, сравнивать их — всё равно что сравнивать луну и солнце, которые, если верить классику, не враждуют на небе. Эта статья напомнила мне о другой публикации. В ней речь идёт о сценариях неожиданного и неочевидного поведения JavaScript, а C# не упоминается от слова совсем, но живое любопытство сподвигло меня попытаться повторить подобное поведение на другом языке.
Внимание
Материал носит исследовательско-развлекательный характер, а описанные приёмы, как должно быть ясно из заголовка, не рекомендованы к использованию в продуктовой разработке.
Примеры написаны под .NET Core 3.1.
Что в сухом остатке?
В JavaScript возможно применять оператор получения остатка от деления к числам с плавающей точкой. Работает это так:
3.14 % 5 // 3.14
13.14 % 5 // 3.1400000000000006
При этом числа имеют тип Number и хранятся в 64 битах в соответствии со стандартом IEEE 754. В .NET у этого типа есть брат-близнец System.Double или просто double. Если числовой литерал содержит плавающую точку, то он по умолчанию приводится к double. Намерение можно выразить явно, добавив к числу суффикс d или D. Возможность делить double с остатком тоже имеется. Так что пробуем. И… Бинго!
Console.WriteLine(3.14d % 5); // 3,14
Console.WriteLine(13.14d % 5); // 3,1400000000000006
Лирическое отступление
Если заменить суффикс на f или F, то уже будет использован тип float (System.Single), который хранит числа с плавающей точкой в 32 битах в соответствии со стандартом IEEE 754. Результат получается аналогичный, отличается только ошибка округления.
.NET позволяет работать с типом decimal (System.Decimal), который минимизирует ошибки округления.
Хотя и не гарантирует их отсутствие.
Console.WriteLine(3.14f % 5); // 3,14
Console.WriteLine(13.14f % 5); // 3,1400003
.NET позволяет работать с типом decimal (System.Decimal), который минимизирует ошибки округления.
Console.WriteLine(3.14m % 5); // 3,14
Console.WriteLine(13.14m % 5); // 3,14
Хотя и не гарантирует их отсутствие.
Console.WriteLine(1m/3m*3m); // 0,9999999999999999999999999999
Изыди, нечистый, ибо нет в тебе истины!
В следующем примере условие оказывается истинным.
const x = { i: 1, toString: function() { return this.i++; } };
if (x == 1 && x == 2 && x == 3)
document.write("This will be printed!");
Это достигается за счёт того, что при проверке на равенство операнды приводятся к одному типу. При этом на x вызывается переопределённый метод toString(), который помимо того, что что-то возвращает, изменяет состояние объекта. Отмечаем про себя, что чистые функции — это прекрасно, но ради интереса пробуем воплотить концепцию из примера.
Trickster x = new Trickster();
if (x == 1 && x == 2 && x == 3)
Console.WriteLine("This will be printed!");
.NET позволяет переопределять методы и приводит типы, где возможно, если соответствующее приведение определено.
class Trickster
{
private int value = 1;
public override string ToString() =>
value++.ToString();
public static implicit operator int(Trickster trickster) =>
int.Parse(trickster.ToString());
}
Ура, работает. Но смущает, что Trickster как будто подстраивается под int. Заменим
public static implicit operator int(Trickster trickster) =>
int.Parse(trickster.ToString());
на
public static implicit operator double(Trickster trickster) =>
double.Parse(trickster.ToString());
Всё равно работает. Теперь Trickster и int приводятся к double.
На самом деле, переопределение ToString() не принципиально для достижения конечного результата и сделано для максимального подражания примеру на JavaScript. Достаточно определить приведение к double. Конечно не забывая о важности побочного эффекта.
class Trickster
{
private int value = 1;
public static implicit operator double(Trickster trickster) =>
trickster.value++;
}
А если вспомнить о перегрузке операторов, то можно реализовать объект одновременно равный и неравный чему угодно
Trickster x = new Trickster();
if (x == 1 && x == 2 && x == 3 && x == new[] { 1, 2, 3 } &&
x != 1 && x != 2 && x != 3 && x != new[] { 1, 2, 3 })
Console.WriteLine("This will be printed!");
таким нехитрым способом
class Trickster
{
public static bool operator ==(Trickster trickster, object o) =>
true;
public static bool operator !=(Trickster trickster, object o) =>
true;
}
Элегантный захват
Этот код на JavaScript три раза выводит 3.
for (i = 0; i <= 2; ++i)
setTimeout(() => console.log(i), 0);
Поскольку в C# функции setTimeout из коробки нет, реализуем аналог самостоятельно и получаем точно такой же результат.
void SetTimeout(Action action, int delay) =>
Task.Run(async () =>
{
await Task.Delay(delay);
action();
});
for (int i = 0; i <= 2; ++i)
SetTimeout(() => Console.WriteLine(i), 0);
// Не даём приложению закончить работу до завершения задач
Console.ReadKey();
Эффект от захвата переменных не очевиден, если не знать, как этот самый захват реализован. Кстати, вопросы на эту тему весьма популярны на собеседованиях дотнетчиков. По моему опыту примерно на каждом пятом спрашивают.
Коммутативность никто не обещал
Операторы в JavaScript иногда не отличаются коммутативностью.
Date() && {property: 1}; // {property: 1}
{property: 1} && Date(); // Uncaught SyntaxError: Unexpected token '&&'
В C# к пользовательским типам по умолчанию операторы вообще неприменимы (исключение == и != для ссылочных типов). Но некоторые из них могут быть перегружены явно в типе. Тогда забота о коммутативности ложится на плечи разработчика.
class Trickster
{
// В C# перегружать && недопустимо
public static object operator +(Trickster trickster, object o) =>
null;
}
И она не обязательно будет реализована.
var a = new Trickster() + new object(); // OK
// Compilation Error:
// Operator '+' cannot be applied to operands of type 'object' and 'Trickster'
var b = new object() + new Trickster();
Скрещиваем ужа с ежом
В JavaScript можно выполнять математические операции совместно над строками и числами.
var a = "41";
a += 1; // "411"
var b = "41";
b -=- 1; // 42
В C# так можно только со сложением.
var a = "41" + 1; // 411
// Compilation Error:
// Operator '-' cannot be applied to operands of type 'string' and 'int'
var b = "41" - (-1);
Но нет препятствий патриотам языка. Добавив немного колдунства, можно заставить «правильно» работать даже такой код:
Trickster a = "41";
Console.WriteLine(a += 1); // 411
Trickster b = "41";
Console.WriteLine(b -=- 1); // 42
Нужно просто реализовать пару неявных преобразований типов и перегрузить пару операторов.
class Trickster
{
private string value;
public static implicit operator Trickster(string s) =>
new Trickster { value = s };
public static implicit operator Trickster(int i) =>
new Trickster { value = i.ToString() };
public static string operator +(Trickster trickster, int i) =>
trickster.value + i;
public static int operator -(Trickster trickster, int i) =>
int.Parse(trickster.value) - i;
public override string ToString() =>
value;
}
Можно заменить перегрузку сложения
public static string operator +(Trickster trickster, int i) =>
trickster.value + i;
на неявное приведение к строке
public static implicit operator string(Trickster trickster) =>
trickster.value;
и получить тот же результат.
Если не заморачиваться с возвращением числа из операции вычитания и помещением этого числа в Trickster, то можно заменить
public static int operator -(Trickster trickster, int i) =>
int.Parse(trickster.value) - i;
на
public static string operator -(Trickster trickster, int i) =>
(int.Parse(trickster.value) - i).ToString();
а приведение int к Trickster удалить.
Жонглируем бананами
В JavaScript строку «banana» можно получить следующим способом:
('b' + 'a' + + 'a' + 'a').toLowerCase(); // "banana"
('b'+'a'++'a'+'a').toLowerCase(); // Uncaught SyntaxError: Invalid left-hand side expression in postfix operation
Здесь применение унарного + ко второй 'a' возвращает NaN, который приводится к строке 'NaN' для сложения с остальными строками, и в итоге получается 'baNaNa', а для красоты всё приводится к нижнему регистру.
В C# создаем класс
class Trickster
{
private string value;
public static implicit operator Trickster(string s) =>
new Trickster { value = s };
public static double operator +(Trickster trickster) =>
double.TryParse(trickster.value, out double result) ? result : double.NaN;
}
и пробуем
// чтобы double.NaN.ToString() возвращал "NaN", а не "не число"
Thread.CurrentThread.CurrentCulture = new CultureInfo("en-us");
Console.WriteLine(("b" + "a" + + (Trickster)"a" + "a").ToLower());
К сожалению, несмотря на неявное приведение компилятор не может построить цепочку string -> Trickster -> double -> string, и приходится явно ему подсказывать. (Если задуматься, такое приведение выглядело бы более чем странно.)
Trickster можно реализовать иначе:
class Trickster
{
private double value;
public static implicit operator Trickster(string s) =>
new Trickster { value = double.TryParse(s, out var d) ? d : double.NaN };
public static double operator +(Trickster trickster) =>
+trickster.value;
}
В этом случае можно даже заменить перегрузку унарного + неявным приведением к double:
public static implicit operator double(Trickster trickster) =>
trickster.value;
В качестве бонуса, если не поставить пробел между двумя плюсами, то как и в примере на JavaScript мы получим ошибку компиляции, да не одну, а целый букет.
Лирическое отступление
Компилятор таки может строить цепочки неявных преобразований типов вида
Тогда для выражения
Будет выполнена цепочка преобразований типов
встроенное -> [встроенное->] пользовательское
. Например, пусть есть типclass Trickster
{
public static implicit operator Trickster(long? i) =>
new Trickster();
public static Trickster operator +(Trickster left, Trickster right) =>
new Trickster();
}
Тогда для выражения
var result = 0u + new Trickster();
Будет выполнена цепочка преобразований типов
uint -> long -> long? -> Trickster
.Мораль
Явное лучше неявного. А код с удивительными результатами можно писать на любом языке. При этом удивительность будет зависеть от количества приложенных усилий и степени знания используемого языка.
VladVR
Гарантирует, пока не превысится точность. Точность не бесконечна естественно, хоть и больше, чем у double.
В JS (как и в шарповом double я думаю) даже без каких либо алгебраических действий можно получить такую кривизну. Просто читаешь ораклом из базы дробное число, получаешь не то что в базе записано. Решали тем, что в селекте значение кастили к строке, а в коде делали new Decimal(val). Библиотечку Decimal использовали, ибо работали с «деньгами» и такие double-фокусы были недопустимы.
chapuza
Decimal с деньгами точно так же недопустимо. Работа с деньгами — штука, стоящая в одном ряду с инвалидацикй кэша и наименованием переменных, просто менее распространенная, поэтому меньше народу ходит по одним и тем же граблям.
При работе с деньгами, например, естественным требованием является коммутативность деления и умножения, что никакими численными классами (кроме
Rational
, есть таковой представлен в языке) не обеспечить. Пример: есть рубль, его нужно разделить на троих вкладчиков, а через год собрать обратно для расчета выплаты дивидентов. Оп-па, копеечка потерялась. Первый же аудит завернет такую математику далеко и надолго.Числа для денег используют только совсем зеленые юнцы, будь эти числа хоть трижды decimal.
VladVR
Кругом столько минусов, что хочется, чтобы кто то написал статью(или дал ссылку) как правильно работать с деньгами.
Как сохранять в БД бесконечные дроби, да хоть бы и конечные, но с большей точностью, чем стандартные типы. Даже не арифметические операции с такими числами, а просто сохранить в базе и считать без потери.
ЗЫ У нас не было операций деления. Сложение, вычитание и умножение на целое количество процентов(не дает бесконечных дробей, очевидно). А порой просто ввод, хранение и вывод. Double валится уже тут. Decimal на этом всём работает. Это кстати были не банковские деньги, а рассчеты, что то типа выставления сметы.
chapuza
Fowler’s Money pattern. А так — ну загляните в код любой библиотеки для любого языка же.
RubyMoney
,ExMoney
,ElixirMoney
, наверняка для вашего стека тоже есть что-то подобное.Как
Rational
, очевидно, если число рациональное. Иррациональным числам там взяться неоткуда (если вы не выплачиваете дивиденты пропорционально квадратным корням из ?, конечно :). Нужно хранить два поля, номинатор и деноминатор. Если ваш язык не умеетRational
из коробки, придется его этому научить. Вот пример на руби:KvanTTT
Для иррациональности достаточно просто корней. В принципе и экспонента и логарифмы тоже наверное могут появиться. Хотя можно пойти дальше и записывать последовательность операций в аналитическом виде.
chapuza
Спасибо, буду знать.
Откуда? Не нужно пытаться решить сферическую задачу в вакууме, нужно решать существующие задачи максимально хорошо.
Иногда можно, да. Вы, наверное, думаете, что удачно сострили, а тем не менее мы даем возможность нашим клиентам определять практически любые формулы для срабатывания триггеров на изменении курсов валют. Я даже вынужден был специальную библиотеку написать. Как вы думаете, сколько клиентов воспользовались формулой, отличной от неравенства типа
курс > 1.2
?KvanTTT
Я не занимаюсь финансовой деятельностью, не могу сходу сказать. К тому же приписал "наверное", это значит неточно.
Я так не думаю — вам показалось.
VladVR
Так и я решах существующую задачу, а не сферическую задачу в вакууме, и эта задача прекрасно решается через Decimal.
И Rational тоже допускает потери, по определению, т.к. не имеет бесконечной точности, и к нему также применимо выражение «гарантирует отсустствие потерь, пока не превысится точность», как и к Decimal. В отличие от Double, который дает погрешность уже на примере операции 0.1 + 0.2
chapuza
У вас неверное определение. Рациональные числа
Rational
обрабатывает без потерь. Хоть факториал миллиарда туда запихните, если памяти хватит. И делает он это именно что по определению.Ни в руби, ни в эрланге, ограничений точности не существует. Если в вашем языке существуют — придется реализовывать самому, я ж говорил там уже.
VladVR
Да, в шарпе нашел в том числе реализацию с BigInteger, бесконечным целым. (первое что нашел было с uint). Стало интересно, не случается ли риск при каких нибудь безобидных действиях получить удар по производительности/памяти?
Kanut
Риск есть. BigInt иммутабельный, то есть у вас постоянно создаются новые объекты. И если у вас достаточно большие числа и/или много операций, то…
Плюс операции на BigInt заметно медленнее.
chapuza
Как же, интересно, тогда хаскели с эрлангами выживают, да еще и уделывают шарп по производительности в хвост и в гриву?
В диапазоне
uint
это не так, а вне этого диапазона сравнивать не с чем.Kanut
Без понятия, я им под капот не заглядывал. Но у шарпа эта проблема есть. И даже сам майкрософт пишет
По моему опыту это и в диапазоне uint так. Ну и как бы вот по быстрому нагуглилось:
The BigInteger calculation loop is over 52 times slower. Both the Int64 and BigInteger values are immutable; a new copy of the value is created in memory each time it is modified. In fact all integral types, including the venerable Integer data type, are immutable. The extra complexity of creating an arbitrarily large number, even when the number is no larger than a standard numeric type, causes the difference in performance. To minimize this performance penalty, especially when modifying BigInteger values in loops, perform as many calculations in standard integral types as possible and assign to the BigInteger structure only when necessary.
visualstudiomagazine.com/articles/2011/01/25/biginteger.aspx?m=1
VladVR
Ну то что оверхед есть это понятно, мне интересно часто ли случается такое, что при какой то относительно простой операции результатом будет число с огромными нумератором и деноминатором, что одно число сожрет пару гигов памяти и соответствующую же производительность.
Kanut
Я бы сказал что одной относительно простой операцией вы себе вряд-ли сможете создать большие проблемы.
А вот если много операций, да ещё если их скажем распараллелить…
В общем я OutOfMemoryException из-за использования BigInteger видел. Причём я так и не понял зачем там был использован этот самый BigInteger.
chapuza
Нет, это тоже непонятно :) Судя по всему (и по цитате, приведенной Kanut), в шарпе очень наивная реализация. Ни в руби, ни в эрланге (говорю про то, о чем знаю) — значимого оверхеда нет, пока есть такая возможность, используется встроенный тип, ложащийся на архитертуру железа. То есть, пока вы не покинули диапазон интов, под капотом используется этот самый инт. Ну да, есть проверка на выход из диапазона, и поэтому все-таки небольшой оверхед есть, но это даже не проценты.
Мы годы используем
Rational
и я никогда не видел ничего даже отдаленно похожего. Наверное, сто?ит написать заметку о том, как оно реализовано, скажем, в эрланге: код открыт, и он не особо прям сложный. В руби все еще проще: Матц просто использовал алгоритм Bruno Haible стянутый из Common LISP.qw1
add rax, rbx
В любом bigint/rational типе — это возня с объектами. В регистры кладутся ссылки, вызывается метод add, внутри проверяются флажки, что каждый из аргументов сейчас хранится как native int64, выполняется операция, проверяется, что результат опять не вышел за пределы int64, кладём результат обратно в поле объекта по ссылке. Достаточно, чтобы получить 50x замедление?
qw1
Если бы ещё платёжные шлюзы и банковские API принимали честный Rational.
Ах, да, их же поголовно писали зеленые юнцы
chapuza
Это не требуется. Расчет — что в платежном шлюзе, что в банке — это терминальная операция, там все равно придется округлять до копеек. Внутри же вашей системы далеко не все операции терминальные. Это просто.
Не умеете в сарказм (особенно если не особо понимаете предметную базу) — не нужно и пытаться.
qw1
chapuza
Банки оперируют целыми землекопами :)
Будь эти бумажки и монетки сто раз фидуциарны, вы можете в любой момент потребовать закрыть счет. Вам по идее должны будут отсыпать фиата — и все, никто никому ничего не должен. Поэтому (наверное) банки не позволяют даже полкопейки туда-сюда.
Также с обменом валют: суммы считаются до четвертого знака после запятой (для валют с деноминатором 100 — это 0.01 цента), но получите на руки/счет вы округленную в пользу банка сумму.
Финансовые воротилы живут по своим законам, но это не повод в своем приложении накапливать ошибку без всякой на то причины.
FreeBa
Т.е. вы реализуете поведение JS, а потом говорите — смотрите, оно ведет себя как JS. Однако.
KvanTTT
Перегрузка операторов — это фича, которая используется редко, но метко, для всяких векторных и матричных операций. В JavaScript же подобное поведение получается по-умолчанию.
Eldhenn
Нога, конечно, прострелена, но достаточно ли это изящно сделано?
anonymous Автор
Нет, конечно. Ибо подражание. Для изящных результатов надо пользоваться собственными особенностями языка. Например, механическим преобразованием linq в вызовы методов с последующей попыткой всё это дело скомпилировать. Тогда в C# «Hello, World!» можно написать так:
Kanut
Это не совсем корректное заявление. В С# в данном случае это не математическая операция, а конкатенация этой самой строки и «выполнения ToString() на числе». Просто если я не ошибаюсь, то с какой-то там версии языка в такой ситуации не нужно вызывать этот самый ToString() самому и это делается за вас. И результатом у вас всегда будет объект типа string.
И самое главное я бы хотел посмотреть как вы в С# выполните вот такое:
Magals
var в шарпе просто сахар, думаю вы все же имели виду dynamic
Kanut
Нет, я имел ввиду именно var. Потому что dynamic мало кто использует в С# просто так. То есть если кто-то использует dynamic, то он обычно проверяет что он там получает в результате. И даже если нет, то значение dynamic переменной просто так не присвоить переменной имеющей тип int.
И проблема на мой взгляд не в том что в С#/JS можно «выполнять математические операции на строке и числе», а в том что в JS вы можете случайно/незаметно выполнить конкатенацию вместо сложения и потом записать string в переменную, которая до этого была числом или от которой ожидается что она является числом.
И единственный способ быть уверенным что этого не произойдёт это проверять являются ли все ваши переменные/параметры числами и не «заползла» ли где-то случайно строка. А это по хорошему куча работы. Особенно если много работаешь с UI и/или сторонними библиотеками.
Iqorek
Нет, Number не брат близнец System.Double, он может вести себя и как целое число int или long и как double. Пока число целое и помещается в 32 бит, оно будет вести себя как int, но любая операция с числом с плавающей точкой превратит его в double. Я не помню со скольки бит (можно почитать в спецификации) но int превращается в long и остается long даже при операциях с double
9007199254740891 + 0.4 // 9007199254740891
9007199254740891 + 0.5 // 9007199254740892
barkalov
Ничто ни во что не превращается! Просто у Number (aka
IEEE 754 double-precision
akabinary64
) хватает точности без ошибок представлять любое целое число вплоть до Number.MAX_SAFE_INTEGER (9007199254740991 = 2^53 - 1
). В С# поведение double идентично.Iqorek
Что с пруфами? Как ведет себя js я написал выше, как c# dotnetfiddle.net/CguG53
Console.WriteLine((9007199254740891+3.0).ToString("0")); // 9007199254740890
Вот так ему точности хватает.
ps. вот еще про точность long и double dotnetfiddle.net/eRwq64
barkalov
Попробуйте собрать этот-же код под x86. ;)
Разница не в точности double, а в точности выполнения «внутренних»/«промежуточных» арифметических операций внутри процессора. Это поведение платформо-зависимо: на x86 используется x87 FPU, на x64 — SSE2.
Обратите внимание, для хранения значения точности double хватает. dotnetfiddle.net/rVcV1U
Iqorek
Занятный пример, непонятно почему если в double есть точное значение, почему оно округляется при печати.
barkalov
Попробуйте
doubleValue.ToString("G17")
;)anonymous
www.ecma-international.org/ecma-262/5.1/#sec-4.3.19
Возможно, под капотом в разных движках (V8, spidermonkey, rhino) и сделаны подобные оптимизации с разным форматом хранения чисел, но для пользователей числа должны вести себя как double. Иначе нужно репортить баг и чинить такое поведение.
Ваш пример показывает только то, что у типа double есть ограничение на количество значащих символов. Точно такое же поведение будет и в питоне, и в плюсах. Значения типа long тут совсем ни при чем.
Iqorek
Оно «крякает» как long, в данной статье речь о c# и js, я относительно недавно переносил логику из c# в js и столкнулся с разным поведением на больших числах. Тот же самый код, может вернуть разные числа. При передаче по сети, если они сериализуются в стринг, то в js это будет long, в c# это будет double. Это надо учитывать.
anonymous Автор
del