Я пристально отслеживаю развитие Dart, видя в нем не только удобный инструмент, но и возможность улучшения навыков за счет современного объектного языка, впитавшего в себя наработки предыдущих поколений. Вполне логично, что меня заинтересовал недавний пост на хабре Использование примесей (mixins) в Dart, где автор поднял очень интересный вопрос о существенных и несущественных признаках, обусловливающих наследование. Я почти не пользуюсь трейтами и решил провести для себя дополнительное расследование их влияния на архитектуру, а также повторить основные способы применения.

Для статьи буду использовать Groovy, который я часто использую для экспериментов и где примеси реализуются трейтами (хотя была аннотация Mixin для этой цели, которая устарела и deprecated). Создадим похожую иерархию, с небольшими изменениями: без draw и Shape для упрощения.

import groovy.transform.*

@ToString(includeNames=true)
class Rectangle {

	final int width
	final int height

	Rectangle(int width, int height) {
		this.width = width
		this.height = height
	}
}

def rectangle = new Rectangle(200, 100)
println rectangle
//Rectangle(width:200, height:100)

Конечно же, такое название очень условно и мало связано с прямоугольником в геометрии, который является четырехугольником, у которого все углы прямые, а данный класс не содержит никакой информации об углах. Также я не использую различные продвинутые аннотации-трансформации вроде @Canonical, как и различные проверки. Например, width == height может превратить его в условный квадрат, а отрицательные значения выглядят подозрительно, но это будет засорять листинги. Groovy автоматически генерирует сеттеры и геттеры когда поле не содержит модификатора доступа (public, protected, private), рассматривая такое поле как property, также я не буду делать проверки на null. Так как присутствует финализация, то заработает только геттер.

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

def largeRectangle = new Rectangle(Integer.MAX_VALUE, Integer.MAX_VALUE)
def squareValue = largeRectangle.width * largeRectangle.height
println squareValue
// 1

Math.multiplyExact(largeRectangle.width, largeRectangle.height)
//Caught: java.lang.ArithmeticException: integer overflow

Достаточно надуманно, но это первое, что пришло в голову. Можно попытаться переложить определение размера на кого-то другого:

class RectangleSizeChecker implements Predicate<Rectangle> {
	final int largeWidth
	final int largeHeight

	RectangleSizeChecker(int largeWidth, int largeHeight) {
		this.largeWidth = largeWidth
		this.largeHeight = largeHeight
	}

	@Override
	boolean test(Rectangle rectangle) {
		return rectangle.width >= largeWidth && rectangle.height >= largeHeight
	}
}

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

class LargeRectangle extends Rectangle {

	LargeRectangle() {
		super(Integer.MAX_VALUE, Integer.MAX_VALUE)
	}
}

Создание прямоугольников резко упрощается ценой усложнения тестирования, разброса значений по классам фигур разных размеров, усложнения последующей настройки размеров разных фигур если это потребуется в будущем, необходимости instanceof для ранжирования их по размеру в полиморфном методе для Rectangle, что иногда считают антипаттерном: такие проверки разбрасываются по коду, склонны к длинным веткам if\switch и т.п. Также маловероятно, что выйдет обойтись пустым конструктором за счет существования прямоугольников в диапазоне от большого значения до максимального значения типа (или верхнего значения). Если ограничением будет первое, то если largeWidth < Integer.MAX_VALUE, то будут прямоугольники где largeWidth <= width <= Integer.MAX_VALUE, что опять таки упрется в проблемы и способ с фабрикой уже не становится таким плохим. Можно, конечно, проверять значения в конструкторе и выбрасывать IllegalArgumentException.

Остановимся на этом варианте как более простом для примера и определим отношение между прямоугольниками LargeRectangle и Rectangle. Объемы этих понятий частично совпадают - это совместимые понятия. Между совместимыми понятиями бывает тождество, пересечение и подчинение. Большие прямоугольники в нашей модели все являются прямоугольниками, нет ни одного большого прямоугольника, который бы являлся чем-то другим, что говорит об отношении подчинения.

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

trait PaintableTrait {
	String color
}

@ToString(includeSuper=true)
class LargeRectangle extends Rectangle implements PaintableTrait {

