Недавно несколько человек просили меня рассказать об использовании трейтов в новом проекте, который я пишу. Примерно в тоже время Рафаэль Домс показал мне его новую речь о сложных когнитивных процессах, которые мы не замечаем. Так как наш мозг — это большой мешок, перемешивающий все, в результате получился пост, который пытается охватить и то как я использую трейты, и то как я решаю где они нужны.
Первое, что вы должны сделать — пойти почитать пост “Abstraction or Leverage” от Майкла Найгарда. Это отличная статья.
Если же у вас мало времени, основная суть поста состоит в том, что части кода (функции, классы, методы и т.д.) могут предназначаться либо для абстракции, либо для воздействия. Разница в:
Общей абстракцией будет паттерн Репозиторий: вы не знаете как объект хранится или где, вам все равно. Детали лежат вне концепции Репозитория.
Общим воздействием будет что-то вроде базового класса контроллера в вашем фреймворке. Он не скрывает реализацию, просто добавляет некоторые классные фичи, облегчающие работу.
В вышеупомянутом посте говорится, что как абстракции, так и воздействия хороши. Но абстракция — немного лучше, потому что она всегда дает вам возможность воздействовать, а воздействие не дает вам абстракции. Тем не менее, я хотел бы добавить, что хорошая абстракция является более трудозатратной в создании и не на всех уровнях возможна. Так что это компромисс.
Некоторые возможности языка лучше чем другие в создании либо воздействия, либо абстракции. Интерфейсы, например, отлично помогают нам строить и применять абстракции.
Наследование, с другой стороны, великолепно в предоставлении воздействия. Оно позволяет нам переопределить части родительского кода без необходимости копировать или извлекать каждый метод, использовать код класса (но не обязательно абстрактного) несколько раз. Так, отвечая на первоначальный вопрос, когда же я могу использовать трейты?
Я использую трейты, когда я хочу создать воздействие, не абстракцию.
Иногда.
Бенджамин Эберлей высказал хороший аргумент, что трейты имеют в основном те же проблемы, что и статический доступ. Вы не можете заменить или переопределить их, они откровенно плохо поддаются тестированию.
Но все же статические методы полезны. Если у вас одна функция без состояния и вы не хотите заменить ее на другую реализацию, то нет ничего плохого в том, чтобы сделать ее статической. Именованные конструкторы (вы же редко хотите именно пустой объект) или получение массива/результата математических операций с хорошо определенными вводом/выводом, без состояния, детерминированные: все это вам интересно. Статическое состояние, а не методы, вот реальное зло.
Трейты имеют примерно те же ограничения, плюс они могут быть использовании только внутри класса. Они более глобальны, чем объект.
Это дает трейтам дополнительную особенность: они могут работать (читать и писать) с внутренним состоянием класса, в который подмешаны. В некоторых случаях это делает их более подходящими чем статические методы.
Например, я часто использую генерацию доменных событий в сущности:
Мы можем сделать рефакторинг и превратить этот код в абстракцию, но это все равно будет хорошим примером того, как трейты могут работать с локальным состоянием объекта в отличии от статических методов. Мы не хотим работать с массивом событий вслепую или перемещать его из объекта. Возможно, мы не хотим добавлять еще одну абстракцию внутрь модели, и нам, конечно же, не хочется копипастить этот шаблонный код везде. И тут опять нам помогут трейты.
Другими практическими примерами могут служить настраиваемое логирование функций, дамп нескольких свойств сразу или общая итерационная/поисковая логика. Мы могли бы решить все эти задачи родительским классом, но поговорим об этом чуть позже.
Итак, трейты являются хорошей заменой в подобных случаях, но это не значит, что статические методы бесполезны. На самом деле, я все же предпочитаю использовать статические методы в случаях, когда не нужно изменять внутреннее состояние объекта, поскольку это всегда безопасней. Статические методы также намного удобнее в тестировании, не требуют mock класса.
Создание утверждений является хорошим примером тех случаев, где я предпочитаю статические методы, несмотря на то, что их обычно можно поместить в трейты. Я нахожу, что
Если у вас есть подобные методы утверждений, всегда возникает соблазн превратить их в трейты, но подобный код уже начинает «попахивать». Возможно метод требует несколько параметров и вы устали их передавать. Возможно проверка
Так что заявляю: Я использую трейты, когда я хочу воздействия, которому требуется доступ к внутреннему состоянию объекта.
Все что мы перечислили также можно реализовать через наследование. В
При прочих равных, я бы ориентировался на правило вроде «Является-A против Имеет-A». Конечно, это не точное правило, потому что трейты не являются композицией, но разумный ориентир.
Другими словами, родительские классы нужно использовать для функций, которые присуще какому-то объекту. Родительские классы хорошо передают другим разработчикам смысл кода: «Сотрудник — это человек». Если нам необходимо воздействие, это не означает, что код не должен быть коммуникативным.
Для другой непрофильной функциональности объекта (логирование, события и т.д.) трейты являются подходящим инструментом. Они не определяют характер класса, это вспомогательные функции, или еще лучше — деталь реализации. Все что вы получите от трейта должно находиться на службе у главной цели объекта, трейты не должны стать важной частью функциональности.
Так что, в случае генерации событий, я все-таки предпочту трейт, потому что создание событий — это вспомогательный функционал.
Я редко (если вообще) расширяю класс или создаю трейт без сопутствующего создания интерфейса.
Если вы будете следовать этому правилу, то обнаружите, что трейты хорошо дополняют принципы разделения интерфейсов. Легко определить интерфейс для дополнительной функциональности и сделать простую реализацию в трейте по умолчанию.
Это позволяет целевому классу реализовывать свою собственную версию интерфейса или использовать трейт по-умолчанию для неважных случаев. Если ваш выбор — это шаблоны и форсирование бедной абстракции, трейты могут быть мощным союзником.
И еще, если у вас только один реализующий интерфейс класс в коде и вы не планируете это менять, то имплементируйте функционал прямо в нем, не беспокойтесь о трейтах. Таким образом вы сделаете ваш код более читабельным и поддерживаемым.
Честно говоря, я не использую трейты довольно часто, возможно раз в несколько месяцев я создаю трейт при постоянной работе над проектами. Вся эта эвристика, которую я обрисовывал (воздействие, требующее доступа к внутреннему состоянию), является крайне нишевой. Если вы используете их слишком часто, возможно вам нужно сделать шаг назад и пересмотреть свой стиль программирования. Есть хороший шанс, что тысячи классов только и ждут, чтобы быть реализованными.
Есть несколько мест, где я не люблю использовать трейты из-за стилевых предпочтений:
И наконец, следует помнить, что трейты не предполагают абстракцию и они не являются композицией, но все равно имеют право занять место среди ваших инструментов. Они полезны для предоставления воздействия по-умолчанию при более мелких реализацию или дублировании кода. Всегда будьте готовы реорганизовать их для лучшей абстракции, как только почувствуете признаки кода «c запашком».
Воздействие vs Абстракция
Первое, что вы должны сделать — пойти почитать пост “Abstraction or Leverage” от Майкла Найгарда. Это отличная статья.
Если же у вас мало времени, основная суть поста состоит в том, что части кода (функции, классы, методы и т.д.) могут предназначаться либо для абстракции, либо для воздействия. Разница в:
- Абстракция содержит высокоуровневый концептуальный код, позволяющий лаконичнее работать с ним другому коду.
- Воздействие содержит код, изменения в котором влияют лишь на определенную часть.
Общей абстракцией будет паттерн Репозиторий: вы не знаете как объект хранится или где, вам все равно. Детали лежат вне концепции Репозитория.
Общим воздействием будет что-то вроде базового класса контроллера в вашем фреймворке. Он не скрывает реализацию, просто добавляет некоторые классные фичи, облегчающие работу.
В вышеупомянутом посте говорится, что как абстракции, так и воздействия хороши. Но абстракция — немного лучше, потому что она всегда дает вам возможность воздействовать, а воздействие не дает вам абстракции. Тем не менее, я хотел бы добавить, что хорошая абстракция является более трудозатратной в создании и не на всех уровнях возможна. Так что это компромисс.
Как это связано с трейтами?
Некоторые возможности языка лучше чем другие в создании либо воздействия, либо абстракции. Интерфейсы, например, отлично помогают нам строить и применять абстракции.
Наследование, с другой стороны, великолепно в предоставлении воздействия. Оно позволяет нам переопределить части родительского кода без необходимости копировать или извлекать каждый метод, использовать код класса (но не обязательно абстрактного) несколько раз. Так, отвечая на первоначальный вопрос, когда же я могу использовать трейты?
Я использую трейты, когда я хочу создать воздействие, не абстракцию.
Иногда.
Иногда?
Бенджамин Эберлей высказал хороший аргумент, что трейты имеют в основном те же проблемы, что и статический доступ. Вы не можете заменить или переопределить их, они откровенно плохо поддаются тестированию.
Но все же статические методы полезны. Если у вас одна функция без состояния и вы не хотите заменить ее на другую реализацию, то нет ничего плохого в том, чтобы сделать ее статической. Именованные конструкторы (вы же редко хотите именно пустой объект) или получение массива/результата математических операций с хорошо определенными вводом/выводом, без состояния, детерминированные: все это вам интересно. Статическое состояние, а не методы, вот реальное зло.
Трейты имеют примерно те же ограничения, плюс они могут быть использовании только внутри класса. Они более глобальны, чем объект.
Это дает трейтам дополнительную особенность: они могут работать (читать и писать) с внутренним состоянием класса, в который подмешаны. В некоторых случаях это делает их более подходящими чем статические методы.
Например, я часто использую генерацию доменных событий в сущности:
trait GeneratesDomainEvents
{
private $events = [];
protected function raise(DomainEvent $event)
{
$this->events[] = $event;
}
public function releaseEvents()
{
$pendingEvents = $this->events;
$this->events = [];
return $pendingEvents;
}
}
Мы можем сделать рефакторинг и превратить этот код в абстракцию, но это все равно будет хорошим примером того, как трейты могут работать с локальным состоянием объекта в отличии от статических методов. Мы не хотим работать с массивом событий вслепую или перемещать его из объекта. Возможно, мы не хотим добавлять еще одну абстракцию внутрь модели, и нам, конечно же, не хочется копипастить этот шаблонный код везде. И тут опять нам помогут трейты.
Другими практическими примерами могут служить настраиваемое логирование функций, дамп нескольких свойств сразу или общая итерационная/поисковая логика. Мы могли бы решить все эти задачи родительским классом, но поговорим об этом чуть позже.
Итак, трейты являются хорошей заменой в подобных случаях, но это не значит, что статические методы бесполезны. На самом деле, я все же предпочитаю использовать статические методы в случаях, когда не нужно изменять внутреннее состояние объекта, поскольку это всегда безопасней. Статические методы также намного удобнее в тестировании, не требуют mock класса.
Создание утверждений является хорошим примером тех случаев, где я предпочитаю статические методы, несмотря на то, что их обычно можно поместить в трейты. Я нахожу, что
Assertion::positiveNumber($int)
дает мне вышеупомянутые преимущества и мне легче понять что делает вызываемый класс.Если у вас есть подобные методы утверждений, всегда возникает соблазн превратить их в трейты, но подобный код уже начинает «попахивать». Возможно метод требует несколько параметров и вы устали их передавать. Возможно проверка
$this->foo
требует значения $this->bar
. В любом из этих случаев рефакторинг класса будет лучшей альтернативой. Помните, всегда лучше, если воздействие уступает место абстракции.Так что заявляю: Я использую трейты, когда я хочу воздействия, которому требуется доступ к внутреннему состоянию объекта.
Родительские классы
Все что мы перечислили также можно реализовать через наследование. В
EventGeneratingEntity
возможно такой подход был бы даже лучше, поскольку массив событий действительно будет индивидуальным. Однако, трейты дают возможность множественного наследования вместо одного базового класса. Помимо набора функций, есть ли еще хорошие аргументы за такой подход?При прочих равных, я бы ориентировался на правило вроде «Является-A против Имеет-A». Конечно, это не точное правило, потому что трейты не являются композицией, но разумный ориентир.
Другими словами, родительские классы нужно использовать для функций, которые присуще какому-то объекту. Родительские классы хорошо передают другим разработчикам смысл кода: «Сотрудник — это человек». Если нам необходимо воздействие, это не означает, что код не должен быть коммуникативным.
Для другой непрофильной функциональности объекта (логирование, события и т.д.) трейты являются подходящим инструментом. Они не определяют характер класса, это вспомогательные функции, или еще лучше — деталь реализации. Все что вы получите от трейта должно находиться на службе у главной цели объекта, трейты не должны стать важной частью функциональности.
Так что, в случае генерации событий, я все-таки предпочту трейт, потому что создание событий — это вспомогательный функционал.
Интерфейсы
Я редко (если вообще) расширяю класс или создаю трейт без сопутствующего создания интерфейса.
Если вы будете следовать этому правилу, то обнаружите, что трейты хорошо дополняют принципы разделения интерфейсов. Легко определить интерфейс для дополнительной функциональности и сделать простую реализацию в трейте по умолчанию.
Это позволяет целевому классу реализовывать свою собственную версию интерфейса или использовать трейт по-умолчанию для неважных случаев. Если ваш выбор — это шаблоны и форсирование бедной абстракции, трейты могут быть мощным союзником.
И еще, если у вас только один реализующий интерфейс класс в коде и вы не планируете это менять, то имплементируйте функционал прямо в нем, не беспокойтесь о трейтах. Таким образом вы сделаете ваш код более читабельным и поддерживаемым.
Когда я не использую трейты
Честно говоря, я не использую трейты довольно часто, возможно раз в несколько месяцев я создаю трейт при постоянной работе над проектами. Вся эта эвристика, которую я обрисовывал (воздействие, требующее доступа к внутреннему состоянию), является крайне нишевой. Если вы используете их слишком часто, возможно вам нужно сделать шаг назад и пересмотреть свой стиль программирования. Есть хороший шанс, что тысячи классов только и ждут, чтобы быть реализованными.
Есть несколько мест, где я не люблю использовать трейты из-за стилевых предпочтений:
- Если код, с которым вы работаете — это просто пара геттеров и сеттеров, я бы не стал заморачиваться. IDE могут быть тут хорошим козырем, а добавление трейта оставит после себя только снижение читаемости.
- Не используйте трейты для внедрения зависимостей. Не столько из-за особенностей трейтов, сколько из-за особенностей сеттеров зависимостей, я против этого.
- Мне не нравится использования трейтов в больших общедоступных API или крупных кусках функциональности. Пропустите этап воздействия и переходите непосредственно к абстракции.
И наконец, следует помнить, что трейты не предполагают абстракцию и они не являются композицией, но все равно имеют право занять место среди ваших инструментов. Они полезны для предоставления воздействия по-умолчанию при более мелких реализацию или дублировании кода. Всегда будьте готовы реорганизовать их для лучшей абстракции, как только почувствуете признаки кода «c запашком».