Scala Trait

Trait в Scala это что то среднее между интерфейсом java и абстрактным классом. Фактически  Trait  может практически все тоже, что и абстрактный класс, но в тоже время им доступны все роли интерфейсов. В самом простом случае Trait это эквивалент интерфейса java:

В этом случае Trait содержит только публичные и абстрактные методы, при этом подобно интерфейсам java возможно расширение других трейтов или интерфейсов java (которые видны системой типов scala как признаки) и могут быть реализованы классами. Класс может реализовывать несколько интерфейсов. При этом такой вариант трейта будет скомпилирован в байт код интерфейса java.

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

Синтаксис наследования

В языке scala существует два ключевых слова для выражения наследования extends и width, при этом эти ключевые слова не эквивалентны инструментам java.

Когда класс расширяет другой класс он использует ключевое слово extends

Однако если есть необходимость добавить в основной класс функционал из трейтов, то используется ключевое слово width:

При этом класс не должен быть абстрактным а может просто подмешивать необходимый функционал:

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

Есть общее правило первое от чего производится наследование определяется ключевым словом extends, а все последующие добавляется после ключевого слова width. Однако строку вида:

Стоит рассматривать как строку:

Поэтому правильно трактовать такой код, как будто мы смешиваем Base, Logging и Utils вместе, а затем класс Impl расширяет этот неназванный смешанный класс.

ПРИМЕЧАНИЕ. Порядок смешивания трейтов в определении класса или другого трейта имеет существенное значение. Кроме того, базовый класс должен всегда появляться сразу же после ключевого слова extends.

Анонимное смешивание

Также возможно создавать анонимные классы, которые смешиваются по нескольким признакам. Например:

Пересечение типов

Вы можете столкнуться с ключевым словом, используемым в другом контексте – в объявлениях типов. Например, вы можете увидеть что-то вроде этого:

Такой тип следует читать как «нечто, что является экземпляром как A, так и B». К сожалению, использование ключевого слова with в таком синтаксисе, сбивает с толку, потому что его значение существенно отличается от синтаксиса наследования. В частности:

  • A и B могут быть произвольными типами, а не только классами или чертами
  • порядок типов не имеет значения

Например, если A и B являются трейтами, то тогда, код будет скомпилирован:

намного больше чем публичные методы

В отличие от интерфейсов Java, методы не обязательно должны быть общедоступными – они могут иметь любой квалификатор доступа, который может иметь метод класса. Они также могут быть окончательными (методы по умолчанию Java 8 не могут):

Данные и состояния

Подобно классам, трейты Scala могут иметь данные и состояние в виде vals, lazy vals, vars и objects. Они могут быть абстрактными или конкретными, они могут реализовывать, переопределять, быть окончательными и т. п.

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

Вот почему мы обычно не говорим, что трейтах «реализованы», а скорее «смешаны». Обычное использование трейтов состоит в том, чтобы разделить реализацию большого класса на набор отдельных признаков и «подмешать» их в класс. Это показывает принципиальную концептуальную разницу между интерфейсами Java и трейтами Scala: интерфейсы предназначены для выражения абстракции (которые могут иметь некоторое поведение по умолчанию, чтобы обеспечить более легкую эволюцию интерфейса), в то время как трейты гораздо более способны быть многоразовыми элементами реализации.

Трейт может наследовать класс

Трейт может наследовать другие трейты и, таким образом, использовать, реализовывать, уточнять и переопределять унаследованные элементы. Но трейты также может унаследовать поведение от класса и делать то же самое со своими членами (почти):

Трейт LoggingHandler это пример трейта созданного для повторного использования кода. Каждый класс который расширяет BaseHandler и хочет добавить некоторое журналирование в метод handle может просто добавить трейт LogginHandler.

Множественное наследование

Вы можете подумать: если трейт может расширять класс, а класс может смешиваться по нескольким трейтам, означает ли это, что класс может иметь несколько (не связанных) базовых классов?

Ответ – нет. У каждого класса в Scala все еще может быть только один (прямой) суперкласс. В нашем предыдущем примере суперклассом ConcreteHandler является BaseHandler (неявно унаследованный в результате смешивания в LoggingHandler), а суперкласс класса BetterConcreteHandler – BetterHandler (явно унаследован). Не существует конфликта, хотя LoggingHandler расширяет BaseHandler, потому что BetterHandler также расширяет BaseHandler. Однако, если бы мы писали:

Мы получили ошибку компиляции, потому что BaseHandler и Unrelated не могут быть и суперклассами ConcreteHandler.

Инициализация кода

Трейт может иметь val, var, lazy val и объекты . Это означает, что у трейта может быть некоторый код инициализации, связанный с ними.

На самом деле, у трейта есть «конструктор». Точно так же, как в классе или объекте, тело трейта может иметь некоторый код, который будет выполняться при создании экземпляра.

Однако принципиальное отличие от классов состоит в том, что конструктор признаков не может принимать параметры.

Линеаризация