	LargeRectangle() {
		super(Integer.MAX_VALUE, Integer.MAX_VALUE)
		//несмотря на this, при определении сеттера в трейте он будет вызываться
		this.color = "black"
	}
}

def largeRectangle = new LargeRectangle()
println largeRectangle
//LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))

Здесь получилось добавление изменяемого свойства в неизменяемый класс, допустим, что так и было задумано. Большой прямоугольник все равно является прямоугольником, но в нем появилось особенное свойство и он теперь является не только прямоугольником, но и PaintableTrait. Если рассмотреть отношение последнего с Rectangle, то оно похоже на еще один вид совместимых понятий - пересечение. Таким образом, отношение между понятиями может измениться с подчинения на пересечение.

В месте пересечения PaintableTrait может проникнуть в код работы с прямоугольниками. Предположим, что сначала два, потом три, а потом все виды прямоугольников нужно покрасить, но при этом работа с цветом и PaintableTrait распространилась по приложению. Предметная область раскрывается в процессе написания софта и понятия постоянно изменяются. Если бы LargeRectangle специализировался и имел цвет на своем поле или наследовался от раскрашиваемого прямоугольника, то все методы принимали бы только какой-то вид прямоугольника (LargeRectangle или его родителя) и это поле с цветом можно было бы поднять вверх тривиальным рефакторингом, но методы завязаны на PaintableTrait и чтобы их не разрушать можно попробовать поднять вверх сам имплемент и сделать как:

class Rectangle implements PaintableTrait {

}

Получается достаточно любопытная ситуация - трейт коварно "захватил" иерархию и теперь все прямоугольники очень сильно от него зависят. Но переместить из Rectangle в PaintableTrait другие поля невозможно - трейт не является каким-либо прямоугольником\фигурой или чем-то похожим вообще. С одной стороны, это можно рассмотреть как Interface Segregation Principle - вполне логично, что где-то вверху находится что-то с цветом. С другой стороны, трейт никак не связан с понятием прямоугольник, ибо его введение как раз таки и обусловлено было малозначительным функционалом. Теперь же оказывается, что этот функционал перекинулся на всю иерархию, да и добавил изменяемое поле, такая себе была идея.

Можно ли как-то скрыть цвет. В Groovy трейт не может иметь конструкторы или protected методы, а если поле и методы доступа сделать private, то прямоугольник их не увидит, ситуация достаточно проблемная. В случае, если прямоугольники имеют какие-либо особенности цвета, например, подразумевая 2D фигуру имеем две стороны: frontColor и backColor, допустим, что PaintableTrait реализовывал дефолтный цвет - frontColor, но теперь нельзя изменить PaintableTrait поскольку есть множество самых разных объектов на него завязанных, в ином же случае можно было бы рефакторить метод, который относится только лишь в иерархии прямоугольников.

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

Например, Groovy вводит отладочный метод dump(), однако разбрасывать его вызов по приложению не хочется с оглядкой на будущие потребности. Или же вывод имени класса, что-то вроде такого, хотя бы оба этих метода можно подвести под единый контракт, пример не особо удачный, но все же:

trait ServiceableDebugInfo {
	//не слишком хорошая идея называть их как геттеры, ну да ладно
	String getDebugInfo(){
		return this.dump()
	}
}

trait ServiceableClassInfo {
	String getClassInfo(){
		return "${this.class}"
	}
}

@ToString(includeSuper=true)
class LargeRectangle extends Rectangle implements ServiceableDebugInfo, ServiceableClassInfo {
	LargeRectangle() {
		super(Integer.MAX_VALUE, Integer.MAX_VALUE)
	}
}
def largeRectangle = new LargeRectangle()
println largeRectangle.getDebugInfo()
println largeRectangle.getClassInfo()
// <LargeRectangle@78a287ed width=2147483647 height=2147483647>
// class LargeRectangle

Предметная область особо ничего не знает о способе устройства класса, пакетной организации и формате имени.
Да, Groovy позволяет реализовать трейт в рантайме, но в результате будет декоратор, что может дать некоторые побочные эффекты:

def debugLargeRectangle = new LargeRectangle() as ServiceableDebugInfo
println debugLargeRectangle.getDebugInfo()
//<LargeRectangle1_groovyProxy@3f2ef586 $closures$delegate$map=[:] $delegate=LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))>

