Сказать по правде, изначально я этой статьи не планировал. Я считал вопросы, которые хочу тут обсудить, тривиальными, не стоящими даже упоминания. Однако в процессе написания статей для этого сайта я поднял в одном из форумов обсуждение множественного наследования. В результате чего выяснилось, что большая часть разработчиков имеет весьма смутное представление о наследовании. И, соответственно, допускает очень много ошибок. Поскольку наследование является одной из важнейших черт ООП (если не самой важной!) – я решил посвятить этому явлению отдельную статью. * * * Сначала я хочу разграничить два понятия – объект и класс. Эти понятия постоянно путают. Между тем, они являются центральными в ООП. И знать различия между ними, на мой взгляд, необходимо. Итак, объект. По сути, это что угодно. Вот кубик лежит. Деревянный, синий. Длина ребра 5 см. Это объект. А вон пирамидка. Пластмассовая, красная. 10 см ребро. Это тоже объект. Что между ними общего? Разные размеры. Разная форма. Разный материал. Однако, общее у них есть. Прежде всего, и кубик, и пирамидка – правильные многогранники. Т.е. сумма количества вершин и количества граней на 2 больше количества ребер. Далее. У обоих фигур есть грани, ребра и вершины. У обоих фигур есть такая характеристика, как размер ребра. Обе фигуры можно вращать. Обе фигуры можно рисовать. Два последних свойства – это уже поведение. Ну и так далее. Практика программирования показывает, что с однородными объектами оперировать существенно проще, нежели с разнородными. А поскольку между этими фигурами все-таки есть что-то общее, то возникает желание это общее как-то выделить. Вот тут и выплывает такое понятие как класс. Итак, определение.
Класс – это описатель общих свойств группы объектов. Этими свойствами могут быть как характеристики объектов (размер, вес, цвет и т.п.), так и поведения, роли и т.п.
Замечание. Слово "всех" (описатель всех свойств) произнесено не было. Что означает, что любой объект может принадлежать к нескольким разным классам. Наследование как явление - 1 Возьмем за основу тот же пример с геометрическими фигурами. Самое общее описание – правильный многогранник. Безотносительно размера ребра, количества граней и вершин. Единственное, что мы знаем – что у этой фигуры есть вершины, ребра и грани, и что длины ребер равны. Дальше. Мы можем конкретизировать описание. Допустим, мы хотим нарисовать этот многогранник. Введем такое понятие как отрисовываемый правильный многогранник. Что нам нужно для рисования? Описание общего способа отрисовки, не зависящего от конкретных координат вершин. Возможно, цвет объекта. Теперь введем классы Куб и Тетраэдр. Объекты, принадлежащие к этим классам, безусловно являются правильными многогранниками. Единственное отличие – числа вершин, ребер и граней уже жестко фиксированы для каждого из новых классов. Далее, зная вид конкретной фигуры, мы можем дать описание способа отрисовки. А значит, любой объект классов Куб или Тетраэдр также является и объектом класса отрисовываемый правильный многогранник. Налицо иерархия классов. В этой иерархии мы спускаемся от самого общего описания к наиболее конкретизированному. Заметьте, что объект любого класса также подходит под описание любого более общего класса по иерархии. Такое отношение классов и называется наследованием. Каждый дочерний класс наследует все свойства родительского, более общего, и (возможно) добавляет к этим свойствам какие-то свои. Либо переопределяет какие-то свойства родительского класса. Здесь я хочу привести цитату из классической уже книги Гради Буча по объектно-ориентированному дизайну:
Inheritance, therefore, defines an "is a" hierarchy among classes, in which subclass inherits from one or more superclasses. This is in fact the litmus test for inheritance. Given classes A and B, if A "is not a" kind of B, then A shouldn't be a subclass of B.
В переводе это звучит так:
Наследование, таким образом, определяет иерархию "является" между классами, в которой подкласс наследует от одного или более суперклассов. Это, фактически, определяющий тест (дословно – лакмусовый тест, прим. моё) для наследования. Если у нас есть классы А и В и если класс А "не является" разновидностью класса В, то А не должен быть подклассом В.
Дочитавшие до этого места, возможно, недоуменно покрутят пальцем у виска. Первая мысль – это же тривиально! Так и есть. Но если бы вы знали, сколько безумных иерархий наследования я видел! В той дискуссии, о которой я упомянул в самом начале, один из участников совершенно серьезно унаследовал танк от... пулемета!!! На том простом основании, что у танка ЕСТЬ пулемет. И это – самая распространенная ошибка. Наследование путают с агрегированием – включением одного объекта в состав другого. Танк не является пулеметом, он его содержит. И из-за этой ошибки чаще всего и возникает желание воспользоваться множественным наследованием. Перейдем теперь непосредственно к Java. Что тут есть в плане наследования? В языке есть два типа классов – способные содержать реализацию, и неспособные на это. Вторые называются интерфейсами, хотя по сути – это полностью абстрактные классы. Наследование как явление - 2 Так вот, язык позволяет унаследовать класс от другого класса, потенциально содержащего реализацию. НО ТОЛЬКО ОТ ОДНОГО! Поясню, зачем это сделано. Дело в том, что каждая реализация может иметь дело только со своей частью – с теми переменными и методами, о которых она знает. И если даже мы унаследуем класс С от А и В, то метод processA, унаследованный из класса А, может работать только с внутренней переменной а, ибо о b он ничего не знает, равно как ничего он не знает и о c, и о методе processC. Точно так же и метод processB может работать только с переменной b. Т.е., по сути, унаследованные части оказываются изолированными. Класс С, безусловно, может с ними работать, но точно так же он может работать с этими частями, если они будут просто включены в его состав, а не унаследованы. Однако тут есть еще одна неприятность, заключающаяся в перекрытии имен. Если бы методы processA и processB назывались одинаково, допустим, process, то какой эффект дало бы обращение к методу process класса С? Какой из двух методов был бы вызван? Разумеется, в С++ есть средства управления в этой ситуации, однако стройности языку это не добавляет. Итак, преимуществ наследование реализации не дает, а недостатки есть. По этой причине это наследования реализации в Java отказались. Однако, разработчикам оставили такой вариант множественного наследования, как наследование от интерфейса. В терминах Java – реализация интерфейса. Что представляет собой интерфейс? Набор методов. (Определение в интерфейсах констант мы сейчас не рассматриваем, подробнее об этом тут.) А что есть метод? А метод, по своей сути, определяет поведение объекта. Не случайно в названии практически каждого метода содержится действие – getXXX, drawXXX, countXXX, и т.д. А поскольку интерфейс – это совокупность методов, то интерфейс представляет собой, фактически, определитель поведения. Другой вариант применения интерфейса – определитель роли объекта. Наблюдатель, Слушатель и т.п. В этом случае метод фактически является воплощением реакции на какое-то внешнее событие. Т.е., опять-таки, поведением. Объект, безусловно, может иметь несколько различных поведений. Если ему нужно отрисоваться – он отрисовывается. Если ему нужно сохраниться – он сохраняется. Ну и т.д. Соответственно, возможность наследования от классов, определяющих поведение – весьма и весьма полезна. Точно так же объект может иметь несколько различных ролей. Однако реализация поведения – целиком и полностью на совести дочернего класса. Наследование от интерфейса (его реализация) говорит, что объект этого класса должен уметь делать то-то и то-то. А КАК он это делает – каждый реализующий интерфейс класс определяет самостоятельно. Вернемся к ошибкам при наследовании. Мой опыт разработки различных систем показывает, что имея наследование от интерфейсов, можно реализовать любую систему, при этом не используя множественного наследования реализации. И потому, когда я встречаюсь с сетованиями на отсутствие множественного наследования в том виде, в котором оно есть в С++, для меня это верный признак неправильного дизайна. Чаще всего совершается ошибка, о которой я уже упоминал – наследование путается с агрегированием. Иногда это происходит из-за неверных предпосылок. Т.е. берется, например, спидометр, утверждается, что измерить скорость можно только измерив расстояние и время, после чего спидометр благополучно наследуется от линейки и часов, становясь, таким образом, линейкой и часами, согласно определению наследования. (На мои просьбы измерить спидометром время обычно отвечали шутками. Или вообще не отвечали.) А в чем тут ошибка? В предпосылке. Дело в том, что спидометр не измеряет времени. И расстояния, кстати, тоже. Одометр, который есть в любом спидометре – это классический пример второго прибора в том же корпусе, т.е. агрегирования. Для измерения скорости он не нужен. Его можно вообще убрать – на измерение скорости никак это не повлияет. Иногда такие ошибки совершают осознанно. Это гораздо хуже. "Да, я знаю, что так неправильно, но мне так удобнее". Во что это может обернуться? А вот во что: унаследуем танк от пушки и пулемета. Удобнее так. В результате танк становится пушкой и пулеметом. Дальше мы оборудуем самолет двумя пулеметами и пушкой. Что получаем? Самолет с подвесным вооружением в виде трех танков! Потому что ОБЯЗАТЕЛЬНО найдется человек, который, не разобравшись, использует танк в качестве пулемета. Исключительно согласно иерархии наследования. И будет абсолютно прав, потому что ошибку сделал тот, кто такую иерархию спроектировал.
Вообще, я не очень понимаю подход "мне так удобнее". Удобние писать как слышыца, а те, кто гаварит пра граматнасть – казлы. Я утрирую, конечно, но основная мысль остается – кроме сиюминутного удобства есть такое понятие как грамотность. Это понятие определено на основании очень большого опыта очень большого количества людей. Фактически, это то, что в английском называется "best practice" – наилучшее решение. И чаще всего решения, которые кажутся более простыми, приносят в дальнейшем немало проблем.
Пример этот, конечно, сильно утрирован и потому абсурден. Однако встречаются не столь явные случаи, приводящие тем не менее к катастрофическим последствиям. Унаследовавшись от объекта, вместо того, чтобы его агрегировать, разработчик дает любому возможность использования функциональности родительского объекта напрямую. Со всеми вытекающими из этого последствиями. Представьте что у вас есть класс, работающий с базой данных, DBManager. Вы создаете еще один класс, который будет работать уже с вашими данными, используя DBManager, – DataManager. Этот класс будет осуществлять контроль данных, преобразования, дополнительные действия и т.п. В общем, прослойка между бизнес-уровнем и уровнем базы. Если унаследовать DataManager от DBManager, то всякий, использующий его, получит доступ к базе напрямую. И, следовательно, сможет выполнить любые действия в обход контроля, преобразований и пр. Ладно, предположим, что умышленного вреда никто наносить не хочет и прямые действия будут грамотными. Но! Допустим, что база изменилась. В смысле, изменились какие-то принципы контроля или преобразований. DataManager изменили. Но тот код, который раньше работал с базой напрямую – так и будет работать. Про него с большой вероятностью не вспомнят. В результате появится ошибка такого класса, что те, кто ее будут искать, поседеют. Никому ведь в голову не придет, что с базой работают в обход DataManager. Кстати, пример из реальной жизни. Ошибку искали ОЧЕНЬ долго. Напоследок повторю еще раз. Наследование необходимо применять ТОЛЬКО при наличии отношения "является". Потому как в этом заключается сама суть наследования – возможность использовать объекты дочернего класса как объекты базового. Если же отношения "является" между классами нет – наследования быть НЕ ДОЛЖНО!!! Никогда и ни при каких обстоятельствах. И тем более – только потому что так удобно. Ссылка на первоисточник: http://www.skipy.ru/philosophy/inheritance.html