Искусство написания простых и коротких функций
Софт постоянно усложняется. Стабильность и простота расширения приложения напрямую зависят от качества кода.
К сожалению, почти каждый разработчик, и я в том числе, в своей работе сталкивается с кодом плохого качества. И это — болото. У такого кода есть токсичные признаки:
- Функции слишком длинные, и на них слишком много задач
- Часто у функций есть побочные эффекты, которые сложно определить, а иногда даже сложно отлаживать
- Непонятные имена у функций и переменных
- Хрупкий код: небольшая модификация неожиданно ломает другие компоненты приложения
- Плохое покрытие кода тестами или вообще его отсутствие
Всем знакомы высказывания «я не понимаю, как работает этот код», «бредовый код», «этот код сложно изменить» и другие.
Однажды мой коллега уволился, потому что пытался справиться с REST API на Ruby, который было трудно поддерживать. Он получил этот проект от предыдущей команды разработчиков.
Исправление текущих ошибок создавало новые, добавление новых функций рождало новую серию ошибок, и так далее (хрупкий код). Клиент не хотел перестраивать приложение, делать ему удобную структуру, и разработчик принял правильное решение — уволиться.
Такие ситуации случаются часто, и это печально. Но что делать?
Во-первых, помнить: создать работающее приложение и позаботиться о качестве кода — разные задачи.
С одной стороны, вы реализуете требования приложения. Но с другой, вы должны тратить время и проверять, не висит ли слишком много задач на какой-нибудь функции, давать содержательные названия переменным и функциям, избегать функций с побочными эффектами и так далее.
Функции (в том числе методы объекта) — это маленькие шестерёнки, которые заставляют приложение работать. В начале вы должны сосредоточиться на их структуре и составе. Статья охватывает лучшие подходы, как писать простые, понятные и легко тестируемые функции.
1. Функции должны быть маленькими. Совсем маленькими.
Избегайте раздутых функций, у которых очень много задач, лучше делать несколько мелких функций. Раздутые функции со скрытым смыслом трудно понять, модифицировать и, особенно, тестировать.
Представьте ситуацию, когда функция должна возвращать сумму элементов массива, map'а или простого объекта JavaScript. Сумма рассчитывается складыванием значений свойств:
- 1 балл за
null
илиundefined
- 2 балла за примитивный тип
- 4 балла за объект или функцию
Например, сумма массива [null, 'Hello World', {}]
вычисляется так: 1
(за null
) + 2
(за строку, примитивный тип) + 4
(за объект) = 7
.
Шаг 0: Первичная большая функция
Давайте начнем с худшего метода. Идея — писать код одной большой функцией getCollectionWeight()
:
function getCollectionWeight(collection) {
let collectionValues;
if (collection instanceof Array) {
collectionValues = collection;
} else if (collection instanceof Map) {
collectionValues = [...collection.values()];
} else {
collectionValues = Object.keys(collection).map(function (key) {
return collection[key];
});
}
return collectionValues.reduce(function(sum, item) {
if (item == null) {
return sum + 1;
}
if (typeof item === 'object' || typeof item === 'function') {
return sum + 4;
}
return sum + 2;
}, 0);
}
let myArray = [null, { }, 15];
let myMap = new Map([ ['functionKey', function() {}] ]);
let myObject = { 'stringKey': 'Hello world' };
getCollectionWeight(myArray); // => 7 (1 + 4 + 2)
getCollectionWeight(myMap); // => 4
getCollectionWeight(myObject); // => 2
Проблема хорошо видна. Функция getCollectionWeight()
слишком раздутая и выглядит как черный ящик, полный сюрпризов.
Вам, скорее всего, с первого взгляда сложно понять, какая у неё задача. А представьте набор таких функций в приложении.
Когда вы работаете с таким кодом, вы растрачиваете время и усилия. А качественный код не вызовет у вас дискомфорта. Качественный код с короткими и не требующими объяснения функциями приятно читать и легко поддерживать.
Шаг 1: Извлекаем вес по типу и ликвидируем магические числа
Теперь цель — разбить длинную функцию на мелкие, независимые и переиспользуемые. Первый шаг — извлечь код, который определяет сумму значения по его типу. Эта новая функция будет называться getWeight()
.
Также обратите внимание на магические цифры этой суммы: 1
, 2
и 4
. Просто чтение этих цифр, без понимания всей истории, не даёт полезной информации. К счастью, ES2015 позволяет объявить const
как read-only, так что можно легко создавать константы со значимыми именами и ликвидировать магические числа.
Давайте создадим небольшую функцию getWeightByType()
и одновременно усовершенствуем getCollectionWeight()
:
// Code extracted into getWeightByType()
function getWeightByType(value) {
const WEIGHT_NULL_UNDEFINED = 1;
const WEIGHT_PRIMITIVE = 2;
const WEIGHT_OBJECT_FUNCTION = 4;
if (value == null) {
return WEIGHT_NULL_UNDEFINED;
}
if (typeof value === 'object' || typeof value === 'function') {
return WEIGHT_OBJECT_FUNCTION;
}
return WEIGHT_PRIMITIVE;
}
function getCollectionWeight(collection) {
let collectionValues;
if (collection instanceof Array) {
collectionValues = collection;
} else if (collection instanceof Map) {
collectionValues = [...collection.values()];
} else {
collectionValues = Object.keys(collection).map(function (key) {
return collection[key];
});
}
return collectionValues.reduce(function(sum, item) {
return sum + getWeightByType(item);
}, 0);
}
let myArray = [null, { }, 15];
let myMap = new Map([ ['functionKey', function() {}] ]);
let myObject = { 'stringKey': 'Hello world' };
getCollectionWeight(myArray); // => 7 (1 + 4 + 2)
getCollectionWeight(myMap); // => 4
getCollectionWeight(myObject); // => 2
Правда, выглядит лучше?
Функция getWeightByType()
— независимый компонент, который просто определяет сумму по типу. И она переиспользуемая, потому что может выполняться в пределах любой другой функции.
getCollectionWeight()
становится чуть более облегчённой
WEIGHT_NULL_UNDEFINED
, WEIGHT_PRIMITIVE
и WEIGHT_OBJECT_FUNCTION
— не требующие объяснения константы, которые описывают типы сумм. Вам не нужно догадываться, что означают цифры 1
, 2
и 4
.
Шаг 2: Продолжаем разделение и делаем функции расширяемыми
Обновленная версия по-прежнему обладает недостатками.
Представьте себе, что у вас есть план реализовать сравнение значений Set или вообще другой произвольной коллекции. getCollectionWeight()
будет быстро увеличиваться в размерах, так как её логика — собирать значения.
Давайте извлечём код, который собирает значения из map getMapValues()
и простых JavaScript-объектов getPlainObjectValues()
в отдельные функции. Посмотрите на улучшенную версию:
function getWeightByType(value) {
const WEIGHT_NULL_UNDEFINED = 1;
const WEIGHT_PRIMITIVE = 2;
const WEIGHT_OBJECT_FUNCTION = 4;
if (value == null) {
return WEIGHT_NULL_UNDEFINED;
}
if (typeof value === 'object' || typeof value === 'function') {
return WEIGHT_OBJECT_FUNCTION;
}
return WEIGHT_PRIMITIVE;
}
// Code extracted into getMapValues()
function getMapValues(map) {
return [...map.values()];
}
// Code extracted into getPlainObjectValues()
function getPlainObjectValues(object) {
return Object.keys(object).map(function (key) {
return object[key];
});
}
function getCollectionWeight(collection) {
let collectionValues;
if (collection instanceof Array) {
collectionValues = collection;
} else if (collection instanceof Map) {
collectionValues = getMapValues(collection);
} else {
collectionValues = getPlainObjectValues(collection);
}
return collectionValues.reduce(function(sum, item) {
return sum + getWeightByType(item);
}, 0);
}
let myArray = [null, { }, 15];
let myMap = new Map([ ['functionKey', function() {}] ]);
let myObject = { 'stringKey': 'Hello world' };
getCollectionWeight(myArray); // => 7 (1 + 4 + 2)
getCollectionWeight(myMap); // => 4
getCollectionWeight(myObject); // => 2
Сейчас читая getCollectionWeight()
вам намного проще понять, что делает эта функция. Выглядит, как интересная история.
Каждая функция очевидна и доходчива. Вы не тратите время, пытаясь понять, что делает такой код. Вот насколько чистым он должен быть.
Шаг 3: Никогда не прекращайте улучшения
Даже на этом этапе у вас есть много возможностей для повышения качества!
Вы можете создать отдельную getCollectionValues()
, которая содержит операторы if/else и дифференцирует типы коллекций:
function getCollectionValues(collection) {
if (collection instanceof Array) {
return collection;
}
if (collection instanceof Map) {
return getMapValues(collection);
}
return getPlainObjectValues(collection);
}
Тогда getCollectionWeight()
станет действительно простой, потому что единственное, что нужно сделать, это получить значения коллекции getCollectionValues()
и применить к нему sum reducer.
Можно также создать отдельную функцию сокращения:
function reduceWeightSum(sum, item) {
return sum + getWeightByType(item);
}
Потому что в идеале getCollectionWeight()
не должна определять функции.
В конце концов начальная большая функция превращается в маленькие:
function getWeightByType(value) {
const WEIGHT_NULL_UNDEFINED = 1;
const WEIGHT_PRIMITIVE = 2;
const WEIGHT_OBJECT_FUNCTION = 4;
if (value == null) {
return WEIGHT_NULL_UNDEFINED;
}
if (typeof value === 'object' || typeof value === 'function') {
return WEIGHT_OBJECT_FUNCTION;
}
return WEIGHT_PRIMITIVE;
}
function getMapValues(map) {
return [...map.values()];
}
function getPlainObjectValues(object) {
return Object.keys(object).map(function (key) {
return object[key];
});
}
function getCollectionValues(collection) {
if (collection instanceof Array) {
return collection;
}
if (collection instanceof Map) {
return getMapValues(collection);
}
return getPlainObjectValues(collection);
}
function reduceWeightSum(sum, item) {
return sum + getWeightByType(item);
}
function getCollectionWeight(collection) {
return getCollectionValues(collection).reduce(reduceWeightSum, 0);
}
let myArray = [null, { }, 15];
let myMap = new Map([ ['functionKey', function() {}] ]);
let myObject = { 'stringKey': 'Hello world' };
getCollectionWeight(myArray); // => 7 (1 + 4 + 2)
getCollectionWeight(myMap); // => 4
getCollectionWeight(myObject); // => 2
Это искусство создания небольших и простых функций!
После всех оптимизаций качества кода появляется горсть недурных преимуществ:
- Читаемость
getCollectionWeight()
упростилась благодаря не требующему объяснения коду - Размер
getCollectionWeight()
значительно уменьшился - Функция
getCollectionWeight()
теперь защищена от быстрого разрастания, если вы захотите реализовать работу с другими типами коллекций - Извлеченные функции теперь — это разгруппированные и переиспользуемые компоненты. Ваш коллега может попросить вас импортировать эти приятные функции в другой проект, и вы сможете это легко сделать.
- Если случайно функция сгенерирует ошибку, стек вызовов будет более точным, поскольку содержит имена функций. Почти сразу можно обнаружить функцию, которая создает проблемы.
- Разделённые функции намного проще тестировать и достигать высокого уровня покрытия кода тестами. Вместо того, чтобы тестировать одну раздутую функцию всеми возможными сценариями, вы можете структурировать тесты и проверять каждую маленькую функцию отдельно.
- Можно использовать формат модулей CommonJS или ES2015. Создавать отдельные модули из извлеченных функций. Это сделает файлы вашего проекта легкими и структурированными.
Эти преимущества помогут вам выжить в сложной структуре приложений.
Общее правило — функции не должны быть больше 20 строк кода. Чем меньше, тем лучше.
Я думаю, теперь у вас появится справедливый вопрос: «Я не хочу создавать по функции для каждой строки кода. Есть какие-то критерии, когда нужно остановиться?» Это тема следующей главы.
2. Функции должны быть простыми
Давайте немного отвлечёмся и подумаем, что такое приложение?
Каждое приложение реализует набор требований. Задача разработчика — разделить эти требования на небольшие исполняемые компоненты (области видимости, классы, функции, блоки кода), которые выполняют чётко определенные операции.
Компонент состоит из других более мелких компонентов. Если вы хотите написать код для компонента, его нужно создавать из компонентов только предыдущего уровня абстракции.
Другими словами, нужно разложить функцию на более мелкие шаги, но все они должны находится на одном, предыдущем, уровне абстракции. Важно это потому, что функция становится простой и подразумевает "выполнение одной задачи, и выполнение это — качественное".
В чём необходимость? Простые функции — очевидны. Очевидность означает лёгкое чтение и модификацию.
Попробуем последовать примеру. Предположим, вы хотите реализовать функцию, которая сохраняет только простые числа (2, 3, 5, 7, 11, и т.д.) массива и удаляет остальные (1, 4, 6, 8, и т.д.). Функция вызывается так:
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]
Какие шаги предыдущего уровня абстракции нужны для реализации функции getOnlyPrime()
? Давайте сформулируем так:
Для реализации
getOnlyPrime()
отфильтруйте массив чисел с помощью функцииIsPrime()
.
Просто примените функцию-фильтр IsPrime()
к массиву.
Есть необходимость на этом уровне реализовать детали IsPrime()
? Нет, потому что тогда у функции getOnlyPrime()
появятся шаги из другого уровня абстракций. Функция примет на себя слишком много задач.
Не забывая эту простую идею, давайте реализуем тело функции getOnlyPrime()
:
function getOnlyPrime(numbers) {
return numbers.filter(isPrime);
}
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]
Как видите, getOnlyPrime()
— элементарная функция. Она содержит шаги из одного уровня абстракции: метод .filter()
массива и IsPrime()
.
Теперь пришло время перейти на предыдущий уровень абстракции.
Метод массива .filter()
входит в JavaScript и используется как есть. Конечно, стандарт описывает именно то, что выполняет метод.
Теперь можно конкретизировать то, как будет реализована IsPrime()
:
Чтобы реализовать функцию
IsPrime()
, которая проверяет, является ли число n простым, нужно проверить, делится ли n на любое число от2
доMath.sqrt(n)
без остатка.
Давайте напишем код для функции IsPrime()
, пользуясь этим алгоритмом (он еще не эффективный, я использовал его для простоты):
function isPrime(number) {
if (number === 3 || number === 2) {
return true;
}
if (number === 1) {
return false;
}
for (let divisor = 2; divisor <= Math.sqrt(number); divisor++) {
if (number % divisor === 0) {
return false;
}
}
return true;
}
function getOnlyPrime(numbers) {
return numbers.filter(isPrime);
}
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]
getOnlyPrime()
— маленькая и элементарная. В ней только строго необходимые шаги предыдущего уровня абстракции.
Чтение сложных функций может быть значительно упрощено, если следовать правилу делать их очевидными. Если код каждого уровня абстракции написан педантично, это предотвратит порождение крупных кусков неудобного кода.
3. Используйте компактные названия функций
Имена функций должны быть компактными: не больше и не меньше. В идеале название должно чётко указывать, что делает функция, без необходимости рыться в деталях реализации.
Для имен функций используйте формат camel case, который начинается с маленькой буквы: addItem()
, saveToStore()
или getFirstName()
.
Поскольку функция — это действие, её имя должно содержать, как минимум, один глагол. Например deletePage()
, verifyCredentials()
. Чтобы получить или установить свойство, используйте префиксы set и get: getLastName()
или setLastName()
.
В production избегайте запутывающие имена, вроде Foo()
, bar()
, а()
, fun()
и подобные. Такие имена не имеют смысла.
Если функции маленькие и простые, а имена компактные: код читается как хорошая книга.
4. Вывод
Конечно, приведенные примеры незамысловаты. Приложения, существующие в реальности, более сложные. Можно жаловаться, что писать простые функции предыдущего уровня абстракции — нудное занятие. Но оно не настолько трудоёмкое если делать это с самого начала проекта.
Если в приложении уже есть слишком раздутые функции, перестроить код, скорее всего, будет сложно. И во многих случаях невозможно в разумных временных промежутках. Начните, хотя бы с малого: извлеките то, что сможете.
Конечно, правильное решение — грамотно реализовать приложение с самого начала. И вложить время не только в реализацию, но и в правильную структуру функций: сделать их маленькими и простыми.
Семь раз отмерь, один раз отрежь.
В ES2015 реализована хорошая модульная система, которая четко показывает, что небольшие функции — это хорошая практика.
Просто помните, что чистый и организованный код всегда требует вложений времени. Вам может быть сложно. Вам, возможно, потребуется долго практиковаться. Вы можете возвращаться и менять функции по нескольку раз.
Нет ничего хуже грязного кода.
Какие методы используете вы, чтобы сделать код организованным?