//или вариант для множества трейтов
def debugLargeRectangle = new LargeRectangle().withTraits ServiceableDebugInfo, ServiceableClassInfo
println debugLargeRectangle.getDebugInfo()
println debugLargeRectangle.getClassInfo()
//<LargeRectangle1_groovyProxy@4e517165 $closures$delegate$map=[:] $delegate=LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))>
//class LargeRectangle1_groovyProxy

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

@ToString(includeSuper = true)
class LargeRectangle extends Rectangle {
	LargeRectangle() {
		super(Integer.MAX_VALUE, Integer.MAX_VALUE)
	}
}

Object.metaClass.getDebugInfo = {
	//где вызываем дамп у делегата
	return delegate.dump()
}

def largeRectangle = new LargeRectangle()
println largeRectangle.getDebugInfo()
//<LargeRectangle@163d04ff width=2147483647 height=2147483647>

Однако тут тоже есть недостатки: при увеличении количества методов будут явные проблемы в т.ч. и с навигацией по ним, несмотря на возможность использовать выдуманные префиксы по группам методов для подсветки в IDE. Если включить режим строгой проверки типов, который тоже является одной из фич Groovy, то нужно будет как-то доказывать компилятору, что метод там есть или частично отключать проверку, так просто код проверку не пройдет. При использовании в скриптах со всей динамикой замыканий и т.п. эта проблема может и не возникнуть, большая часть кода и так не пройдет строгие проверки.

С другой стороны, если мы вводим понятие предметной области и обслуживающей\служебной, то обязательно будет логика на их стыке или та, принадлежность которой трудно сходу оценить. Например, преобразования объектов в JSON. Предметная область мало что знает об этом формате, с другой стороны, наличие в прямоугольнике координат или размеров может потребовать специального формата, заточенного под геометрию, который обнаружится намного позже, но к этому времени весь код будет завязан на json, на такие грабли легко наступить. Скорее всего решающим фактором будет относимость функционала к внутреннему устройству приложения, фреймворка или языка. JSON - формат, который используется независимо, в разных приложениях и в разных условиях.

Еще одна достаточно частая ситуация - в приложении уже сформированы компоненты, отлажена их сборка и вдруг библиотека требует наследования от класса, что полностью блокирует любое другое наследование. Трейт или миксин может быть спасением в этом случае, он и так является рутовым компонентом. Например, частая проблема - разброс в логгерах, когда в приложении используется один логгер, а в библиотеке - другой. За счет повсеместной финализации классов написание декораторов\адаптеров с агрегацией логгера может стать задачей нетривиальной, нужно вручную пробрасывать огромное количество методов. Можно попробовать использовать трейт:

trait LoggableComponent {
}

//злая библиотека
class Application {
}

class MyApplication extends Application implements LoggableComponent {
//не нужно дублировать сложную работу с логгером, которая может потребоваться позже
}

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

Если не учитывать такие особенности, то иногда встречается такой случай: на самом нижнем уровне фреймворка есть множество миниатюрных компонентов с одним полем, например, Logger, I18n, Config. Могут быть различные варианты: Logger и I18n, I18n и Config и т.п. При этом удобно иметь компонент с самыми минимальными зависимостями, поскольку в ином случае что-то наверняка не будет использоваться, вися мертвой зависимостью. В случае ограничений наследования нужно дублировать код:

class LoggableComponent {
	Logger logger
}

class ConfigurableComponent {
	Config config
}

//В случае ограничения наследования получаем
class ApplicationComponent extends LoggableComponent {
	Config config
} 

//или же...
class ApplicationComponent extends ConfigurableComponent {
	Logger logger
}

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

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

interface RectangleTransformer {
	void transform(Rectangle rectangle)
}

trait MoveLeftTransformer implements RectangleTransformer {
	void transform(Rectangle rectangle) {
		println "Moved to the left!"
		super.transform(rectangle)
	}
}

trait MoveRightTransformer implements RectangleTransformer {
	void transform(Rectangle rectangle) {
		println "Moved to the right!"
		super.transform(rectangle)
	}
}

trait FakeTransformer implements RectangleTransformer {
	void transform(Rectangle rectangle) {
		println "Not transformed"
	}
}

Rectangle rectangle = new Rectangle(100, 100)

