ООП — это скам
- суббота, 10 мая 2025 г. в 00:00:14
На хабре и в остальном интернете хватает статей с критикой ООП. Кто-то ругает эту концепцию за излишнюю многословность, кто-то рассуждает о плохих аспектах ООП, кто-то сравнивает реализации ООП в разных языках.
После прочтения большинства этих статей и нескольких лет кодинга на C# я заявляю: «ООП - это один большой обман. Никто не понимает, что это такое. Люди просто говорят какие-то умные термины, их собеседники с умным видом кивают, хотя на деле трактуют эти же термины совершенно по-разному».
И вот почему.
Ладно, давать определение таких вещам сложно, не буду спорить. Удобнее и проще говорить про характерные признаки ООП. И они есть, что не может не радовать. Их озвучил программист и учёный Алан Кэй, который официально и считается автором термина "Объектно-ориентированное программирование". В последствии его идеи были реализованы в языке Simula. Алану реализация не совсем пришлась по вкусу, поэтому он пошёл делать свой язык - так и появился Smalltalk. Идеальная реализация ООП по мнению автора этого самого ООП.
Но потом что-то пошло не так. Сейчас самыми трушными ООП-языками в сообществе считаются C++, Java и C#. Реализация ООП у них сильно похожа, поэтому критика одного из этих языков автоматически распространяется на оставшиеся два. И вот что говорит Алан Кей про C++:
Я изобрёл термин “объектно-ориентированный”. И я точно не имел в виду C++
Неприятно. Кто-то скажет, что нет ничего плохого в том, что у сообщества расходится мнение с Аланом относительно ООП. И я даже соглашусь с этим, чтобы не раздувать объём статьи ещё больше. Но дело даже не в этом. У сообщества точно есть понимание, что такое это ваше ООП? Они прям сходятся во мнении?
Когда этот вопрос спрашивали у меня на собеседованиях, я говорил про инкапсуляцию, наследование и полиморфизм. Похожий список публикует Microsoft в своём гайде по C#. Правда в этом списке почему-то ещё присутствует термин "Абстракция", но да ладно. Примем на веру знание о том, что абстракция - это ключевая особенность именно ООП, а не всего программирования в целом вне зависимости от языка и области (за очень редким исключением).
Правда и с остальными пунктами есть проблемы. Инкапсуляцию каждый описывает по-своему. Microsoft даёт такое определение:
Инкапсуляция - это сокрытие внутреннего состояния и функций объекта и предоставление доступа только через открытый набор функций
Звучит довольно размыто. Можно ли сказать, что в C реализована инкапсуляция через ключевые слова static
и extern
? Ну вроде да.
В википедии пишут чуть другое:
Инкапсуляция - это размещение в одном компоненте данных и методов, которые с ними работают
Ок, такого в C точно нет. К структурам невозможно добавить метод. То есть инкапсуляции нет? Получается, что нет. Но всегда можно обратиться к другому источнику и узнать следующее:
Инкапсуляция — методика минимизации взаимозависимостей между отдельно написанными модулями при помощи задания строгих внешних интерфейсов
А реализация "интерфейсов" в С через typedef
подходит под это определение? Ну вроде бы да...
И это мы ещё SOLID не обсудили, там тоже много чего интересного есть. Может, спустя ещё десяток статей про правильное понимание принципа единой ответственности и сотню споров на кодревью, надо ли этот один интерфейс делить на несколько маленьких по принципу разделения интерфейсов, мы поймём, что же такое это ваше ООП, но пока этого не произошло.
В настоящее время определения ключевых аспектов ООП даны крайне размыто. Трактовать их можно как угодно в зависимости от желания автора кода. Что входит в этот список ключевых аспектов, тоже не особо понятно.
Представьте, что вы читаете учебник по функциональному программированию. Там вам рассказывают про чистые функции, монады, функторы и прочие похожие штуки. Приводят примеры кода и показывают, как и где этими инструментами можно пользоваться. А потом вы открываете код реального проекта в продакшене на условном Haskell'е, а там ничего этого нет. Представили? А фанатам ООП и представлять не надо.
Если открыть любой учебник по ООП, то с большой долей вероятностью он начнётся с примера про животных или примера про геометрические фигуры. Остановимся на первом варианте:
class Animal
{
void Say()
{
Console.WriteLine("Animal");
}
}
class Dog : Animal
{
override void Say()
{
Console.WriteLine("Гав");
}
}
class Cat : Animal
{
override void Say()
{
Console.WriteLine("Мяу");
}
}
static void Foo(Animal a)
{
a.Say();
}
Суть довольно простая. Хоть метод Foo
принимает в качестве аргумента класс Animal
, вы всё ещё можете передать туда экземпляр класса Dog
или экземпляр класса Cat
. Таким образом можно делать сколько угодно наследников класса Animal
, какую-то логику оставлять как у базового класса, какую-то логику переопределять.
Круто, отличный пример полиморфизма. Только это первый и последний раз, когда мы пользуемся ООП как инструментом для описания поведения объектов реального мира. В настоящем продакшн коде никаких кошек и собак, которые умеют говорить, не будет. Условного класса User
с методом Save
для сохранения данных в БД тоже не будет. Будут сотни DTO без каких-либо методов, которые гоняются между контроллером, сервисом и репозиторием. А каждый метод этого контроллера, сервиса и репозитория представляет из себя обычную функцию, которую мы зачем-то завернули в класс.
Да, из-за инкапсуляции мы не прокидываем в каждый метод репозитория соединение к базе, а задаём его один раз в конструкторе. Но в остальном-то мы пишем обычные функции. Или это маленькая деталь настолько меняет правила игры? Сомневаюсь.
Адепты ООП скажут известную фразу: "На границах ООП не является ООП". Но можно ли как-то детальнее показать, где эти самые границы ООП начинаются и заканчиваются?
Ещё в книгах пишут, что в ООП всё есть объект. Но по какой-то неизвестной причине большая часть стандартной библиотеки C# написана на статических классах и статических методах. Для незнающих уточню, что это просто набор чистых функций. Нам остаётся только догадываться, почему авторы так поступили. Связано ли это с тем, что в случае следования правилу про "всё есть объект" код превратился бы в неподдерживаемую лапшу? Этого мы не узнаем.
Но погодите. Стандартная библиотека написана на обычных чистых функциях. Большая часть продуктового кода - это набор методов, которые просто принимают и возвращают DTO, не меняя при этом состояние своего класса. Где же те границы ООП? Ну в самом верху они есть. Мы же наследуем наш обработчик запросов (контроллер) от ControllerBase
. Вот наследованием пользуемся, получается. Полиморфизма пока что нигде нет. Остаётся только надеяться, что оно обязательно повится позже.
Разве всё это не означает, что только на границах ООП является ООП?
В конце девяностых 4 автора написали книгу "Design Patterns: Elements of Reusable Object-Oriented Software". В ней они описали типовые проблемы, с которыми встречаются программисты, и способы их решения. Примеры реализованы на C++ и Smalltalk.
Всего они выделили 23 паттерна. Ключевая особенность их в том, что почти все из них построены на наследовании. Приведу пример. Давайте реализуем на go функцию map
из стандартной тройки map/filter/reduce
. Код будет примерно таким:
func Map[T any, R any](arr []T, iteratee func(item T) R) []R {
result := make([]R, len(arr))
for i := range arr {
result[i] = iteratee(arr[i])
}
return result
}
Это простой и понятный код. Мы просто передаём в функцию другую функцию, в которой описываем, что же мы будем делать с исходным массивом. В ООП это предлагали решать путём наследования. Примерно так:
// Описание контракта стратегии
abstract class MapStrategy<TIn, TOut>
{
public abstract TOut Process(TIn item);
}
// Реализация стратегии
class DoubleStrategy : MapStrategy<int, int>
{
public override int Process(int item) => item * 2;
}
// Класс-контекст, использующий стратегию
class MapFunction<TIn, TOut>
{
private readonly MapStrategy<TIn, TOut> _strategy;
public MapFunction(MapStrategy<TIn, TOut> strategy)
{
_strategy = strategy;
}
public TOut[] Map(TIn[] input)
{
TOut[] result = new TOut[input.Length];
for (int i = 0; i < input.Length; i++)
{
result[i] = _strategy.Process(input[i]);
}
return result;
}
}
Это не шутка. Это эталонное решение в ООП-стиле. Один абстрактный класс и два обычных класса. Внезапно вся эта портянка начала исчезать с мониторов по мере развития языков. Ну то есть когда появилась возможность просто описать функцию как аргумент через ключевое слово Func
, то жизнь стала проще.
Некоторые даже скажут, что чем выразительнее система типов в языке, тем меньше надо пользоваться классическими инструментами от мира ООП. Но мы не будем поддаваться на провокации и просто скажем, что паттерны - это всё ещё важно, и что каждый разработчик должен знать отличия абстрактной фабрики от фабричного метода.
Кстати, если я захочу написать реализацию функции filter
из той же тройки, то мне надо создавать новый класс FilterFunction
для соблюдения SRP? Или я могу просто добавить метод в уже имеющийся MapFunction
? Кто знает.
Ещё пример. В C# есть несколько интерфейсов, которые описывают коллекции данных: ICollection
, IEnumarable
, IList
, IReadOnlyCollection
. Наверняка есть ещё какие-то, но в первую очередь вспомнились именно эти. Каждый из них решает конкретную задачу.
IEnumerable
описывает коллекцию, по которой можно итерироваться. IReadOnlyCollection
наследуется от IEnumerable
и описывает коллекцию, по которой можно итерироваться и для которой можно посчитать количество элементов. И так далее. Идеальная реализация принципа разделения интерфейсов (буква I из SOLID). Такие маленькие интерфейсы позволяют максимально точно описывать контракты методов.
Звучит круто. Но точно ли у нас существует проблема, для решения которой надо миллион маленьких интерфейсов, описывающих коллекции? Я бы даже посмотрел на проблему ещё шире. Нам точно нужны интерфейсы, описывающие коллекции?
Ну вот есть у нас какой-то класс, который принимает в конструкторе массив. Допустим, однажды нам пришлось поменять этот класс, чтобы он принимал на вход вместо массива односвязный список. Мы действительно столкнёмся с какой-то серьёзной проблемой, которая заставит нас страдать и переписывать тонны кода? И мы так часто будем это делать, что нам необходимы все эти интерфейсы? Я не уверен.
Может, у меня не так много опыта, но за всю жизнь я встретил буквально 3 проекта, которые были написаны по всем заветам ООП. С абстрактными фабриками, соблюдением SOLID и тремя слоями наследования. Читать такой код было невозможно. Менять тоже. Не подумайте, что я намеренно преувеличиваю, но когда мой тимлид передавал мне один из таких проектов, он с сочувствием посмотрел на меня и похлопал по плечу.
Кто-то скажет, что это было неправильное ООП (а какое тогда правильное?). Что не надо слепо следовать всем заветам ООП (а каким тогда надо?) и пр. Что ж, надеюсь в этот раз в комментариях мы раз и навсегда решим, что же такое настоящее и правильное ООП!
Но всё же обращаюсь к комментаторам. Неужели у вас были ситуации, когда у вас не получалось написать красивый код, а потом коллега просто посоветовал вам... Ну не знаю... Разделить один большой класс на 2 маленьких, чтобы соответствовать SRP, а потом сверху полирнуть всё паттерном наблюдатель и пачкой абстрактных классов. И вот вы всё это сделали, и код стал сильно лучше? Я искренне хочу услышать эти истории успеха в комментариях.
Вроде как ООП создавался, чтобы писать читабельный и легко изменяемый код. Но есть ли у нас данные, что ООП действительно помогает в этом? Мне в голову просто приходит пример ядра линукса, который написан на чистом C, и мемный проект FizzBuzzEnterpriseEdition, в котором по максимуму используют подходы ООП, что превратило проект в помойку. Но там тоже ООП неправильный, наверное.
Ладно, вот мы приняли все вышеперечисленные особенности ООП. Ну то есть всё есть объект и пр. Взяли джаву, подключили в проект Spring и начали писать простой обработчик http-запроса. Какой код получится в итоге? По неизвестной причине никаких наследований и полиморфизмов не будет. Связано ли это с тем, что они просто не нужны для большинства задач? Вопрос риторический.
Тут внезапно оказывается, что для запуска всего этого безобразия нужна тонна рефлексии и немного магии DI-контейнера, который просканирует все сборки и сопоставит интерфейсы с реализациями.
И вот после всех условностей мы получаем код, половина которого явно нигде не вызывается (методы контроллера). У каждого класса есть конструктор, который тоже вызываем не мы, а DI-контейнер (в каком порядке и как часто?). И это точно тот инструмент, который решает проблему поддержки кода?
Кто-то наверняка заметит, что в ООП вообще-то нет ни слова про рефлексию и DI-контейнеры. Просто это я нашёл неправильную реализацию ООП. И вообще никто же не заставляет меня пользоваться всеми этими инструментами. Что ж, наверное.
Наверняка есть задачи, которые быстро и элегантно решаются с помощью ООП. Но зачем мы создали целые языки в этой парадигме, я не понимаю. Для большой части задач не надо никакого наследования и никакого полиморфизма. И жизнь не станет удобнее, если мы попытаемся всё превратить в объект.
Инкапсуляция, наследование и полиморфизм в том или ином виде есть в большинстве современных языков. Но никто не пытается абсолютно все проблемы решать только этими инструментами. Потому что помимо всего вышеперечисленного есть функции, типы, лямбды и многое другое.
Не могу сказать, что я зря потратил годы на C#, но я рад, что в моей жизни уже давно нет всех этий срачей про SOLID и выбор нужного интерфейса для описания списка. Надеюсь, никто в здравом уме не будет вслепую тащить текущую реализацию ООП в новые языки, ибо она должна умереть за ненадобностью.