В отличие от многих других пользователей Интернета, мне нравится писать Javascript. Я также фанат функционального программирования; как с практической точки зрения, так и с эстетической точки зрения. В искусстве кода функциональность - это прекрасно.

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

Вот проблема:

  1. Интернет по своей природе асинхронный. Ожидание сетевых запросов, пользовательского ввода или анимации - все это должно происходить, не задерживая остальную часть нашего кода.
    (Кстати: асинхронность, конечно, не уникальна для Интернета. И как любой программист, который имел дело с многопоточными системами, скажет вам: это никогда не бывает просто.)
  2. Функциональное программирование, естественно, не соответствует асинхронным задачам. Почему? Чистая функция должна иметь два атрибута:
    A) Она должна быть детерминированной: для определенного ввода она всегда должна выдавать один и тот же результат.
    Б) У нее не должно быть побочных эффектов: то есть функция не должна влиять ни на что вне себя. Кроме того, он не должен ни на что полагаться в глобальном состоянии.

Вот некоторые примеры:

// Functional - Deterministic and side-effect free.
function add(x, y){
    return x + y;
}
// Not functional - Not deterministic (still side effect free)
function addRand(x){
    return x + Math.random();
}
// Not functional - Deterministic, but has side-effects
var k = 6;
function addToGlobal(x){
    k += x;
    return k;
}
// Not functional - Not deterministic and has side-effects
var k = 6;
function addRandToGlobal(x){
    k += (x*Math.random());
    return k;
}

Как это противоречит асинхронному программированию?

Предположим, вы хотите что-то вычислить на основе асинхронного запроса: стоимость кошелька ETH пользователя (токенов криптовалюты Ethereum) в долларах США. Вы бы взяли общее количество ETH и умножили его на самый последний обменный курс, который вы получаете где-нибудь из API.

var ethWallet = 2.3;
function getETHinUSD(){
    let rate = api.ethPrice('USD');
    return ethWallet*rate;
}

Здесь есть несколько проблем.

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

function getETHinUSD(ethWallet){
    let rate = api.ethPrice('USD');
    return ethWallet*rate;
}

Во-вторых, при условии, что функция api.ethPrice() асинхронная, этот код, вероятно, просто выдаст ошибку. Это связано с тем, что rate не будет иметь значения до завершения api.ethPrice(), но ничего не говорит нашему коду ждать api.ethPrice(), поэтому функция вернет результат ethWallet*rate до того, как rate получит значение.

Есть два способа исправить это. Обещания или async / await.

Обещания

Я не буду здесь подробно останавливаться на обещаниях, есть гораздо лучшие ресурсы. Но напомню:

Обещание - это просто объект, который представляет окончательное завершение (или отказ) асинхронной операции и ее результирующее значение.

Вкратце, обещания либо завершаются (разрешаются), либо терпят неудачу (отклоняются). Мы можем что-то сделать, когда обещание завершается, используя функцию then(), и сделать что-то, когда оно не выполняется, используя функцию catch().

Предположим, api.ethPrice() возвращает обещание. В последнем примере rate станет объектом Promise, который будет иметь функцию then().
Мы могли вызвать rate.then( fn(result) ) с помощью функции обратного вызова fn, которая получит result, когда обещание завершится.
ИЛИ
Мы могли бы неявно рассматривать api.ethPrice() как обещание и использовать then напрямую:

function getETHinUSD(ethWallet){
    return api.ethPrice('USD').then(function(rate){
        return ethWallet*rate;
    })
}

Обещания также делают такую ​​забавную вещь, которая называется цепочкой. Функция then также возвращает обещание, что означает, что мы могли бы асинхронно делать что-то еще внутри этой внутренней функции и иметь после этого then. Эта цепочка также позволяет нам хорошо обрабатывать ошибки. Мы можем просто поставить один catch в конце, и он будет обрабатывать любые асинхронные сбои:

function getETHinUSD(ethWallet){
    return api.ethPrice('USD')
    .then(function(rate){
        return ethWallet*rate;
    })
    .catch(function(error){
        console.log("Something went wrong:", error);
        return false;
    })
}

Прекрасно, но есть еще одна проблема: Он даже близко не работает.

Детерминированный? getETHinUSD вернет другое значение в разное время - даже с одним и тем же вводом. Это нормально, если мы ожидаем, что обменный курс доллара со временем изменится, но это еще не все. Если запрос api не выполняется, мы не получаем то же значение, но мы даже не получаем тот же тип данных!

Побочные эффекты? Сама функция getETHinUSD не влияет на наше глобальное состояние, но как насчет внутренней функции, переданной в then? Он полагается на значение ethWallet, которое не может быть передано напрямую.
А как насчет самого вызова api? Как мы узнаем, что эта функция не влияет на глобальное состояние нашего приложения каким-либо другим способом? Может быть, запрашивая цену ETH, он обновляет счетчик запросов для нашего состояния пользователя, ограничивая количество запросов, которые мы можем сделать, предотвращая доступ к api после X запросов, или просто аннулируя наши учетные данные и эффективно выводя нас из всего api?

Асинхронный / ожидание

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

Асинхронная функция - это функция, которая работает асинхронно с использованием синтаксиса async / await…
… использование асинхронных функций больше похоже на использование стандартных синхронных функций.

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

Еще раз, начиная с:

function getETHinUSD(ethWallet){
    let rate = api.ethPrice('USD');
    return ethWallet*rate;
}

Мы идентифицируем getETHinUSD как асинхронную функцию и ждем результата api.ethPrice():

async function getETHinUSD(ethWallet){
    let rate = await api.ethPrice('USD');
    return ethWallet*rate;
}