//выполнение цепочки идет справа налево
def rightToLeftTransformer = new Expando().withTraits FakeTransformer, MoveLeftTransformer, MoveRightTransformer
rightToLeftTransformer.transform(rectangle)
//Moved to the right!
//Moved to the left!
//Not transformed

//меняем порядок трейтов
def leftToRightTransformer = new Expando().withTraits FakeTransformer, MoveRightTransformer, MoveLeftTransformer
leftToRightTransformer.transform(rectangle)
//Moved to the left!
//Moved to the right!
//Not transformed

В данном примере трейты вмешиваются в предметную область прямоугольников и, несмотря на интересную затею, в такой цепочке есть грабли, на которые чрезвычайно легко наступить - трейт, который вызывается последним не должен вызывать super, а иначе код рухнет в рантайме: груви будет искать метод в самом классе, которые имплементит интерфейсы (в данном случае в Expando), а там его нет. Если же его там определить, то он перезапишет все методы трейтов. С другой стороны, хотя реализовать подобное можно и без трейтов, но, например, без IDE в скрипте где весь код нужно впихнуть в один файл для облегчения его запуска способ выше может быть явно удобнее.

Для полноты картины добавлю пример изощренного трейта с единственным абстрактным методом - Single Abstract Method (SAM):

trait PaintableTrait {
	void draw(){
		println "Color: $color"
	}
	abstract String getColor()
}

@ToString(includeSuper=true)
abstract class LargeRectangle extends Rectangle implements PaintableTrait {
	LargeRectangle() {
		super(Integer.MAX_VALUE, Integer.MAX_VALUE)
	}
}

LargeRectangle largeRectangle = { "black" }
println largeRectangle
largeRectangle.draw()
//LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))
//Color: black

Однако магия... какого-то поля цвета нигде не видно. Свойство цвета недоступно для изменения, сеттер цвета у большого прямоугольника не сработает. За счет более простого способа конструирования объекта может быть полезным в скриптах. С другой стороны, в некоторых случаях поведение может быть не столь интуитивным (напомню, что it - это именованная по соглашению переменная, которая ссылается на первый параметр в замыкании):

List<LargeRectangle> rectangleList = [{'black'} as LargeRectangle, {'white'}]
rectangleList.each { println it }
// LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))
// Main$_run_closure2@4fad94

Хотя IDE немного удивится, но такое сработает:

LargeRectangle r1, r2, r3
(r1, r2, r3) = [{'white'}, {'black'}, {'red'}]
[r1, r2, r3].each {println it}

//LargeRectangle(white, Rectangle(width:2147483647, height:2147483647))
//LargeRectangle(black, Rectangle(width:2147483647, height:2147483647))
//LargeRectangle(red, Rectangle(width:2147483647, height:2147483647))

Напоследок рассмотрим задачу по наследованию от собаки и кота, ближайшей аналогией которого является герой известного мультфильма - КотоПес. Этот пример достаточно распространен. Учитываем, что к моменту появления КотоПса в приложении весь код пропитался методами, завязанными на Cat или Dog. Возьму пример из сети, не изменяя его:

abstract class Animal {
	abstract String voice();
}

class Cat extends Animal {
	String voice() {
		return "Meow";
	}
}

class Dog extends Animal {
	String voice() {
	return "Woof!";
	}
}

На первый взгляд достаточно логично, что КотоПес может наследоваться от собаки и от кота одновременно. С другой стороны, логика вводит еще одну категорию понятий - пустые. Пустое понятие — понятие, объём которого не содержит ни одного объекта, к этой же категории относится и, например, русалка, которая должна, по идее, наследоваться от рыбы и человека. Но есть определенная разница между встречающимися в реальности котом и собакой и КотоПсом. Если объемы понятий не совпадают и не содержат пересекающихся элементов, то они несовместимы друг с другом.

Учитывая, что КотоПес появился позже, в приложении уже появилось много методов:

void voiceCat(Cat cat){
	println cat.voice()
}

void voiceDog(Dog dog){
	println dog.voice()
}

