«С множественным наследованием в C++ связан один неоспоримый факт: оно открывает ящик Пандоры, полный проблем, которые просто невозможны при единичном наследовании.» © Скотт Майерс
Наследование — мощный и... довольно опасный инструмент. В некотором смысле наследование похоже на цепную пилу: при соблюдении мер предосторожности оно может быть невероятно полезным, но при неумелом обращении последствия могут оказаться очень и очень серьезными.
Если наследование — цепная пила, то множественное наследование — это старинная цепная пила с барахлящим мотором, не имеющая предохранителей и не поддерживающая автоматического отключения. Иногда такой инструмент может пригодиться, но большую часть времени его лучше хранить в гараже под замком.
Некоторые эксперты рекомендуют широкое применение множественного наследования (Meyer, 1997), но по опыту могу сказать, что оно полезно главным образом только при создании «миксинов» — простых классов, позволяющих добавить ряд свойств в другой класс. Миксины называются так потому, что они позволяют «подмешать (mix in)» свойства в производные классы. Миксинами могут быть классы вроде Displayable, Persistent, Serializable или Sortable. Миксины почти всегда являются абстрактными и не поддерживают создания экземпляров независимо от других объектов.
Миксины требуют множественного наследования, но пока все миксины по-настоящему независимы друг от друга, вы можете не бояться классической проблемы, связанной с ромбовидной схемой наследования. Кроме того, «объединяя» атрибуты, они делают проект системы понятнее. Программисту легче разобраться с объектом, использующим миксины Displayable и Persistent, а не 11 более конкретных методов, которые понадобились бы для реализации этих двух свойств в противном случае.
Похоже, разработчики Java и Visual Basic понимали ценность миксинов, разрешив множественное наследование интерфейсов, но только единичное наследование классов. C++ поддерживает множественное наследование и интерфейсов, и реализации. Используйте множественное наследование, только тщательно рассмотрев все альтернативные варианты и проанализировав влияние выбранного подхода на сложность и понятность системы.
Какова мораль?
Наследование часто противоречит главному техническому императиву программирования — управлению сложностью. Ради управления сложностью относитесь к наследованию с подозрением. Вот как использовать наследование и включение:
● если несколько классов имеют общие данные, но не формы поведения, создайте общий объект, который можно было бы включить во все эти классы;
● если несколько классов имеют общие формы поведения, но не данные, сделайте эти классы производными от общего базового класса, определяющего общие методы;
● если несколько классов имеют общие данные и формы поведения, сделайте эти классы производными от общего базового класса, определяющего общие данные и методы;
● используйте наследование, если хотите, чтобы интерфейс определялся базовым классом, и включение, если хотите сами контролировать интерфейс.
А теперь строгие определения
Множественное наследование — свойство, поддерживаемое частью объектно-ориентированных языков программирования, когда класс может иметь более одного суперкласса (непосредственного класса-родителя), интерфейсы поддерживают множественное наследование во многих языках программирования. Эта концепция является расширением «простого (или одиночного) наследования» (англ. single inheritance), при котором класс может наследоваться только от одного суперкласса.
Языки программирования, поддерживающие множественное наследование
В список языков, поддерживающих множественное наследование, входят: Io, Eiffel, C++, Dylan, Python, некоторые реализации классов JavaScript (например, dojo.declare), Perl 6, Curl, Common Lisp (благодаря CLOS), OCaml, Tcl (благодаря Incremental Tcl), а также Object REXX (за счёт использования классов-примесей).
Ромбовидное наследование
Ромбовидное наследование (англ. diamond inheritance) — ситуация в объектно-ориентированных языках программирования с поддержкой множественного наследования, когда два класса B и C наследуют от A, а класс Dнаследует от обоих классов B и C. При этой схеме наследования может возникнуть неоднозначность: если метод класса D вызывает метод, определенный в классе A (и этот метод не был переопределен в классе D), а классы B и C по-своему переопределили этот метод, то от какого класса его наследовать: B или C?
Например, в области разработки графических интерфейсов класс Button («Кнопка») может одновременно наследовать от класса Rectangle («Прямоугольник», для внешнего вида) и от класса Clickable («Доступен для кликанья мышкой», для реализации функциональности/обработки ввода), а Rectangle и Clickable наследуют от класса Object («Объект»). Если вызвать метод equals («Равно») для объекта Button, и в классе Button не окажется такого метода, но в классе Object будет присутствовать метод equals по-своему переопределенный как в классе Rectangle, так и в Clickable, то какой из методов должен быть вызван?
Проблема ромба (англ. diamond problem) получила своё название благодаря очертаниям диаграммы наследования классов в этой ситуации. В данной статье, класс A обозначается в виде вершины, классы B и C по отдельности указываются ниже, а D соединяется с обоими в самом низу, образуя ромб.
Решения
Различные языки программирования решают проблему ромбовидного наследования следующими способами:
- C++ по умолчанию не создает ромбовидного наследования: компилятор обрабатывает каждый путь наследования отдельно, в результате чего объект Dбудет на самом деле содержать два разных подобъекта A, и при использовании членов A потребуется указать путь наследования (B::A или C::A). Чтобы сгенерировать ромбовидную структуру наследования, необходимо воспользоваться виртуальным наследованием класса A на нескольких путях наследования: если оба наследования от A к B и от A к C помечаются спецификатором virtual (например, class B : virtual public A), C++ специальным образом проследит за созданием только одного подобъекта A, и использование членов A будет работать корректно. Если виртуальное и невиртуальное наследования смешиваются, то получается один виртуальный подобъект A и по одному невиртуальному подобъекту A для каждого пути невиртуального наследования к A. При виртуальном вызове метода виртуального базового класса используется так называемое правило доминирования: компилятор запрещает виртуальный вызов метода, который был перегружен на нескольких путях наследования.
- Common Lisp пытается реализовать и разумное поведение по умолчанию, и возможность изменить его. По умолчанию выбирается метод с наиболее специфичными классами аргументов; затем, методы выбираются по порядку, в котором родительские классы указаны при определении подкласса. Однако программист вполне может изменить это поведение путём указания специального порядка разрешения методов или указания правила для объединения методов.
- Eiffel обрабатывает подобную ситуацию при помощи директив select и rename, и методы предка, которые используются в потомках, указываются явно. Это позволяет совместно использовать методы родительского класса в потомках или предоставлять им отдельную копию родительского класса.
- Perl и Io обрабатывают наследования через поиск в глубину в том порядке, который используется в определении класса. Класс B и его предки будут проверены перед классом C и его предками, так что метод в A будет унаследован от B; список разрешения — [D, B, A, C]. При этом в Perl данное поведение может быть изменено при помощи mro или других модулей для применения C3-линеаризации (как в Python) или других алгоритмов.
- В Python проблема ромба остро встала в версии 2.3 после введения классов с общим предком object; начиная с этой версии было решено создавать список разрешения при помощи C3-линеаризации. В случае ромба это означает поиск в глубину, начиная слева (D, B, A, C, A), а затем удаление из списка всех, кроме последнего включения каждого класса, который в списке повторяется. Следовательно, итоговый порядок разрешения выглядит так: [D, B, C, A].
- Scala список разрешения создается аналогично Python, но через поиск в глубину начиная справа. Следовательно, предварительный список разрешения ромба — [D, C, A, B, A], а после удаления повторений — [D, C, B, A].
- JavaFX Script, начиная с версии 1.2, позволяет множественное наследование за счет применения примесей. В случае конфликта, компилятор запрещает прямое использование неопределенных переменных или функции. К каждому наследуемому члену по-прежнему будет возможен доступ за счет приведения объекта к нужной примеси, например, (individual as Person).printInfo();.
Прочие примеры
Языки, допускающие лишь простое наследование (как например, Ада, 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.
Обзор
Множественное наследование позволяет классу перенимать функциональность у множества других классов, как например, класс StudentMusician может наследовать от класса Person, класса Musician и класса Worker, что сокращённо можно написать:
StudentMusician : Person, Musician, Worker
Неопределённость при множественном наследовании, как в примере выше, возникает если, к примеру, класс Musician наследует от классов Person и Worker, а класс Worker, в свою очередь, наследует от Person; подобная ситуация называется ромбовидным наследованием. Таким образом, у нас получаются следующие правила:
Если компилятор просматривает класс StudentMusician, то ему необходимо знать, нужно ли объединять возможности классов или они должны быть раздельными. Например, логично будет присоединить «Age» (возраст) класса Person к классу StudentMusician. Возраст человека не меняется, если вы рассматриваете его как Person (человек), Worker (рабочий) или Musician (музыкант). Однако, будет довольно логичным отделить свойство «Name» (имя) в классах Person и Musician, если они используют сценический псевдоним, отличающийся от настоящего имени. Варианты объединения и разделения вполне корректны для каждого из собственных контекстов и только программист знает, какой вариант является правильным для проектируемого класса.
Языки обладают различными способами разрешения таких проблем вложенного наследования, а именно:
- Eiffel предоставляет программисту возможность явным образом объединить или разделить унаследованные элементы от суперклассов. Eiffel автоматически объединит элементы, если у них будет одинаковое имя и реализация. Автор класса имеет возможность переименовать наследуемые элементы для их разделения. Кроме того, Eiffel позволяет явным образом выполнять повторное наследование вида A: B, B.
- C++ требует, чтобы программист указал, элемент какого из родительских классов должен использоваться, то есть «Worker::Person.Age». C++ не поддерживает явно повторяемое наследование, так как отсутствует способ определить какой именно суперкласс следует использовать (смотри критику). C++, также, допускает создание единственного экземпляра множественного класса благодаря механизму виртуального наследования (например, «Worker::Person» и «Musician::Person» будут ссылаться на один и тот же объект).
- Perl использует список классов для наследования в указанном порядке. Компилятор использует первый метод, который он находит при глубинном поискев списке суперклассов или использовании C3-линеаризации иерархии классов. Различные расширения обеспечивают альтернативные схемы композиции классов.
- Python (см. наследование и множественное наследование в Python) имеет синтаксическую поддержку для множественного наследования, а порядок базовых классов определяется алгоритмом C3-линеаризации.
- Common Lisp Object System предусматривает полный контроль методов комбинации со стороны программиста, а если этого не достаточно, то метаобъектный протокол (Metaobject Protocol) дает программисту возможность модифицировать наследование, динамическое управление, реализация класса и другие внутренние механизмы без опасения повлиять на стабильность системы.
- Logtalk поддерживает оба интерфейса и реализацию мультинаследования, предусматривая объявление метода алиасов, поддерживающего как переименование, так и доступ к методам, которые могут оказаться недоступными, благодаря механизму разрешения конфликтов.
- Curl допускает только такие классы, которые явным образом отмечены как доступные для повторного наследования. Доступные классы должны определять вторичный конструктор для каждого обычного конструктора класса. Сначала вызывается обычный конструктор, статус доступного класса инициализируется за счет конструктора подкласса, а вторичный конструктор вызывается для всех остальных подклассов.
- Ocaml выбирает последнее совпавшее определение в списке наследования классов для определения метода реализации, используемого в случае неопределенности. Для переопределения поведения по умолчанию нужно просто указать метод, вызываемый при определении предпочитаемого класса.
- Tcl допускает существование множества родительских классов — их последовательность влияет на разрешение имен членов класса.
- Delphi с версии 2007 позволяет частично реализовать множественное наследование с помощью помощников классов (Class Helpers)
Smalltalk, C#, Objective-C, Java, Nemerle и PHP не допускают множественного наследования, что позволяет избежать многих неопределенностей. Однако, они, кроме Smalltalk, позволяют классам реализовать множественные интерфейсы. Кроме того, PHP и Ruby позволяют эмулировать множественное наследование за счет использования примесей (traits в PHP и mixins в Ruby), которые, как и интерфейсы, полноценными классами не являются. Множественное наследование интерфейсов позволяет расширить ограниченные возможности.
Критика
Множественное наследование критикуется за следующие проблемы, возникающие в некоторых языках, в частности, C++:
- семантическая неопределённость часто совокупно представляется как Проблема ромба.
- отсутствует возможность непосредственного многократного наследования от одного класса.
- порядок наследования изменяет семантику класса.Конструктор дочернего класса вызывает конструкторы непосредственных родителей, а те, в свою очередь - конструктор прародителя. Однако прародительский объект имеется в единственном экземпляре и конструировать его дважды нельзя, поэтому сработает вызов конструктора прародителя только конструктором первого родительского класса в списке наследования.
Множественное наследование в языках с конструкторами в стиле C++/Java усиливает проблему наследования конструкторов и последовательностей конструкторов, таким образом создавая проблемы с поддержкой и расширяемостью в этих языках. Объекты в отношениях наследования со значительно отличающимися методами конструирования довольно трудны для реализации в рамках парадигмы последовательности конструкторов.
Тем не менее, существуют языки, обрабатывающие эти технические тонкости (например Eiffel).
Существует мнение, что множественное наследование — это неверная концепция, порождённая неверным анализом и проектированием. В частности, для приведённого выше примера справедлив следующий вариант проектирования. Класс Person включает в себя один и более объектов класса Profession. Классы Student и Musician наследуют от Profession. Таким образом, StudentMusician будет представлен объектом класса Person содержащим объекты класса Student и Musician. Формально множественное наследование можно перепроектировать путём введения класса, являющегося «метаклассом» классов, от которых должно происходить множественное наследование. В приведённом примере таким метаклассом является Profession — профессия.
Поддержать Physics.Math.Code донатом
Спасибо, что дочитали статью до конца! Если вам нравится такие заметки, то оставьте комментарий, лайк или любую другую обратную связь :)
Наша библиотека в telegram (много книг для физиков, математиков и программистов) : https://t.me/physics_lib
Еще много полезного и интересного вы сможете найти на наших ресурсах:
Physics.Math.Code в контакте (VK)