
На хабре и в остальном интернете хватает статей с критикой ООП. Кто-то ругает эту концепцию за излишнюю многословность, кто-то рассуждает о плохих аспектах ООП, кто-то сравнивает реализации ООП в разных языках.
После прочтения большинства этих статей и нескольких лет кодинга на C# я заявляю: «ООП - это один большой обман. Никто не понимает, что это такое. Люди просто говорят какие-то умные термины, их собеседники с умным видом кивают, хотя на деле трактуют эти же термины совершенно по-разному».
И вот почему.
Нет никакого определения ООП
Ладно, давать определение таких вещам сложно, не буду спорить. Удобнее и проще говорить про характерные признаки ООП. И они есть, что не может не радовать. Их озвучил программист и учёный Алан Кэй, который официально и считается автором термина "Объективно-ориентированное программирование". В последствии его идеи были реализованы в языке Simula. Алану реализация не совсем пришлась по вкусу, поэтому он пошёл делать свой язык - так и появился Smalltalk. Идеальная реализация ООП по мнению автора этого самого ООП.
Но потом что-то пошло не так. Сейчас самыми трушными ООП-языками в сообществе считаются C++, Java и C#. Реализация ООП у них сильно похожа, поэтому критика одного из этих языков автоматически распространяется на оставшиеся два. И вот что говорит Алан Кей про C++:
Я изобрёл термин “объектно-ориентированный”. И я точно не имел в виду C++
Неприятно. Кто-то скажет, что нет ничего плохого в том, что у сообщества расходится мнение с Аланом относительно ООП. И я даже соглашусь с этим, чтобы не раздувать объём статьи ещё больше. Но дело даже не в этом. У сообщества точно есть понимание, что такое это ваше ООП? Они прям сходятся во мнении?
Когда этот вопрос спрашивали у меня на собеседованиях, я говорил про инкапсуляцию, наследование и полиморфизм. Похожий список публикует Microsoft в своём гайде по C#. Правда в этом списке почему-то ещё присутствует термин "Абстракция", но да ладно. Примем на веру знание о том, что абстракция - это ключевая особенность именно ООП, а не всего программирования в целом вне зависимости от языка и области (за очень редким исключением).
Правда и с остальными пунктами есть проблемы. Инкапсуляцию каждый описывает по-своему. Microsoft даёт такое определение:
Инкапсуляция - это сокрытие внутреннего состояния и функций объекта и предоставление доступа только через открытый набор функций
Звучит довольно размыто. Можно ли сказать, что в C реализована инкапсуляция через ключевые слова static
и external
? Ну вроде да.
В википедии пишут чуть другое:
Инкапсуляция - это размещение в одном компоненте данных и методов, которые с ними работают
Ок, такого в 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 и выбор нужного интерфейса для описания списка. Надеюсь, никто в здравом уме не будет вслепую тащить текущую реализацию ООП в новые языки, ибо она должна умереть за ненадобностью.