Мы обсуждали тему вреда наследования в рамках недавней встречи клуба (уже доступно видео для желающих послушать про беды ООП, а также правильные подходы к дизайну и архитектуре). Я решил для закрепления темы и разжигания очередного холивара повторить основные тезисы в статье. 🙂
Итак, вроде бы наследование входит в состав трех китов, на которых держится весь ООП. Тем не менее, про прошествии лет люди осознали, что вреда от наследования обычно больше чем пользы и на самом деле наследование нужно в исключительных случаях. Что же не так с наследованием?
Дело в том, что наследование не учитывает будущие изменения родительского класса. Ведь когда вы наследуете новый класс от существующего, вы подписываете контракт о том, что новый класс всегда будет вести себя как существующий, возможно расширяя его поведение в некоторых местах. Для простоты понимания представьте себе, что вы разработчик и встретили своего друга, тоже разработчика. Поболтали, обсудили работу и поняли, что занимаетесь одним и тем же, только вы еще немного управляете командой. И тут вы можете сделать вывод – вы такой же как друг (вот оно наследование), но только делаете немного больше (расширение в дочернем классе). Прошло время и ваш друг немного забросил разработку и начал заниматься больше менеджментом (но по-прежнему время от времени пишет код, да и писать не разучился). Получается, у родительского класса появились новые функции, которые все дочерние классы по умолчанию наследуют. И вы нежданно-негаданно начинаете тоже заниматься менеджментом (тут может ничего плохого и нет), по крайней мере так следует из вашего “отношения наследования”. 🙂
В языках программирования такой эффект наблюдается за счет отсутствия необходимости переопределять публичные методы родительского класса. А это значит, что их с момента наследования могут добавить сколько угодно и вам никто, включая компилятор, об этом не скажет. А это, в свою очередь, может поломать вашу логику наследования с расширением возможностей родительского класса. На самом деле варианты есть – написать модульный тест, который посчитает количество публичных и полупубличных методов и проверит их количество. Это тоже не даст полной гарантии уведомления, но уже лучше. В идеале, стоило бы при наследовании во избежание сторонних эффектов добавлять тест, фиксирующий сигнатуры методов родительского класса. Но кто это делает? Я не встречал на практике. Более подробно с примерами о плохом наследовании можно прочитать в “Effective Java”. Надеюсь, эту книгу и так все Java разработчики прочли. 🙂
Это не все минусы наследования. Когда у класса появляется несколько наследников, то у них есть общее поведение, а есть расширенное. И зачастую приходится извращаться с тестами, чтобы избежать дубликатов. Это вырождается в абстрактный тест, который каждый тест на дочерний класс должен наследовать. А что делать, если вы наследуете класс из сторонней библиотеки. В этом случае нужно ли писать тесты на базовое поведение, дублируя существующие тесты в библиотеке? Или стоит оставить их как “надежные” без тестов?
Но ведь есть же шаблоны проектирования, которые предрасполагают нас к использованию абстрактных классов и наследования. Есть, и это большое зло. Мало кто правильно реализует тот же Template Method, делая публичные методы final и давая ровно столько точек для расширения дочерним классам, сколько требует базовый алгоритм. Но и правильная реализация не защитит от добавления новых точек для расширения при модификации алгоритма или рефакторинге. И ни один из наследников не получит уведомления от компилятора об этом. В данном случае гораздо лучше работает шаблон Strategy, где шаги алгоритма расписаны в интерфейсе, и обычный класс с реализацией самого алгоритма. Таким образом, каждая реализация интерфейса стратегии будет давать конкретную реализацию шагов алгоритма.
В Java нельзя разорвать наследование состояния от наследования поведения. Получается, что при желании наследовать состояние (набор полей и действия над ними) вы вынуждены тянуть за собой и все поведение, что приводит к нежелательным эффектам при будущих изменениях. Возможно, наличие других типов данных наподобие структур решило бы эту проблему.
Практически любое использование наследования можно заменить на композицию. Для этого нужен только интерфейс. Тестировать становится значительно проще, гибкости становится больше, уведомления об изменениях будет давать компилятор, код становится менее связанным… Одни преимущества! Почему же так не делают на практике? Ответ простой – из-за лени. Гораздо проще добавить еще одного наследника и дописать строчку кода, перекрыв один из методов. Это требует меньших усилий. А на то, что есть какие-то сложности в будущем, плевать. В будущем поддерживать этот код будет кто-то другой. 🙂
Может быть у вас есть возражения или удачные применения наследования? Напишите о них в комментариях!
Не хочешь пропускать ничего интересного? Подпишись на ленту RSS или следи за нами в Twitter!
В целом согласен.
Поэтому поделюсь причинами, которые мне видятся.
1. При преподавании ООП закладывается тяга к “преждевременной типизации”, к замутнению семантики. Например, любят: “Вот у нас есть фрукт, и наследуем – яблоко”
Но вообще-то мы не программируем нечто вселенское, какую-то супер-бупер онтологию. А вначале выясняем, не обязательно в спеке, но выясняем – а что такое – фрукт? чем он отличается от нефрукта? Оказывается это нечто что в одном случае – лежит на складе, в другом – отображается в детской обучающей программе. Значит и базовый класс будет не “фрукт”, а “нечто лежащее на складе”, или “нечто отображаемое схематично ребенку”. То есть при самом обучении закладывается утяжеленное представление, за которое потом ругают не только из-за наследования, а и “вот, понаприкрутят к фреймворку на фреймворк абстрактных фабрик, которые выбираются стратегиями, за прокси на стейте, а задача то была простая”
И наследование типов предметной области – такая же бяка
2. Никто толком не объясняет, что в общем случае наследовать можно отдельно интерфейс и отдельно – реализацию(с состоянием). посмотрите внимательно на коллекции в JRE.
Для простоты кодирования в Java класс является и тем и другим. Но когда мы наследуемся – то по и по интерфейсу и по реализации. Когда же потом нам надо расширить, или изменить что-то одно, оказывается приходится как-то изъё*ться, чтобы не менять другое. Или менять – но мимодумно, случайно, а потом мучиться, что как-то код стал пахнуть, хотя вроде ж все правильно делал. миксины, трейты – помогли бы. но их нет. в 8ой ожидаются интерфейсы с методами, будет получше. А пока да, трижды подумай, или – композиция.
Удачные примеры наследования привести можно. Уже упомянул системную библиотеку. Но неудачных будет намного больше, из-за указанных вами и мной причин 🙂
НЕ наследуйте сущности предметной области – примерно 100% правило 🙂 Инфраструктурные, инструментальные средства – можно.
Ну почему же из-за лени. “Не усложняй без надобности”. Композиция зачастую требует делегирующих фасадов. А делегаты в яве это куча boilerplate кода. Поэтому для группы рядом лежащих объектов вполне можно использовать наследование. При разростании всегда можно (и нужно) отрефакторить.
Отдельный вопрос – публичное API. Тут сложные родители зло, а простые – затычка в ожидании дефолтных реализаций методов.
Вы совершенно правильно сказали – хочется наследоваться от инфраструктуры (набор полей и методов над ними) и набора конструкторов (опять же отношение к данным). Но мы не можем просто наследовать структуры, поэтому вместе с ними наследуем бизнес-логику. Если отнаследоваться от абстрактного драйвера, то может быть беда – ведь в любой момент в абстрактный класс может быть добавлен метод, который надо перекрывать в реализациях. И приходится быть очень осторожным и продуманным с таким API. 🙂
Для групп однородных объектов применять наследование от одного абстрактного класса (вместо интерфейса или вместе) вполне себе нормально (например, несколько драйверов со своей спецификой, где общий примитивный конструктор и некую инфраструктуру можно поднять в абстрактный базовый класс). Любой класс должен быть примитивным. Также наследование полезно для мелкого декорирования или адаптирования какого-либо чужого монстра, внутрь которого не залезешь.
А вот крупные системы или сложную бизнес-логику наследовать друг от друга не стоит. Любой бизнес-логике место в одном классе без наследников, а в драйвере нужна лишь реализация доступа к файлам и т.п. Так что большой класс с кучей логики лучше разбить на несколько подсистем-стратегий, а уже варианты этих примитивных подсистем фабриковать и наследовать от интерфейсов как душе угодно.
Тогда если надо будет что-то похожее по логике сделать, то скопипастить пятистрочный код любого класса будет не сложнее, чем отнаследоваться.