ООП используется уже давно, оно применяется в большинстве программ. Но всегда ли ООП является правильным путём? Далеко нет.
Что такое ООП?
ООП — это парадигма, при которой код разделён на множество классов, что приводит к настраиваемому доступу и разъединению компонентов. Основные преимущества использования ООП заключаются в следующем:
1. Сокрытие подробностей реализации
Благодаря использованию слоёв абстракций мы можем обеспечить приватность работы внутреннего устройства ПО. Абстрагирование помогает с безопасностью и удобством использования, так как другие разработчики не знают (и не должны знать) внутреннюю реализацию вашего ПО.
2. Разъединённые компоненты
Классы и интерфейсы — это удобный способ превращения конкретных частей кода в единый модуль. Другие части кода могут получать доступ только к тому, что им разрешено.
3. Иерархия классов
Использование наследования позволяет нам расширять поведение классов без многократного повторения кода. Это помогает с реализацией принципа DRY (Don»t repeat yourself, «не повторяйся»). Наличие иерархии классов также добавляет полиморфизм, что позволяет работать с подклассами как с базовыми классами, и наоборот.
Как видите, ООП обеспечивает множество преимуществ, но использовать его не всегда правильно. С ним связано много недостатков.
Чем плохо ООП?
Во‑первых, нужно сделать небольшое пояснение: ООП не плохо само по себе, проблема в том, как мы его используем. ООП имеет право на существование. Проблема в том, что его использование чрезмерно. На самом деле, многие программисты даже не задумываются о другой парадигме при написании ПО. ООП стало незыблемым стандартом.
С учётом всего этого давайте перечислим его основные недостатки.
ООП непредсказуемо
Иерархии классов часто приводят к многократному переопределению методов. Само по себе это не проблема, но быстро может ею стать. Посмотрите на следующую диаграмму классов:
Можете ли вы найти потенциальную проблему? Возможно, пока нет. Посмотрите на ещё один пример:
Уже есть какие‑то соображения? Может быть, это неочевидно, но не написав ни одной строки кода, мы уже создали непредсказуемый код.
Почему? Каждый класс содержит метод update. Это базовая концепция ООП. Она позволяет переопределять методы из базового класса. Это полезный способ добавления или изменения поведения. Но в то же время он создаёт угрозу. Проблема называется VTable. VTable — это список всех виртуальных методов, которые содержит класс. Он отвечает за поиск нужного метода в среде исполнения.
VTable для показанного выше примера будут выглядеть примерно так:
VTable также являются причиной того, что мы можем преобразовать тип в его базовый тип, или наоборот, и вызывать базовый метод/метод подтипа. Когда мы ссылаемся на тип как на другой базовый/подтип, мы также ссылаемся на VTable, которая имеет собственную запись для каждого метода.
Рассмотрим следующий код, написанный на Swift:
Легко понять, что у нас есть список из A, но благодаря принципу полиморфизма мы можем хранить в нём и 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).
Дополнительные ресурсы: ссылка