Главное — в деталях. Что на самом деле даёт ООП? +3




Я уже более двух лет денно и нощно копаюсь в ООП. Прочитал толстую стопку книг, потратил человеко-месяцы на рефакторинг кода с процедурного стиля в объектно-ориентированный и обратно. Друг говорит, что я заработал ООП головного мозга. Но есть ли у меня уверенность, что я умею решать сложные задачи и писать понятный код?

Я завидую людям, которые умеют уверенно пропихивать свое бредовое мнение. Особенно, когда это касается разработки, архитектуры. В общем того, к чему я страстно стремлюсь, но в чём испытываю бесконечные сомнения. Потому что я не гений, и не ФП-шник, у меня нет истории успеха. Но позвольте вставить 5 копеек.

Инкапсуляция, полиморфизм, объектное мышление…?


Любите, когда вас грузят терминами? Я прочитал достаточно, но слова выше до сих пор не говорят мне ни о чем конкретном. Я привык объяснять вещи на понятном мне языке. Уровне абстракции, если хотите. И давно хотел знать ответ на простой вопрос: «Что даёт ООП?». Желательно с примерами кода. И сегодня я попробую ответить на него сам. Но сперва небольшая абстракция.

Сложность задачи


Разработчик так или иначе занимается решением задач. Каждая задача состоит из множества деталей. Начиная от специфики АПИ взаимодействия с компьютером, заканчивая деталями бизнес-логики.

На днях я собирал с дочкой мозаику. Раньше мы собирали пазлы большого размера, буквально из 9 частей. А теперь она справляется и с мелкой мозаикой для детей от 3-х лет. Это интересно! Как мозг находит среди разбросанных пазлов каждому своё место. И чем определяется сложность?

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

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

Декомпозиция


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

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

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

Рассмотрим пример. Не так давно я писал скрипт, который делает сборки проектов и закидывает в нужные папки.

interface BuildConfig {
  id: string;
  deployPath: string;
  options: BuildOptions;
  // ...
}

interface TestService {
  runTests(buildConfigs: BuildConfig[]): Promise<void>;
}

interface DeployService {
  publish(buildConfigs: BuildConfig[]): Promise<void>;
}

class Builder {
  constructor(
    private testService: TestService,
    private deployService: DeployService
  ) // ...
  {}

  async build(buildConfigs: BuildConfig[]): Promise<void> {
    await this.testService.runTests(buildConfigs);
    await this.build(buildConfigs);
    await this.deployService.publish(buildConfigs);
    // ...
  }

  // ...
}


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

Взгляните на интерфейс BuildConfig. Это структура, которую я создал ещё в самом начале написания кода. Я заранее понимал, что не смогу предусмотреть все параметры наперед, и просто добавлял в эту структуру поля по мере необходимости. Уже к середине работы конфиг оброс кучей полей, которые использовались в разных частях системы. Меня раздражало наличие «объекта», который нужно допиливать при каждом изменении. В нем сложно ориентироваться, и легко что-то сломать, перепутав названия полей. И при этом, все части системы сборки зависят от BuildConfig. Так как эта задача не столь объемная и критичная, катастрофы не произошло. Но ясно, что будь система посложней, я бы запорол проект.

Объект


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

Давайте вспомним, зачем нам данные? Чтобы произвести над ними операции и получить результат. Часто мы знаем какие подзадачи нужно решить, но не понимаем, что за данные для этого потребуются.

Внимание! Мы можем манипулировать операциями, зная, что они заранее владеют данными для своего выполнения.

Объект позволяет заменить набор данных набором операций. И если это уменьшает количество деталей, то и упрощает часть задачи!

// Структура, содержащая данные для решения задач/подзадач 
interface BuildConfig {
  id: string;
  deployPath: string;
  options: BuildOptions;
  // ...
}

// vs

// Интерфейс объекта, в котором мы перечислили нужные нам операции без лишних подробностей
interface Project {
  test(): Promise<void>;
  build(): Promise<void>;
  publish(): Promise<void>;
}


Преобразование очень простое: f(x) -> o.f(), где количество деталей o меньше количества деталей x. Второстепенное скрылось внутри объекта. Казалось бы, какой эффект от переноса кода с конфигом из одного места в другое? Но у этого преобразования есть далеко идущие последствия. Мы можем выполнить такой же трюк для остальных частей программы.