Здесь можно вспомнить о полиморфизме, но в имени метода ясно указано, что его интересует только кот и никто другой, наверное, для каких-то целей. Может нужно мяукнуть, чтобы кот мяукнул тоже, а для собаки нужна команда или автор по каким-то причинам решил не отражать побочные эффекты в имени метода, например, предсказывая будущее их изменение. Все же классы создаются для специализации и сделать единый контракт для них проблемно. Раз такие методы есть, то скорее всего разработчик не захотел идти на каст или дополнительные проверки, чтобы не создавать дополнительных мест для рантаймовых ошибок. Наиболее простой вариант - ассоциация, но т.к. проблематично сделать кота и пса приватными, то:

class CatDog {
	final Cat cat
	final Dog dog

	CatDog(Cat cat, Dog dog) {
		this.cat = cat
		this.dog = dog
	}
}

С одной стороны, не особо логично, что код принимает КотоПса по частям, с другой - метод интересует ровно та часть, которая ведет себя как кот. Однако проблема в том, что КотоПес является инопланетянином, который способен разговаривать и управлять космическим кораблем. В случае же подобной затеи:

def catDog = new CatDog(new Cat(), new Dog())
voiceCat(catDog.cat)
//"Meow"

Инопланетянин скажет "Meow", что достаточно глупо: строение КотоПса и кота с собакой сильно различаются - общая нервная система и т.п. Изменим:

class AlienCat extends Cat {
	String voice() {
		return "I'm a cat"
	}
}

class AlienDog extends Dog {
	String voice() {
		return "I'm a dog"
	}
}
def catDog = new CatDog(new AlienCat(), new AlienDog())
voiceCat(catDog.cat)
//I'm a cat

Это больше походит на правду. Хотя внушает опасение тот факт, что метод изначально ориентировался только на контракт обычного кота, а получил инопланетное существо, которое умеет говорить... кгм, хотя это и работает. Допустим, есть и такой универсальный метод:

void voiceAnimal(Animal animal){
	println animal.voice()
}

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

class CatDog extends Animal {
	final Cat cat
	final Dog dog

	CatDog(Cat cat, Dog dog) {
		this.cat = cat
		this.dog = dog
	}

	@Override
	String voice() {
		return cat.voice()
	}
}

Насколько это корректно... хороший вопрос, учитывая, что КотоПес - пустое понятие. Если принять, что кот и пес - живые существа, то Animal будет скорее непустым понятием, а значит между CatDog и Animal не может быть подчинения. Если потребуется управлять космическим кораблем, то также будут проблемы и напрашивается еще один класс:

abstract class AlienAnimal extends Animal {
	
}

от которого и был рожден КотоПес. Теперь можно добавить управление кораблем и сложное мышление. Все выглядит вполне логично, но мы ничего не знаем об устройстве инопланетян, их происхождении и про КотоПса мы знаем только то, что он похож на кота и пса в отдельных признаках, поэтому скорее всего AlienAnimal тоже будет пустым понятием и должно наследоваться от какого-нибудь Alien, которое тоже пустое (разве что уфологи это оспорят). Однако в коде вышло использовать метод voiceCat для части КотоПса. Вероятно, такое поведение условно и даже выделение интерфейса не уберет эту условность:

interface SpeakingEntity {
	abstract String voice()
}

который реализуют AlienAnimal (или Alien) вверху иерархии и Animal. Получается, что в примерах выше в методе есть попытка использовать несуществующее существо как существующее, а это явно нарушение логики, что можно заподозрить даже на уровне метода - кот просто мяукает, а КотоПес умеет разговаривать. Насколько это опасно... хороший вопрос, учитывая, что код в любом случае не может достоверно отразить реальность и целиком построен на условностях. Вероятно, это может осложнить перенос кода в приложение, которое моделирует более реальный мир, без пустых понятий, например, ветклинику. Можно выдрать код, работающий с котом и псом и удалить КотоПса, однако если инопланетная логика способна распространиться по приложению, то это, по идее, превратит кота и пса тоже в пустые понятия, что приведет к проблемам при моделировании реальных животных, постепенно насыщая код условностями и модель начнет очень сильно расходиться с реальностью. С другой стороны, обычно расхождение и так настолько велико, что такие условности уже мало на что повлияют.

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

abstract class Animal {
	abstract String voice()
}

trait Cat  {
	String voice() {
		return "Meow"
	}
}

trait Dog {
	String voice() {
	return "Woof!"
	}
}

