ООП существует уже давно, и большинство программного обеспечения использует его. Но всегда ли ООП правильный путь? Это далеко не так.
Что такое ООП?
ООП — это парадигма, в которой код разделен на классы, что приводит к возможности точной настройки доступа и разделении компонентов. Основные преимущества, возникающие при использовании ООП, следующие:
- Скрытие деталей реализации. Используя уровни абстракции, мы можем сохранить внутреннюю работу нашего программного обеспечения в тайне. Абстракция помогает с безопасностью и удобством использования, поскольку другие разработчики не знают (и не должны знать) внутреннюю реализацию нашего программного обеспечения.
- Разделенные компонентов. Классы и интерфейсы обеспечивают удобный способ хранения определенных частей нашего кода в одном блоке. Другие части кода могут получить доступ только к тому, что мы им разрешаем.
- Иерархия классов. Использование наследования позволяет нам расширять поведение наших классов без повторения кода во многих местах. Это помогает с DRY. Наличие иерархии классов также вводит полиморфизм, который позволяет рассматривать подклассы как базовые классы и наоборот.
Как видите, ООП дает много преимуществ, но все же это не всегда правильный путь. Есть много опасностей, которые приходят с ним.
Почему ООП плох?
Прежде всего, небольшая оговорка: ООП плох не сам по себе, а в том, как и когда мы его используем. ООП имеет право на существование. Проблема в том, что им сильно злоупотребляют. Фактически, многие разработчики вообще не рассматривают другую парадигму при написании программного обеспечения. ООП высечен в камне.
Сказав это, давайте рассмотрим основные недостатки.
ООП непредсказуем
Иерархии классов часто приводят к многократному переопределению методов. Хотя это не проблема сама по себе, она может быстро стать таковой. Взгляните на эту диаграмму классов:
Можете ли вы определить возможную проблему? Вероятно, еще нет. Взгляните на следующий рисунок:
Что-нибудь видите? Это может быть неочевидно, но, не написав ни строчки кода, мы уже ввели непредсказуемый код.
Почему? Каждый класс включает метод update. Это основная концепция ООП. Это позволяет нам переопределять методы из нашего базового класса. Это полезный способ добавить поведение или изменить его. Но это также сопряжено с некоторой опасностью. Проблема называется VTable. VTable (таблица виртуальных методов) — это список всех виртуальных методов, содержащихся в классе. Он отвечает за поиск правильного метода во время выполнения.
VTables для нашего приведенного выше примера будет выглядеть примерно так:
VTables также являются причиной, по которой мы можем привести тип к его базовому типу или наоборот и вызвать метод базы/подтипа. Когда мы ссылаемся на тип как на другой базовый/подтип, мы также неявно ссылаемся на его таблицу методов, которая имеет свою собственную запись для каждого метода.
Рассмотрим следующий код, написанный на Swift:
Легко понять, что у нас есть список А, но благодаря принципу полиморфизма мы можем хранить в нем и B. VTable используется для динамической отправки правильного метода в рантайме.
Но это первая возможная опасность. Мы не знаем фактический тип каждого элемента в списке, пока не проверим его явно. Обычно мы делаем это специально, чтобы хранить в списке элементы общего типа. Это хорошо для большинства случаев, но открывает двери для реальной проблемы. Мы скоро к этому придем.
Но сначала давайте посмотрим на другой пример:
Это, конечно, довольно глупый пример, но он показывает большую угрозу:
Зависит от реализации подкласса, следует ли также вызывать метод его базового класса.
Это может привести к большим проблемам. Мы не можем делать здесь предположений; на самом деле нам нужно проверить каждый отдельный подкласс и посмотреть, вызывает ли он базовый метод. Конечно, в реальном реальном проекте есть документация и стандарты компании, которые разрешают или запрещают это. Хотя эти меры предосторожности помогают, проблема остается. В огромных иерархиях классов становится непредсказуемым, что, где и когда вызывается.
ООП медленный
Да, сегодня у нас безумно быстрые устройства, и производительность редко является проблемой. Но производительность приводит не только к ускорению работы приложений. Хорошая производительность также приводит к снижению энергопотребления при сохранении той же скорости выполнения. Особенно на мобильных устройствах обязательна хорошая производительность, иначе разрядка батареи может стать проблемой.
Но почему ООП медленнее? Мы уже говорили о динамической диспетчеризации. Это одна из трех основных причин, почему ООП работает медленно. Чтобы выполнить правильный метод, сначала необходимо проверить VTable. Только после этого метод выполняется. Это означает, что у нас есть как минимум один дополнительный вызов для каждого объекта.
Есть еще один недостаток: ссылки. Классы являются ссылочными типами, а ссылки требуют развертывания/разыменования. Это означает, что нам нужен еще один вызов. Если мы хотим вызвать, скажем, E.draw(), в нашем примере мы получим шесть вызовов! Почему?
- Во-первых, мы разыменовываем ссылку.
- Затем мы консультируемся с VTable для динамической отправки E.draw().
- E.draw() вызывает super.draw().
- Это означает, что мы также вызываем C.draw().
- C.draw() также вызывает super.draw().
- Это означает, что мы также вызываем A.draw().
Это довольно большие накладные расходы. Даже если мы не вызываем super.draw() для какого-либо экземпляра, нам все равно нужно каждый раз выполнять динамическую диспетчеризацию, что приводит к потере времени выполнения.
Но мы еще не закончили. Помните про память стека и кучи? ООП здесь тоже не очень хорошо работает. Наши объекты в основном хранятся в куче. Куча с произвольным доступом и по своей природе медленнее, чем стековая память. Я не буду вдаваться в подробности по этому поводу, но в целом большинство языков в большинстве случаев хранят ссылочные типы в куче.
Резюме:
- Нам нужно каждый раз разыменовывать наши ссылочные типы.
- Методы каждый раз динамически отправляются через VTable.
- Доступ к ссылочным типам обычно медленнее.
ООП поощряет спагетти-код
Хотя ООП помогает нам держать блоки вместе, разделять логику и т.д., оно также создает свои собственные проблемы. Часто мы получаем огромную цепочку наследований и ссылок. Когда нужно изменить что-то одно, ломаются десятки блоков кода. Это проблема самого дизайна. ООП предназначено для определения того, кто обращается к нашим данным. Это означает, что по большей части мы заботимся о разделении, сохранении DRY, абстракциях и т.д. Из-за этого мы получаем несколько уровней и ссылок просто чтобы не нарушать принципы ООП, такие как контроль доступа.
ООП отговаривает нас от раскрытия свойств наших классов внешнему миру до тех пор, пока в этом нет крайней необходимости. Таким образом, нам нужно написать общедоступные методы/обертки, которые отвечают за операции над данными. Если эти операции нужно изменить, нам нужно либо изменить несколько подклассов, либо изменить базовый класс.
Это хорошо, потому что мы можем изменить внутренности, не сообщая никому об этом. Но это также может привести к поломке, потому что внешний мир ожидает очень специфический набор данных, поступающих от таких методов.
Скажем, у нас есть простой сервис, которому нужен доступ к данным нашего класса. Если мы изменим внутренности нашего класса, возвращаемые данные могут больше не соответствовать ожиданиям службы. Поэтому мы меняем сервис. Но теперь и наша ViewModel больше не работает, потому что сервис другой. Да, в некоторых случаях мы можем изменить данные в соответствии с нашими потребностями внутри самого нижнего уровня, чтобы каждый верхний уровень оставался неизменным, но, тем не менее, всякий раз, когда класс резко меняется, нам нужно изменить как минимум один дополнительный уровень.
Куда податься?
ООП — отличный паттерн. Он помогает нам разделять вещи, писать удобный для сопровождения код и устанавливать общую структуру для всех частей нашего программного обеспечения. Но это не идеальное решение для каждой ситуации.
Разработчики должны переосмыслить свой выбор, прежде чем каждый раз слепо использовать ООП. Не каждая часть нашего программного обеспечения должна быть отделена и идеально абстрагирована. Иногда более важно, как наши данные размещаются и обрабатываются. Особенно, когда производительность имеет решающее значение, ООП — плохой выбор, и такие вещи, как Data-Oriented-Design (DOD), подходят лучше.