LT;DR: это очень личный взгляд на ООП, который я обдумывал довольно долго, но окончательно сформировал после прочтения книги Егора “Elegant Objects”. Если коротко, я высказываюсь против идеи представления кода в объектной модели. Некоторые аргументы почерпнуты из сторонних публикаций и адаптированы для краткости. В таких случаях я даю ссылку на оригинал.

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

Объекты, как утверждают Википедия и учебники, помогают отобразить картину физического мира в коде. Думаю, всем это объяснял преподаватель в школе или университете на примере класса кота или собаки. Потом примеры с наследованием, переопределением класса “голос” и так далее.

За последние годы мое увлечение ООП плавно сошло на нет. В одном из постов я прямо признавался, что не понимаю его принципов. Главное, что меня смущает: во время работы, если это был не строго объектный язык, а Питон, JS или PHP, то все задачи я решал простыми функциями. Каждый раз мне говорили, что просто проект легкий, что однажды наступит БОЛЬШОЙ ПРОЕКТ, где с функциями ты хлебнешь. Но время шло, БОЛЬШОЙ ПРОЕКТ так и не наступил, и, кажется, в эпоху микро-сервисов его уже не дождешься. А я все пишу на функциях и неизменяемых коллекциях. В чем же дело?

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

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

Представим, что Вася отправляет письмо Пете. На языке объектов это записывается так (опустим определения классов):

User vasya = new User("Vasya");
User petya = new User("Petya");
Message msg = new Message("Hello!");
vasya.send(msg, petya);

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

msg.send(from=vasya, to=petya)

Это уже не синтаксис Джавы, в ней нет именованных аргументов, а скорее Питона. Написал так, чтоб было понятно: метод .send() принимает отправителя и получателя и сам отправляет письмо.

Уже на этом этапе видна вся неоднозначность и зыбкость объектного мышления. Согласно паттернам, отправку письма логичней вынести в класс сообщения. С другой стороны, в реальном мире именно пользователь инициирует отправку. Письмо это неодушевленная сущность, набор байт или листок бумаги. Как он может что-то отправлять?

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

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

Проблему отношений между объектами я называю “кто на ком стоял”. В самом деле, ручка пишет по бумаге pen.writeOn(paper) или бумага с помощью ручки paper.write(pen)? Каждый, кто поспешит ответом в духе “ну конечно первый (второй) вариант”, не учитывает, что это совершенно субъективно.

Предполагаю, что именно поэтому в ООП-среде так популярен рефакторинг. Ключевая фича каждой промышленной IDE – облегчение рефакторинга: переименование и перенос методов, автоматическая адаптация кода.

В окружающем нас реальном мире физические объекты играют далеко не решающую роль. Иными словами, не все можно выразить через объекты. Рассмотрим, например, акт рукопожатия. Следуя принципам Егора, отразить в коде это можно так:

class User {
  private String name

  User(String name) {
    this.name = name;
  }
}

class Handshake {
  User user1;
  User user2;

  Handshare(User user1, User user2) {
    this.user1 = user1;
    this.user2 = user2;
  }

  void shake() {
    // ... лог в консоль или что угодно
  }
}

User user1 = new User("Ivan");
User user2 = new User("Petr");
Handshake hs = new Handshake(user1, user2);
hs.shake();

Но ведь физически рукопожатия не существует. Нет в нашем мире такого объекта, его нельзя купить, смастерить, поставить на полку. Это событие, акт или, выражаясь точнее, действие! А что в программировании выражает действие? Функция.

Предположим теперь, что друзья пожали руки несколько раз с интервалом в 10 минут. Будет ли правильным вызывать у того же объекта метод .shake()? Или на каждый раз создавать новый объект?

Если первое, не противоречит ли это принципам ООП? Ведь все это разные рукопожатия, совершенно независящие друг от друга. Что если они жали руки с перерывом в 10 лет?

Если второе, то чем это отличается от вызова функции? Мы создаем объект, вызываем единственный метод и тут же забываем его? Зачем тогда класс и объект? Если один-два метода это все, что нам нужно от класса, не проще ли завести функцию?

На эту тему уже неписана отличная статья “перестаньте писать классы”, изучите обязательно. Видео со слайдами, перевод на Хабре.

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

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

В книге “Elegant Objects” Егор справедливо замечает, что сегодняшнее ООП это просто структура данных с прикрепленными к ней функциями. Однако, не делается акцент на том, сколь масштабно это явление. С выходом языков Go и Rust на них перешли тысячи бывших Java и С++ разработчиков, и, похоже, сочли новые языки вполне себе объектными. А ведь и в Go и в Rust объекты – это банальные сишные структуры. И если функция принимает первым аргументом такую структуру, то вместо some_action(data, 1, "test") можно написать data.some_action(1, "test"). Вот и вся разница.

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

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

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

В следующий раз поговорим о технической стороне ООП.