Множественное наследование реализации и тот факт, что трейты имеют код инициализации («конструктор») создает некоторые серьезные проблемы:

  • проблема с ромбовидным наследованием: что происходит, когда класс несколько раз смешивается с одним и тем же трейтом?
  • порядок инициализации: в каком порядке должны выполняться конструкторы базового класса и «конструкторы» трейтов при создании экземпляра?
  • разрешение конфликтов: как разрешать конфликты между двумя унаследованными реализациями одного и того же метода?

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

Алгоритм линеаризации может быть суммирован более формально следующим образом: имея класс C, который расширяет B1 с B2 с … с Bn, линеаризация класса C определяется следующим образом:

Где оператор >+ обозначает конкатенацию, которая дополнительно удаляет все элементы из ее левого операнда, которые дублируются в правом операнде.

Рассмотрим пример:

Тогда:

ПРИМЕЧАНИЕ: на самом деле lin (E) – это [E, D, C, B, A, AnyRef, Any], но мы опускаем автоматическое Any и AnyRef для краткости.

Обратите внимание, что этот алгоритм дает понять, почему порядок объявленных смешанных в трейтах важен!

Линеаризация дает нам достаточно четкие ответы на все три вопроса, которые мы поставили раньше.

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

Линеаризация однозначно разрешает порядок инициализации – конструкторы и инициализаторы трейтов запускаются один за другим в порядке обратного инициализации, то есть начиная с Any и заканчивая конструктором самого создаваемого класса.

В приведенном выше примере линеаризация E является [E, D, C, B, A], и поэтому вызов “new E” будет напечатано: ABCDE.

Разрешение конфликтов

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

Пример:

Теперь, если мы создадим класс, который смешивает B и C, мы получим ошибку компиляции:

Существует несколько способов устранения этого конфликта, либо остановить автоматическую линеаризацию, либо явно ее выполняя:

  • Override в классе D
  • Override в классе D и ссылка на родительскую реализацию
    Ключевое слово super выберет первую реализацию someString, доступную в цепочке линеаризации. В этом случае линеаризация есть [D, C, B, A], и из-за этого будет использоваться реализация из C. Конечно, реализация переопределенного метода не ограничивается вызовом super  – и может содержать произвольный код и выполнять вызов super в любой точке.
  • Override в классе D и ссылка на особую родительскую реализацию
    В этом случае мы использовали новое синтаксическое – ключевое ключевое слово super, чтобы явно указать, что мы хотим использовать реализацию из trait B.
  • Добавить ключевое ovveride в реализации трейта С
    Добавляя модификатор переопределения ovveride к методу someString в трейте C, мы говорим, что «реализация в C имеет приоритет над тем, что приходит после него в порядке линеаризации». В нашем случае линеаризация – [D, C, B, A] (C – до B), и поэтому реализация C имеет приоритет над B. Однако компилятор scala потребовал от явного указания такого переопределения ключевым словом override.

    Если бы мы добавили ovveride к реализации в B вместо C или изменили D, чтобы расширить C с B вместо B с C, то такие изменения приведут к ошибкам компиляции. Мы могли бы также добавить переопределение ovveride к обеим реализациям – таким образом мы могли бы могли объявлять классы, которые расширяют оба B с C и C с B, а линеаризация всегда будет разрешать конфликт автоматически.

Неопределенное значение супер

Предыдущие примеры разрешения конфликтов показали очень своеобразное свойство  ключевого слова super в Scala: когда какая-то трейт обращается к super (задействует родительскую реализацию), он не знает, где эта реализация, пока какой-то класс фактически не смешивается с этим трейтом, а линеаризация будет иметь последнее слово. Это серьезное семантическое отличие от Java и может иметь очень удивительные свойства.

В частности, обратите внимание, что трейт B определяется следующим образом:

может не понять, что метод super.method будет ссылаться на метод super [A], несмотря на то, что A является прямым супертипом B, когда какой-либо класс смешивается с B, может быть еще один трейт, помещенный «между» A и B.

abstract override

Мы только что показали, что, когда трейт переопределяет метод из своего родительского  типа и обращается к родительской реализации, кто-то может обеспечить эту родительскую реализацию позже – на данный момент наш трейт смешан с классом. Это, в частности, означает, что при определении трейта родительская реализация может вообще отсутствовать!

Это означает, что этот трейт может фактически переопределить абстрактный метод. Для Scala есть специальная комбинация ключевых слов для этого: abstract ovveride. Пример:

Мы переопределили абстрактный метод и в то же время ссылались на супер-реализацию. Эта реализация еще не существует – класс (не абстрактный), который, наконец, смешивается в нашей функции AppendMoreStuff, должен «внедрить» некоторую реализацию someString в AppendMoreStuff. Например:

Значение, возвращаемое методом someString класса Klass, вернет “Impl and more stuff”.

Тот факт, что трейт не обязательно связан с какой-либо супер-реализацией, дает нам возможность повторно использовать трейты и составлять их во многих разных конфигурациях. Таким образом в Scala трейты могут стэкироватся в необходимом порядке, для получения желаемого функционала.

Проблемы функционирования трейтов
ловушка инициализации

