Различные типы наследования в Ruby, когда их использовать и как Ruby следует цепочке наследования в пути поиска метода.

Наследование в объектно-ориентированном программировании — это концепция, которая относится к способности классов наследовать поведение других классов. Однако это не двустороннее наследование. Другими словами, один класс, подкласс, наследует поведение другого класса, суперкласса; суперкласс не наследует поведения от подкласса.

В Ruby существует два типа наследования: наследование классов и наследование интерфейсов. И то, и другое одинаково важно, и вместе они расширяют наши возможности по приданию функциональности объектам, добавляя преимущества простоты, гибкости и масштабируемости в наш код.

Наследование классов

Наследование классов создается, когда общие поведения (методы) извлекаются в суперкласс, от которого один или несколько подклассов наследуют эти поведения. Это означает, что объект подкласса может вызывать методы, определенные в суперклассе, без необходимости определения метода в подклассе. Мы можем думать о суперклассе как о библиотеке методов, которые при наследовании расширяют функциональность подкласса; другими словами, подкласс обладает не только функциональностью, определенной внутри него самого, но также и функциональностью своего суперкласса. Синтаксис для обозначения наследования классов прост: при определении класса мы используем символ < после имени класса, за которым следует имя суперкласса.

Например, допустим, у нас есть ресторан, в котором мы хотим определить суперкласс Employee с несколькими подклассами разных типов сотрудников. Вот как мы можем указать, что класс Host является подклассом суперкласса Employee:

Чтобы увидеть, как в приведенном выше коде реализовано наследование, мы можем просто вызвать метод clocked_in из класса Employee для объекта класса Host.

Чтобы еще больше продемонстрировать реализацию и преимущества наследования, мы можем немного усложнить наш пример.

Как упоминалось ранее, наследование позволяет нам извлекать общее поведение в один суперкласс. Это означает, что мы можем написать код один раз, а затем повторно использовать тот же самый код в стольких подклассах, сколько необходимо, и все это без необходимости переписывать один и тот же код снова и снова. Мы видим это в приведенном выше коде с методами clock_in и clock_out из Employeesuperclass: мы определили методы один раз и можем вызывать их из любого из подклассов, которые мы определили после него. Без наследования нам пришлось бы определять методы clock_in и clock_out в каждом классе, где требуется такое поведение.

Кроме того, при наследовании мы по-прежнему можем определить более точно настроенное поведение в подклассе, которое может быть применимо не ко всем подклассам суперкласса, от которого наследуется подкласс. Чтобы продолжить наш пример выше, в то время как каждый сотрудник имеет возможность включать и выключать время начала и окончания смены, каждый сотрудник также имеет поведение, характерное для роли сотрудника. Например, сервер может обслуживать гостей, а хост — нет.

Структурирование кода в классах, которые наследуют поведение от общего суперкласса, обеспечивает большую гибкость кода, который мы пишем. Благодаря такой гибкости мы можем легко повторно использовать общие поведения, которые мы извлекли в суперклассы, в то же время определяя более конкретные поведения в подклассах, где это необходимо. Например, мы можем определить столько подклассов класса Employee, сколько необходимо — руководителей, менеджеров, маркетологов, посудомойщиков и т. д. — без необходимости переписывать и повторять какое-либо поведение, унаследованное от суперкласса Employee.

Но что, если у нас есть общее поведение, разделяемое несколькими, но не всеми подклассами? Например, предположим, что серверы могут выполнять те же функции, что и хост — записывать имя гостя, усаживать гостя и давать ему меню. Означает ли это, что нам нужно определить все эти поведения в обоих классах? Или есть способ извлечь эти поведения в общее, наследуемое место?

Второй тип наследования — наследование interface — позволяет нам делать именно это.

Наследование интерфейса

Прежде чем мы углубимся в обсуждение наследования интерфейсов, давайте продолжим наш мысленный эксперимент с нашим воображаемым рестораном. Для этого предположим, что объект класса Server способен выполнять многие из тех же функций, что и объект класса Host, особенно функции, связанные с посадкой гостя. Например, и хосты, и серверы могут записать имя гостя, посадить гостя за стол, дать гостю меню и сообщить гостю о специальном блюде дня, когда гость садится. Однако, поскольку это поведение не является общим для всех подклассов суперкласса Employee, мы не хотим, чтобы эти поведения были определены в этом классе, а затем унаследованы подклассами, для которых это поведение не подходит (например, повара и официанты).

Давайте изменим наш код, чтобы дать нашим классам Host и Server эти новые поведения.

Это много повторяющегося кода, что заставляет нас задаться вопросом: есть ли способ извлечь этот код в общее место, от которого классы Host и Server могут наследовать такое поведение, как мы это сделали с наследование класса от класса Employee? Наследование Interface позволяет нам делать именно это. Разница, однако, заключается в том, что поведение можно наследовать не от суперкласса, а от модуля.

