Часть 1, Часть 2, Часть 3, Часть 4
Декларативный код — популярное понятие, но что оно означает на самом деле? Это что-то хорошее? Давайте разберёмся.
Если вы программируете, то скорее всего в императивном стиле. Вы пишете множество инструкций для достижения результата. В декларативном стиле вы описываете желаемый результат, но не инструкции в деталях. SQL, HTML, JSX — декларативные языки. В SQL вы пишете не то, как извлекаете данные, а то, что хотите извлечь:
SELECT * FROM Users WHERE Country='USA';
Это может быть грубо представлено в императивном JavaScript:
let user = null;
for (const u of users) {
if (u.country === 'USA') {
user = u;
break;
}
}
Или в декларативном JavaScript с экспериментальным конвейерным оператором:
import { filter, first } from 'lodash/fp';
const filterByCountry =
country => filter( user => user.country === country );
const user =
users
|> filterByCountry('USA')
|> first;
Второй вариант чище и читается лучше.
Возвращайте значение. Избегайте операторов
Выражения возвращают значения, тогда как операторы используются для выполнения действий и ничего не возвращают. В функциональном программировании это называется “побочные эффекты”. Изменение состояния, которое обсуждалось раньше — побочный эффект. Простой пример:
const calculateStuff = input => {
if (input.x) {
return superCalculator(input.x);
}
return dumbCalculator(input.y);
};
Сделаем код декларативным:
const calculateStuff = input => {
return input.x
? superCalculator(input.x)
: dumbCalculator(input.y);
};
И теперь он может быть лямбда-выражением:
const calculateStuff = input =>
input.x ? superCalculator(input.x) : dumbCalculator(input.y);
Операторы вызывают побочные эффекты и мутации, склонные к недетерминизму. Это снижает читаемость и надёжность кода.
Небезопасно переупорядочивать операторы. Их трудно распараллелить, потому что они изменяют состояния за пределами области видимости. С другой стороны, выражения легко распараллелить и возможно переупорядочить. Таким образом, они безопаснее.
Декларативное программирование требует усилий
Декларативное программирование — не то, что можно выучить за ночь. Особенно учитывая, что большинство людей училось императивному программированию. Декларативное программирование требует дисциплины и умения мыслить совершенно иначе. Удачный первый шаг — научиться программировать без изменяемого состояния. Например, не используя ключевое слово let
. Наверняка я могу сказать одно: если вы попробуете декларативный стиль, то удивитесь элегантности кода.
Конфигурация ESLint:
rules:
fp/no-let: warn
fp/no-loops: warn
fp/no-mutating-assign: warn
fp/no-mutating-methods: warn
fp/no-mutation: warn
fp/no-delete: warn
Не более двух параметров в функции
JavaScript типизирован динамически. Нет никаких гарантий, что функция вызывается с корректными параметрами. ES6 привносит множество функций, в том числе деструктурирование объектов, которое можно использовать для аргументов функций. Код ниже интуитивно понятен? Вы можете рассказать, чем являются параметры? Я — нет.
const total = computeShoppingCartTotal(itemList, 10.0, 'USD');
А в таком примере?
const computeShoppingCartTotal = ({ itemList, discount, currency }) => {...};
const total = computeShoppingCartTotal({ itemList, discount: 10.0, currency: 'USD' });
Второй пример читается гораздо лучше. Особенно это касается вызовов функций из другого модуля. Кроме того, при использовании объекта как аргумента не нужно следить за порядком аргументов в объекте. Конфигурация ESLint:
rules:
max-params:
- warn
- 2
Возвращайте объекты из функций
Сколько следующий фрагмент кода расскажет вам о сигнатуре функции? Что она возвращает? Возвращает ли она объект пользователя, его идентификатор или статус операции? Трудно сказать.
const result = saveUser(...);
Возврат объектов из функций проясняет намерения разработчика, код читается значительно лучше:
const { user, status } = saveUser(...);
...
const saveUser = user => {
...
return {
user: savedUser,
status: "ok"
};
};
Контроль потока выполнения исключениями
Обрадуетесь ли вы ошибке 500, когда неверен только ввод в форму? Как насчет работы с API, которое не дает никаких подробностей, а возвращает ошибку 500 направо и налево? Нас учат бросать исключения, когда происходит что-то непредвиденное, но это не лучший способ обработки ошибок. И вот почему.
Исключения нарушают безопасность типов
Исключения нарушают безопасность типов даже в статически типизированных языках. Согласно своей сигнатуре, функция fetchUser (id: number): User
должна вернуть пользователя. Сигнатура не говорит, что будет брошено исключение, когда пользователь не найден. Если ожидается исключение, то более подходящей сигнатурой будет: fetchUser (…): User | throws UserNotFoundError
. Конечно, такой синтаксис недопустим независимо от языка.
Рассуждать о программах, генерирующих исключения, становится сложно. Никто никогда не узнает, будет ли функция бросать исключение. Да, мы могли бы обернуть каждый вызов в try-catch
, но это непрактично и значительно ухудшает читаемость.
Исключения ломают функциональную композицию
Исключения делают функциональную композицию практически невозможной. В примере ниже сервер вернет ошибку 500, если одна из публикаций блога не будет найдена.
const fetchBlogPost = id => {
const post = api.fetch(`/api/post/${id}`);
if (!post) throw new Error(`Post with id ${id} not found`);
return post;
};
const html = postIds |> map(fetchBlogPost) |> renderHTMLTemplate;
А если сообщение было удалено, но пользователь пытается получить доступ к нему из-за какой-то неясной ошибки? Такое значительно ухудшает пользовательский опыт.
Кортежи для обработки ошибок
Не вдаваясь в функциональное программирование, простой способ обработки ошибок — возврат кортежа, содержащего результат и ошибку. Да, JS не поддерживает кортежи, но их можно легко эмулировать массивом [error, result]
. Кстати, это стандартный метод обработки ошибок в Go:
const fetchBlogPost = id => {
const post = api.fetch(`/api/post/${id}`);
return post
// null в ошибке, если пост найден.
? [null, post]
// null в результате, если пост не найден.
: [`Post with id ${id} not found`, null];
};
const blogPosts = postIds |> map(fetchBlogPost);
const errors =
blogPosts
|> filter(([err]) => !!err) // только элементы с ошибками.
|> map(([err]) => err); /* деструктурирует кортеж и возвращает ошибку. */
const html =
blogPosts
Иногда исключения необходимы
У исключений своё место в кодовой базе. Как правило, вы должны задать себе вопрос: хочу ли я, чтобы программа завершилась аварийно?
Любое брошенное исключение может привести к завершению процесса. Даже если мы думаем, что тщательно рассмотрели все потенциальные крайние случаи, исключения остаются небезопасными и приведут к аварийному завершению в будущем. Бросайте их только тогда, когда намерены вывести программу из строя, например, из-за ошибки разработчика.
Исключения называются исключениями не просто так. Их лучше использовать только тогда, когда произошло нечто исключительное и у программы нет альтернативы падению. Неверный ввод — не исключительная ошибка.
Избегайте обработки исключений
Это подводит нас к правилу: избегайте перехвата исключений. Всё верно: можно бросать исключения, если нужно аварийно завершить работу программы. Но мы никогда не должны обрабатывать их. Это подход, рекомендуемый функциональными языками, такими как Haskell и Elixir.
Спросите себя: кто несёт ответственность за ошибку? Если это пользователь, то обработайте её изящно. Покажите приятное сообщение, а не ошибку сервера.
Исключение из правила — использование сторонних API. Но даже тогда лучше использовать вспомогательную обёртку, чтобы возвращать кортеж [error, result]
. Для этого вы можете использовать Saferr. Подробнее о нём в следующем разделе.
К сожалению, у ESLint нет правила no-try-catch
. Ближайшее по сути — no-throw
. Конфигурация:
rules:
fp/no-throw: warn
Частичное применение функций
Частичное применение, вероятно, один из лучших механизмов совместного использования кода. Вы можете внедрить зависимости, не перебирая бойлерплейт ООП.
В следующем примере оборачивается библиотека Axios, печально известная тем, что вместо возврата ошибочного ответа она бросает исключение. Работать с такими библиотеками неоправданно сложно, особенно при использовании async/await
.
Каррируем и частично применим функцию, чтобы сделать небезопасный метод безопасным:
// Оборачиваем axios в безопасный вызов API без исключений.
const safeApiCall = ({ url, method }) => data =>
axios({ url, method, data })
.then( result => ([null, result]) )
.catch( error => ([error, null]) );
// Частично применим универсальную функцию выше для работы с API.
const createUser = safeApiCall({
url: '/api/users',
method: 'post'
});
// Безопасный вызов API.
const [error, user] = await createUser({
email: '[email protected]',
password: 'Password'
});
Обратите внимание, что функция safeApiCall
записывается как func = (params) => (data) => {…}
. Эта техника называется каррирование и она идёт рука об руку с частичным применением функций. Это означает, что func
при вызове с params
возвращает другую функцию, которая и выполняет работу. Другими словами, функция частично применяется с параметрами:
const func = (params) => (
(data) => {...}
);
Обратите внимание, что зависимости (params
) передаются как первый параметр, а данные — как второй. Чтобы упростить задачу, вы можете использовать npm пакет Saferr, который также работает с промисами и async/await
:
import saferr from "saferr";
import axios from "axios";
const safeGet = saferr(axios.get);
const testAsync = async url => {
const [err, result] = await safeGet(url);
if (err) {
console.error(err.message);
return;
}
console.log(result.data.results[0].email);
};
// Печатает: [email protected]
testAsync("https://randomuser.me/api/?results=1");
// Печатает: Network Error
testAsync("https://shmoogle.com");
Несколько маленьких трюков
Несколько крошечных, но полезных трюков. Они не обязательно делают код надёжнее, но могут сделать проще нашу жизнь.
Немного безопасности типов
Да, JavaScript типизирован не статически, но мы можем сделать код устойчивее, обозначив аргументы функции. Код ниже бросает ошибку, когда значение не было передано. Это не работает для null
, но защищает от undefined
.
const req = name => {
throw new Error(`The value ${name} is required.`);
};
const doStuff = ( stuff = req('stuff') ) => {
...
}
Оптимизации логических выражений
Оптимизации логических выражений широко известны и используются для доступа к значениям во вложенных объектах.
const getUserCity = user =>
user && user.address && user.address.city;
const user = {
address: {
city: "San Francisco"
}
};
// Возвращает "San Francisco"
getUserCity(user);
// Оба undefined
getUserCity({});
getUserCity();
Они могут обеспечить альтернативное значение выражению:
const userCity = getUserCity(user) || "Detroit";
Двойное отрицание
Двойное отрицание — прекрасный способ преобразовать какое-то значение в логическое. Но любое ложное значение будет преобразовано в ложное. А это не всегда то, чего мы хотим. Поэтому не используйте двойное отрицание с 0
.
const shouldShowTooltip = text => !!text;
// возвращает true
shouldShowTooltip('JavaScript rocks');
// всё возвращает false
shouldShowTooltip('');
shouldShowTooltip(null);
shouldShowTooltip();
Отладка с логированием на месте
Пример использования оптимизации логических выражений для отладки в React:
const add = (a, b) =>
console.log('add', a, b)
|| (a + b);
const User = ({email, name}) => (
<>
<Email value={console.log('email', email) || email} />
<Name value={console.log('name', name) || name} />
</>
);
Итоги
Нужен ли вам надёжный код? Решать вам. Ваша организация измеряет вашу продуктивность количеством закрытых багов в JIRA? Вы работаете на фабрике функций, где ценят только количество? Надеюсь, что нет, но если так, то подумайте о поиске лучшего места работы.
Не пытайтесь применить все правила сразу. Это очень сложно. Поместите статью в закладки, выберите одну из частей и сосредоточьтесь, включив соответствующие правила ESLint, чтобы помочь себе в путешествии по коду. Удачи!
Читайте также
- Функции-генераторы в JavaScript для оптимизации памяти
- Лучшие JavaScript библиотеки за 2019 год для построения диаграмм
- Рекомендации по изучению JavaScript
Перевод статьи Ilya Suzdalnitski: Here’s How Not to Suck at JavaScript