[Перевод] Я перехожу на JavaScript
После того, как я 5 лет писал на Go, я решил, что мне пора двигаться дальше. Go хорошо послужил мне. Вероятно, это был лучший язык, которым я мог бы пользоваться столько времени, но теперь настал момент оставить Go.
Не могу сказать, что я насмотрелся на ограничения и проблемы Go. За годы работы не случилось ничего такого, что подтолкнуло бы меня к переходу на язык, лучше соответствующий требованиям завтрашнего дня, или на язык, вокруг которого сложилось более успешное сообщество.
Мне не хотелось бы писать материал о том, почему я перешёл с Go на JavaScript, перечисляя минусы Go. Я полагаю, что подобные материалы оторваны от жизни и приносят читателям очень мало пользы. Поэтому я написал материал о том, что мне нравится в JavaScript, о том, что подвигло меня на переход.
Система типов и работа с числами
В JavaScript очень долго не было целочисленного типа данных. В языке были только какие-то странные числа двойной точности, которые иногда сносно играли роль целых чисел. Теперь всё уже не так. Теперь в JavaScript есть и целые числа, и массивы целых чисел. Разные значения такого рода можно преобразовывать друг в друга. Всё это делает JS языком с самыми лучшими числовыми типами.
var sb = new ArrayBuffer(4)
var intArr = new Int32Array(sb)
intArr[0] = 1 << 16
// 65536
intArr[0]
// 65536
Это — именно то, чего я долго ждал: точные вычисления. Очень приятно знать, что выбранный тобой язык поддерживает такие возможности.
Но учитывайте то, что JavaScript на этом не останавливается, так как вышеозначенные возможности вполне обычны во многих других языках. Мы можем использовать Int32Array
как Int16Array
, и всё будет работать так, как ожидается, без выдачи исключений:
var sb = new ArrayBuffer(4)
var intArr = new Int32Array(sb)
intArr[0] = 1 << 16
// 65536
intArr[0]
// 65536
// Тут я использую тот же буфер
var shortArray = new Int16Array(sb)
shortArray[0]
// 0
shortArray[1]
// 1
Вы можете тут спросить: «Зависит ли результат от порядка следования байтов в системе, в которой выполняется код? А если так — почему я пользуюсь архитектурой, где сначала идёт старший байт?». А я на это отвечу: «Спасибо, что спросили. Хороший вопрос».
Теперь поговорим о ещё одной замечательной особенности этих целых чисел. А именно, переполнения, в отличие от любого другого языка, обрабатываются именно так, как этого можно ожидать:
var shortArr = new Int16Array(1)
var c = shortArr[0] = 1 << 15 // бонус: приятное множественное присваивание
c == shortArr[0]
// false
shortArr[0]
// -32768
c
// 32768
Полагаю, что к этому моменту я уже продемонстрировал мощь чисел в JavaScript, но, чтобы окончательно прояснить мою мысль, приведу ещё примеры.
Оператор получения остатка от деления (%
) можно применять при работе с числами с плавающей точкой. И ведёт себя этот оператор именно так, как он и должен работать:
3.14 % 5
// 3.14
13.14 % 5
// 3.1400000000000006
Кроме того, массивы целых чисел легко сортировать, что называется, «на месте». При этом программисту даже не надо видеть стандартного кода, выполняющего сортировку. Это выгодно отличает JavaScript от многих языков со строгой статической типизацией:
[-2, -7, 0.0000001, 0.0000000006, 6, 10].sort()
// [-2, -7, 10, 1e-7, 6, 6e-10]
А синтаксис языка всегда чёток и интуитивно понятен:
(1,2,3,4,5,6) === (2,4,6)
true
Теперь давайте представим, что у нас есть строка, представляющая число, введённое пользователем. Это число нужно увеличить на единицу. Во многих языках для того чтобы это сделать, приходится тратить время, писать много муторного кода, выполняющего преобразование типов. В JavaScript же это делается так, что код получается изящным и читабельным:
var a = "41"
a += 1
// 411, неправильно, несбалансированно, странно.
var b = "41"
b -=- 1
// 42, а вот использование этой симметричной конструкции приводит к просто замечательному результату
Тут я обнаружил лишь одну вещь, которую трудно удержать в голове (но мне нужно будет к этому привыкнуть). Дело в том, что при работе с датами нужно учитывать то, что нумерация месяцев начинается с 0, а нумерация всего остального — с 1. Ничего другого, такого, что стоит запомнить, я не нашёл.
Подробности о типах
Как оказалось, самая красота JavaScript — это, бесспорно, его система типов. Поэтому я потрачу тут ещё немного времени на рассказ о том, что мне удалось выяснить в ходе экспериментов с языком или благодаря знатокам JavaScript из Twitter.
Благодаря новой возможности языка, представленной оператором ??
, теперь можно писать такой код:
~~!![[]]||__``&&$$++<<((""??''))**00==ಠಠ--//\\
// 1
Нужна строка banana
? Внимательно относитесь к расстановке пробелов:
('b'+'a'++'a'+'a').toLowerCase()
// Uncaught SyntaxError: Invalid left-hand side expression in postfix operation
('b' + 'a' + + 'a' + 'a').toLowerCase()
// "banana"
Нравится писать «однострочники»? JavaScript вам в этом поможет:
// Лучший код - это краткий код
input ?? obj?.key ? 'yes' : 'no'
А вот — моё любимое. Я вообще без ума от регулярных выражений. А в JavaScript есть много такого, что никому не даст скучать:
var re = /a/g
re.test('ab')
// true
re.test('ab')
// false
re.test('ab')
// true
Операторы в JS иногда не отличаются коммутативностью. Помните об этом:
{property: "value"} && {property: "value"}
// {property: "value"}
Date() && Date()
// "Wed Apr 1 2020 00:01:00 GMT+0100 (Central European Standard Time)"
Date() && {property: "value"}
// {property: "value"}
{property: "value"} && Date()
// Uncaught SyntaxError: Unexpected token '&&'
Типы могут меняться в самых неожиданных местах, что помогает программисту не заснуть во время долгих рабочих ночей:
typeof([1][0])
// number
for(let i in [1]){console.log(typeof(i))}
// string
Даже значения могут меняться только от того, что мы к ним обращаемся. Это, опять же, помогает продуктивно трудиться:
const x={
i: 1,
toString: function(){
return this.i++;
}
}
if(x==1 && x==2 && x==3){
document.write("This will be printed!")
}
Я могу тут приводить и другие примеры, демонстрирующие лёгкость чтения и понимания JavaScript-кода, но я закончу этот раздел следующим примером, который предусматривает работу с DOM:
document.all
// HTMLAllCollection(359) [html.inited, head, …]
document.all == true
// false
document.all == false
// false
Углубляемся в кроличью нору
Теперь представляю вам более серьёзный раздел этого материала. Обычно мне нравится лезть вглубь тех инструментов и языков, которыми пользуюсь. Лучший способ с чем-то разобраться — задавать вопросы коллегам и друзьям.
Я недавно начал работать с одним человеком. Он прислал мне этот прекрасный фрагмент JS-кода:
(function({substr}){return substr.call})("")
// function call()
var x = (function({substr}){return substr.call})("")
// undefined
x.name
// "call"
x + ""
// "function call() {
// [native code]
// }"
typeof x
// "function"
x("this is a string")
// TypeError: Function.prototype.call called on incompatible undefined
На этом он не остановился и прислал мне ещё вот это:
(function(){return this}.call(1)) == (function(){return this}.call(1))
// false
Последняя капля: конкурентность
Когда я дошёл до этого, я, в общем-то, уже был уверен в том, что мне необходимо перейти на JavaScript.
Последним, что мне хотелось проверить, было то, что мне нравится больше всего: конкурентность и параллелизм.
Полное отсутствие в JavaScript стандартной библиотеки, непредсказуемая система типов, странно ведущие себя операторы, изменение состояния сущностей при их сравнении и молчание системы при ошибках компиляции — это прочный фундамент, как раз то, к чему я обычно стремлюсь, создавая свои проекты. Но мне, прежде чем принять окончательное решение, нужно было увериться в том, что конкурентность и параллелизм в JavaScript ведут себя так же хорошо.
Так как я иду в JavaScript из Go — мне было очень легко понять причину, по которой этот код три раза выводит 3:
for (i = 1; i <= 2; ++i) {
setTimeout(function(){
console.log(i);
}, 0);
}
Но я должен признать, что результаты работы следующего фрагмента кода, на первый взгляд, не отличаются тем же уровнем интуитивной понятности:
console.log(0)
setTimeout(_ => console.log(1), 0)
requestAnimationFrame(_ => console.log(2))
Promise.resolve().then(_ => console.log(3))
console.log(4)
// 0
// 4
// 3
// 2
// 1
Всё становится гораздо яснее в том случае, если учесть, что тут мы вызываем две синхронных функции, планируем «макро-задачу», запускаем «микро-задачу» и сообщаем браузеру о том, что хотим произвести анимацию.
Полагаю, зная это, ничего не стоит понять механизмы конкурентного исполнения кода в JavaScript. А как насчёт параллелизма?
Вот код, которым я собираюсь тут пользоваться. Благодарю за него codediodeio.
// Этот код предназначен для логирования сведений о прошедшем времени:
const start = Date.now();
function log(v){console.log(`${v} \n Elapsed: ${Date.now() - start}ms`);}
// Тут запускаем тяжёлую задачу, блокирующую текущий поток
function doWork(){
for(let i = 0; i < 1000000000; i++); // обратите внимание на точку с запятой
return 'work done';
}
log('before work');
doWork();
log('after work');
// before work
// Elapsed: 0ms
// after work
// Elapsed: 624ms
Этот код позволяет узнать о том, сколько времени уходит на выполнение задачи в синхронном режиме.
Давайте теперь попробуем воспользоваться промисом:
function doWork(){
return new Promise((resolve, reject) => {
for(let i = 0; i < 1000000000; i++);
resolve('work done');
})
}
log('before work');
doWork().then(log);
log('after work');
// before work
// Elapsed: 0ms
// after work
// Elapsed: 637ms
// work done
// Elapsed: 637ms
Здесь мы сталкиваемся с той же проблемой. Кто-то может тут возразить, сказав, что проблема заключается в том, что код всё ещё выполняется как «макро-задача», или в том, что данная задача блокирует всё тот же поток. Поэтому давайте переработаем код так, чтобы весь тяжёлый цикл выполнялся бы в виде «микро-задачи»:
function doWork(){
// Добавление `resolve` приводит к выполнению этого в задаче другого вида
return Promise.resolve().then(v => {
for(let i = 0; i < 1000000000; i++);
return 'work done';
})
}
log('before work');
doWork().then(log);
requestAnimationFrame(()=>{log('time to next frame')});
log('after work');
// before work
// Elapsed: 0ms
// after work
// Elapsed: 1ms
// work done
// Elapsed: 631ms
// time to next frame
// Elapsed: 630ms
Если посмотреть на первые строки вывода, то возникнет такое ощущение, что проблему мы решили. Но это не так. Мы просто переместили работу в следующий доступный слот выполнения в главном потоке. Мы, может быть, смогли выполнить какой-то объём нашего синхронного кода быстрее, но асинхронный код всё ещё влияет на время, необходимое на обработку кадра, в результате приложение не реагирует на воздействия пользователя примерно полсекунды.
Это — кое-что такое, что меня прямо-таки поразило: если у нас имеются тяжёлые вычисления, которые нужно выполнить в JavaScript, то они заблокируют главный поток и замедлят интерфейс приложения. Можно найти множество видеоматериалов и учебных руководств о том, как пользоваться конструкцией async/await или промисами для решения этой задачи, но все они будут не в тему. Эти примитивы дают возможность конкурентного, а не параллельного выполнения кода. Единственный вид промисов, которые я смог запустить в настоящем параллельном режиме, это встроенные промисы браузера, вроде fetch
. А то, что описано в коде, никогда не будет выполняться параллельно.
Как же выполнять код в параллельном режиме? Надо создать веб-воркер — нечто вроде потока из других языков. Это — отличная новая возможность, которая вводит в JavaScript так необходимое в этом языке состояние гонок параллельное выполнение кода.
Пользоваться веб-воркерами очень просто: надо поместить код, который планируется запускать параллельно, в отдельный файл, а потом — создать экземпляр нового воркера, с которым можно обмениваться данными, используя postMessage
. Это даёт нам настоящий параллелизм. Всё, кроме того, хорошо документировано на MDN. Всё это выглядит несколько громоздко? Да, это так, но это — возможность, которая есть в распоряжении программиста. Кроме того, создаются библиотеки, которые облегчают использование этого примитива. В конце концов — что плохого в ещё одной дополнительной зависимости?
Так как в JavaScript теперь есть эта замечательная возможность, это значит, что в языке должны быть предусмотрены механизмы, представленные примитивами для оркестрации или синхронизации асинхронных задач. Верно? Верно. Язык даёт нам postMessage и MessageChannel (это похоже на chan
), что очень хорошо. Если пользоваться только этими механизмами, в вашем коде никогда не случится «гонка потоков», в этом коде будет очень легко ориентироваться, о нём будет легко рассуждать. Чудно.
Однако если вам нужно что-то более производительное, что-то такое, чему не нужно вызывать события и ожидать планирования коллбэков, если вам нужно, чтобы что-то работало по-настоящему быстро, тогда к вашим услугам SharedArrayBuffer. Речь идёт о фрагментах памяти, которые можно совместно использовать в разных потоках, выполняя над данными из этих фрагментов атомарные операции. Нет ни Mutex
, ни select
, ни WaitGroup
. Если вам нужны эти примитивы — вам понадобится самим их написать (как я). Как вам это понравится? Этот API даже возвращает значения, которые невозможно использовать правильно!
Да, в конце концов, кому понравится лёгкая работа‽ (Это — вопроцательный знак, который пока ещё не относится к числу операторов JavaScript).
Итоги
JavaScript — это зрелый язык, который даёт мне всё, что мне нужно: конкурентность, параллелизм, строго типизированные переменные. Более того, JS оживляет программирование и делает работу веселей, выдавая всякие непредсказуемые и таинственные вещи без каких-либо дополнительных библиотек, что называется, «из коробки». В результате тому, кто пишет на JavaScript, не будет скучно при отладке. Что тут может не понравиться?
Уважаемые читатели! Планируете ли вы переходить на JavaScript с того языка, на котором пишете сейчас?