Модуль похож на класс тем, что представляет собой набор связанных поведений; однако ключевое отличие состоит в том, что объект не может создаваться из модуля. В то время как класс обеспечивает план состояния и поведения объектов, созданных из класса, модуль просто добавляет функциональность классу, в который этот модуль был включен. Как набор поведений, модуль можно «примешать» к классу, вызвав метод include, за которым следует имя модуля. Когда модуль используется таким образом, он соответственно называется mixin module. Модули определяются как классы, за исключением того, что вместо них используется ключевое слово module.

Давайте вернемся к нашему примеру выше и извлечем методы, общие для классов Host и Server, в модуль с именем Hostable

Как вы можете видеть в нашем примере выше, мы дедуплицировали все функции, связанные с размещением гостей, и извлекли их в один модуль с именем Hostable. Затем мы предоставили доступ к этой функциональности в наших классах Host и Server, смешав модуль Hostable с помощью вызова метода include. На самом деле, мы можем включить столько модулей, сколько подходит для рассматриваемого класса. Например, предположим, что и хосты, и официанты, но не официанты и повара, могут петь песню на день рождения, когда гость приходит в ресторан в свой день рождения. Вместо того, чтобы писать метод sing_birthday_song в классах Host и Server, мы можем определить этот метод в модуле Singable, а затем включить этот модуль в два класса, как мы это сделали с модулем Hostable.

Структурирование наследования через модули дает классам Ruby возможность наследовать из нескольких мест. В то время как некоторые языки программирования имеют множественное наследование, что означает, что подкласс может наследоваться более чем от одного суперкласса, Ruby — строго язык с одним наследованием. Однако благодаря использованию модулей и наследования интерфейсов Ruby может имитировать множественное наследование.

Наследование классов против наследования интерфейсов

До сих пор мы видели наследование как классов, так и интерфейсов в практических примерах, но, возможно, еще не ясно, когда можно использовать одно или другое для структурирования поведения и классов, которые их наследуют.

Вы помните ранее в нашем обсуждении, что наследование — это группировка общего поведения в одном месте, из которого несколько классов могут наследовать эти поведения, причем это единственное место является либо суперклассом, либо модулем. В обоих случаях цель состоит в том, чтобы сгруппировать общие варианты поведения, которые можно определить один раз, а затем повторно использовать столько раз, сколько необходимо, без ненужного дублирования кода. Тем не менее, мы хотим сгруппировать поведение таким образом, чтобы наследуемое поведение соответствовало классам, которые их наследуют. При этом мы можем писать более гибкий, хорошо масштабируемый и простой в обслуживании код.

В общем, если между классами существует отношение «есть-а», лучше всего начать с наследования классов. Наследование классов позволяет нам структурировать классы и поведение в логические иерархии, часто моделируя иерархии, которые мы видим в реальном мире. Возвращаясь к примерам, которые мы использовали ранее, хост «является» сотрудником, так же как официант, официант и повар. И, естественно, как члены более общего класса Employee, у каждого из них будут общие черты поведения, такие как приход и уход в начале и в конце смены.

Однако часто бывают исключения из такого рода иерархического моделирования, например, может быть поведение, общее для некоторых подклассов, но не для всех. Мы видели это с классами Host и Server, которые могли выполнять функции хозяина (т. е. рассаживать гостей). Поскольку такое поведение характерно только для них, а не для других подклассов суперкласса _41, мы не должны определять эти поведения в классе Employee, так как это приведет к тому, что подклассы (такие как официанты и повара) наследуют поведение, которое им не подходит. иметь.

Альтернативой, конечно, является определение этих методов в отдельных подклассах, которые в них нуждаются; но это не очень хорошая альтернатива, так как наш код становится излишне повторяющимся. Именно здесь вступает в действие наследование интерфейса через модули примесей. Когда общие поведения не обязательно вписываются в иерархические модели, которые мы создали с помощью подклассов, и между этими поведениями и классами, которые в них нуждаются, существует отношение «имеет-а», тогда наследование интерфейса, как правило, будет лучшим выбором в таком случае. Это именно то, что мы видели с классами Host и Server и модулем Hostable, где поведение не соответствовало суперклассу Employee, и оба класса Host и Server «имели» поведение в модуле Hostable.

Цепочка наследования и путь поиска метода

Несмотря на то, что Ruby является языком одиночного наследования, где класс может иметь только один суперкласс, класс также наследует поведение, которое наследует его суперкласс. Это немного многословно, но это просто означает, что всегда существует цепочка наследования, связанная с созданием подклассов одного класса из другого. Подобно воде, бегущей сверху вниз по цепочке, вода (поведение) проходит через любые звенья (классы) в цепочке, созданные путем создания подклассов дополнительных классов, пока не достигнет последнего звена в последовательности.

Для нашего ресторанного мысленного эксперимента мы создали суперкласс Employee с несколькими подклассами типа сотрудников. Чтобы продемонстрировать эту цепочку наследования, мы собираемся определить суперкласс Person, от которого подклассы класса Employee. Кроме того, мы также создадим класс Guest, который также является подклассом класса Person в дополнение к классам Adult и Child, которые являются подклассами Guest.

