Trait в Scala это что то среднее между интерфейсом java и абстрактным классом. Фактически Trait может практически все тоже, что и абстрактный класс, но в тоже время им доступны все роли интерфейсов. В самом простом случае Trait это эквивалент интерфейса java:
1 2 3 4 |
trait StuffHandler { def handleStringStuff(stuff: String): Unit def handleIntStuff(stuff: Int): Unit } |
В этом случае Trait содержит только публичные и абстрактные методы, при этом подобно интерфейсам java возможно расширение других трейтов или интерфейсов java (которые видны системой типов scala как признаки) и могут быть реализованы классами. Класс может реализовывать несколько интерфейсов. При этом такой вариант трейта будет скомпилирован в байт код интерфейса java.
При этом стоит отметить, что до появления java 8 (с ее вариантом методов по умолчанию), в том случае если в трейте содержались методы с реализацией scala формировала специальный байт код на основе абстрактных классов, теперь такой код аналогичен коду java 8.
Синтаксис наследования
В языке scala существует два ключевых слова для выражения наследования extends и width, при этом эти ключевые слова не эквивалентны инструментам java.
Когда класс расширяет другой класс он использует ключевое слово extends
1 2 |
class Base class Impl extends Base |
Однако если есть необходимость добавить в основной класс функционал из трейтов, то используется ключевое слово width:
1 2 3 4 |
class Base trait Logging trait Utils class Impl extends Base with Logging with Utils |
При этом класс не должен быть абстрактным а может просто подмешивать необходимый функционал:
1 2 3 |
trait Logging trait Utils class Impl extends Logging with Utils |
Как вы можете видеть, мы по-прежнему используем ключевое слово extends, хотя мы явно не наследуем никакого базового класса. Кроме того, когда трейт наследуется от других трейтов, и при этом также используется extends:
1 2 3 |
trait Logging trait Utils trait Commons extends Logging with Utils |
Есть общее правило первое от чего производится наследование определяется ключевым словом extends, а все последующие добавляется после ключевого слова width. Однако строку вида:
1 |
class Impl extends Base with Logging with Utils |
Стоит рассматривать как строку:
1 2 |
// этот код не корректен в Scala, но он демонстрирует подход к применению ключевых слов наследования class Impl extends (Base with Logging with Utils) |
Поэтому правильно трактовать такой код, как будто мы смешиваем Base, Logging и Utils вместе, а затем класс Impl расширяет этот неназванный смешанный класс.
ПРИМЕЧАНИЕ. Порядок смешивания трейтов в определении класса или другого трейта имеет существенное значение. Кроме того, базовый класс должен всегда появляться сразу же после ключевого слова extends.
Анонимное смешивание
Также возможно создавать анонимные классы, которые смешиваются по нескольким признакам. Например:
1 |
val b: Base = new Base with Logging with Utils |
Пересечение типов
Вы можете столкнуться с ключевым словом, используемым в другом контексте – в объявлениях типов. Например, вы можете увидеть что-то вроде этого:
1 |
val ab: A with B = ??? |
Такой тип следует читать как «нечто, что является экземпляром как A, так и B». К сожалению, использование ключевого слова with в таком синтаксисе, сбивает с толку, потому что его значение существенно отличается от синтаксиса наследования. В частности:
- A и B могут быть произвольными типами, а не только классами или чертами
- порядок типов не имеет значения
Например, если A и B являются трейтами, то тогда, код будет скомпилирован:
1 |
val awb: A with B = new B with A |
намного больше чем публичные методы
В отличие от интерфейсов Java, методы не обязательно должны быть общедоступными – они могут иметь любой квалификатор доступа, который может иметь метод класса. Они также могут быть окончательными (методы по умолчанию Java 8 не могут):
1 2 3 4 5 6 7 8 |
trait SafeStuffHandler { final def handleSafely(stuff: String): Unit = try handleUnsafely(stuff) catch { case NonFatal(t) => t.printStackTrace() } protected def handleUnsafely(stuff: String): Unit } |
Данные и состояния
Подобно классам, трейты Scala могут иметь данные и состояние в виде vals, lazy vals, vars и objects. Они могут быть абстрактными или конкретными, они могут реализовывать, переопределять, быть окончательными и т. п.
Это настоящий игровой набор, так как трейт может иметь состояние и данные, то он может содержать значительную часть наследуемой реализации и больше не представляет собой некоторую абстракцию.
Вот почему мы обычно не говорим, что трейтах «реализованы», а скорее «смешаны». Обычное использование трейтов состоит в том, чтобы разделить реализацию большого класса на набор отдельных признаков и «подмешать» их в класс. Это показывает принципиальную концептуальную разницу между интерфейсами Java и трейтами Scala: интерфейсы предназначены для выражения абстракции (которые могут иметь некоторое поведение по умолчанию, чтобы обеспечить более легкую эволюцию интерфейса), в то время как трейты гораздо более способны быть многоразовыми элементами реализации.
Трейт может наследовать класс
Трейт может наследовать другие трейты и, таким образом, использовать, реализовывать, уточнять и переопределять унаследованные элементы. Но трейты также может унаследовать поведение от класса и делать то же самое со своими членами (почти):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
abstract class BaseHandler { def handle(stuff: String): Int = println("Handling!") } trait LoggingHandler extends BaseHandler { override def handle(stuff: String): Int = { println(s"About to handle $stuff") super.handle(stuff) } } abstract class BetterHandler extends BaseHandler { def doBetterWork(): Unit = println("Doing it!") } class ConcreteHandler extends LoggingHandler class BetterConcreteHandler extends BetterHandler with LoggingHandler |
Трейт LoggingHandler
это пример трейта созданного для повторного использования кода. Каждый класс который расширяет BaseHandler и хочет добавить некоторое журналирование в метод handle может просто добавить трейт LogginHandler.
Множественное наследование
Вы можете подумать: если трейт может расширять класс, а класс может смешиваться по нескольким трейтам, означает ли это, что класс может иметь несколько (не связанных) базовых классов?
Ответ – нет. У каждого класса в Scala все еще может быть только один (прямой) суперкласс. В нашем предыдущем примере суперклассом ConcreteHandler является BaseHandler (неявно унаследованный в результате смешивания в LoggingHandler), а суперкласс класса BetterConcreteHandler – BetterHandler (явно унаследован). Не существует конфликта, хотя LoggingHandler расширяет BaseHandler, потому что BetterHandler также расширяет BaseHandler. Однако, если бы мы писали:
1 2 3 4 5 |
abstract class BaseHandler abstract class Unrelated trait LoggingHandler extends BaseHandler // error! ошибка компиляции class ConcreteHandler extends Unrelated with LoggingHandler |
Мы получили ошибку компиляции, потому что BaseHandler и Unrelated не могут быть и суперклассами ConcreteHandler.
Инициализация кода
Трейт может иметь val, var, lazy val и объекты . Это означает, что у трейта может быть некоторый код инициализации, связанный с ними.
На самом деле, у трейта есть «конструктор». Точно так же, как в классе или объекте, тело трейта может иметь некоторый код, который будет выполняться при создании экземпляра.
Однако принципиальное отличие от классов состоит в том, что конструктор признаков не может принимать параметры.
Линеаризация
Множественное наследование реализации и тот факт, что трейты имеют код инициализации («конструктор») создает некоторые серьезные проблемы:
- проблема с ромбовидным наследованием: что происходит, когда класс несколько раз смешивается с одним и тем же трейтом?
- порядок инициализации: в каком порядке должны выполняться конструкторы базового класса и «конструкторы» трейтов при создании экземпляра?
- разрешение конфликтов: как разрешать конфликты между двумя унаследованными реализациями одного и того же метода?
Scala решает эти проблемы с помощью механизма, называемого линеаризацией. Это алгоритм, который принимает все базовые классы и смешивается в трейтах некоторого класса и упорядочивает их в линейную последовательность. То, как это происходит, хорошо определено и детерминировано.
Алгоритм линеаризации может быть суммирован более формально следующим образом: имея класс C, который расширяет B1 с B2 с … с Bn, линеаризация класса C определяется следующим образом:
1 |
lin(C) = C >+ lin(Bn) >+ lin(Bn-1) >+ ... >+ lin(B1) |
Где оператор >+ обозначает конкатенацию, которая дополнительно удаляет все элементы из ее левого операнда, которые дублируются в правом операнде.
Рассмотрим пример:
1 2 3 4 5 |
trait A trait B extends A trait C extends A trait D class E extends B with C with D |
Тогда:
1 2 3 4 5 6 7 8 |
lin(E) = E >+ lin(D) >+ lin(C) >+ lin(B) lin(D) = D lin(A) = A lin(C) = [C, A] lin(B) = [B, A] // here we drop the duplicated A from the left side lin(C) >+ lin(B) = [C,A] >+ [B,A] = [C,B,A] lin(E) = [E, D, C, B, A] |
ПРИМЕЧАНИЕ: на самом деле lin (E) – это [E, D, C, B, A, AnyRef, Any], но мы опускаем автоматическое Any и AnyRef для краткости.
Обратите внимание, что этот алгоритм дает понять, почему порядок объявленных смешанных в трейтах важен!
Линеаризация дает нам достаточно четкие ответы на все три вопроса, которые мы поставили раньше.
Линеаризация удаляет дубликаты, так что состояние и данные из одного и того же трейта не могут быть унаследованы несколько раз независимо.
Линеаризация однозначно разрешает порядок инициализации – конструкторы и инициализаторы трейтов запускаются один за другим в порядке обратного инициализации, то есть начиная с Any и заканчивая конструктором самого создаваемого класса.
1 2 3 4 5 |
trait A { print("A") trait B extends A { print("B") } trait C extends A { print("C") } trait D { print("D") } class E extends B with C with D { print("E") } |
В приведенном выше примере линеаризация E является [E, D, C, B, A], и поэтому вызов “new E” будет напечатано: ABCDE.
Разрешение конфликтов
При наследовании нескольких реализаций одного и того же метода из более чем одного базового трейта важно не только линеаризованный порядок, но и устранить конфликты связанные с использованием ключевых слов ovveride и final.
Пример:
1 2 3 4 5 6 7 8 9 |
trait A { def someString: String } trait B extends A { def someString = "B" } trait C extends A { def someString = "C" } |
Теперь, если мы создадим класс, который смешивает B и C, мы получим ошибку компиляции:
1 2 |
// ошибка: класс D наследует конфликтующие члены class D extends B with C |
1 2 |
// ошибка: класс D наследует конфликтующие члены class D extends B with C |
Существует несколько способов устранения этого конфликта, либо остановить автоматическую линеаризацию, либо явно ее выполняя:
- Override в классе D
123class D extends B with C {override def someString = "D"} - Override в классе D и ссылка на родительскую реализацию
123class D extends B with C {override def someString = super.someString} - Override в классе D и ссылка на особую родительскую реализацию
123class D extends B with C {override def someString = super[B].someString} - Добавить ключевое ovveride в реализации трейта С
123trait C extends A {override def someString = "C"}Если бы мы добавили ovveride к реализации в B вместо C или изменили D, чтобы расширить C с B вместо B с C, то такие изменения приведут к ошибкам компиляции. Мы могли бы также добавить переопределение ovveride к обеим реализациям – таким образом мы могли бы могли объявлять классы, которые расширяют оба B с C и C с B, а линеаризация всегда будет разрешать конфликт автоматически.
Неопределенное значение супер
Предыдущие примеры разрешения конфликтов показали очень своеобразное свойство ключевого слова super в Scala: когда какая-то трейт обращается к super (задействует родительскую реализацию), он не знает, где эта реализация, пока какой-то класс фактически не смешивается с этим трейтом, а линеаризация будет иметь последнее слово. Это серьезное семантическое отличие от Java и может иметь очень удивительные свойства.
В частности, обратите внимание, что трейт B определяется следующим образом:
1 2 |
trait A { def method: String = "A" } trait B extends A { override def method: String = super.method + "B" } |
может не понять, что метод super.method будет ссылаться на метод super [A], несмотря на то, что A является прямым супертипом B, когда какой-либо класс смешивается с B, может быть еще один трейт, помещенный «между» A и B.
abstract override
Мы только что показали, что, когда трейт переопределяет метод из своего родительского типа и обращается к родительской реализации, кто-то может обеспечить эту родительскую реализацию позже – на данный момент наш трейт смешан с классом. Это, в частности, означает, что при определении трейта родительская реализация может вообще отсутствовать!
Это означает, что этот трейт может фактически переопределить абстрактный метод. Для Scala есть специальная комбинация ключевых слов для этого: abstract ovveride. Пример:
1 2 3 4 5 6 |
trait Abs { def someString: String } trait AppendMoreStuff extends Abs { abstract override def someString = super.someString + " and more stuff" } |
Мы переопределили абстрактный метод и в то же время ссылались на супер-реализацию. Эта реализация еще не существует – класс (не абстрактный), который, наконец, смешивается в нашей функции AppendMoreStuff, должен «внедрить» некоторую реализацию someString в AppendMoreStuff. Например:
1 2 3 4 |
trait AbsImpl extends Abs { def someString = "Impl" } class Klass extends AbsImpl with AppendMoreStuff |
Значение, возвращаемое методом someString класса Klass, вернет “Impl and more stuff”.
Тот факт, что трейт не обязательно связан с какой-либо супер-реализацией, дает нам возможность повторно использовать трейты и составлять их во многих разных конфигурациях. Таким образом в Scala трейты могут стэкироватся в необходимом порядке, для получения желаемого функционала.
Проблемы функционирования трейтов
ловушка инициализации
Вот пример одной из наиболее распространенных ловушек наследования в Scala (или, возможно, даже для всего языка Scala). Он посвящен порядку инициализации и тому факту, что vals могут быть абстрактными и переопределенными.
1 2 3 4 5 6 7 |
trait A { val someString: String val someStringUpper = someString.toUpperCase } class B extends A { val someString = "String from B" } |
Теперь, что произойдет, когда мы создадим экземпляр B? Оказывается, мы получим исключение NullPointerException в someString.toUpperCase! Почему это?
Это прямое следствие порядка инициализации B. Из линеаризации мы знаем, что код инициализации A будет выполняться перед конструктором B. Итак, вот что происходит, когда мы пытаемся вызвать new B:
- Создается новый объект. Никаких конструкторов и инициализаторов еще не было. Поэтому поля, содержащие значения someString и someStringUpper, являются нулевыми.
- Срабатывает инициализатор A. Он пытается присвоить начальное значение someStringUpper и выполняет someString.toUpperCase, который, к сожалению, завершается с ошибкой NullPointerException, потому что someString не получил возможность инициализироваться конструктором B.
- Если бы не NPE, здесь был бы задействован конструктор B, и он назначил бы начальное значение someString.
Итак, как мы это исправим? Существует несколько способов, но все они имеют свои ограничения:
- Реализовать абстрактный val с параметром конструктора:
12345trait A {val someString: Stringval someStringUpper = someString.toUpperCase}class B(val someString: String) extends A - Реализуйте свой абстрактный val с lazy val
1234567trait A {val someString: Stringval someStringUpper = someString.toUpperCase}class B extends A {lazy val someString = "String from B"}
1234class B extends A {lazy val someString = s"Number is $someNumber"val someNumber = 42} - Создайте не абстрактный элемент (например, someStringUpper) lazy val или def:
1234567trait A {val someString: Stringlazy val someStringUpper = someString.toUpperCase}class B extends A {val someString = "String from B"}
12345trait A {val someString: Stringlazy val someStringUpper = someString.toUpperCaseval inBrackets = s"[$someStringUpper]"} - В качестве крайней последней меры используйте ранний инициализатор
1234567trait A {val someString: Stringlazy val someStringUpper = someString.toUpperCase}class B extends {val someString = "String from B"} with A
Аннотация собственного типа
Аннотация собственного типа в Scala это способ указать требование к трейту или абстрактному классу, что не абстрактный класс который подмешивает ваш трейт аннотированный собственным типом должен обязательно подмешать указанный трейт или в более общем случае реализовать указанный тип. Например:
1 2 |
trait A trait B { this: A => } |
Теперь:
- любой не абстрактный класс наследующий В также должен наследовать и А
- любой трейт или класс наследующий В, который при этом НЕ наследующий А, должен повторять это требование в аннотации собственного типа
Например:
1 2 3 4 5 6 |
class C1 extends A with B // OK class C2 extends B with A // OK class C3 extends B // error! extending B without extending A trait C4 extends A with B // OK trait C5 extends B { this: A => } // OK, repeating the requirement trait C6 extends B // error! extending B without extending or requiring A |
Аннотации собственного типа позволяют использовать API этих описанных типов внутри трейтов:
1 2 3 4 5 6 7 |
trait Logging { def log(msg: String): Unit } trait Handler { this: Logging => def handle(stuff: String): Unit = log(s"Handling $stuff") // using method from Logging trait } |
Другими словами, аннотация собственного типа – это способ для трейта или абстрактного класса объявить «зависимость», но без создания явного отношения наследования и подтипирования. Таким образом, вы можете думать об этом механизме как о «более слабой» форме наследования – которая делает некоторые вещи, аналогичные наследованию, но не все из них. И такое поведение делает этот механизм несколько более гибким.
Имеем:
1 2 3 |
trait Person trait Childish extends Person trait Selfish { this: Person => } |
Разница между собственным типом (Selfish) и наследованием (Childish) в следующем:
- Childish это под тип Person, но при этом Selfish не является под типом Person. Другими словами, наследование выражает, что «Childish- это Person», в то время как аннотация собственного типа выражает, что «все, что есть Selfish, должно быть также Person». Тот факт, что Selfish можно рассматривать как Person, виден только внутри тела Selfish.
- Childish, как гарантируется, должен быть инициализирован перед Person в порядке линеаризации, чтобы он мог реализовать и переопределить членов Person. Нет никакой гарантии, что Selfish будет инициализирован до Person в порядке линеаризации, чтобы и таким образом он не может реализовать или переопределить членов Person. Тем не менее, он все еще может их использовать.
- Поскольку сам тип является всего лишь требованием, он должен, в конечном счете, быть фактически унаследован каким-то классом или признаком или повторяться признаком или абстрактным классом. Это вводит некоторые шаблоны поддержания. Изменение собственного типа на другой или более конкретный тип может потребовать, чтобы вы отрегулировали многие подзаголовки или подклассы признака с аннотацией собственного типа. Это обычно не обязательно при использовании наследования.
- Аннотации собственного типа могут выражать вещи, которые невозможно выразить с наследованием, например:
123456// a cycle!trait A { this: B => }trait B { this: A => }// self-type may be arbitrary type that can possibly refer to type parameterstrait Self[T] { this: T => } // referring to generic in self-typetrait ObjectBase { this: SomeObject.type => } // trait `ObjectBase` may only be extended by `object SomeObject`
Синоним this
Собственные типы могут включать синоним для идентификатора this, что полезно когда ваш класс или трейт имеет внутренний класс, которому необходимо ссылаться на внешний:
1 2 3 4 5 6 7 8 9 10 11 |
trait Logging { def log(msg: String): Unit } trait Handler { handler: Logging => def handle(stuff: String): Unit = new Thread { def run(): Unit = { handler.log(s"Handling $stuff") // using `handler` instead of `Handler.this` } }.start() } |
Когда аннотация собственного типа используется в таком ключе, то актуальный тип такой аннотации может быть упущен:
1 2 3 |
trait Handler { handler => // коде использующий `handler` } |