Вот пример одной из наиболее распространенных ловушек наследования в Scala (или, возможно, даже для всего языка Scala). Он посвящен порядку инициализации и тому факту, что vals могут быть абстрактными и переопределенными.

Теперь, что произойдет, когда мы создадим экземпляр B? Оказывается, мы получим исключение NullPointerException в someString.toUpperCase! Почему это?

Это прямое следствие порядка инициализации B. Из линеаризации мы знаем, что код инициализации A будет выполняться перед конструктором B. Итак, вот что происходит, когда мы пытаемся вызвать new B:

  1. Создается новый объект. Никаких конструкторов и инициализаторов еще не было. Поэтому поля, содержащие значения someString и someStringUpper, являются нулевыми.
  2. Срабатывает инициализатор A. Он пытается присвоить начальное значение someStringUpper и выполняет someString.toUpperCase, который, к сожалению, завершается с ошибкой NullPointerException, потому что someString не получил возможность инициализироваться конструктором B.
  3. Если бы не NPE, здесь был бы задействован конструктор B, и он назначил бы начальное значение someString.

Итак, как мы это исправим? Существует несколько способов, но все они имеют свои ограничения:

  • Реализовать абстрактный val с параметром конструктора:
    Поля, которые имеют свои значения из параметров конструктора, инициализируются перед суперконструкторами и инициализаторами, поэтому это работает.
  • Реализуйте свой абстрактный val с lazy val
    Это работает, но обратите внимание, что это также вызывает инициализацию someString до вызова конструктора B. Поэтому вы должны быть осторожны, чтобы не ссылаться на то, что инициализируется этим конструктором (например, другим значением val, определенным в классе B), в начальном значении вашего lazy val. Например, если вы сделаете так:
    то вернется ошибка NPE (null pointer exception) только уже в другом месте. Также обратите внимание на неинтуитивный факт: lazy ключевое слово обычно должно задерживать инициализацию поля, но здесь оно делает обратное – на самом деле инициализация происходит раньше обычного.
  • Создайте не абстрактный элемент (например, someStringUpper) lazy val или def:
    Однако для того, чтобы это сработало, вы должны убедиться, что никто не использует someStringUpper, прежде чем конструктор B инициализирует someString. Например, если вы это сделаете:
    все опять сломается
  • В качестве крайней последней меры используйте ранний инициализатор
    Это странный синтаксис, который мы еще не показали в этом руководстве. Он принудительно инициализирует поле someString до того, как будет выполнен код инициализации A. Эта особенность – очень темный угол Скалы, и его следует избегать. Если вы обнаружите, что используете такой синтаксис, вам, вероятно, следует снова взглянуть на свой код и найти способ его перепроектировать, чтобы он не зависел от порядка инициализации.
Аннотация собственного типа

Аннотация собственного типа в Scala это способ указать требование к трейту или абстрактному классу, что не абстрактный класс который подмешивает ваш трейт аннотированный собственным типом должен обязательно подмешать указанный трейт или в более общем случае реализовать указанный тип. Например:

Теперь:

  • любой не абстрактный класс наследующий В также должен наследовать и А
  • любой трейт или класс наследующий В, который при этом НЕ наследующий А, должен повторять это требование в аннотации собственного типа

Например:

Аннотации собственного типа позволяют использовать API этих описанных типов внутри трейтов:

Другими словами, аннотация собственного типа – это способ для трейта или абстрактного класса объявить «зависимость», но без создания явного отношения наследования и подтипирования. Таким образом, вы можете думать об этом механизме как о «более слабой» форме наследования – которая делает некоторые вещи, аналогичные наследованию, но не все из них. И такое поведение делает этот механизм несколько более гибким.

Имеем:

Разница между собственным типом (Selfish) и наследованием (Childish) в следующем:

  • Childish это под тип Person, но при этом Selfish не является под типом Person. Другими словами, наследование выражает, что «Childish- это Person», в то время как аннотация собственного типа выражает, что «все, что есть Selfish, должно быть также Person». Тот факт, что Selfish можно рассматривать как Person, виден только внутри тела Selfish.
  • Childish, как гарантируется, должен быть инициализирован перед Person в порядке линеаризации, чтобы он мог реализовать и переопределить членов Person. Нет никакой гарантии, что Selfish будет инициализирован до Person в порядке линеаризации, чтобы и таким образом он не может реализовать или переопределить членов Person. Тем не менее, он все еще может их использовать.
  • Поскольку сам тип является всего лишь требованием, он должен, в конечном счете, быть фактически унаследован каким-то классом или признаком или повторяться признаком или абстрактным классом. Это вводит некоторые шаблоны поддержания. Изменение собственного типа на другой или более конкретный тип может потребовать, чтобы вы отрегулировали многие подзаголовки или подклассы признака с аннотацией собственного типа. Это обычно не обязательно при использовании наследования.
  • Аннотации собственного типа могут выражать вещи, которые невозможно выразить с наследованием, например:
Синоним this

Собственные типы могут включать синоним для идентификатора this, что полезно когда ваш класс или трейт имеет внутренний класс, которому необходимо ссылаться на внешний:

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

 

 

Обсуждение закрыто.