Это уже выглядит очень красиво, правда?
Что произойдет, если API выйдет из строя? Что ж, мы должны использовать старый добрый try / catch.

async function getETHinUSD(ethWallet){
    try{
        let rate = await api.ethPrice('USD');
        return ethWallet*rate;
    } catch (error){
        console.log("Something went wrong:", error)
        return false
    }
}

Это детерминировано? Нет, если мы предполагаем, что API со временем возвращает другое значение и никогда не дает сбоев.

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

У api все еще могут быть побочные эффекты, но у нас нет выбора.

Боковое сравнение

Использование обещаний:

function getETHinUSD(ethWallet){
    return api.ethPrice('USD')
    .then(function(rate){
        return ethWallet*rate;
    })
    .catch(function(error){
        console.log("Something went wrong:", error);
        return false;
    })
}

Использование async / await:

async function getETHinUSD(ethWallet){
    try{
        let rate = await api.ethPrice('USD');
        return ethWallet*rate;
    } catch (error){
        console.log("Something went wrong:", error)
        return false
    }
}

Некоторые наблюдения

  1. Версия Promise длиннее с точки зрения строк кода на одну огромную строку. Я видел много сообщений в блогах, в которых называли это победой для async / await, но, честно говоря, это не повлияет на кодовую базу из 10 000+ строк.
  2. Версия async / await все еще использует обещания, они просто скрыты в синтаксисе.
  3. Версия async / await больше похожа на нашу синхронную версию, без обработки асинхронного вызова API. Это победа? Многие блоги, кажется, так думают. Лично я нет.
    С первого взгляда легко понять, что с версией Promise происходит что-то странное. Разница невелика, но мы сразу знаем, что происходит что-то асинхронное. По структуре, а не по синтаксису, мы знаем, что rate не будет иметь немедленного значения.
    Конечно, можно утверждать, что буквальное включение слова async делает его достаточно очевидным.
  4. Как будут выглядеть эти два подхода в более длинной цепочке асинхронных операций?
    В моей текущей кодовой базе повседневной работы у нас есть несколько ключевых функций контроллера с множеством асинхронных шагов.
    Представьте, что вы создаете новый виджет:
    а) вам нужно проверить пользователя (асинхронно)
    б) затем вам нужно добавить новый виджет в базу данных.
    c) затем Вам необходимо обновить 3 гаджета, которые ссылаются на новый виджет.
    d) затем Вам необходимо уведомить 2 пользователей, использующих каждый гаджет (всего 6), о создании виджета.
    д) затем Вам необходимо зарегистрировать, что все это произошло успешно.
    Если какой-либо из этих шагов завершится неудачно, мы хотим сохранить в журнале как можно больше других сообщений и ролей.
    Используя async / await, мы, вероятно, поместим каждый await вызов в одном блоке try и обработайте защелку в конце. Но что, если нам нужны отдельные блоки catch для одних awaits, а не для других? Представьте, как бы это выглядело!
    Теперь рассмотрим, что каждый шаг происходит в аккуратной функции then (), а отдельные сбои могут обрабатываться отдельными catch (), связанными вместе.

А как насчет того, чтобы быть функциональным?

Дело в том, что если у вас есть недетерминированный вызов api, вы просто не можете написать чистую функцию. Однако в JavaScript функциональное программирование всегда будет ситуацией максимальных усилий. Язык не предназначен для того, чтобы быть действительно функциональным.

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

Когда вы думаете о функциональном javascript, вы, вероятно, также думаете о цепочке функций. Обещания позволяют нам функционально связывать асинхронные участки кода. Мы можем рассматривать каждую then как функцию, возвращаемую предыдущим then (даже если на самом деле это метод объекта Promise…).

<Array>.map().filter().sort()
<Promise>.then().then().catch().finally()

Видите сходство? Использование await побуждает вас не связывать асинхронные запросы в цепочку, а использовать промежуточные значения.

let intermediateValue1 = await asynchronousFunction1() ;
let intermediateValue2 = await asynchronousFunction2() ;
let intermediateValue3 = await asynchronousFunction3() ;

Это процедурно.
(Неплохо! Но тема этого поста - функциональная…)

Это полезно в определенных ситуациях, например, при использовании intermediateValue1 в качестве аргумента asynchronousFunction3(). В любом случае вы сохраняете результат в промежуточной переменной вне цепочки обещаний. Это огромное преимущество async / await, но, на мой взгляд, оно менее функционально.

Конечно, поскольку await в любом случае возвращает неявное обещание, вы можете превратить его в цепочку обещаний:

await asynchronousFunction().then(...);

Лучшее из обоих миров или нечестивый взлом? Я позволю вам решать самому.

Заключение

Может показаться, что я предпочитаю обещания, но, честно говоря, все сводится к одному: Используйте правильный инструмент для работы!

  • Если в вашей кодовой базе уже используется только один подход, продолжайте использовать его. Это избавит кого-нибудь от путаницы.
  • Если один подход явно легче понять в данном контексте, используйте более очевидное решение!
  • Если сомневаетесь, спросите коллегу. Если у вас нет коллеги (вам повезло), попробуйте оба варианта и посмотрите, какой из них лучше.

Спасибо за прочтение!

Я Эйдан Брин, и я руковожу консалтингом по программному обеспечению в Дублине, Ирландия.

Если вам понравился этот пост, подумайте о подписке на мой личный список рассылки для получения обновлений реже, чем раз в месяц.

Большое спасибо Майку van Россуму за вычитку этого поста и предоставление действительно ценных и содержательных отзывов.