Издательский дом «Питер» corporate blog
Programming
Perfect code
ООP
Functional Programming
27 September 2019

Типичные заблуждения об ООП

Original author: Janos Pasztor
Translation
Привет, Хабр!

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



Мертва ли парадигма ООП? Можно ли сказать, что за функциональным программированием будущее? Кажется, что во многих статьях пишут именно об этом. Склонен с такой точкой зрения не согласиться. Давайте обсудим!

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

Ранее я уже писал о том, что ООП и ФП не противоречат друг другу. Более того, мне удалось весьма успешно сочетать их.

Почему же у авторов этих статей возникает такая масса проблем с ООП, и почему ФП кажется им настолько очевидной альтернативой?

Как обучают ООП


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

Однако, ООП, как и ФП – это инструмент. Для решения задач. Его можно употреблять, им же можно злоупотреблять. Например, создавая неверную абстракцию, вы злоупотребляете ООП.
Так, класс Square никогда не должен наследовать класс Rectangle. В математическом смысле они, конечно же, связаны. Однако, с точки зрения программирования они не находятся в отношениях наследования. Дело в том, что требования к квадрату жестче, чем к прямоугольнику. Тогда как в прямоугольнике – две пары равных сторон, у квадрата обязательно должны быть равны все стороны.

Наследование


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

class BlogController extends FrameworkAbstractController {
}

Предполагается, что таким образом вам будет проще выполнять вызовы вроде this.renderTemplate(...), поскольку такие методы наследуются от класса FrameworkAbstractController.

Как указывается во многих статьях на эту тему, здесь возникает ряд ощутимых проблем. Любая внутренняя функция в базовом классе фактически превращается в API. Она больше не может меняться. Любые защищенные переменные базового контроллера теперь будут в большей или меньшей степени относиться к API.

В этом ничего не стоит запутаться. А если бы мы избрали подход с композицией и внедрением зависимостей, то получилось бы так:

class BlogController {
    public BlogController (
        TemplateRenderer templateRenderer
    ) {
    }
}

Вот видите, вы больше не зависите от какого-то туманного FrameworkAbstractController, а зависите от очень хорошо определенной и узкой штуки, TemplateRenderer. На самом деле, BlogController не занимается никаким наследованием от какого-либо другого контроллера, так как не наследует никаких поведений.

Инкапсуляция


Вторая зачастую критикуемая черта ООП – инкапсуляция. На литературном языке смысл инкапсуляции формулируется так: данные и функционал поставляются вместе, а внутреннее состояние класса скрывается от внешнего мира.

Эта возможность, опять же, допускает употребление и злоупотребление. Основной пример злоупотребления в данном случае – дырявое состояние (leaky state).

Условно говоря, предположим, что в классе List<> содержится список элементов, и этот список можно изменить. Давайте создадим класс для обработки корзины заказов следующим образом:

class ShoppingCart {
    private List<ShoppingCartItem> items;
    
    public List<ShoppingCartItem> getItems() {
        return this.items;
    }
}

Здесь в большинстве ООП-ориентированных языков произойдет следующее: переменная items будет возвращаться по ссылке. Поэтому далее можно сделать так:

shoppingCart.getItems().clear();

Таким образом мы фактически очистим список элементов в корзине, а ShoppingCart об этом даже не узнает. Однако, если как следует присмотреться к этому примеру, то становится понятно, что проблема отнюдь не в принципе инкапсуляции. Здесь как раз нарушается этот принцип, поскольку из класса ShoppingCart утекает внутреннее состояние.

В данном конкретном примере автор класса ShoppingCart мог бы воспользоваться неизменяемостью, чтобы обойти проблему и убедиться, что принцип инкапсуляции не нарушается.

Неопытные программисты часто нарушают принцип инкапсуляции и другим образом: вводят состояние там, где в нем нет нужды. Такие неопытные программисты часто используют переменные приватного класса для передачи данных от одной функции к другой в пределах одного и того же класса, тогда как правильнее было бы использовать объекты передачи данных (Data Transfer Objects), чтобы передавать иной функции сложную структуру. В результате таких ошибок код излишне усложняется, что может приводить к возникновению багов.

