Русский
Русский
English
Статистика
Реклама

Ооп головного мозга

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

02.09.2020 14:13:27 | Автор: admin


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

Я завидую людям, которые умеют уверенно пропихивать свое бредовое мнение. Особенно, когда это касается разработки, архитектуры. В общем того, к чему я страстно стремлюсь, но в чём испытываю бесконечные сомнения. Потому что я не гений, и не ФП-шник, у меня нет истории успеха. Но позвольте вставить 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.tsexport 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. Потом попробуй привести их к объектам реального мира. Не наоборот. Главное в деталях.
Подробнее..

Recovery mode ООП Кто взял Измаил? Вопрос принадлежности методов объекту

19.07.2020 10:08:57 | Автор: admin
Данная статья посвящена разбору вопроса о том, какому именно объекту ООП должен принадлежать метод, осуществляющий взаимодейстие между несколькими сущностями.
Это распространённая тема для холиваров. Например:
Не используйте ООП. Никогда. Это ошибка.
На эту тему есть много материалов, к примеру: www.youtube.com/watch?v=QM1iUe6IofM

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

Теперь сравните это с функциональной реализацией:
У вас есть функция покормитьКошку() принимающая в качестве аргумента ссылку на кошку и кормушку.
Цитата из холивара

Как ответить на данный вопрос?
Вначале давайте рассмотрим пример из физики.

Кейс 1 (из физики): закон Ома

Закон Ома: I=U/R, где I сила тока, U напряжение, R сопротивление.

Несложно заметить, что закон Ома, как и любую другую формулу из трех переменных, можно записать тремя способами: I=U/R, R=U/I, U=IR. Как выработать правило, позволяющее однозначно определить единственную форму записи? Очень просто: надо записать с левой стороны производную величину, т.е. ту, которая становится имеющей определённое значение, в зависимости от остальных величин.

I=U/R Сила тока СТАНОВИТСЯ равной отношению напряжения на концах проводника к сопротивлению проводника верно.

U=IR Напряжение на концах проводника СТАНОВИТСЯ равным его сопротивлению, умноженному на силу тока через проводник не верно.

R=U/I Сопротивление проводника СТАНОВИТСЯ равным отношению напряжения на концах к силе тока не верно.

Видите как только мы условились, что производное значение находится слева, остался только один вариант.

Вот так же, мы поступим и в ООП. Условимся, что метод принадлежит тому, кто воздействует:

Кто_действует.Метод(Объект_воздействия);

Кейс 2: Кто взял Измаил?


Следовательно, при ответе на вопрос Кто взял Измаил? с точки зрения объектно ориентированного программирования, и с позиции кто воздействует?, правильный ответ будет Суворов.ВзятьКрепость(Измаил, Турки):boolean. Все другие варианты, такие как: Турки.Про***тьКрепость(Измаил, Суворову), Измаил.СменаСобственника(Турки, Суворов), АбстрактнаяКрепость.БытьЗахваченной(Исмаил, Суворов, Турки) и т.д. все эти варианты не верны.

Кейс 3: Человек, кормушка и кошка

Покорми кошку! с сайта corchaosis.ru

Человек насыпал еды в кормушку. Это метод: Человек.НасыпатьЕдыКошке(ПакетЕды, Кормушка). При выполнении метода, в глобальной переменной ПакетЕды количество еды уменьшается, а в глобальной переменной Кормушка появляется.

А как же кошка? А кошка существует ДО вызова метода НасыпатьЕдыКошке, как условие его вызова. Если кошка на даче, то и метод НасыпатьЕдыКошке вызываться не будет.

Кейс 4: Игрок в DOOM, шотган и монстр

Допустим, что вопрос попадания в монстра не имеет градаций: либо попал, либо не попал.
Правильная реализация. Всё начинается с метода Игрока:

Игрок (либо, Игрок_1 в многопользовательской игре).Выстрел(Монстр_1)
Внутри реализации метода Выстрел, мы видим, что текущее оружие игрока шотган.
Следовательно, вызываем метод вложенного объекта: Игрок_1.Оружие[Игрок_1.Номер_выбранного_оружия].Выстрел(Монстр_1)

