Разработка

Совершенный код: Нисходящее и восходящее проектирование

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

В книгах, часто, эти подходы противопоставляются. Считается, что если выбран один подход, то второй исключен. Но это не так. В статье я объясню почему следование только в одном направлении приводит к проблемам.

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

Разработка через тестирование (TDD) имеет множество плюсов, среди которых выделяют как раз описанное выше. Программист использующий TDD встает на место пользователей своего кода и понимает как лучше его спроектировать.

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

Из сказанного выше, можно сделать вывод, что на начальном этапе всегда стоит выбирать проектирование сверху-вниз и это правда. Сначала нужно понять, что от кода требуется и как он будет использоваться, а вот потом начинаются варианты.

В качестве примера возьмем второй проект Хекслета, где разрабатывается библиотека для построения различий между двумя файлами. Если коротко, то весь интерфейс библиотеки это одна функция, которая принимает на вход два пути до файлов определенных форматов (yml, json, ini) и возвращает описание различий между структурами этих файлов. Вот ее использование:

import genDiff from differ;
const result = genDiff(pathToFile1, pathToFile2);
console.log(result);

Код выше является примером подхода сверху-вниз. Мы описали то как она будет использоваться, но еще не знаем про то как она будет написана (или уже написана). Самое приятное, что реализовать эту функциональность можно совершенно разными способами и ни один из них не повлияет на то, как библиотека будет использоваться. Это значит что мы получили отличную модульность.

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

import fs from fs;
import os from os;
import path from path;

export default (path1, path2) => {
  // получаем полный путь используя текущую рабочую директорию
  const fullPath1 = path.resolve(path1, os.cwd()); 
  const data1 = fs.readFileSync(fullPath1).toString();

  const fullPath2 = path.resolve(path2, os.cwd()); 
  const data2 = fs.readFileSync(fullPath2).toString();

  // остальной код
};

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

import fs from fs;
import os from os;
import path from path;

const readFiles = (path1, path2) => {
  const fullPath1 = path.resolve(path1, os.cwd()); 
  const data1 = fs.readFileSync(path1).toString();

  const fullPath2 = path.resolve(path2, os.cwd()); 
  const data2 = fs.readFileSync(path2).toString();

  return [data1, data2];
}

export default (path1, path2) => {
  const [data1, data2] = readFiles(path1, path2);

  // остальной код
};

Перед тем как читать дальше, попробуйте ответить сами себе, что не так с этим кодом?

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

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

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

Попробуем переписать этот код правильно, с выделением хороших абстракций:

import fs from fs;
import os from os;
import path from path;

const readFile = (path) => {
  const fullPath1 = path.resolve(path, os.cwd()); 
  const data1 = fs.readFileSync(path1).toString();
}

export default (path1, path2) => {
  const data1 = readFile(path1);
  const data2= readFile(path2);

  // остальной код
};

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

const results = paths.map(readFile);

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

genDiff(paths);

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

$ gendiff path/to/file1 path/to/file2

Аргументы командной строки это массив. Программист который начинает думать с этого угла, исходит из того что на вход ему пришел массив с путями и дальше он начинает подстраивать под это внутренности, то есть функцию genDiff.

Kirill Mokevnin 17 ноября 2019
Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Node, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →