[Из песочницы] Как С#-разработчик у JavaScript плохому учился
Недавно мне на глаза попалась одна статья на Хабре. В ней сравниваются 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
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 = 1; 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
.Мораль
Явное лучше неявного. А код с удивительными результатами можно писать на любом языке. При этом удивительность будет зависеть от количества приложенных усилий и степени знания используемого языка.