Не знаю как вам, но для меня нет лучшего начала дня, чем потрепаться о программировании. Кровь кипит при виде удачной критики одного из "жирных" языков, которым пользуются плебеи, мучаясь с ним на протяжении рабочего дня между стыдливыми посещениями StackOverflow.
(Тем временем, вы и я используем только самый просветленный язык и отточенные инструменты, разработанные для ловких рук таких мастеров, как мы).
Конечно, как автор проповеди, я иду на риск. Вам может нравиться язык, который я высмеиваю! Безрассудный памфлет мог бы неосторожно привлечь в мой блог яростную толпу черни с вилами и факелами наперевес.
Чтобы защититься от праведного огня и не оскорбить ваши (вероятно деликатные) чувства, я буду рассказывать о языке...
… который только что придумал. О соломенном чучеле, чья единственная роль — сгореть на костре критики.
Я знаю, что это звучит глупо, но поверьте, в конце мы увидим, чье лицо (или лица) были нарисованы на соломенной башке.
Новый язык
Будет перегибом учить совершенно новый (и отстойный) язык только для статьи в блоге, поэтому допустим, что он очень похож на язык, который мы уже знаем. Например Javascript. Фигурные скобки и точки с запятой. if
, while
и т.д. — Lingua franca нашей толпы.
Я выбрал JS не потому, что эта статья о нем. Просто это язык, в который среднестатистический читатель скорее всего врубается. Вуаля:
function thisIsAFunction(){
return "Круто!";
}
Так как наше чучело — это крутой (читай — хреновый) язык, то он имеет функции первого класса. Так что вы можете написать что-то такое:
// вернуть список, содержащий все элементы из коллекции,
// которые соответствуют условию
function filter(collection, predicate) {
var result = [];
for (var i = 0; i < collection.length; i++) {
if (predicate(collection[i])){
result.push(collection[i]);
}
}
return result;
}
Это одна из тех самых функций первого класса, и, как следует из названия, они классные и супер-полезные. Вы наверное привыкли преобразовывать коллекции данных с их помощью, но, как только ухватываешь концепцию, начинаешь использовать их везде, блин.
Может в тестах:
describe("Яблоко", function(){
it("не апельсин", function(){
expect("Яблоко").not.toBe("Апельсин");
});
};
Или когда надо разобрать (распарсить) данные:
tokens.match(Token.LEFT_BRACKET, function(token){
// Parse a list literal...
tokens.consume(Token.RIGHT_BRACKET);
});
Потом, разогнавшись, вы пишете все виды крутых переиспользуемых библиотек и приложений, крутящихся вокруг функций, вызовов функций, возвратов функций из функций — функциональный балаган.
переводчик: в оригинале "Functapalooza". Приставка-слово -a-palooza такое классное, что хочется им поделится для всех.
Какого цвета ваша функция?
И тут начинаются странности. Наш язык имеет одну своеобразную особенность:
1. Каждая функция имеет цвет.
Каждая функция — анонимный callback или обычная функция с именем — является или красной или синей. Так как подсветка кода в нашем блоге не выделяет разный цвет функций, давайте договоримся что синтакс такой:
blue*function doSomethingAzure(){
// это синяя функция...
}
red*function doSomethingCarnelian(){
// а это красная функция...
}
У нашего языка нет бесцветных функций. Хотите сделать функцию? — должны выбрать цвет. Такие вот правила. И есть еще несколько правил, которым вы должны следовать:
2. Цвет влияет на способ вызова функции
Представьте, что есть два синтаксиса вызова функций — "синий" и "красный". Что-нибудь типа:
doSomethingAzure(...)*blue;
doSomethingCarnelian()*red;
Когда вызываете функцию — вы должны использовать вызов, который соответствует её цвету. Если не угадали — вызвали красную функцию с *blue
после скобок (или наоборот) — произойдет что-то очень плохое. Давно забытый детский кошмар, типа клоуна со змеями вместо рук, который прятался под вашей кроватью. Он выпрыгнет из монитора и высосет ваши глаза.
Дурацкое правило, правда? Ой, но вот еще одно:
3. Только красная функция может вызвать красную функцию.
Вы можете вызвать синюю функцию из красной. Это кошерно:
red*function doSomethingCarnelian(){
doSomethingAzure()*blue;
}
Но не наоборот. Если вы попробуете:
blue*function doSomethingAzure(){
doSomethingCarnelian()*red;
}
— вас посетит старый Клоун Паучья Пасть.
Это делает сложнее написание высших функций, таких как filter()
из примера. Мы должны выбрать цвет для каждой новой функции и это влияет на цвет функций, которые мы можем ей передать. Очевидное решение — сделать filter()
красной. Тогда мы можем вызывать хоть красные, хоть синие функции. Но тогда мы поранимся о следующую колючку в терновом венце, которым является данный язык:
4. Красные функции больнее вызывать
Не будем точно определять это "больнее", просто представьте, что программист должен прыгнуть через обруч каждый раз, как он вызывает красную функцию. Может быть вызов слишком многосложный или нельзя запускать функцию внутри некоторых выражений. Или можно обратиться к красной функции только из нечетных строк.
Неважно что это, но если вы решили сделать функцию красной, каждый, кто использует ваш API, захочет плюнуть в ваш кофе или сделать чего похуже.
Очевидное решение в таком случае — никогда не использовать красные функции. Просто сделать все синим, и вы снова в нормальном мире, где все функции одного цвета, что равно тому, что у них нет цвета и что наш язык не совсем тупой.
Увы, но садисты, которые разработали данный язык (все знают, что авторы языков программирования являются садистами, правда?), втыкают в нас последний шип:
5. Некоторые функции ядра языка — красные.
Некоторые функции, встроенные в платформу, функции, которые нам нужно использовать, которые невозможно написать самим — доступны только в красном цвете. В этот момент разумный человек может начать подозревать, что этот язык ненавидит нас.
Это все вина функциональных языков!
Вы можете подумать, что проблема в том, что мы пытаемся использовать функции высшего порядка. Если мы просто перестанем валять дурака со всей этой функциональной ерундой, и начнем писать нормальные синие функции первого порядка (функции, которые не оперируют другими функциями — прим. переводчика), как и планировалось Богом — мы избавимся от всей этой боли.
Если мы вызываем только синие функции — делаем все наши функции синими. Иначе — делаем красными. Пока мы не создаем функции, которые принимают функции, нам не нужно беспокоиться о "полиморфности к цвету функции" (полихроматичность?) или других глупостях.
Но увы, функции высшего порядка это только один пример. Проблема возникает всякий раз, как мы хотим разбить нашу программу на функции для переиспользования.
Например, у нас есть приятный маленький кусочек кода, который, ну я не знаю, реализует алгоритм Дейкстры над графом, представляющим как сильно ваши социальные связи давят друг на друга. (я потратил кучу времени, пытаясь решить, что бы означал результат. Транзитивная нежелательность?)
Позже вам понадобилось использовать этот алгоритм где-то еще. Естественно, вы оборачиваете код в отдельную функцию. Вызываете ее из старого места и из нового. Но какого цвета должна быть функция? Вероятно вы постараетесь сделать ее синей, но что, если она использует одну из этих противных "только красных" функций из библиотеки ядра?
Допустим, что новое место, из которого вы хотите вызывать функцию — синее? Но теперь вам нужно переписать вызывающий код в красный. И потом переделать функцию, которая вызывает этот код. Уф. Вам придется постоянно помнить про цвет в любом случае. Это будет песком в ваших плавках на пляжном отдыхе программирования.
Цветная аллегория
На самом деле я тут не про цвет говорю. Это аллегория, литературный прием. Сничи — это не про звезды на животиках, это про расу. Вы уже наверное подозреваете ...
Красные функции — асинхронные
Если вы программируете на JavaScript или Node.js, каждый раз, определяя функцию, которая вызывает функцию обратного вызова (коллбэк), чтобы "вернуть" результат — вы пишете красную функцию. Посмотрите на этот список правил и заметьте, как они укладываются в мою метафору:
- Синхронные функции возвращают результат, асинхронные — нет, взамен они вызывают коллбэк.
- Синхронные функции выдают результат как возвращаемое значение, асинхронные выдают его, вызывая коллбек, который вы им передали.
- Вы не можете вызвать асинхронную функцию из синхронной, потому, что вы не сможете узнать результат, пока асинхронная функция не выполнится позже.
- Асинхронные функции не составляются в выражения из-за коллбэков, требуют иначе обрабатывать их ошибки и не могут быть использованы в
try/catch
блоке или в ряде других выражений, управляющих программой. - вся фишка Node.js в том, что библиотека ядра вся асинхронная. (Хотя они сдают назад и начали добавлять
_Sync()
версии множеству вещей.)
Когда люди рассказывают про "ад обратных вызовов" — они говорят о том, как досадно иметь "красные" функции в их языке. Когда они создают 4089 библиотек для асинхронного программирования (в 2019-м уже 11217 — прим. Переводчика), они пытаются на уровне библиотеки совладать с проблемой, которую им всучили вместе с языком.
I promise the future is better
в переводе: "Я обещаю, что будущее лучше" теряется игра слов из названия и содержимого раздела
Люди в обществе Node.js уже давно осознали, что коллбэки это больно, и искали решения. Одна из техник, которая воодушевила многих людей, это promises
, которые вы также можете знать по кличке futures
.
в русском IT вместо перевода "promises" как "обещания", установилась калька с английского — "промисы". Слово "Futures" же используется, как есть, вероятно потому что "фьючерсы" уже заняты финансовым сленгом.
Промис это обертка для коллбэка и обработчика ошибок. Если вы думаете о передаче коллбэка для результата и другого коллбэка для ошибки, то future
является воплощением этой идеи. Это базовый объект, который представляет собой асинхронную операцию.
Я только что разродился кучей причудливых формулировок и это может звучать как отличное решение, но в основном это змеиное масло. Промисы и правда позволяют писать асинхронный код чуть проще. Их проще составлять в выражения, так что правило №4 немного менее жесткое.
Но, если честно, это как разница между ударом в живот или в пах. Да, это не так больно, но никто не будет в восторге от подобного выбора.
Вы все еще не можете использовать промисы с обработкой исключений или другими
управляющими операторами. Вы не можете вызывать функцию, которая возвращает future
, из синхронного кода. (вы таки можете, но тогда следующий майнтейнер вашего кода изобретет машину времени, вернется в момент, когда вы это сделали, и воткнет вам в лицо карандаш по причине №2.)
Промисы все еще делят ваш мир на асинхронную и синхронную половинки со всем вытекающим из этого страданием. Так что, если даже ваш язык поддерживает promises
или futures
, он все еще очень похож на мое чучело.
(Да, это включает даже Dart, который я использую. Поэтому я так рад, что часть команды пробует другие подходы к параллельности)
проект по ссылке официально заброшен
I'm awaiting a solution
Программисты С#, вероятно, чувствуют себя самодовольно (причина, по которой они все более становятся жертвами, это то, что Хейлсберг и компания все посыпают и посыпают язык синтаксическим сахаром). В C# вы можете использовать ключевое слово await
, чтобы вызвать асинхронную функцию.
Это позволяет делать асинхронные вызовы так же легко, как синхронные, с добавлением милого маленького ключевого слова. Вы можете вставить вызов await
в выражениях, использовать их в обработке исключений, в инструкциях потока выполнения. Можете сходить с ума. Пусть await'ы польются дождем, как баксы за ваш новый рэперский альбом.
Async-await приятный, поэтому мы добавляем его в Dart. С ним гораздо легче писать асинхронный код. Но, как всегда, есть одно "Но". Вот оно. Но... вы все еще делите мир пополам. Асинхронные функции теперь легче писать, но они все еще асинхронные функции.
У вас все еще два цвета. Async-await решают досадную проблему №4 — они делают вызов красных функций не труднее вызова синих. Но остальные правила все еще здесь:
- Синхронные функции возвращают значения, асинхронные возвращают обертку (
Task<T>
в С# илиFuture<T>
в Dart) вокруг значения. - Синхронные просто вызываются, асинхронным нужен
await
. - Вызывая асинхронную функцию, вы получаете объект-обертку, когда на самом деле вы хотите значение. Вы не можете развернуть значение, пока вы не сделаете вашу функцию асинхронной и не вызовете ее с
await
(но см. следующий пункт). - Помимо небольшого украшения await'ом, по крайней мере эту проблему мы решили.
- Библиотека ядра C# старше, чем асинхронность, так что я думаю, они никогда не имели этой проблемы.
Async
действительно лучше. Я предпочту async-await голым коллбэкам в любой день недели. Но мы лжем себе, если думаем, что все проблемы решены. Как только вы начинаете писать функции высшего порядка, или переиспользовать код — вы снова понимаете, что цвет все еще там, кровоточит через весь ваш исходный код.
Какой язык не цветной?
Итак JS, Dart, C# и Python имеют эту проблему. CoffeeScript и большинство других языков, компилирующихся в JS — тоже (и Dart унаследовал). Я думаю, даже у ClojureScript есть эта загвоздка, несмотря на их активные старания с core.async
Хотите знать, какой не имеет? Java. Я прав? Как часто вы говорите — "да уж, Java единственная делает это правильно"? И вот это случилось. В их защиту, они активно пытаются исправить свою оплошность, продвигая futures
и async IO. Это как гонка "кто хуже".
в Java уже все есть
C#, на самом деле, тоже может обойти эту проблему. Они выбрали иметь цвет. До того, как они добавили async-await и все это Task<T>
барахло, вы могли просто использовать обычные синхронные вызовы API. Три других языка, которые не имеют "цветной" проблемы: Go, Lua и Ruby.
Догадываетесь, что у них общего?
Потоки. Или, более точно: множество независимых стеков вызовов, которые могут переключатся. Это не обязательно потоки операционной системы. Корутины в Go, корутины в Lua и нити в Ruby — все вполне адекватны.
(Вот почему для C# есть эта маленькая оговорка — вы можете избежать асинхронной боли в C#, используя потоки.)
Память о прошлых операциях
Фундаментальная проблема это "как продолжить с того же места, когда (асинхронная) операция завершится"? Вы погрузились в пучину стека вызовов и потом вызвали какую-то операцию ввода-вывода. Ради ускорения, эта операция использует нижележащий асинхронный API вашей ОС. Вы не можете ждать, пока она завершится. Вы должны вернуться к циклу событий вашего языка и дать ОС время, чтобы выполнить операцию.
Как только это произойдет, вам нужно возобновить то, что вы делали. Обычно язык "вспоминает где это было" через стек вызовов. Он следует через все функции, которые на данный момент были вызваны, и смотрит, куда показывает счетчик команд в каждой из них.
Но, чтобы выполнить асинхронный ввод-вывод, вы должны размотать, отбросить весь стек вызовов в языке С. Типа Уловка-22. У вас супер быстрый ввод-вывод, но вы не можете использовать результат! Все языки с асинхронным вводом-выводом под капотом — или, в случае JS, циклом событий браузера — вынуждены как-то справлятся с этим.
Node, с его "вечно-марширующими-вправо" коллбэками, запихивает все эти вызовы в замыкания. Когда вы пишете:
function makeSundae(callback) {
scoopIceCream(function (iceCream) {
warmUpCaramel(function (caramel) {
callback(pourOnIceCream(iceCream, caramel));
});
});
}
Каждое из этих функциональных выражений замыкает весь свой окружающий контекст. Это переносит параметры, такие как iceCream
и caramel
, из стека вызовов в кучу. Когда внешняя функция возвращает результат и стек вызовов уничтожен, это круто. Данные всё ещё гдето в куче.
Проблема в том, что вы должны снова воскресить каждый из этих чертовых вызовов. Есть даже специальное название для этого преобразования: continuation-passing style
по ссылке лютая функциональщина
Это изобрели языковые хакеры в 70-х, как промежуточное представление для использования под капотом компиляторов. Это очень причудливый способ представить код, который облегчает выполнение некоторых оптимизаций компилятора.
Никто никогда не думал, что программист может писать подобный код. А потом появился Node, и внезапно мы все притворяемся, что пишем бекэнд компилятора. Где мы свернули не туда?
Заметьте, что промисы и futures
мало чем помогают на самом деле. Если вы используете их, вы знаете, что по прежнему нагромождаете гигантские пласты функциональных выражений. Вы просто передаете их в .then()
вместо самой асинхронной функции.
Awaiting a generated solution
Async-await действительно помогает. Если заглянуть под капот компилятору, когда он встречает await
, вы увидите, что он фактически выполняет CPS-преобразование. Вот почему вам нужно использовать await
в C# — это подсказка компилятору — "остановите функцию здесь посередине". Все, что после await
, становится новой функцией, которую компилятор синтезирует от вашего имени.
Вот почему async-await не нуждается в поддержке среды выполнения внутри .NET фреймворка. Компилятор компилирует это в цепочку связанных замыканий, которые он уже умеет обрабатывать. (Интересно, что замыканиям тоже не требуется поддержка среды выполнения. Они компилируются в анонимные классы. В C# замыкания — это просто объекты.)
Вам наверное интересно, когда я упомяну генераторы. В вашем языке есть yield
? Тогда он может делать чтото очень похожее.
(я считаю, что генераторы и async-await изоморфны на самом деле. Где-то в пыльных закоулках моего жесткого диска валяется кусок кода, в котором реализован игровой цикл на генераторах с использованием только async-await.)
Так, где это я? Ах да. Так что с коллбэками, промисами, async-await и генераторами, вы кончаете тем, что берете свою асинхронную функцию и разбиваете ее в пачку замыканий, которые живут в куче.
Ваша функция вызывает внешнюю во время выполнения. Когда цикл событий или операция ввода-вывода закончена, ваша функция вызывается и продолжает оттуда, где была. Но это значит, что все, что сверху вашей функции, тоже должно вернуться. Вы все еще должны восстановить весь стек.
Вот откуда берется правило "вызвать красную функцию можно только из красной функции". Вы должны сохранить в замыканиях весь стек вызовов до самого main()
или обработчика событий.
Реализация стека вызовов
Но используя треды (зеленые или уровня ОС), вам не нужно это делать. Вы можете просто приостановить весь тред и прыгнуть к ОС или циклу событий без необходимости возвращаться из всех этих функций.
Язык Go, в моем понимании, делает это наиболее совершенно. Как только вы делаете любую операцию ввода-вывода, Go паркует эту корутину и продолжает любую другую, которая не заблокирована вводом-выводом.
Если посмотреть на операции ввода-вывода в стандартной библиотеке Golang, они кажутся синхронными. Другими словами, они просто работают и потом возвращают результат, когда готовы. Но эта синхронность не означает то же, что в Javascript. Другой Go-код может работать, пока мы ждем IO операцию. Так Go устранил различие между синхронным и асинхронным кодом.
Параллелизм в Go — это как вы выбираете моделировать вашу программу, а не цвет каждой функции в стандартной библиотеке. Это значит что вся боль пяти правил, которые я упомянул, полностью и абсолютно устранена.
Так что в следующий раз, как вы решите рассказать мне историю о новом популярном языке и его крутом асинхронном API, вы будете знать, почему я начинаю скрежетать зубами. Потому что мы снова вернулись к нашим красным и синим баранам.
От переводчика
Язык автора, его саркастичные метафоры и мастерская игра слов показались мне настолько хороши, что было бы упущением не познакомить с ним русскоязычного читателя. Зная английский на уровне чуть выше среднего, вы получите огромное удовольствие от чтения в оригинале. Надеюсь, мне удалось передать хотя бы 50% авторского юмора и перевод не стал при этом слишком тяжеловесным.
Я, как упомянутый в статье средний программист, несколько раз пытался писать асинхронный код, но каждый раз натыкался на острые шипы асинхронных функций и их интерфейсов и собирал все вышеперечисленные грабли.
В Javascript еще хоть как-то, не без страданий, получалось, так как JS изначально был асинхронным, половина гайдов по JS касается этой темы, и любой коллега по команде владеет некоторыми из асинхронных инструментов. Я даже думаю, что промисы в JS не лишены изящности.
Но вот в моем любимом Питоне, сколько бы я ни брался (на самом деле всего пару раз) за асинхронность — вместо удовольствия, которое я получаю от программирования, я получал непонимание, где нужен и где не нужен этот async
и как это все втиснуть в существующие синхронные программы. Обычно я заканчивал с import threading
и переставал забивать себе голову (а может быть, мне просто еще не попался в работе проект на AsyncIO, Twisted или Tornado, чтобы въехать в тему).
Я уже было начал думать, что я совсем тупой для всех этих новомодных вещей и пора на пенсию, и тут я читаю статью, в которой человеку тоже кажется, что есть нормальный и уютный мир синхронного кода, и вдруг тебе надо натянуть на него сверху кровоточащую мешанину анонимных коллбэков и не запутатся в своих ногах, пытаясь обойти все скрытые асинхронные капканы.
Боб как будто полил бальзамом мои раны, мне даже захотелось выучить Go, несмотря на все критикующие Go статьи.
Уж точно, что я не один такой программист средней руки, которому кажется, что (вероятно из-за врожденного асинхронного порока мозга) он один не может легко и просто начать применять асинхронность, несмотря на толпу гуру со всеми их статьями про "async-await для чайников". Для них и сделан этот перевод.
Обратная связь горячо приветствуется, надеюсь мы еще улучшим этот текст вместе.
Надеюсь, что другие статьи Боба так же хороши, и мне захочется переводить еще.