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