// project.ts
// Убедитесь, что в Project от конфига не осталось и следа.
class Project {
  constructor(
    private buildTester: BuildTester,
    private builder: Builder,
    private buildPublisher: BuildPublisher
  ) {}

  async test(): Promise<void> {
    await this.buildTester.runTests();
  }

  async build(): Promise<void> {
    await this.builder.build();
  }

  async publish(): Promise<void> {
    await this.buildPublisher.publish();
  }
}

// builder.ts

export interface BuildOptions {
  baseHref: string;
  outputPath: string;
  configuration?: string;
}

export class Builder {
  constructor(private options: BuildOptions) {}

  async build(): Promise<void> {
    //  ...
  }
}


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

export interface ProjectParams {
  id: string;
  deployPath: Path | string;
  configuration?: string;
  buildRelevance?: BuildRelevance;
}

const distDir = new Directory(Path.fromRoot("dist"));

const buildRecordsDir = new Directory(Path.fromRoot("tmp/builds-manifest"));

export function createProject(params: ProjectParams): Project {
  return new ProjectFactory(params).create();
}

class ProjectFactory {
  private buildDir: Directory = distDir.getSubDir(this.params.id);
  private deployDir: Directory = new Directory(
    Path.from(this.params.deployPath)
  );

  constructor(private params: ProjectParams) {}

  create(): Project {
    const builder = this.createBuilder();
    const buildPublisher = this.createPublisher();
    return new Project(this.params.id, builder, buildPublisher);
  }

  private createBuilder(): NgBuilder {
    return new NgBuilder({
      baseHref: "/clientapp/",
      outputPath: this.buildDir.path.toAbsolute(),
      configuration: this.params.configuration,
    });
  }

  private createPublisher(): BuildPublisher {
    const buildHistory = this.getBuildsHistory();
    return new BuildPublisher(this.buildDir, this.deployDir, buildHistory);
  }

  private getBuildsHistory(): BuildsHistory {
    const buildRecordsFile = this.getBuildRecordsFile();
    const buildRelevance = this.params.buildRelevance ?? BuildRelevance.Default;
    return new BuildsHistory(buildRecordsFile, buildRelevance);
  }

  private getBuildRecordsFile(): BuildRecordsFile {
    const buildRecordsPath = buildRecordsDir.path.join(
      `${this.params.id}.json`
    );
    return new BuildRecordsFile(buildRecordsPath);
  }
}


Все детали, связанные со сложной структурой изначального конфига, легли на процесс создания объекта Project и его зависимостей. За всё нужно платить. Но порой это выгодное предложение — избавиться от второстепенных деталей в целом модуле, и сосредоточить их внутри одной фабрики.

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

SOLID, абстракция, инкапсуляция...


Есть куча книг, посвященных ООП. В них проводятся глубокие исследования, отражающие опыт написания объектно-ориентированных программ. Но мой взгляд на разработку перевернуло именно осознание того, что ООП упрощает код в первую очередь за счет ограничения деталей. И буду полярным..., но если вы не избавляетесь от деталей с помощью объектов, вы не используете ООП.

Можно пытаться соблюсти SOLID, но в этом нет большого смысла, если вы не скрыли второстепенные детали. Можно привести интерфейсы к виду, отражающему объекты реального мира, но в этом нет большого смысла, если вы не скрыли второстепенные детали. Можно улучшить семантику через использование существительных в коде, но… Вы поняли.

Я нахожу SOLID, паттерны и прочие рекомендации по написанию объектов отличным руководством к рефакторингу. После сборки пазла вы видите картину в целом, и можете выделить более простые части. В общем, это важные инструменты и метрики, требующие внимания, но часто разработчики переходят к их изучению и использованию до преобразования программы к объектному виду.

Когда ты знаешь правду


ООП — инструмент решения сложных задач. Сложные задачи побеждаются разделением на простые за счет ограничения деталей. Способ уменьшить количество деталей — заменить данные набором операций.

Теперь, когда ты знаешь правду, попробуй избавиться от лишнего в своем проекте. Приведи получившиеся объекты к соответствию SOLID. Потом попробуй привести их к объектам реального мира. Не наоборот. Главное — в деталях.

Недавно написал расширение VSCode для рефакторинга Extract class. Считаю, это неплохой пример обектно-ориентированного кода. Лучший, что у меня есть. Буду рад замечаниям по реализации, или предложениям по улучшению кода/функциональности. Хочу в ближайшее время оформить ПР в Abracadabra




К сожалению, не доступен сервер mySQL