Блог - Linux, программирование, Я!

[ПЕРЕВОД] Коллбеки императивны, обещания функциональны: крупнейшее упущение NodeJS.

Перевод статьи James Coglan Callbacks are imperative, promises are functional: Node’s biggest missed opportunity

"Главная особенность обещаний в том, что они невосприимчивы к изменяющимся обстоятельствам"
Франк Андервуд, "Карточный домик"

Возможно, вы часто слышите, что JavaScript это "функциональный" язык программирования. Его так называют просто потому, что функции в нём - объекты первого рода: многие другие особенности, присущие функциональным языкам, такие как неизменяемые данные, предпочтение циклам рекурсии, алгебраические системы типов, избежание побочных эффектов - полностью отсутствуют. И, хотя функции высшего порядка, безусловно, полезны и позволяют программирвать в функциональном стиле, но представление, что JS это функциональный язык, зачастую затмевает основной аспект функционального программирования - программирование со значениями.

Сам термин "функциональное программирование" сбивает с толку тем, что заставляет множество людей считать его "программированием с функциями", как противопоставление программированию с объектами. Но, если объектно-ориентированное программирование рассматривает всё как объект, функциональное программирование рассматривает как значение - не только функции, а абсолютно всё. Это, конечно, включает в себя такие очевидные вещи, как числа, строки, списки и прочие данные, но также некоторые другие вещи, о которых мы, поклонники ООП, обычно не думаем как о значениях: операции ввода-вывода и прочие побочные эфекты, потоки событий GUI, проверки на NULL, даже понятие последовательности вызова функций. Если вы когда-либо слышали фразу "программируемая точка с запятой", то вы понимаете, о чём я говорю.

И в лучшем проявлении, функциональное программирование декларативно. В императивном программировании, мы пишем последовательности инструкций, которые сообщают машине, как сделать то, что мы хотим. В функциональом программировании мы описываем зависимости между значениями, которые сообщают машине, что мы хотим вычислить, и машина предлагает последовательности команд, необходимых для этого вычисления.

Если вы когда либо пользовались Excel, вы использовали и функциональное программирование: вы моделировали проблему, описывая в виде графа как связаны между собой различные значения. Когда добавлялись новые данные, Excel выясняет, как это отразится на графе данных и обновляет для вас всё самостоятельно, не заставляя вас писать для этого последовательности из инструкций.

Имея такое определение, я хочу пояснить, что, по моему мнению, является основной ошибкой дизайна, внесённой Node.JS: решение, сделаное в самом начале его развития: решение использовать API на основе коллбеков, вместо "обещаний" (promises).

Все используют [коллбеки]. Если вы опубликуете модуль, который возвращает "обещания", никому это не будет интересно. Никто не будет пользоваться им.

Если я напишу свою небольшую библиотеку, которая будет общаться с Redis, и это последнее, что она делает, то я могу просто передать ей коллбек, который направит меня к редису. ***????*** И когда мы встретим такие проблемы, как коллбек-хелл, я открою вам секрет: существует так же и корутин-хелл и монад-хелл и ад для любых абстракций, если вы используете их достаточно много.

Для 90% случаев у нас имеется этот супер простой интерфейс, так что когда вам нужно сделать одну вещь, вам достатчно сделать небольшой шажок, и всё готово. Но если у вас сложный случай, то вы пойдёте и установите "async", так же, как и другие 827 модулей, которые зависят от него на npm.

Mikeal Rogers, LXJS 2012

Эта цитата из недавнего выступления Mikeal Rogers, которое охватывает различные аспекты философии дизайна Node.JS:

В свете заявленной цели дизайна Node - сделать разработку быстрых, конкурентных, сетевых программ простой для не-экспертов, я считаю такое отношение контрпродуктивным. "Обещания" позволяют гораздо проще конструировать корректные, максимально конкурентные програмы, делая управление потоком исполнения чем-то, чем будет заниматься рантайм языка, вместо того, чтобы заставлять пользователя реализовывать его самостоятельно.

Написание корректных конкурентных программ обычно сводится к тому, чтобы совершать столько операций параллельно, сколько возможно, и при этом быть уверенным, что операции продолжают выполняться в правильном порядке. Несмотря на то, что JavaScript однопоточен, мы всё ещё можем получить race-condition из за асинхронности: любое действие, которое включает в себя IO, может освободить ресурсы процессора для других действий, пока оно ожидает срабатывания коллбека. Множество конкуррентных действий могут работать с общими данными в памяти, или выполнять перекрывающиеся последовательности команд в базе данных или DOM. Что я надеюсь показать в этой статье, "обещания" предоставляют способ описать прблемы, используя взаимозависимости между значениями, как в Экселе, так что ваши инструменты смогут корректно оптимизировать решение за вас, вместо того, чтобы вам придумывать управление потоками самостоятельно.

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

Для начала, я бы хотел вернуться к статье, которую я написал несколько лет назад, о том, что "обещания" это монады асинхронного программирования. Главный урок той статьи был в том, что монады это инструмент, который помогает вам соединять функции, т.е. строить цепочки, где результаты одних функций становятся входом для других. Это достигается использоваием структурных связей между значениями, и эти значения и их связи вновь будут играть здесь важную роль.

Я собираюсь использовать нотации типов Haskell далее, чтобы проиллюстрировать некоторые вещи. В Haskell запись foo :: bar обозначает " foo является значением типа bar". Запись foo :: Bar -> Qux обозначает " foo является функцией, которая принимает значение типа Bar и возвращает значение типа Qux". Если конкретные типы ввода/вывода функции не важны, мы используем одиночные строчные буквы foo :: a -> b. Если foo принимает несколько аргументов, мы добавляем больше стрелок, т.е. foo :: a -> b -> c означает, что foo принимает два аргумента с типами a и b и возвращает что-то с типом c.

Давайте, для примера, рассмотрим функцию Node.JS fs.readFile(). Она принимает имя файла в виде строки ( String) и коллбек, и не возвращает ничего. Коллбек принимает ошибку ( Error, которая может быть null) и Buffer, в котором находится содержимое файла, и так же не возвращает ничего. Мы можем сказать, что тип функции readFile это:

              
readFile :: String -> Callback -> ()

            

() в нотации Haskell это тип null. Коллбек сам по себе это функция, описание типа которой:

              
Callback :: Error -> Buffer -> ()

            

Совместив всё, мы можем сказать, что readFile принимает String и функцию, которая вызывается с Buffer:

              
readFile :: String -> (Error -> Buffer -> ()) -> ()

            

Теперь, давайте представим, что Node использует "обещания". В этой ситуации, ReadFile будет просто принимать String и возвращать "обещание Buffer"

              
readFile :: String -> Promise Buffer

            

В более общем виде, мы можем сказать, что функции, использующие коллбеки принимают какие-то входные данные и коллбек, который будет вызван с какими-то выходными данными, а функции, использующие "обещания" принимают какие-то входные данные и возвращают "обещание" каких-то выходных данных.

              
callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b

            

Эти null-значения, возвращаемые функциями, использующими коллбеки и есть причина того, что программирование с коллбеками сложное: такие функции ничего не возвращают, из за чего их сложно объединять. Функция, которая ничего не возвращает, вызывается только ради её сайд-эффекта, т.к. функция без сайд-эффекта и без возвращаемого значения - это просто чёрная дыра. Так что, программирование с коллбеками императивно по своей сути, речь идёт об упорядочивании выполнения полных сайд-эффектов процедур, вместо отображения входящих данных в выходящие посредством применения функций. Речь идёт ***!!!???*** о ручном управлении потоком исполнения, вместо решения