В целом, хорошо бы вообще обходиться без состояния – хранить в классах изменяемые данные, когда только возможно. Делая так, нужно обеспечить надежную инкапсуляцию и убедиться, что нигде не возникает утечек.

Абстракция


Абстракцию, опять же, понимают во многом неправильно. Ни в коем случае не следует нашпиговывать код абстрактными классами и делать в нем глубокие иерархии.

Если вы так делаете без веской на то причины, то просто ищете неприятностей на свою голову. Не важно, как именно делается абстракция – как абстрактный класс или как интерфейс; в любом случае, в коде появится лишняя сложность. Эта сложность должна быть оправданной.
Проще говоря, интерфейс можно создавать лишь при условии, если вы готовы потратить время и документировать поведение, которое ожидается от реализующего его класса. Да, вы меня верно прочли. Мало просто составить список функций, которые потребуется реализовать – также опишите, как (в идеале) они должны работать.

Полиморфизм


Наконец, давайте поговорим о полиморфизме. Он предполагает, что один класс может реализовывать множество поведений. Плохой хрестоматийный пример — написать, что Square при полиморфизме может быть как Rectangle, так и Parallelogram. Как я уже указывал выше, подобное в ООП решительно невозможно, так как поведения этих сущностей отличаются.

Говоря о полиморфизме, следует держать в уме поведения, а не код. Хороший пример — класс Soldier в компьютерной игре. Он может реализовывать как поведение Movable (ситуация: он может двигаться), так и поведение Enemy (ситуация: стреляет в вас). Напротив, класс GunEmplacement может реализовывать только поведение Enemy.

Итак, если написать Square implements Rectangle, Parallelogram, это утверждение не становится истинным. Ваши абстракции должны работать в соответствии с бизнес-логикой. Следует подробнее задумываться о поведении, чем о коде.

Почему ФП – не серебряная пуля


Итак, когда мы повторили четыре основных принципа ООП, давайте задумаемся, в чем же особенность функционального программирования, и почему с его помощью не решить всех проблем в вашем коде?

С точки зрения многих адептов ФП, классы – это кощунство, и код должен быть представлен в виде функций. В зависимости от языка, данные могут передаваться от функции к функции при помощи примитивных типов, либо в виде того или иного структурированного множества данных (массивы, словари, т.д.).

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

Такой подход отделяет данные от функционала – на первый взгляд, этим ФП кардинально отличается от ООП. ФП упирает на то, что таким образом код сохраняет простоту. Вы хотите что-то сделать, пишете функцию для этой цели – вот и все.

Проблемы начинаются, когда одни функции должны опираться на другие. Когда функция A вызывает функцию B, а функция B вызывает еще пять-шесть функций, а в самом конце обнаруживается функция заполнения нулями, которая может сломаться – вот тут-то вам не позавидуешь.

Большинство программистов, считающих себя сторонниками ФП, любят ФП за его простоту и не считают такие проблемы серьезными. Это достаточно честно, если ваша задача – просто сдать код и больше никогда о нем не задумываться. Если же вы хотите наработать базу кода, удобную в поддержке, то лучше придерживаться принципов чистого кода, в частности, применять инверсию зависимостей, при которой ФП на практике также значительно усложняется.

ООП или ФП?


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

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

Все эти вопросы никак не связаны с используемой парадигмой программирования, на уровне такой парадигмы их даже не решить. Хороший программист изучает парадигму, чтобы освоить предлагаемые ею инструменты, а затем выбирает, какие из них лучше всего подходят для решения поставленной задачи.
Only registered users can participate in poll.Log in, please.
Книги о ФП и ООП
48.19% Требуется базовая книга о принципах ФП 93
33.16% Требуется базовая книга о принципах ООП 64
46.63% Статья не впечатлила 90
193 users voted. 66 users abstained.

+11
10.9k 128
Comments 89
Top of the day