Билдеры и дженерики

от
Совершенный код    паттерны, generics, ненормальное программирование, java, csharp

Признаюсь честно, иммутабельность объектов — моя идея фикс. Только от одного вида изменяемых объектов меня бросает дрожь, а необходимость добавить классу сеттер заставляет меня рыдать. Километровые конструкторы в моём коде — дело привычное (spoiler: преувеличение, конечно же). Работать с такими, откровенно говоря, очень неприятно. К счастью, есть один способ (не приносящий боли), позволяющий решить эту проблему. Имя ему — паттерн «Строитель» (бурж. Builder).
Суть паттерна заключается в вынесении процедуры конструирования объекта за пределы его собственного класса, то есть в класс «строителя». Помимо решения вышеописанной проблемы, такой подход позволяет:
  - унифицировать процедуру конструирования для объектов одной иерархии,
  - создавать сценарии конструирования объектов.

Но мы будем рассматривать простейший из случаев.
Имеется класс Musician (см. ниже) с некоторым набором условно неизменяемых полей; необходимо упростить процедуру конструирования такого объекта.
  1. public class Musician {
  2.   private String name;
  3.   private List<String> roles;
  4.   private String mainInstrument;
  5.  
  6.   public Musician(String name, List<String> roles, String mainInstrument) {
  7.     this.name = name;
  8.     this.roles = roles;
  9.     this.mainInstrument = instrument;
  10.   }
  11. }

По заветам предковНачнём с библейской реализации паттерна Builder:
  1. public class MusicianBuilderGof {
  2.   private String name;
  3.   private List<String> roles;
  4.   private String mainInstrument;
  5.  
  6.   public MusicianBuilderGof withName(String name) {
  7.     this.name = name;
  8.     return this;
  9.   }
  10.  
  11.   public MusicianBuilderGof withRoles(List<String> roles) {
  12.     this.roles = roles;
  13.     return this;
  14.   }
  15.  
  16.   public MusicianBuilderGof withMainInsturment(String mainInstrument) {
  17.     this.mainInstrument = mainInstrument;
  18.     return this;
  19.   }
  20.  
  21.   public Musician build() {
  22.     return new Musician(name, roles, mainInstrument);
  23.   }
  24. }
  25.  
  26. // Пример использования:
  27. Musician yui = new MusicianBuilderGof()
  28.   .withName("Yui Hirasawa")
  29.   .withRoles(Arrays.asList("Lead guitarist", "Lead vocalist"))
  30.   .withMainInstrument("Gibson Les Paul Standard")
  31.   .build();

И на этом можно было бы остановиться, если бы не одно «но» — такая реализация не безопасна, пользователь может запросто создать не до конца инициализированный объект. А кому нужны NPE в ходе работы программы?
Здесь есть простейшее решение — добавить пару условий в метод build и успокоиться на этом. Однако, душа просит большего. Заполненность всех полей объекта должна быть обязательным условием типа Musician, и хорошо было бы проверять это ограничение ещё до компиляции, статически. Тогда в дело вступают... дженерики.

Типобезопасность и все, все, всеВажная для нас особенность дженериков — вывод типов. Это инструмент невероятной мощи, что смогли доказать особо извращённые умы, смастерившие машину Тьюринга на одних лишь дженериках (причём формальным методом). Нам-то оно и нужно.
Прежде всего создадим несколько пустых интерфейсов Ok и Err:
  1. public interface Ok  { }
  2. public interface Err { }

Они могут быть как внешними, так и внутренними — всё зависит от ваших намерений использовать данный способ построения Builder'ов в проекте в дальнейшем.
И класс самого «строителя»:
  1. public class Builder<TName, TRoles, TInstr> {
  2.   private String name;
  3.   private List<String> roles;
  4.   private String mainInstrument;
  5.  
  6.   public static Builder<Err, Err, Err> create() {
  7.     return new Builder<>();
  8.   }
  9.  
  10.   private Builder() { }
  11.  
  12.   private Builder(String name, List<String> roles, String mainInstrument) {
  13.     this.name = name;
  14.     this.roles = roles;
  15.     this.mainInstrument = mainInstrument;
  16.   }
  17.  
  18.   public Builder<Ok, TRoles, TInstr> withName(String name) {
  19.     return new Builder<>(name, roles, mainInstrument);
  20.   }
  21.  
  22.   public Builder<TName, Ok, TInstr> withRoles(List<String> roles) {
  23.     return new Builder<>(name, roles, mainInstrument);
  24.   }
  25.  
  26.   public Builder<TName, TRoles, Ok> withMainInsturment(String mainInstrument) {
  27.     return new Builder<>(name, roles, mainInstrument);
  28.   }
  29. }

Каждому обязательному для инициализации полю соответствует свой тип-переменная:
  - TName → name,
  - TRoles → roles,
  - TInstr → mainInstrument.
Интерфейсы Ok и Err служат маркером, указывающим на инициализацию соответствующего поля.
Таким образом, любой промежуточный шаг возвратит объект с типом из списка (для удобства название класса опущено): <Ok, Err, Err>, <Ok, Ok, Err>, <Ok, Err, Ok>, <Err, Ok, Err>, <Err, Ok, Ok> или <Err, Err, Ok. И, как несложно уже было догадаться, полностью инициализированный объект будет иметь тип <Ok, Ok, Ok>.

Для создания готового объекта можно воспользовать одним из описанных ниже способов.