//Добавил ограничение для примера, иной тип не может использовать трейт
@SelfType(CatDog)
trait AlienCat extends Cat {
	String voice() {
		return "I'm a cat"
	}
}

@SelfType(CatDog)
trait AlienDog extends Dog {
	String voice() {
		return "I'm a dog"
	}
}

class CatDog extends Animal implements AlienCat, AlienDog {
}

void voiceCat(Cat cat){
	println cat.voice()
}

void voiceAnimal(Animal animal){
	println animal.voice()
}

def catDog = new CatDog()
voiceCat(catDog)
voiceAnimal(catDog)
//I'm a dog
//I'm a dog

Упс, похоже методы конфликтуют и сработал только последний трейт, а КотоПес сошел с ума. С одной стороны, кот в какой-то степени и пес, с другой стороны, трейты грубо добавляют поведение и сам КотоПес уже на знает как себя вести. Спасти ситуацию может появление разума, однако откуда ему знать кто должен говорить. В Groovy можно выкрутиться так:

class CatDog extends Animal implements AlienCat, AlienDog {
	@Override
	String voice(){
		return AlienCat.super.voice()
	}
}

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

class CatDog extends Animal {

	private List<Animal> animals = []

	CatDog(Cat cat, Dog dog) {
		animals << cat
		animals << dog
	}

	@Override
	String voice() {
		return animals.collect {it.voice()}.join(System.lineSeparator())
	}
}
//I'm a cat
//I'm a dog

Вроде работает, хотя и нужно решить вопрос со сторонними методами для кота или пса. Теперь на трейтах:

class CatDog extends Animal implements AlienCat, AlienDog {
	@Override
	String voice() {
		//[AlienCat, AlienDog].each не работает
		return [AlienCat.super.voice(), AlienDog.super.voice()].join(System.lineSeparator())
	}
}
//I'm a cat
//I'm a dog

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

Краткие выводы, которые у меня получились:

  • Трейт - это все же не класс, а если трейт хотя бы незначительно отличается от класса, то он может быть опасен неожиданными отложенными проблемами, например, как у меня получилось с груви-свойствами.
  • Из-за таких отличий в Groovy они не заменяют множественное наследование, скорее всего поэтому в документации об этом и умалчивается.
  • Если трейт имеет состояние и добавляется к потомку, то трейт может проникнуть в предметную область каких-либо классов, а используясь в классах предметной области может неожиданно вступить в конфликт с постоянно появляющимися новыми требованиями. Например, изначально цвет рассматривался для вывода на экран, но в пакете прямоугольников появился класс-менеджер, который в т.ч. знает о цвете больших прямоугольников... ну и похожие случаи.
  • При таком отношении пересечения возможно распространение пересекающегося функционала на всю иерархию.
  • Из-за такой вероятности перемещения вверх при знакомстве с языком есть смысл изучить способность класса, который имплементит трейт изменять (в общем случае - ограничивать) контракт трейта, особенно обращая внимание на наличие конструктора, финализацию и возможность ограничения доступа.
  • Побочным эффектом, как и в случае с интерфейсом может стать зависимость от другого пакета\модуля\пространства имен, что понизит реиспользование кода. В примере выше PaintableTrait вряд ли будет располагаться в пакете прямоугольников.

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

С другой стороны, можно попытаться понизить риски:

  • Использовать трейты для того функционала, который не связан с предметной областью вообще. Хотя это достаточно субъективно и легко допустить ошибку. Но тут все равно может быть проблема с наличием состояний и добавлением мутабельности если поля будут открытыми.
  • Трейты как костыли в крайнем случае из-за особенностей библиотек или в случае архитектурной ошибки, которую трудно исправить. Например, при очень сильном расслоении кода теми же дженериками есть некоторый шанс получить класс с несочетаемой комбинацией. Код эволюционирует постепенно, а эволюция склонна идти и по тупиковой ветви тоже.
  • Трейты в коде со специальными требованиями: например, скрипты требуют правки в боевых условиях, при отсутствии IDE, часто в терминале, где текстовый редактор может вообще отключить подсветку кода. Нужно сильное сокращение кода и упрощения, конечно же, ценой чего-то другого. Например, цепочка трейтов может сильно облегчить код.

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