Наследование в 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) для этого интерфейса.
Также, при изменении формата конфигурационного файла, бизнес логика приложения не затрагивается. Единственное чего требует полный переход от одного форматирования к другому — написания новой реализации уже существующего абстрактного класса (класса-парсера). В дальнейшем, возврат к изначальному формату файла требует минимальной работы — подмены одного уже существующего парсера другим.
Заключение
Наследование предоставляет множество преимуществ, но должно быть тщательно спроектировано во избежание проблем, возможность для которых оно открывает. В контексте наследования, С++ предоставляет широкий спектр инструментов который открывает массу возможностей для программиста.
Урок №154. Базовое наследование
Обновл. 15 Сен 2021 |
Теперь, когда мы уже поговорили о наследовании в абстрактном ключе, давайте поговорим о том, как это используется в языке C++ на практике.
Наследование в С++
Наследование в C++ происходит между классами и имеет тип отношений «является». Класс, от которого наследуют, называется родительским (или «базовым», «суперклассом»), а класс, который наследует, называется дочерним (или «производным», «подклассом»).
В диаграмме, представленной выше, Фрукт является родительским классом, а Яблоко и Банан — дочерними классами.
В этой диаграмме Треугольник является дочерним классом (родитель — Фигура ) и родительским (для Правильного треугольника ) одновременно.
Дочерний класс наследует как поведение (методы), так и свойства (переменные-члены) от родителя (с учетом некоторых ограничений доступа, которые мы рассмотрим чуть позже). Эти методы и переменные становятся членами дочернего класса.
Поскольку дочерние классы являются полноценными классами, то они могут (конечно) иметь и свои собственные члены. Сейчас мы это всё рассмотрим детально.
Класс Human
Вот простой класс Human для представления Человека:
В этом классе мы определили только те члены, которые являются общими для всех объектов этого класса. Каждый Человек (независимо от пола, профессии и т.д.) имеет Имя и Возраст.
Обратите внимание, в примере, приведенном выше, мы сделали все переменные-члены и методы класса открытыми. Это сделано ради простоты примера. Обычно переменные-члены нужно делать private. О средствах контроля доступа и о том, как это работает в наследовании, мы поговорим на соответствующих уроках.
Класс BasketballPlayer
Предположим, что нам нужно написать программу, которая будет отслеживать информацию о баскетболистах. Мы можем сохранять средний уровень игры баскетболиста и количество очков.
Вот наш незавершенный класс BasketballPlayer:
Также нам нужно знать Имя и Возраст баскетболиста, а эта информация уже у нас есть: она хранится в классе Human.
У нас есть три варианта добавления Имени и Возраста в BasketballPlayer:
Добавить Имя и Возраст в класс BasketballPlayer непосредственно в качестве членов. Это плохой вариант, так как произойдет дублирование кода, который уже существует в классе Human. Любые обновления в Human также должны быть продублированы и в BasketballPlayer.
Добавить класс Human в качестве члена в класс BasketballPlayer, используя композицию. Но возникает вопрос: «Может ли BasketballPlayer иметь Human?». Нет, это некорректно.
Сделать так, чтобы BasketballPlayer унаследовал необходимые атрибуты от Human. Помните, что тип отношений в наследовании — «является». Является ли BasketballPlayer Human-ом (т.е. Человеком)? Конечно! Поэтому наш выбор — наследование.
Делаем класс BasketballPlayer дочерним
Чтобы класс BasketballPlayer унаследовал информацию от класса Human, нам нужно после объявления BasketballPlayer ( class BasketballPlayer ) использовать двоеточие, ключевое слово public и имя класса, от которого мы хотим унаследовать. Это называется открытым наследованием:
Таким образом, объекты BasketballPlayer будут иметь 4 члена:
m_gameAverage и m_points от BasketballPlayer;
m_name и m_age от Human.
Полный код программы:
Результат выполнения программы:
Это работает, так как anton является объектом класса BasketballPlayer, а все объекты класса BasketballPlayer имеют переменную-член m_name и метод getName(), унаследованные от класса Human.
Дочерний класс Employee
Теперь напишем еще один класс, который также будет наследовать свойства Human. Например, класс Employee (Работник). Работник «является» Человеком, поэтому использовать наследование здесь уместно:
Работник наследует m_name и m_age от Human-а (а также два метода) и имеет еще две собственные переменные-члены и один метод. Обратите внимание, метод printNameAndWage() использует переменные как из класса, к которому принадлежит ( Employee::m_wage ), так и с родительского класса ( Human::m_name ).
Обратите внимание, классы Employee и BasketballPlayer не имеют прямых отношений, хотя оба наследуют свойства класса Human.
Результат выполнения программы:
Цепочка наследований
Можно наследовать от класса, который сам наследует от другого класса. При этом ничего примечательного или чего-нибудь особенного не происходит — всё аналогично тому, что мы рассмотрели выше. Например, напишем класс Supervisor (Супервайзер). Супервайзер — это Работник, который «является» Человеком. Мы уже написали класс Employee, поэтому будем его использовать в качестве родительского класса:
Построив такие цепочки наследований, мы можем создать набор повторно используемых классов, которые будут иметь общие свойства вверху и становиться всё более специфичными на каждом последующем уровне наследования.
Почему наследование является полезным?
Использование наследования означает, что нам не нужно переопределять информацию из родительских классов в дочерних. Мы автоматически получаем методы и переменные-члены суперкласса через наследование, а затем просто добавляем специфичные методы или переменные-члены, которые хотим. Это не только экономит время и усилия, но также является очень эффективным: если мы когда-либо обновим или изменим базовый класс (например, добавим новые функции или исправим ошибку), то все наши производные классы автоматически унаследуют эти изменения!
Например, если мы добавим новый метод в Human, то Employee и Supervisor автоматически получат доступ к нему. Если мы добавим новую переменную в Employee, Supervisor также получит доступ к ней. Это позволяет создавать новые классы более простым, интуитивно-понятным способом!
Заключение
Наследование позволяет повторно использовать классы путем наследования членов этих классов другими классами. На следующих уроках мы будем разбираться детально, как это всё работает.
Поделиться в социальных сетях:
Функции, которые не наследуются автоматически
В C ++ я читал Брюсом Экелом, что функции, которые не наследуются автоматически:
но этот код говорит что-то еще
Вывод: вызывается оператор присваивания базового класса
Решение
На самом деле, неправильно говорить, что operator = не наследуется. Проблема в том, что это скрытый неявно сгенерированным operator = для производного класса, так что (например) присвоение, такое как в приведенном ниже коде, будет недопустимым:
Ситуация не отличается от случая обычных функций-членов в производном классе, скрывающих функции-члены с одинаковыми именами в базовом классе.
А что касается обычных функций-членов, вы можете иметь using объявление в вашем классе, что делает базовый класс operator = имеется в наличии:
Это сделает компиляцию в предыдущем примере.
Другие решения
«Вместо наследования эти функции синтезируются компилятором, если вы сами их не создаете. (С помощью конструкторов вы не можете создавать конструкторы, чтобы компилятор синтезировал конструктор по умолчанию и конструктор копирования.) Синтезированные конструкторы используют для каждого члена инициализацию, а синтезированный оператор = использует для каждого элемента. «
Таким образом, B имеет синтезированный = Оператор автоматически генерируется компилятором.
(См. Стандарт [Проект N3242] §12.8 для получения информации о том, когда неявно генерируется задание по умолчанию для копирования или перемещения и в какой форме.)
Стандарт (проект N3242) гласит:
§12,8 / 29
Неявно определенный оператор присваивания копирования / перемещения для класса X, не являющегося объединением, выполняет присваивание для каждого элемента копирование / перемещение его подобъектов. Прямые базовые классы X назначены сначала в порядке их объявления в списке базовых спецификаторов, а затем присваиваются непосредственные нестатические члены-данные X в том порядке, в котором они были объявлены в определении класса.
Там мы получаем оператор присваивания на место.
Примечание. Полезно указывать параметр оператора присваивания по константной ссылке (или по значению, если используется копия&своп).
Введение в ООП с примерами на C#. Часть вторая. Все, что нужно знать о наследовании
Авторизуйтесь
Введение в ООП с примерами на C#. Часть вторая. Все, что нужно знать о наследовании
Вступление
В первой статье этой серии мы рассматривали работу разных вариантов реализации перегрузки. В этой части мы сосредоточимся на таком разделе объектно-ориентированного программирования, как наследование.
Давайте сразу тезисно опишем, что такое наследование:
Рассмотрим наследование в действии
Как вы можете видеть, класс A пуст, а в B мы добавили два метода и переменную x со значением 100.
Теперь в главном методе Program.cs напишите следующее:
Разумеется, этот код вызовет ошибку:
Error: ‘InheritanceAndPolymorphism.ClassA’ does not contain a definition for ‘Display1’ and no extension method ‘Display1’ accepting a first argument of type ‘InheritanceAndPolymorphism.ClassA’ could be found (are you missing a using directive or an assembly reference?)
Очевидно, причина в том, что в классе А нет метода, который мы вызываем. Однако он есть у класса B. Было бы здорово, если бы мы могли получить доступ ко всему коду в B из A!
Теперь измените описание первого класса на следующее:
Теперь после выполнения программы мы получим:
Что нужно запомнить: как сын получается похожим на отца, наследует его черты, так и дочерний класс имеет параметры родительского.
Теперь давайте представим, что ClassA тоже имеет метод Display1 :
Что будет, если мы запустим код теперь? Каким будет вывод? И будет ли вывод вообще или выйдет ошибка компиляции? Давайте проверим.
Однако мы также получим предупреждение:
Warning: ‘InheritanceAndPolymorphism.ClassA.Display1()’ hides inherited member ‘InheritanceAndPolymorphism.ClassB.Display1()’. Use the new keyword if hiding was intended.
Что нужно запомнить: ничто не может помешать создать в дочернем классе такой же метод, как и в родительском.
Что нужно запомнить: методы дочерних классов имеют приоритет при выполнении.
Такая возможность нам даётся для того, чтобы мы могли изменить поведение методов предка, если оно нас не устраивает. Однако мы всё равно можем вызывать методы родительского класса следующим образом:
В таком случае вывод будет:
Что нужно запомнить: ключевое слово base может быть использовано для обращения к методам класса-предка.
Что же, вверх по иерархии мы обращаться можем. Давайте попробуем сделать наоборот:
Error: ‘InheritanceAndPolymorphism.ClassB’ does not contain a definition for ‘Display2’ and no extension method ‘Display2’ accepting a first argument of type ‘InheritanceAndPolymorphism.ClassB’ could be found (are you missing a using directive or an assembly reference?)
Что нужно запомнить: наследование не работает в обратном направлении.
Когда класс A наследуется от B, он получает все его методы и может их использовать. Однако методы, которые были добавлены в A, не загружаются наверх в B, наследование не имеет обратной совместимости. Если попытаться вызвать из класса-родителя метод, который создан в классе-наследнике, вы получите ошибку.
Что нужно запомнить: кроме конструкторов и деструкторов, дочерний класс получает от родителя абсолютно всё.
Если класс С, будет унаследован от класса B, который, в свою очередь, будет унаследован от класса A, то класс C унаследует члены как от класса B, так и от класса A. Это транзитивное свойство наследования. Потомок перенимает все члены родителей и не может исключить какие-либо. Он может «спрятать» их, создав свой метод с тем же именем. Конечно, это никак не повлияет на родительский класс, просто в дочернем метод не будет виден.
Автоматически воспринимается C# так:
Теперь ещё один момент. Если мы захотим сделать так:
То у нас это не получится:
‘InheritanceAndPolymorphism.ClassW’ cannot derive from special class ‘System.ValueType’
‘InheritanceAndPolymorphism.ClassX’ cannot derive from special class ‘System.Enum’
‘InheritanceAndPolymorphism.ClassY’ cannot derive from special class ‘System.Delegate’
‘InheritanceAndPolymorphism.ClassZ’ cannot derive from special class ‘System.Array’
Заметили словосочетание «special class»? Такие классы нельзя расширять.
Compile time Error: Class ‘InheritanceAndPolymorphism.ClassY’ cannot have multiple base classes: ‘InheritanceAndPolymorphism.ClassW’ and ‘ClassX’.
Что ещё нужно запомнить: класс может иметь только одного родителя, множественное наследование в C# не поддерживается (оно поддерживается у интерфейсов, но в этой статье мы о них речи не ведём).
Если мы попробуем обойти это правило таким образом:
Error: Circular base class dependency involving ‘InheritanceAndPolymorphism.ClassX’ and ‘InheritanceAndPolymorphism.ClassW’.
Что нужно запомнить: классы не могут наследоваться циклически (1-й от 2-го, 2-й от 3-го 3-й от 1-го), что, в общем-то, логично.
Операции с объектами
Здесь мы пытаемся приравнять объект от разных классов друг к другу.
Cannot implicitly convert type ‘InheritanceAndPolymorphism.ClassB’ to ‘InheritanceAndPolymorphism.ClassA’
Cannot implicitly convert type ‘InheritanceAndPolymorphism.ClassA’ to ‘InheritanceAndPolymorphism.ClassB’
Однако у нас это плохо получается. Даже несмотря на то, что они имеют одинаковые поля с одинаковыми значениями. Даже если бы эти поля имели одинаковые названия. C# работает с типами очень чётко — вы не можете приравнять два объекта от двух независимых классов. Однако, если бы класс A наследовался от B:
…мы бы продвинулсь немногим дальше:
Error: Cannot implicitly convert type ‘InheritanceAndPolymorphism.ClassB’ to ‘InheritanceAndPolymorphism.ClassA’. An explicit conversion exists (are you missing a cast?)
Как я уже говорил, C# подходит к вопросам типов очень дотошно. Класс A унаследован от B, значит, имеет все его поля и методы — при назначении переменной типа B объекта типа A проблем не возникает. Однако вы уже знаете, что в обратную сторону это не работает — в классе B нет полей и методов, которые могут быть в A.
Что нужно запомнить: вы можете назначить переменной родительского типа объект дочернего, но не наоборот.
Здесь нам наконец-то представляется шанс обмануть правило:
Приведение типа здесь сработает, но только потому, что эти классы находятся в наследственных отношениях. Два обособленных непримитивных типа привести друг к другу нельзя.
Итак, наш последний блок кода:
Error: Cannot implicitly convert type ‘int’ to ‘char’. An explicit conversion exists (are you missing a cast?)
Заключение
В этой части мы рассмотрели наследование. Мы попробовали запускать разные варианты кода, чтобы возможно глубже понять суть этого принципа. *этот текст будет изменён после перевода следующей статьи* In my next article, we’ll be discussing about run time polymorphism. Inheritance plays a very important role in run time polymorphism.
Вот что вы должны были запомнить за сегодня:
Напоминаем вам, что в первой статье этой серии вы можете прочитать о полиморфизме. Продолжайте учиться программировать с нами!