Холодильник — тоже космосПервый способ полностью основан на особенностях внутренних классов в Java, а именно: внешний класс имеет доступ к приватным полям внутренних классов. На самом деле, Builder не обязательно делать внутренним классом — достаточно изменить уровень видимости его полей. Суть метода проста — передаём в конструктор класса создаваемых объектов его «строителя». Чтобы не портить исходный класс, расширим его следующим образом:
  1. class MusicianCtor extends Musician {
  2.   public MusicianCtor(Builder<Ok, Ok, Ok> builder) {
  3.     super(builder.name, builder.roles, builder.mainInstrument)''
  4.   }
  5.  
  6.   public static class Builder<TName, TRoles, TInstr> {
  7.     // ...
  8.   }
  9. }
  10.  
  11. // Пример использования:
  12. Musician ritsu = new MusicianCtor(MusicianCtor.Builder.create()
  13.   .withName("Ritsu Tainaka")
  14.   .withRoles(Arrays.asList("Drummer"))
  15.   .withMainInstrument("Yamaha Rick Marotta"));

Нужно больше дженериковНо раз мы начали активно использовать дженерики, то почему бы не продолжить это делать? Чем больше, тем лучше.
Для начала добавим метод build в Builder:
  1. class MusicianBuilderGen<TName, TRoles, TInstr> {
  2.   // Те же самые поля и методы, что и ранее
  3.  
  4.   public Musician build() {
  5.     return new Musician(name, roles, mainInstrument);
  6.   }
  7. }

Всё, что нам нужно — каким-то образом при вызове метода build проверять были ли все обязательные поля полностью инициализированы. Если размышлять на уровне типов, то build должен вызываться только у объекта с типом <Ok, Ok, Ok>. Для этого реализуем класс, позволяющий сравнивать типы на уровне дженериков:
  1. public class Guard<T, U> {
  2.   private Guard() { }
  3.   public static <V> Guard<V, V> check() {
  4.     return new Guard<>();
  5.   }
  6. }

Тогда метод build можно будет модифицировать следующим образом:
  1. public Musician build(Guard<MusicianBuilderGen<Ok, Ok, Ok>, MusicianBuilderGen<TName, TRoles, TInstr>> guard) {
  2.   return new Musician(name, roles, mainInstrument);
  3. }

Пример использования:
  1. Musician mio = MusicianBuilderGen.create()
  2.   .withName("Mio Akiyama")
  3.   .withRoles(Arrays.asList("Bassist", "Backup vocalist", "Main lyrics composer"))
  4.   .withMainInstrument("Fender Japan '62 Reissue Jazz Bass")
  5.   .build(Guard.check());

Дополнение. Реализация на C#
  1. public class Musician {
  2.   public string Name { get; }
  3.   public IReadOnlyList<string> Roles { get; }
  4.   public string MainInstrument { get; }
  5.  
  6.   public Musician(string name, IReadOnlyList<string> roles, string mainInstrument) {
  7.     Name = name;
  8.     Roles = roles;
  9.     MainInstrument = mainInstrument;
  10.   }
  11. }
  12.  
  13. public class MusicianBuilder<TName, TRoles, TInstr> {
  14.   public string Name { get; }
  15.   public string IReadOnlyList<string> Roles { get; }
  16.   public string MainInstrument { get; }
  17.  
  18.   public MusicianBuilder() { }
  19.   private MusicianBuilder(string name, IReadOnlyList<string> roles, string mainInstrument) {
  20.     Name = name;
  21.     Roles = roles;
  22.     MainInstrument = mainInstrument;
  23.   }
  24.  
  25.   public MusicianBuilder<Ok, TRoles, TInstr> WithName(string name) {
  26.     return new MusicianBuilder<Ok, TRoles, TInstr>(name, Roles, MainInstrument);
  27.   }
  28.  
  29.   // дальше по аналогии с кодом на Java
  30. }
  31.  
  32. public static class MusicianBuilder { // 0
  33.   public static MusicianBuilder<Err, Err, Err> Create() { // 1
  34.     return new MusicianBuilder<Err, Err, Err>();
  35.   }
  36.  
  37.   public static Musician Build(this MusicianBuilder<Ok, Ok, Ok> builder) { // 2
  38.     return new Musician(builder.Name, builder.Roles, builder.MainInstrument);
  39.   }
  40. }
  41.  
  42. // Пример использования:
  43. MusicianBuilder.Create()
  44.   .WithName("Azusa Nakano")
  45.   .WithRoles(new List<string> { "Rhythm guitarist", "Lead guitarist", "Lead vocalist" })
  46.   .WithMainInstrument("Electric guitar")
  47.   .Build();

1. Зачем нам нужен ещё один класс и как вообще возможно иметь два класса с одним названием в одном пакете? Всё очень просто. В C# нет type erasure, как в Java, и классы Foo<T> и Foo представляют разные типы, кроме этого Foo<int> и Foo<string> тоже представляют разные типы.
2. Почему фабричный метод вынесен в отдельный класс? Для удобства вызова: MusicianBuilder.Create() вместо MusicianBuilder<T1, T2, T3> (где T[x] — любой тип).
3. Что за this около аргумента метода? Таким образом объявляется метод расширения (extension method). Его можно вызвать одним из следующих способов: MusicianBuilder.Build(builder) или builder.Build().
  • +3
  • views 4266