В этой статье я расскажу про неочевидные примеры неправильного проектирования аргументов функций. Про необязательные параметры в JavaScript, передачу флагов, нарушениях интерфейсов и использовании оператора rest не по назначению.
- Обязательные и необязательные параметры
- Использование ...
- Флаги
- Параметры для внутренних нужд
- Дополнительные материалы
Некоторые примеры ниже специфичны для JavaScript, остальные встречаются повсеместно
Обязательные и необязательные параметры
JavaScript, в отличие от большинства других языков, не проверяет наличие обязательных параметров при вызове функций. Любую функцию можно вызывать без аргументов, и интерпретатор молча выполнит этот код, подставив во все обязательные параметры undefined
:
// Значение обязательно, но мы его не передали. Внутри оказался undefined.
// Квадратный корень из undefined — это NaN, то есть не число
Math.sqrt(); // NaN
// Здесь происходит ошибка, но не на этапе вызова, а уже внутри функции map
// Так как не передан колбек, то внутри он становится равным undefined
[].map();
// Uncaught TypeError: undefined is not a function
// at Array.map (<anonymous>)
Ситуаций, когда такое поведение желательно, пожалуй нет. Скорее к этому стоит относиться как к легаси, от которого уже не уйти. И его точно не нужно эксплуатировать в своем коде. Почему?
Такое поведение функций рождает неоднозначность при работе с необязательными параметрами. Представьте себе функцию, которая принимает на вход Markdown и возвращает HTML. Вторым параметром такая функция обычно принимает различные настройки поведения, такие как экранирование тегов:
text = '*text*';
markdown(text); // '<b>text</b>'
// sanitize удаляет потенциально опасные теги
markdown(text, { sanitize: true });
Определить эту функцию можно несколькими способами. Один из них такой: const markdown = (text, options) =>
. В этом случае, если опции не переданы, то значением параметра options
станет undefined
. Дальше внутри тела функции уже строятся нужные проверки для работы с этими опциями.
Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика
Несмотря на то, что такой код работает и не выглядит страшно, он создает сложный в анализе код. Программисту, которому понадобится воспользоваться этой библиотекой, недостаточно посмотреть в определение этой функции (например, по подсказке редактора). Из этого определения неочевидна необязательность. Ее придется явно задавать в документации, а значит пропадает возможность генерировать документацию автоматически. К тому же не видно, что из себя представляет options
. Даже с точки зрения написания функции подобный подход неудобен. Из-за того, что options
может менять тип (либо объект, либо undefined
), внутри придётся делать дополнительную проверку на то, с чем мы работаем. Если бы там всегда был объект, то такая проверка не понадобилась бы. Правильное определение выглядит так:
const markdown = (text, options = {}) => {
// ...
}
Использование ...
В JavaScript существует rest-оператор, который позволяет свернуть значения в массив. Он полезен, например, в функциях, которые работают с переменным количеством однотипных параметров. Например встроенная функция Math.min()
принимает на вход любое число аргументов и находит среди них минимальное:
Math.min(); // Что вернет такой вызов?)
Math.min(1, 10); // 1
Math.min(2, -3, 1, 10) // -3
// Определение функции выглядит так:
const min = (...params) => { // params – массив содержащий все переданные значения в том же порядке
// Тут логика поиска минимального
};
Это пример правильного использования. Но иногда его используют неправильно. Например, в функциях, которые принимают на вход фиксированный набор значений. Ниже пример функции, которая сравнивает два файла:
// Эта функция работает ровно с двумя параметрами
const difference = checkDifference(path1, path2);
// Неправильное определение
const checkDifference = (...paths) => {
// логика
}
У такого определения довольно много проблем. Оно не просто неочевидно, но еще и семантически некорректно. Rest-оператор говорит о том, что функция работает с любым числом путей, но это не так, она работает только с двумя. Это гарантированно запутает тех, кто собирается пользоваться функцией. При таком определении невозможно задать значения по умолчанию. И усложняется обращение к путям внутри самой функции, в худшем случае появится подобный код: paths[0]
и paths[1]
. Индексы плохо читаются и с ними гораздо проще ошибиться.
Общее правило довольно простое: использовать rest-оператор в определениях функций нужно только тогда, когда функция способна обрабатывать любое число однотипных параметров.
Флаги
Попробуйте ответить на вопрос, что делает второй параметр в этой функции:
validate(data, false);
Ответить на этот вопрос практически невозможно. Параметры–флаги, чаще всего, — признак плохой абстракции. Такое встречается, когда в рамках одной функции объединяют, по сути, две и более функций. Флаг тут выступает как переключатель с одного варианта поведения на другой.
В примере выше флаг означает переключения из режима предиката, который возвращает true
или false
в зависимости от успешности валидации, в режим бросания исключения в случае ошибок. Правильный подход — разделить эту функцию на две:
isValid(data); // предикат, возвращает true или false
validate(data); // функция, которая бросает исключение если что пошло не так. Внутри она вызывает isValid
Иногда флаги — это опции, и в таком случае их лучше заменять на явную передачу опций:
// Плохо
markdown(text, true, false);
// Хорошо
markdown(text, { sanitize: true, autoLinks: false });
Но иногда, все же встречаются ситуации, где без флагов неудобно. Например функция, которая устанавливает видимость элемента:
setVisible(false);
Здесь флаг уместен, но очень важно следить за названием функции. Текущее название не очень удачное, потому что оно переводится как «сделать видимым», но по коду видно обратное. Правильно было бы назвать эту функцию setVisibility()
.
Параметры для внутренних нужд
В рекурсивных функциях бывают нужны дополнительные параметры-аккумуляторы, которые пробрасываются вглубь при повторных вызовах. Типичный пример — обход деревьев. При погружении в глубину может понадобится знать текущий обрабатываемый уровень дерева. Представьте себе функцию, которая выводит содержание книги на экран:
const result = generateContent(data);
// Реальное же ее определение такое:
const generateContent = (data, depth = 1) => {
// Логика
// Где-то внутри происходит рекурсивный вызов
return generateContent(subdata, depth + 1); // +1 потому что новый уровень
}
Подобная реализация имеет серьезный недостаток. Параметр depth
не предназначен для клиентского кода, но его видно в определении, а значит и в подсказках редактора и в документации. Из-за этого складывается впечатление, что им можно пользоваться, но это не так. В данном случае depth
— исключительно внутренний параметр, который никак не касается тех, кто вызывает эту функцию.
В правильной реализации этот параметр не просачивается наружу. Это легко делается с помощью определения внутренней функции, которая и будет вызываться рекурсивно:
const generateContent = (data) => {
const iter = (innerData, depth) => {
// Логика
return iter(innerSubData, depth + 1);
};
iter(data, 1); // Начинаем с первого уровня
}