Игрок_1.Оружие это класс TWeapon.
В данном случае, вызывается метод класса TShotgun, который является дочерним к TWeapon.
Итак, имеем: Шотган.Выстрел(Монстр_1)

Шотган, при выполнении данного действия, изменяет внутреннее состояние: для шотгана это количество патронов (а для другого вида оружия могла бы быть, например, и температура). Также, мы определяем силу урона так, шотган стреляет по 2 патрона, но может выстрелить и один, если в заряде остался только один патрон.

Если бы мы выстрелили из ракетницы, то появился бы новый объект ракета. Со своим методом Tick, обрабатывающим действия за один тик игрового времени (игровое время изменяется, обычно, в тиках). Но мы выстрелили из оружия, поражающего без задержки, поэтому знаем количество выстреленных патронов (1 или 2), знаем расстояние (сравнивая Игрок.Position и Монстр_1.Position), и внутри метода класса Шотган, рассчитываем ущерб.

И, наконец, Монстр_1.НанесёноПовреждение(сила_повреждения:Float). Теперь, как и перед этим шотган менял внутреннее состояние (кол-во патронов), теперь Монстр_1 меняет внутреннее состояние Монстр_1.Здоровье, и сценарий поведения (особенно, если Здоровье стало меньше нуля).

Итак, мы видим, что благодаря ООП, мы можем легко добавить новое оружие: достаточно описать его как дочерний класс от TWeapon, определить Выстрел и разместить на карте. Класс Игрок уже умеет подбирать и добавлять в свой игровой набор объекты TWeapon. Всё. Хотя нет, не всё. Если оружие будет дарить монстрам цветочки, заставляя их влюбляться в игрока, то и монстрам следует прописать метод ОтветитьНаПризнаниеВЛюбви:boolean, а также набор других методов в зависимости от степени проработки, в этом случае вам может потребоваться и ряд новых ООП объектов и их методов.

Кейс 5: Потрогать руками это функция или метод

Не только ответ на этот вопрос, но и сам интерфейс взаимодействия, очевидно, находится в зависимости от трогаемого объекта.

Потрогать_руками:: дом это функия.

Потрогать_руками:: ноутбук это метод объекта ноутбук. Вы должны использовать интерфейс, чтобы вызвать методы ноутбука KeyDown, KeyUp, KeyPressed передав в эти методы правильные данные: какая кнопка была нажата, в какой момент времени и т.д.

Потрогать_руками:: огонь это метод объекта вы. Вам необходим объект self, изменение состояние которого правильно опишет ожог руки. Огонь состояния не изменит.

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

Почему здравый смысл важнее паттернов, а Active Record не так уж и плох

18.08.2020 12:17:00 | Автор: admin
Так уж вышло, что разработчики, особенно молодые, любят паттерны, любят спорить о том, какой паттерн нужно применять здесь или там. Спорить до хрипоты: это фасад или прокси, а может даже синглтон. А если у вас не чистая, гексагональная архитектура, то некоторые разработчики готовы сжечь на костре Святой Инквизиции.

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

А наличие во фреймворке нужных паттернов никак не гарантирует их правильного и осознанного применения.



Блеск и нищета Active Record


Давайте рассмотрим в качестве антипаттерна паттерн Active Record, которого в некоторых языках программирования и фреймворках стараются избегать всеми возможными путями.

Суть Active Record проста: мы храним бизнес-логику с логикой хранения сущности. Иными словами, если очень упрощенно, каждой табличке в БД соответствует класс сущности вместе с поведением.


Есть достаточно устойчивое мнение, что объединять бизнес логику с логикой хранения в одном классе это очень плохой, негодный паттерн. Он нарушает принцип единственной ответственности. И по этой причине Django ORM плоха by design.

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

Возьмём для примера модели User и Profile. Это довольно распространенный паттерн. Есть основная табличка, и есть дополнительная, в которой хранятся не всегда обязательные, но иногда нужные данные.


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

   def create(self, validated_data):        # create user         user = User.objects.create(            url = validated_data['url'],            email = validated_data['email'],            # etc ...        )        profile_data = validated_data.pop('profile')        # create profile        profile = Profile.objects.create(            user = user            first_name = profile_data['first_name'],            last_name = profile_data['last_name'],            # etc...        )        return user

