Что такое ромбовидное наследование
Наследование в C++: beginner, intermediate, advanced
В этой статье наследование описано на трех уровнях: beginner, intermediate и advanced. Expert нет. И ни слова про SOLID. Честно.
Beginner
Что такое наследование?
Наследование является одним из основополагающих принципов ООП. В соответствии с ним, класс может использовать переменные и методы другого класса как свои собственные.
Класс, который наследует данные, называется подклассом (subclass), производным классом (derived class) или дочерним классом (child). Класс, от которого наследуются данные или методы, называется суперклассом (super class), базовым классом (base class) или родительским классом (parent). Термины “родительский” и “дочерний” чрезвычайно полезны для понимания наследования. Как ребенок получает характеристики своих родителей, производный класс получает методы и переменные базового класса.
Наследование полезно, поскольку оно позволяет структурировать и повторно использовать код, что, в свою очередь, может значительно ускорить процесс разработки. Несмотря на это, наследование следует использовать с осторожностью, поскольку большинство изменений в суперклассе затронут все подклассы, что может привести к непредвиденным последствиям.
Важное примечание: приватные переменные и методы не могут быть унаследованы.
Типы наследования
В C ++ есть несколько типов наследования:
Конструкторы и деструкторы
В C ++ конструкторы и деструкторы не наследуются. Однако они вызываются, когда дочерний класс инициализирует свой объект. Конструкторы вызываются один за другим иерархически, начиная с базового класса и заканчивая последним производным классом. Деструкторы вызываются в обратном порядке.
Важное примечание: в этой статье не освещены виртуальные десктрукторы. Дополнительный материал на эту тему можно найти к примеру в этой статье на хабре.
Множественное наследование
Множественное наследование происходит, когда подкласс имеет два или более суперкласса. В этом примере, класс Laptop наследует и Monitor и Computer одновременно.
Проблематика множественного наследования
Множественное наследование требует тщательного проектирования, так как может привести к непредвиденным последствиям. Большинство таких последствий вызваны неоднозначностью в наследовании. В данном примере Laptop наследует метод turn_on() от обоих родителей и неясно какой метод должен быть вызван.
Несмотря на то, что приватные данные не наследуются, разрешить неоднозначное наследование изменением уровня доступа к данным на приватный невозможно. При компиляции, сначала происходит поиск метода или переменной, а уже после — проверка уровня доступа к ним.
Intermediate
Проблема ромба
Ромбовидная проблема — прежде всего проблема дизайна, и она должна быть предусмотрена на этапе проектирования. На этапе разработки ее можно разрешить следующим образом:
Проблема ромба: Конструкторы и деструкторы
Поскольку в С++ при инициализации объекта дочернего класса вызываются конструкторы всех родительских классов, возникает и другая проблема: конструктор базового класса Device будет вызван дважды.
Виртуальное наследование
Виртуальное наследование (virtual inheritance) предотвращает появление множественных объектов базового класса в иерархии наследования. Таким образом, конструктор базового класса Device будет вызван только единожды, а обращение к методу turn_on() без его переопределения в дочернем классе не будет вызывать ошибку при компиляции.
Примечание: виртуальное наследование в классах Computer и Monitor не разрешит ромбовидное наследование если дочерний класс Laptop будет наследовать класс Device не виртуально ( class Laptop: public Computer, public Monitor, public Device <>; ).
Абстрактный класс
В С++, класс в котором существует хотя бы один чистый виртуальный метод (pure virtual) принято считать абстрактным. Если виртуальный метод не переопределен в дочернем классе, код не скомпилируется. Также, в С++ создать объект абстрактного класса невозможно — попытка тоже вызовет ошибку при компиляции.
Интерфейс
С++, в отличии от некоторых ООП языков, не предоставляет отдельного ключевого слова для обозначения интерфейса (interface). Тем не менее, реализация интерфейса возможна путем создания чистого абстрактного класса (pure abstract class) — класса в котором присутствуют только декларации методов. Такие классы также часто называют абстрактными базовыми классами (Abstract Base Class — ABC).
Advanced
Несмотря на то, что наследование — фундаментальный принцип ООП, его стоит использовать с осторожностью. Важно думать о том, что любой код который будет использоваться скорее всего будет изменен и может быть использован неочевидным для разработчика путем.
Наследование от реализованного или частично реализованного класса
Если наследование происходит не от интерфейса (чистого абстрактного класса в контексте С++), а от класса в котором присутствуют какие-либо реализации, стоит учитывать то, что класс наследник связан с родительским классом наиболее тесной из возможных связью. Большинство изменений в классе родителя могут затронуть наследника что может привести к непредвиденному поведению. Такие изменения в поведении наследника не всегда очевидны — ошибка может возникнуть в уже оттестированом и рабочем коде. Данная ситуация усугубляется наличием сложной иерархии классов. Всегда стоит помнить о том, что код может изменяться не только человеком который его написал, и пути наследования очевидные для автора могут быть не учтены его коллегами.
В противовес этому стоит заметить что наследование от частично реализованных классов имеет неоспоримое преимущество. Библиотеки и фреймворки зачастую работают следующим образом: они предоставляют пользователю абстрактный класс с несколькими виртуальными и множеством реализованных методов. Таким образом, наибольшее количество работы уже проделано — сложная логика уже написана, а пользователю остается только кастомизировать готовое решение под свои нужды.
Интерфейс
Наследование от интерфейса (чистого абстрактного класса) преподносит наследование как возможность структурирования кода и защиту пользователя. Так как интерфейс описывает какую работу будет выполнять класс-реализация, но не описывает как именно, любой пользователь интерфейса огражден от изменений в классе который реализует этот интерфейс.
Интерфейс: Пример использования
Прежде всего стоит заметить, что пример тесно связан с понятием полиморфизма, но будет рассмотрен в контексте наследования от чистого абстрактного класса.
Приложение выполняющее абстрактную бизнес логику должно настраиваться из отдельного конфигурационного файла. На раннем этапе разработки, форматирование данного конфигурационного файла до конца сформировано не было. Вынесение парсинга файла за интерфейс предоставляет несколько преимуществ.
Отсутствие однозначности касательно форматирования конфигурационного файла не тормозит процесс разработки основной программы. Два разработчика могут работать параллельно — один над бизнес логикой, а другой над парсером. Поскольку они взаимодействуют через этот интерфейс, каждый из них может работать независимо. Данный подход облегчает покрытие кода юнит тестами, так как необходимые тесты могут быть написаны с использованием мока (mock) для этого интерфейса.
Также, при изменении формата конфигурационного файла, бизнес логика приложения не затрагивается. Единственное чего требует полный переход от одного форматирования к другому — написания новой реализации уже существующего абстрактного класса (класса-парсера). В дальнейшем, возврат к изначальному формату файла требует минимальной работы — подмены одного уже существующего парсера другим.
Заключение
Наследование предоставляет множество преимуществ, но должно быть тщательно спроектировано во избежание проблем, возможность для которых оно открывает. В контексте наследования, С++ предоставляет широкий спектр инструментов который открывает массу возможностей для программиста.
Множественное наследование в С++
Множественного наследования зачастую можно избежать, нужно помнить что открытое наследование выражает отношение «является». Тут мы разберем именно такую задачу. В этой статье я постарался показать как можно больше проблем, которые могут возникнуть при таком наследовании.
Иерархия классов будет такой: Человек, Профессия, Рабочие.
Человек ( Man ) имеет закрытые поля для хранения имени и возраста, публичные функции получения их значения и метод преобразования объекта в строку. Задаваться поля будут с помощью конструктора или с помощью функции readMan, считывающий данные с потока istream. Класс имет виртуальный деструктор, так как планируется наследование от него — значит, класс может быть использован полиморфным образом;
Профессия ( Profession ) имеет закрытые поля для названия и заработной платы. Как и Man, она имеет функции получения данных, ввода с потока, метод преобразования в строку и виртуальный деструктор.
Рабочий ( Worker ) реализуется с помощью множественного наследования, а также имеет поле для хранения доли ставки и оператор сравнения (сравнивать будем по получаемому доходу с учетом заработной платы и доли ставки).
Такая иерархия имеет ряд проблем — в данном случае они созданы намеренно, но такие ситуации возникают на практике:
Решение проблем
Ромбовидное наследование
В нашем примере такой проблемы нет, но это похожая проблема, имеющая стандартное решение.
При наследовании интерфейса (как на рисунке) проблемы нет, так как интерфейс — это соглашение (контракт) между классами. Проблема возникает лишь если у общего предка есть поля. Проблема ромбовидного наследования решается с помощью виртуального наследования, выглядеть это могло бы примерно так:
Member in multiple base classes of different types
В ромбовидном наследовании включение содержимого общего предка является недопустимым — это основная проблема, которая и решается с помощью виртуального наследования. В нашем же случае:
То есть основной проблемой является перекрытие имен. Решить проблему можно двумя путями:
Оба способа показаны в следующей функции:
Множественное наследование в Java. Композиция в сравнении с Наследованием
Множественное наследование в Java
Ромбовидная проблема
Множественное наследование Интерфейсов
Композиция против Наследования
Предположим, что у нас есть суперкласс и класс, расширяющий его:
Обратите внимание, что метод test() уже существует в подклассе, но тип возвращаемого значения отличается. Теперь класс ClassD не будет компилироваться и если вы будете использовать какой-либо IDE, то она вам предложит изменить тип возвращаемого значения в суперклассе или подклассе.
Теперь представьте себе ситуацию, когда мы имеем многоуровневую иерархию наследования классов и не имеем доступа к суперклассу. У нас не будет никакого выбора, кроме как изменить нашу сигнатуру метода подкласса или его имя, чтобы удалить ошибку компиляции. Также мы должны будем изменить метод подкласса во всех местах, где он вызывается. Таким образом, наследование делает наш код хрупким.
Вышеупомянутая проблема никогда не произойдет с композицией и это делает ее более привлекательной по отношению к наследованию.
Другая проблема с наследованием состоит в том, что мы предоставляем все методы суперкласса клиенту и если наш суперкласс должным образом не разработан и есть пробелы в системе безопасности, то даже при том, что мы наилучшим образом выполняем реализацию нашего класса, на нас влияет плохая реализация суперкласса. Композиция помогает нам в обеспечении управляемого доступа к методам суперкласса, тогда как наследование не обеспечивает управления методами суперкласса. Это также одно из основных преимуществ композиции от наследования.
Результат программы представленной выше:
Эта гибкость при вызове метода не доступна при наследовании, что добавляет еще один плюс в пользу выбора композиции.
Поблочное тестирование легче делать при композиции, потому что мы знаем, что все методы мы используем от суперкласса и можем копировать их для теста. Тогда как в наследовании мы зависим в большей степени от суперкласса и не знаем всех методов суперкласса, которые будут использоваться. Таким образом, мы должны тестировать все методы суперкласса, что является дополнительной работой из-за наследования.
В идеале мы должны использовать наследование только когда отношение подкласса к суперклассу определяется как «является». Во всех остальных случаях рекомендуется использовать композицию.
Ромбовидное наследование
Проблема ромба (англ. diamond problem ) получила свое название благодаря очертаниям диаграммы наследования классов в этой ситуации. В данной статье, класс A обозначается в виде вершины, классы B и C по отдельности указываются ниже, а D соединяется с обоими в самом низу, образуя ромб.
Содержание
Решения
Различные языки программирования решают проблему ромбовидного наследования следующими способами:
Прочие примеры
Языки, допускающие лишь простое наследование (как например, Ада, Objective-C, PHP, C#, Delphi/Free Pascal и Java), предусматривают множественное наследование интерфейсов (в Objective-C называемые протоколами). Интерфейсы по сути являются абстрактными базовыми классами, все методы которых также абстрактны, и где отсутствуют поля. Таким образом, проблема не возникает, так как всегда будет только одна реализация определенного метода или свойства, не допуская возникновения неопределенности.
Проблема ромба не ограничивается лишь наследованием. Она также возникает в таких языках, как Си и C++, когда заголовочные файлы A, B, C и D, а также отдельные предкомпилированные заголовки, созданные из B и C, подключаются (при помощи инструкции #include ) один к другому по ромбовидной схеме, указанной вверху. Если эти два предкомпилированных заголовка объединяются, объявления в A дублируются, и директива защиты подключения #ifndef становится неэффективной. Также проблема обнаруживается при объединении стеков подпрограммного обеспечения; например, если A — это база данных, а B и C — кэши, то D может запросить как B, так и C подтвердить (COMMIT) выполнение транзакции, приводя к дублирующим вызовам подтверждений A.
Ромбовидное наследование
Например, в области разработки графических интерфейсов класс Button («Кнопка») может одновременно наследовать от класса Rectangle («Прямоугольник», для внешнего вида) и от класса Clickable («Доступен для кликанья мышкой», для реализации функциональности/обработки ввода), а Rectangle и Clickable наследуют от класса Object («Объект»). Если вызвать метод equals («Равно») для объекта Button, и в классе Button не окажется такого метода, но в классе Object будет присутствовать метод equals по-своему переопределенный как в классе Rectangle, так и в Clickable, то какой из методов должен быть вызван?
Проблема ромба (англ. diamond problem) получила своё название благодаря очертаниям диаграммы наследования классов в этой ситуации. В данной статье, класс A обозначается в виде вершины, классы B и C по отдельности указываются ниже, а D соединяется с обоими в самом низу, образуя ромб.
Связанные понятия
Стиль о́тступов (индентация) — правила форматирования исходного кода, в соответствии с которыми отступы программных блоков проставляются в удобочитаемой манере.
В программировании, ассемблерной вставкой называют возможность компилятора встраивать низкоуровневый код, написанный на ассемблере, в программу, написанную на языке высокого уровня, например, Си или Ada. Использование ассемблерных вставок может преследовать следующие цели.
В языках программирования объявле́ние (англ. declaration) включает в себя указание идентификатора, типа, а также других аспектов элементов языка, например, переменных и функций. Объявление используется, чтобы уведомить компилятор о существовании элемента; это весьма важно для многих языков (например, таких как Си), требующих объявления переменных перед их использованием.
Парсер (англ. parser; от parse – анализ, разбор) или синтаксический анализатор — часть программы, преобразующей входные данные (как правило, текст) в структурированный формат. Парсер выполняет синтаксический анализ текста.