Чтобы было легче увидеть, давайте извлечем структуру, которую мы создали между нашими классами.

Person < Guest < Adult
Person < Guest < Child
Person < Employee < Host
Person < Employee < Server
Person < Employee < Busser
Person < Employee < Cook

При наличии этой структуры последние подклассы в созданной нами цепочке наследуют поведение, определенное в верхней части цепочки, а также любые промежуточные классы. Например, класс Host наследует поведение класса Person, поскольку эти поведения унаследованы в классе Employee, а класс Host наследует поведение класса Employee.

Чтобы увидеть эту цепочку наследования на практике, мы создадим объект из класса Adult, а затем вызовем name метод, определенный в классе Person.

Как мы видели при обсуждении наследования классов, мы можем вызывать name метод, определенный в классе Person, для объекта класса Adult, даже если name метод не определен ни в классе Adult, ни в Guest сорт. Это, опять же, наследование в действии.

Чтобы еще больше проиллюстрировать цепочку наследования, рассмотрим часто используемый метод object_id. Этот метод можно вызвать для любого объекта Ruby, даже если этот метод не определен ни в одном из классов объектов, для которых мы обычно вызываем этот метод (например, строки, целые числа и массивы). Это связано с тем, что object_id метод является методом экземпляра класса _75, и все объекты Ruby в конечном итоге наследуются от класса Object в цепочке наследования.

Например, если бы мы вызывали метод object_id для объекта класса Cook, цепочка наследования выглядела бы так:

Object < Person < Employee < Cook

В Ruby существует особый механизм, лежащий в основе этой цепочки наследования, который называется путем поиска метода. Это средство, с помощью которого Ruby находит определение метода, чтобы выполнить вызов метода с тем же именем. Проще говоря, это ответ на вопрос: откуда Ruby знает, где найти определение метода при его вызове? Мы можем думать о пути поиска метода именно так, как следует из названия: путь, по которому Ruby проходит через цепочку наследования, чтобы найти определение метода для вызываемого метода.

Для любого вызова метода общая последовательность, которой Ruby следует, чтобы найти соответствующее определение метода, выглядит следующим образом:

Class (of the object upon which the method was invoked)
  ↓
Module(s)
  ↓
Superclass
  ↓
[repeat w/the Superclass now in the beginning position taken by Class]

Как видно из схемы выше, Ruby сначала ищет в классе объекта, для которого был вызван метод. Если Ruby не находит там подходящего определения метода, Ruby затем ищет включенные модули, если таковые имеются. Если в класс включены два или более модулей, Ruby сначала ищет последний включенный модуль (который будет последним включенным модулем), а затем просматривает все оставшиеся модули в обратном порядке. Если определение метода все еще не найдено, Ruby затем ищет в суперклассе класса, повторяя один и тот же цикл столько раз, сколько необходимо. Как только Ruby исчерпал все пользовательские классы и модули, не найдя подходящего определения метода, Ruby продолжает работу со следующими классами и модулями:

Object
  ↓
Kernel (module in the Object class)
  ↓
BasicObject

Если Ruby не может найти соответствующее определение метода где-либо в пути поиска метода, возникает ошибка NoMethodError.

Чтобы увидеть путь поиска метода на практике, давайте рассмотрим следующий код.

В приведенном выше примере мы создали два объекта, один из класса Host и один из класса Server. На нашем host объекте мы вызвали метод seat_guest.

После вызова метода seat_guest в host вот путь поиска метода, который Ruby использует для поиска соответствующего определения метода.

Host
  ↓
Singable
  ↓
Hostable

[seat_guest method definition found in Hostable module]

Если бы метод seat_guest не был бы найден, Ruby исчерпал бы весь путь поиска метода, прежде чем вызвать NoMethodError.

Host
  ↓
Singable
  ↓
Hostable
  ↓
Employee
  ↓
Person
  ↓
Object
  ↓
Kernel
  ↓
BasicObject
  ↓
[NoMethodError]

Заключение

Подводя итог, наследование относится к способности одного класса наследовать поведение другого класса, что позволяет нам извлекать общий код в одно наследуемое место. Поскольку Ruby — это язык с единым наследованием, а это означает, что класс может быть подклассом только одного суперкласса, в Ruby существует два типа наследования: наследование классов и наследование интерфейсов. Хотя наследование классов позволяет нам создавать иерархические отношения между классами, не всегда имеет смысл наследовать определенные модели поведения, общие для некоторых подклассов, от суперкласса. В результате наследование интерфейса через модули примесей дает нам возможность имитировать множественное наследование. Чтобы выполнить вызов метода, Ruby использует путь поиска метода, чтобы найти соответствующее определение метода.

  1. Обычным соглашением об именах для модулей миксинов является добавление суффикса «-able» к имени модуля.