Чтобы получить список пользователей, нужно обязательно думать, а будет ли у этих пользователей забираться атрибут profile, чтобы сразу заселектить две таблички с джоином и не получить SELECT N+1 в цикле.

user = User.objects.get(email='example@examplemail.com')user.userprofile.company_nameuser.userprofile.country

Всё становится ещ` хуже, если в рамках микросервисной архитектуры часть данных о пользователе хранится в другом сервисе например, роли и права в LDAP-е.

При этом, конечно же, очень не хочется, чтобы внешних пользователей API это как-то заботило. Есть REST-ресурс /users/{user_id}, и с ним хотелось бы работать, не думая о том, как внутри устроено хранение данных. Если они хранятся в разных источниках, то изменять пользователя или получать список данных будет сложнее.

Вообще говоря, модель ОРМ != модель предметной области!


И чем больше отличается реальный мир от предположения одна табличка в БД одна сущность предметной области, тем больше проблем с паттерном Active Record.

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

Методы ORM самый низкий уровень абстракции. Они не поддерживают никаких ограничений предметной области, а значит, дают возможность ошибиться. А еще скрывают от пользователя, какие запросы на самом деле делаются в БД, что приводит к неэффективным и долгим запросам. Классика, когда запросы делаются в циклах, вместо join-а или фильтра.

А что ещё, кроме querybuilding (возможности выстраивать запросы), нам дает ОRМ? Да ничего. Возможность переехать на новую БД? А кто в здравом уме и твердой памяти переезжал на новую БД и ему в этом помогла ОRМ? Если воспринимать её не как попытку смаппить модель предметной области (!) в БД, а как простую библиотеку, которая позволяет делать запросы к БД в удобном виде, то всё становится на свои места.

И даже несмотря на то, что в названиях классов используется Model, а в названии файлов models, моделями они не становятся. Не надо себя обманывать. Это просто описание табличек. Они не помогут ничего инкапсулировать.

Но если всё так плохо, то что же делать? На помощь приходят паттерны из слоистых архитектур.

Слоистая архитектура наносит ответный удар!


Идея слоистых архитектур проста: мы отделяем бизнес логику, логику хранения и логику использования.

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

Мы всю логику хранения оставляем, например, в классе-хранилище Repository. А контроллеры (или сервисный слой) используют его только для получения и сохранения сущностей. Тогда мы можем как угодно менять логику хранения и получения, и это будет одно место! А когда пишем клиентский код, можем быть спокойны, что не забыли еще одно место, в котором надо сохранить или из которого надо забрать, и не повторяем один тот же код кучу раз.


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

Но такое разделение ответственностей не дается бесплатно. Надо понимать, что дополнительные слои абстракции созданы для того, чтобы мешать плохим изменениям кода. Очевидно, что Repository скрывает факт хранения объекта в SQL-БД, поэтому надо стараться не давать SQL-изму вылезать за пределы Repository. И все запросы, даже самые простые и очевидные, придется протаскивать через слой хранения.

Например, если возникнет необходимость получить офис по имени и подразделению, придется написать так:

# пример на котлинообразном языке программированияinterface OfficeRepository: CrudRepository<OfficeEntity, Long> {    @Query("select o from OfficeEntity o " +            "where o.number = :office and o.branch.number = :branch")    fun getOffice(@Param("branch") branch: String,                  @Param("office") office: String): OfficeEntity? ...

А в случае с Active Record всё значительно проще:

Office.objects.get(name=Name, branch=Branch)

Всё не так просто и в том случае, если бизнес-сущность на самом деле хранится нетривиальным образом (в нескольких табличках, в разных сервисах и т.д.). Чтобы реализовать это хорошо (и правильно) для чего этот паттерн и создавался, чаще всего приходится использовать такие паттерны, как агрегаты, Unit of work и Data mappers.

Правильно выделить агрегат, правильно соблюсти все накладываемые на него ограничения, правильно сделать data mapping это сложно. И справится с этой задачей только очень хороший разработчик. Тот самый, который и в случае с Active Record смог бы сделать всё правильно.

А что происходит с обычными разработчиками? Которые знают все паттерны и свято уверены, что если они используют слоистую архитектуру, то у них автоматически код становится поддерживаемым и хорошим, не чета Active Record. А они создают CRUD-репозитории на каждую табличку. И работают в концепции

одна табличка один репозиторий один объект (entity).

А не:

один репозиторий один объект предметной области.


Они так же слепо уверены, что если в классе используется слово Entity, то оно отражает модель предметной области. Как слово Model в Active Record.

А в результате получается более сложный и менее гибкий слой хранения, который имеет все отрицательные свойства как Active Record, так и Repository/Data mappers.

Но на этом слоистая архитектура не заканчивается. Еще обычно выделяют сервисный слой.

Правильная реализация такого сервисного слоя это тоже сложная задача. И, например, неопытные разработчики делают сервисный слой, который представляет собой сервисы прокси к репозиториям или ORM (DAO). Т.е. сервисы написаны так, что на самом деле не инкапсулируют бизнес-логику:

# псевдокод на котлинообразном языке в @Serviceclass AccountServiceImpl(val accountDaoService: AccountDaoService) : AccountService {    override fun saveAccount(account: Account) =            accountDaoService.saveAccount(convertClass(account, AccountEntity::class.java))    override fun deleteAccount(id: Long) =            accountDaoService.deleteAccount(id)

И возникает сочетание недостатков как Active Record, так и Service layer.

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


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

Наличие во фреймворке паттернов ООП не гарантирует их правильного и адекватного применения.

Серебряной пули нет


Совершенно очевидно, что серебряной пули нет. Сложные решения для сложных проблем, а простые решения для простых проблем.

Да и не бывает плохих и хороших паттернов. В одной ситуации хорош Active Record, в других слоистая архитектура. И да, для подавляющего большинства небольших и средних приложений Active Record достаточно хорошо работает. И для подавляющего большинства небольших и средних приложений слоистая архитектура (а-ля Spring) работает хуже. И ровно наоборот для богатых логикой сложных приложений и веб-сервисов.

Чем проще приложение или сервис, тем меньше слоев абстракций нужно.

В рамках микросервисов, в которых не так уж и много бизнес-логики, зачастую бессмысленно использовать слоистые архитектуры. Обычные transactional script скрипты в контроллере могут оказаться совершенно адекватными для решаемой задачи.

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

Перевод Программирование только классами

18.09.2020 12:15:01 | Автор: admin

image


В моем посте Implementing numbers in "pure" Ruby ("Разрабатываем числа на "чистом" Ruby") я обозначил рамки, которые разрешали использовал базовые вещи из Ruby вроде оператора равенства, true/false, nil, блоки и т.п.


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


Рамки


  • Можем определять классы и методы
  • Мы должны писать код так, будто в Ruby нет никаких готов классов из коробки. Просто представьте, что мы начинаем с абсолютного нуля. Даже nil не существует
  • Единственный оператор, который мы можем использовать присваивание (x = something).

Никакого if-оператора? Серьезно? Он есть даже у процессоров!


Условные операторы важны они являются основой логики для наших программ. Как же справляться без них? Я придумал такое решение: мы можем интегрировать логику во ВСЕ объекты


Сами подумайте, в динамических языках вроде Ruby логические выражения не обязательно должны вычисляться в какой-нибудь класс вроде "Boolean". Вместо этого, эти языки считают любой объект правдимым кроме некоторых особых случаев (nil и false в Ruby;
false, 0 и '' в JS). Именно поэтому добавление этого функционала не так уж дико, как кажется на первый взгляд. Но давайте начнем.


Базовые классы


Давайте создадим самый базовый класс, который будет предком всего остального:


class BaseObject  def if_branching(then_val, _else_val)    then_val  endend

Метод if_branching основа нашей логической системы. Как видите, мы сразу же предполагаем, что любой объект правдив, так что мы возвращаем then_val.


Что насчет лжи? Давайте начнем с null:


class NullObject < BaseObject  def if_branching(_then_val, else_val)    else_val  endend

То же самое, но возвращаем второй параметр.


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


class NormalObject < BaseObjectend

Все, что мы определим позже должно быть унаследовано от NormalObject. Потом мы можем добавить в него глобальные вспомогательные методы (вроде #null?).


If-выражения


Всего этого уже достаточно для того, чтобы определить наши if-выражения:


class If < NormalObject  def initialize(bool, then_val, else_val = NullObject.new)    @result = bool.if_branching(then_val, else_val)  end  def result    @result  endend

И все! Я серьезно. Оно просто работает.


Гляньте вот этот пример:


class Fries < NormalObjectendclass Ketchup < NormalObjectendclass BurgerMeal < NormalObject  def initialize(fries = NullObject.new)    @fries = fries  end  def sauce    If.new(@fries, Ketchup.new).result  endendBurgerMeal.new.sauce # ==> NullObjectBurgerMeal.new(Fries.new).sauce # ==> Ketchup

Возможно, вы уже думаете: "каким боком нам это полезно, если мы не можем использовать с ним блоки кода?". И что насчет "ленивости"?


Ознакомьтесь с этим примером:


# Псевдокодif today_is_friday?  order_beers()else  order_tea()end# Наш If классIf.new(today_is_friday?, order_beers(), order_tea()).result

В нашем примере мы закажем пиво ВМЕСТЕ с чаем вне зависимости от дня недели. Это происходит из-за того, что аргументы вычисляются до передачи в конструктор.


И это (ленивость) очень важный механизм, т.к. без него наши программы были бы медленные и даже неправильные.


Решением этого является просто оборачивание кода в другой класс. Позже я буду называть такие обертки процедурами (ориг. "callable"):


class OrderBeers  def call    # do something  endendclass OrderTea  def call    # do something else  endendIf.new(today_is_friday?, OrderBeers.new, OrderTea.new)  .result  .call

Собственно, код не будет исполнен, пока мы явно не вызовем метод #call. Вот и все. Таким образом мы можем комбинировать сложную логику и наш класс If.


Булевы типы (просто потому, что мы можем)


У нас уже есть логические типы (null и все остальное), но было бы неплохо добавить специальные булевы классы для выразительности. Приступим:


class Bool < NormalObject; endclass TrueObject < Bool; endclass FalseObject < Bool  def if_branching(_then_val, else_val)    else_val  endend

Мы определили собирательный класс Bool, класс TrueObject без какой либо логики (она не нужна, т.к. любой экземпляр этого класса уже автоматически будет считаться правдивым) и класс
FalseObject, переопределяющий #if_branching так же, как и NullObject.


Вот и все. У нас есть специальные булевы классы. Я еще добавил логическое НЕ для удобства:


class BoolNot < Bool  def initialize(x)    @x = x  end  def if_branching(then_val, else_val)    @x.if_branching(else_val, then_val)  endend

Оно всего-лишь "переворачивает" аргументы для #if_branching. Просто, но очень полезно.


Циклы


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


В целом он выглядит так:


while some_condition  do_somethingend

Что может быть описано вот так: "если условие выполнено, то сделай вот это и повтори цикл".


Интересная особенность в нашем случае то, что условие должно быть динамичным оно должно быть в состоянии меняться между шагами цикла. Процедуры спешат на помощь!


class While < NormalObject  def initialize(callable_condition, callable_body)    @cond = callable_condition    @body = callable_body  end  def run    is_condition_satisfied = @cond.call    If.new(is_condition_satisfied,           NextIteration.new(self, @body),           DoNothing.new)      .result      .call  end  # Запускает "тело" и потом снова While#run.  # Таким образом цикличность определена рекурсивно  # (жаль, что хвостовая рекурсия не оптимизирована)  class NextIteration < NormalObject    def initialize(while_obj, body)      @while_obj = while_obj      @body = body    end    def call      @body.call      @while_obj.run    end  end  class DoNothing < NormalObject    def call      NullObject.new    end  endend

Программа для примера


Давайте создадим связные списки и функцию, которая считает сколько в списке null-объектов.


Список


Ничего особенного:


class List < NormalObject  def initialize(head, tail = NullObject.new)    @head = head    @tail = tail  end  def head    @head  end  def tail    @tail  endend

Еще нам нужно как-то его обходить (никаких #each с блоком в этот раз!). Давайте создадим класс, который будет этим заниматься:


## Позволяет обойти лист один раз#class ListWalk < NormalObject  def initialize(list)    @left = list  end  def left    @left  end  # Возвращает текущую голову и присваивает хвост к current.  # Возвращает null если конец достигнут  def next    head = If.new(left, HeadCallable.new(left), ReturnNull.new)             .result             .call    @left = If.new(left, TailCallable.new(left), ReturnNull.new)              .result              .call    head  end  def finished?    BoolNot.new(left)  end  class HeadCallable < NormalObject    def initialize(list)      @list = list    end    def call      @list.head    end  end  class TailCallable < NormalObject    def initialize(list)      @list = list    end    def call      @list.tail    end  end  class ReturnNull < NormalObject    def call      NullObject.new    end  endend

Думаю, основная логика вполне проста. Нам также понадобились вспомогательные процедуры для #head и #tail, чтобы избежать null-pointer ошибок (даже при том, что наш null на самом деле не null, мы все равно рискуем вызвать несуществующий метод).


Счетчик


Просто объект, который будет использоваться для подсчетов:


class Counter < NormalObject  def initialize    @list = NullObject.new  end  def inc    @list = List.new(NullObject.new, @list)  end  class IncCallable < NormalObject    def initialize(counter)      @counter = counter    end    def call      @counter.inc    end  end  def inc_callable    IncCallable.new(self)  endend

У нас пока нет чисел и я решил не тратить время на их создание. Вместо этого я использовал списки (гляньте мой пост про создание чисел здесь).


Интересная штука здесь метод #inc_callable. Мне кажется, если бы мы хотели разработать наш собственный "язык" со всеми этими базовыми классами, то это могло быть принятым соглашанием добавлять методы, оканчивающиеся на _callable и возвращающие процедуру. Это что-то вроде передачи функций в качестве аргументов в функциональном программировании.


Считаем null в списках


Для начала нам нужна проверка на null. Мы можем добавить ее в NormalObject и NullObject как вспомогательный метод #null? (схожий с #nil? из Ruby):


class NormalObject < BaseObject  def null?    FalseObject.new  endendclass NullObject < BaseObject  def null?    TrueObject.new  endend

Ну а теперь мы можем определить наш null-счетчик:


## Возвращает счетчик, увеличенный раз за каждый NullObject в списке#class CountNullsInList < NormalObject  def initialize(list)    @list = list  end  def call    list_walk = ListWalk.new(@list)    counter = Counter.new    While.new(ListWalkNotFinished.new(list_walk),              LoopBody.new(list_walk, counter))         .run    counter  end  class ListWalkNotFinished < NormalObject    def initialize(list_walk)      @list_walk = list_walk    end    def call      BoolNot.new(@list_walk.finished?)    end  end  class LoopBody < NormalObject    class ReturnNull < NormalObject      def call        NullObject.new      end    end    def initialize(list_walk, counter)      @list_walk = list_walk      @counter = counter    end    def call      x = @list_walk.next      If.new(x.null?, @counter.inc_callable, ReturnNull.new)        .result        .call    end  endend

Вот и все. Мы можем скормить ему любой список и он подсчитает количество null-объектов в нем.


Заключение


Объектно-ориентированное программирование очень интересный концепт и, видимо, очень мощный. Мы, по сути, создали язык программирования (!), используя только лишь чистое ООП без каких-либо дополнительных операторов. Все, что нам нужно было: способ создать класс и переменные. Другая прикольная фишка у нас нет никаких примитивов в нашем языке (например, у нас нет null, вместо этого мы просто создаем экземплярNullObject). О, чудеса программирования...


Код можно найти в моем репозитории experiments.

Подробнее..

Чиним наследование?

03.10.2020 18:20:42 | Автор: admin
Сначала здесь было долгое вступление про то, как я додумался до гениальной идеи (шутка), которой и посвящена статья. Не буду тратить ваше время, вот виновник сегодняшнего торжества (осторожно, 5 строчек на JS):

function Extends(clazz) {    return class extends clazz {        // ...    }}

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

const Class = Extends(Base)const object = new Class(...args)

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

Почти не оффтоп
Я бы даже сделал ЯП с этим приёмом как основной фичей, но, боюсь, этот pet project умрёт, как и другие мои pet project'ы. Так что пусть хотя бы будет статья, чтобы идея пошла в массы.

Договоримся об именах. У меня есть два варианта названия таких функций:

  • Наследование от интерфейса по аналогии с тем, как обычно классы наследуются от классов, здесь классы наследуются от заранее неизвестного класса, который, тем не менее, должен отвечать какому-то интерфейсу.
  • Late-bound class аналогично late-bound this.

Второй вариант звучит круче, первый может запутать C++-программистов, так что дальше буду называть такие функции LBC. Если у вас есть варианты названий получше, жду их в комментариях.

Проблемы наследования классов


Все мы знаем, как все не любят наследование классов. Какие же у него проблемы? Давайте разберёмся и заодно поймём, как LBC их решает.

Наследование реализации нарушает инкапсуляцию


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

LBC, в свою очередь, сильно снижает coupling: от поведения какого базового класса зависеть наследнику, если базового класса в момент объявления класса-наследника просто нет? Однако, благодаря late-bound this и перегрузке методов, Yo-yo problem остаётся. Если вы используете наследование в своём дизайне, от неё никуда не деться, но, например, в Котлине ключевые слова open и override должны сильно облегчать ситуацию (не знаю, не слишком тесно знаком с Котлином).

Наследование лишних методов


Классический пример со списком и стеком: если наследовать стек от списка, в интерфейс стека попадут методы из интерфейса списка, которые могут нарушить инвариант стека. Не сказал бы, что это проблема наследования, потому что, например, в C++ для этого есть приватное наследование (а отдельные методы можно сделать публичными с помощью using), так что это скорее проблема отдельных языков.

Недостаток гибкости


  1. Если мы наследуемся от класса, мы наследуем всю его функциональность: мы не можем унаследовать только его часть. Однако, если вам нужно наследовать только часть класса, пора разбивать базовый класс на два: скорее всего, эта часть слабо связана с остальным поведением класса, так что cohesion только повысится. Опять же, это не проблема наследования как такового.
  2. Если в языке нет множественного наследования (и это хорошо), мы не можем наследовать реализацию нескольких классов. Кажется, в таком случае лучше вообще использовать композицию вместо наследования: если вам действительно нужна открытая рекурсия в условиях множественного наследования, мне вас искренне жаль.
  3. Использование конкретных классов ограничивает полиморфизм. Если нужно обобщить функцию над каким-то объектом, достаточно заменить тип в сигнатуре функции с класса на интерфейс. Почему нельзя сделать то же самое с наследованием, и обобщить наследуемые характеристики, что LBC и делает? Ведь в каком-то смысле класс это просто фабрика объектов, т.е. функция.
  4. Использование конкретных классов ограничивает переиспользование кода. Если мы хотим добавить какую-нибудь фичу через наследование классов, мы можем добавить её только к какому-то одному базовому классу. С LBC, очевидно, такой проблемы больше нет.

Проблема хрупкого базового класса


Если класс наследуется от реализации другого класса, изменение этой реализации может сломать класс-наследник. В этой статье есть очень хорошая иллюстрация этой проблемы со Stack и MonitorableStack.

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

Банан, горилла и джунгли


ООП обещает компонируемость, т.е. возможность переиспользовать отдельные объекты в разных ситуациях и даже в разных проектах. Однако если класс наследуется от другого класса, чтобы переиспользовать наследника, нужно скопировать все зависимости, базовый класс и все его зависимости, и его базовый класс. Т.е. хотели банан, а вытащили гориллу, а потом и джунгли. Если объект был создан с учётом Dependency Inversion Principle, с зависимостями всё не так плохо достаточно скопировать их интерфейсы. Однако с цепочкой наследования так сделать не получится.

LBC, в свою очередь, делает возможным (и обязывает) использование DIP в отношении наследования.

Прочие приятности LBC


На этом плюсы LBC не заканчиваются. Давайте посмотрим, что ещё можно сделать с их помощью.

Смерть иерархии наследования


Классы больше не зависят друг от друга: они зависят только от интерфейсов. Т.е. реализация становится листьями графа зависимостей. Это должно облегчить рефакторинг теперь модель домена не связана с его реализацией.

Смерть абстрактных классов


Абстрактные классы теперь не нужны. Рассмотрим пример паттерна Фабричный Метод на Java, позаимствованный у refactoring guru:

interface Button {    void render();    void onClick();}abstract class Dialog {    void renderWindow() {        Button okButton = createButton();        okButton.render();    }    abstract Button createButton();}

Да, конечно, Фабричные методы эволюционируют в паттерны Строитель и Стратегия. Но с LBC можно сделать и так (представим на секунду, что в Java есть LBC):

interface Button {    void render();    void onClick();}interface ButtonFactory {    Button createButton();}class Dialog extends ButtonFactory {    void renderWindow() {        Button okButton = createButton();        okButton.render();    }}

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

abstract class Abstract {    void method() {        abstractMethod();    }    abstract void abstractMethod();}class Concrete extends Abstract {    private encapsulated = new Encapsulated();    @Override    void method() {        encapsulated.method();        super.method();    }    void abstractMethod() {        encapsulated.otherMethod();    }}

Здесь поле encapsulated нужно и в перегрузке method, и в реализации abstractMethod. То есть, без нарушения инкапсуляции класс Concrete нельзя разделить на потомка Abstract и на суперкласс Abstract. Но я не уверен, что это пример хорошего дизайна.

Гибкость, сравнимая с типажами


Внимательный читатель заметит, что всё это очень похоже на типажи из Smalltalk / Rust. Отличий два:

  1. Экземпляры LBC могут содержать данные, которых не было в базовом классе;
  2. LBC не модифицируют класс, от которого наследуются: чтобы использовать функциональность LBC, нужно явно создать объект LBC, а не базового класса.

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

Эти отличия приближают LBC к обычному наследованию, так что эта штука мне представляется забавным компромиссом между наследованием и типажами.

Минусы LBC


Ох, если бы всё было так просто. У LBC точно есть одна небольшая проблема и один жирный минус.

Взрыв интерфейсов


Если наследоваться можно только от интерфейса, очевидно, интерфейсов в проекте станет больше. Конечно, если в проекте соблюдается DIP, ещё несколько интерфейсов погоды не сделают, но далеко не все следуют SOLID. Эту проблему можно решить, если на основе каждого класса будет генерироваться интерфейс, содержащий все публичные методы, и при упоминании имени класса различать, имеется в виду класс как фабрика объектов или как интерфейс. Что-то похожее сделано в TypeScript, но там почему-то в сгенерированном интерфейсе упомянуты и приватные поля и методы.

Сложные конструкторы


Если использовать LBC, самой сложной задачей станет создать объект. Рассмотрим два варианта в зависимости от того, включен ли конструктор в интерфейс базового класса:

  1. Если конструктор не включён в интерфейс, мы не можем его перегружать, только расширять. Например, при использовании в базовом классе паттерна Стратегия мы не сможем в классе-наследнике подменить стратегию своим Декоратором. Тем более не понятно, в каком порядке нужно будет передавать аргументы в конструктор.
  2. Если конструктор включён в интерфейс, мы рискуем сильно ограничить множество подходящих базовых классов. Например:

    interface Base {    new(values: Array<int>)}class Subclass extends Base {    // ...}class DoesntFit {    new(values: Array<int>, mode: Mode) {        // ...    }}
    

    Класс DoesntFit не подходит в качестве базового для Subclass, но два аргумента его конструктора не связаны каким-то инвариантом. Так что Subclass можно было бы использовать в качестве наследника DoesntFit, не будь интерфейс Base таким ограниченным.
  3. На самом деле, есть ещё один вариант передавать в конструктор не список аргументов, а словарь. Это решает проблему выше, потому что { values: Array<int>, mode: Mode } очевидно подходит под шаблон { values: Array<int> }, но это приводит к непредсказуемой коллизии имён в таком словаре: например, и суперкласс A, и наследник B используют одинаково называющиеся параметры, но это имя не указано в интерфейсе базового класса для B.

Вместо заключения


Я уверен, что пропустил какие-то аспекты этой идеи. Либо то, что это уже дикий баян и лет двадцать назад был язык, использующий эту идею. В любом случае, жду вас в комментариях!

Список источников


neethack.com/2017/04/Why-inheritance-is-bad
www.infoworld.com/article/2073649/why-extends-is-evil.html
www.yegor256.com/2016/09/13/inheritance-is-procedural.html
refactoring.guru/ru/design-patterns/factory-method/java/example
scg.unibe.ch/archive/papers/Scha03aTraits.pdf
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru