Совершенный код: проектирование функций

Читать в полной версии →

Создавать функции легко, но создавать их правильно — гораздо сложнее, чем кажется. Плохо спроектированные функции плохо тестируются, с трудом адаптируются под новые требования и часто переписываются. В этой статье мы пройдёмся по ключевым подходам создания удобных функций: научимся правильно разделять ответственности, строить цепочки функций и проектировать их сигнатуру. Материал статьи базируется на ошибках, которые совершают студенты Хекслета на проектах.

Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика

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

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

const result = makeFriendship(user1, user2);
if (result) {
  // …
}

Функция makeFriendship существует не потому, что её код повторяется в разных местах. А потому, что она прячет в себе подробности выполняемой операции. Такой код гораздо более «человеческий».

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

Задача состоит в том, чтобы спроектировать код библиотеки, которая анализирует два курса на Хекслете и возвращает название того курса, который короче (в часах). Для выполнения этой задачи библиотека должна запросить HTML страницы, найти внутри них время прохождения курса, сравнить его и вернуть название курса.

Её использование выглядит так:

import compareCourses from 'course-comparator';

const courseLink1 = 'https://ru.hexlet.io/courses/js-asynchronous-programming';
const courseLink2 = 'https://ru.hexlet.io/courses/js-testing';
// Функция асинхронная, так как она выполняет http запросы
const courseName = await compareCourses(courseLink1, courseLink2);
console.log(courseName); // JS: Автоматическое тестирование

Если реализовать эту функцию в лоб, без выделения каких-либо абстракций, то она будет такой:

const axios = require('axios'); // http-клиент
const cheerio = require('cheerio'); // аналог jquery на nodejs

const compareCourses = async (link1, link2) => {
  // запрос страниц с курсами
  const response1 = await axios.get(link1);
  const response2 = await axios.get(link2);

  // Извлечение времени и названия курса из HTML-страницы первого курса
  const dom1 = cheerio.load(response1.data); // загрузка HTML в cheerio
  const h1Element1 = dom1('h1'); // извлечение заголовка
  const courseName1 = h1Element1.text();
  const divElement1 = dom1('div.h3.mt-1').first(); // извлечение элемента содержащего время
  const time1 = divElement1.text().split(' ')[0]; // парсинг строки и извлечение времени

  // Извлечение времени и названия курса из HTML-страницы второго курса
  const dom2 = cheerio.load(response2.data);
  const h1Element2 = dom2('h1');
  const courseName2 = h1Element2.text();
  const divElement2 = dom2('div.h3.mt-1').first();
  const time2 = divElement2.text().split(' ')[0];

  // Основная логика функции
  return time1 > time2 ? courseName1 : courseName2;
};

Проверить её работу можно на repl.it.

Эта функция настолько маленькая, что в реальной жизни она бы такой и осталась. Код прямой, понятный и короткий. Практически любое изменение этой функции приведёт к раздуванию кода. Но для демонстрации это нормально, слишком сложная функция могла бы сделать статью неподъёмной для большинства читателей.

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

Первое, что бросается в глаза, повторение логики для каждой страницы:

  1. Скачивание страницы
  2. Извлечение названия курса и его продолжительности

Вынести повторяющийся код можно так:

const getPageInfo = async (link) => {
  const response = await axios.get(link);
  const dom = cheerio.load(response.data);
  const h1Element = dom('h1');
  const courseName = h1Element.text();
  const divElement = dom('div.h3.mt-1').first();
  const time = divElement.text().split(' ')[0];

  return { time, courseName };
};

const compareCourses = async (link1, link2) => {
  const data1 = await getPageInfo(link1);
  const data2 = await getPageInfo(link2);

  return data1.time > data2.time ? data1.courseName : data2.courseName;
};

Этот код выглядит вполне прилично. Он хорошо читается, дублирование убрано. Но всё же, он спроектирован неправильно.

Также полезно: Гайд по Nest.js что это такое и как написать свой первый код

Побочные эффекты и чистота

Главная проблема в том, что код, взаимодействующий с внешней средой (выполняющий побочный эффект) axios.get(link) переместился с внешнего уровня на более глубокий во внутреннюю функцию, сделав её асинхронной. Почему это плохо? Побочные эффекты автоматически усложняют любой код, в котором они встречаются:

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

Главный архитектурный принцип звучит так: "Изолируйте побочные эффекты от чистого кода". Соответственно, всё, что связано с вводом/выводом, должно быть не внутри, а, желательно, на самом верхнем уровне. Причём, чаще всего в начале работы программы происходит чтение необходимых данных, потом большой блок основной логики (чистый код) и на выходе снова побочный эффект, например, запись в файл. Это не всегда возможно, но к этому нужно стремиться.

Перепишем код в соответствии с этим знанием:

// Функция перестала быть асинхронной!
const getPageInfo = (response) => {
  const dom = cheerio.load(response.data);
  const h1Element = dom('h1');
  const courseName = h1Element.text();
  const divElement = dom('div.h3.mt-1').first();
  const time = divElement.text().split(' ')[0];

  return { time, courseName };
};

const compareCourses = async (link1, link2) => {
  const response1 = await axios.get(link1);
  const response2 = await axios.get(link2);

  const data1 = getPageInfo(response1);
  const data2 = getPageInfo(response2);

  return data1.time > data2.time ? data1.courseName : data2.courseName;
};

Извлечение запроса из getPageInfo(response) сделало эту функцию чистой. Она перестала быть асинхронной, перестала порождать сетевые ошибки. Её легко протестировать, и её поведение зависит только от входных аргументов (так как нет внешней среды, сети). Но она всё ещё не так хороша, как могла бы быть.

Модульность и зависимости

После изменений ответственность функции getPageInfo сократилась до парсинга входного HTML и возврата необходимых данных. Может ли её поведение зависеть от того, каким образом получен HTML? Правильный ответ нет, ей должно быть совершенно без разницы, как он попал в программу. Но фактически это не так: прямо сейчас функция принимает на вход объект response, который имеет прямое отношение к сети и специфичен для конкретной библиотеки axios.

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

Проблемы начинаются тогда, когда разработчик подстраивает поведение и интерфейс нижних модулей под верхние модули. Приведу пример. В первом проекте Хекслета есть задача сделать текстовую игру, в которой пользователю выводится число, и он должен указать, чётное оно или нет. Делается это вводом yes или no в консоль. В этой задаче есть чёткое разделение уровней: у нас есть число и механизм определения его чётности, и есть отдельная задача — проверка ввода пользователя, угадал ли он. Но вот, что делают многие разработчики:

const isEven = number => (number % 2 === 0 ? 'yes' : 'no');
// userAnswer это 'yes' или 'no'
if (isEven(number) === userAnswer) {
  console.log('Вы угадали!');
}

Из-за того, что в программу вводятся значения yes и no, разработчик "подстроил" процесс работы с чётным числом под него. Про такой код говорят, что "течёт абстракция". Это очень серьёзное нарушение модульности. Модули нижнего уровня не должны знать про модули верхнего уровня. Это верхний уровень должен подстраиваться под нижний уровень. Правильная реализация должна выглядеть так:

const isEven = number => number % 2 === 0;
const expectedAnswer = isEven(number) ? 'yes' : 'no';
if (expectedAnswer === userAnswer) {
  console.log('Вы угадали!');
}

Теперь функция isEven(number) выполняет свою задачу так, как она должна выполнять. Она может быть использована в любом контексте. Её даже можно вынести в отдельную библиотеку, удобную для дальнейшего переиспользования.

Другой прекрасный пример, который должен знать любой программист — модель OSI. Благодаря тому, что сеть построена с использованием этой идеи, программисты используют HTTP, не думая про то, как конкретно (по воздуху, по проводам) данные доставляются пользователю.

Вернёмся к нашему коду и изменим функцию getPageInfo:

// На вход поступает html как строка
const getPageInfo = (html) => {
  const dom = cheerio.load(html);
  const h1Element = dom('h1');
  const courseName = h1Element.text();
  const divElement = dom('div.h3.mt-1').first();
  const time = divElement.text().split(' ')[0];

  return { time, courseName };
};

const compareCourses = async (link1, link2) => {
  const response1 = await axios.get(link1);
  const response2 = await axios.get(link2);

  const data1 = getPageInfo(response1.data);
  const data2 = getPageInfo(response2.data);

  return data1.time > data2.time ? data1.courseName : data2.courseName;
};

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

Уровневое проектирование — сложная штука, она требует глубокого понимания процессов, происходящих в коде, аккуратного обращения с побочными эффектами и грамотного менеджмента состояния. Единственная известная мне книга, которая по-настоящему прокачивает уровневое проектирование, это СИКП. Например, в ней разрабатывается графическая библиотека, где уровневое проектирование используется на полную катушку.

Композиция вместо вложенности (Пайплайн)

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

const compareCourses = async (link1, link2) => {
  // На вход ссылка на выходе ответ
  const response1 = await axios.get(link1);
  // На вход тело ответа, на выходе данные
  const data1 = getPageInfo(response1.data);

  const response2 = await axios.get(link2);
  const data2 = getPageInfo(response2.data);

  return data1.time > data2.time ? data1.courseName : data2.courseName;
};

В программировании такой порядок исполнения кода называют пайплайном (Pipeline). Он возможен только в хорошо структурированном коде. Там, где функции не зависят друг от друга напрямую, а фокусируются на выполнении только своих задач. Благодаря этому появляется возможность соединять их и переиспользовать. Пайплайн распространён не только в программировании: любой человек, знакомый с командной строкой, так или иначе использует его:

# символ | — это пайп, а сама цепочка пайплайн
$ ls -l | grep package | sort

Пайплайн настолько мощная вещь в программировании, что во многих языках он существует на уровне синтаксиса. Например: F#, OCaml, Elixir, Elm, Julia, Hack. И прямо сейчас ведётся работа по внедрению пайплайна в JavaScript. Посмотрите, как меняется код с ним:

// цепочка функций до внедрения пайплайна (хотя можно создавать промежуточные константы)
const result = exclaim(capitalize(doubleSay('hello'))); // 'Hello, hello!'

// пайплайн на уровне синтаксиса
// Входная строчка пропускается через цепочку функций в указанном порядке.
// Выход каждой функции становится входом на следующем шаге
const result = 'hello'
  |> doubleSay
  |> capitalize
  |> exclaim;

Вот как будет выглядеть код нашей функции с применением нового синтаксиса:

// Пайплайн в этой функции был и до введения нового синтаксиса
// Новый синтаксис особенно удобен в длинных цепочках
const compareCourses = async (link1, link2) => {
  const data1 = await axios.get(link1)
    |> getPageInfo(#.data); // # - данные, пришедшие с предыдущего шага, в текущем примере это response

  const data2 = await axios.get(link2)
    |> getPageInfo(#.data);

  return data1.time > data2.time ? data1.courseName : data2.courseName;
};

Дополнительные материалы