Билдеры и дженерики
от aRiGaTo
Признаюсь честно, иммутабельность объектов — моя идея фикс. Только от одного вида изменяемых объектов меня бросает дрожь, а необходимость добавить классу сеттер заставляет меня рыдать. Километровые конструкторы в моём коде — дело привычное (spoiler: преувеличение, конечно же). Работать с такими, откровенно говоря, очень неприятно. К счастью, есть один способ (не приносящий боли), позволяющий решить эту проблему. Имя ему — паттерн «Строитель» (бурж. Builder).
Суть паттерна заключается в вынесении процедуры конструирования объекта за пределы его собственного класса, то есть в класс «строителя». Помимо решения вышеописанной проблемы, такой подход позволяет:
- унифицировать процедуру конструирования для объектов одной иерархии,
- создавать сценарии конструирования объектов.
Но мы будем рассматривать простейший из случаев.
Имеется класс Musician (см. ниже) с некоторым набором условно неизменяемых полей; необходимо упростить процедуру конструирования такого объекта.
По заветам предковНачнём с библейской реализации паттерна Builder:
И на этом можно было бы остановиться, если бы не одно «но» — такая реализация не безопасна, пользователь может запросто создать не до конца инициализированный объект. А кому нужны NPE в ходе работы программы?
Здесь есть простейшее решение — добавить пару условий в метод build и успокоиться на этом. Однако, душа просит большего. Заполненность всех полей объекта должна быть обязательным условием типа Musician, и хорошо было бы проверять это ограничение ещё до компиляции, статически. Тогда в дело вступают... дженерики.
Типобезопасность и все, все, всеВажная для нас особенность дженериков — вывод типов. Это инструмент невероятной мощи, что смогли доказать особо извращённые умы, смастерившие машину Тьюринга на одних лишь дженериках (причём формальным методом). Нам-то оно и нужно.
Прежде всего создадим несколько пустых интерфейсов Ok и Err:
Они могут быть как внешними, так и внутренними — всё зависит от ваших намерений использовать данный способ построения Builder'ов в проекте в дальнейшем.
И класс самого «строителя»:
Каждому обязательному для инициализации полю соответствует свой тип-переменная:
- 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 не обязательно делать внутренним классом — достаточно изменить уровень видимости его полей. Суть метода проста — передаём в конструктор класса создаваемых объектов его «строителя». Чтобы не портить исходный класс, расширим его следующим образом:
Нужно больше дженериковНо раз мы начали активно использовать дженерики, то почему бы не продолжить это делать? Чем больше, тем лучше.
Для начала добавим метод build в Builder:
Всё, что нам нужно — каким-то образом при вызове метода build проверять были ли все обязательные поля полностью инициализированы. Если размышлять на уровне типов, то build должен вызываться только у объекта с типом <Ok, Ok, Ok>. Для этого реализуем класс, позволяющий сравнивать типы на уровне дженериков:
Тогда метод build можно будет модифицировать следующим образом:
Пример использования:
Дополнение. Реализация на C#
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().
Суть паттерна заключается в вынесении процедуры конструирования объекта за пределы его собственного класса, то есть в класс «строителя». Помимо решения вышеописанной проблемы, такой подход позволяет:
- унифицировать процедуру конструирования для объектов одной иерархии,
- создавать сценарии конструирования объектов.
Но мы будем рассматривать простейший из случаев.
Имеется класс Musician (см. ниже) с некоторым набором условно неизменяемых полей; необходимо упростить процедуру конструирования такого объекта.
- public class Musician {
- private String name;
- private List<String> roles;
- private String mainInstrument;
- public Musician(String name, List<String> roles, String mainInstrument) {
- this.name = name;
- this.roles = roles;
- this.mainInstrument = instrument;
- }
- }
По заветам предковНачнём с библейской реализации паттерна Builder:
- public class MusicianBuilderGof {
- private String name;
- private List<String> roles;
- private String mainInstrument;
- public MusicianBuilderGof withName(String name) {
- this.name = name;
- return this;
- }
- public MusicianBuilderGof withRoles(List<String> roles) {
- this.roles = roles;
- return this;
- }
- public MusicianBuilderGof withMainInsturment(String mainInstrument) {
- this.mainInstrument = mainInstrument;
- return this;
- }
- public Musician build() {
- return new Musician(name, roles, mainInstrument);
- }
- }
- // Пример использования:
- Musician yui = new MusicianBuilderGof()
- .withName("Yui Hirasawa")
- .withRoles(Arrays.asList("Lead guitarist", "Lead vocalist"))
- .withMainInstrument("Gibson Les Paul Standard")
- .build();
И на этом можно было бы остановиться, если бы не одно «но» — такая реализация не безопасна, пользователь может запросто создать не до конца инициализированный объект. А кому нужны NPE в ходе работы программы?
Здесь есть простейшее решение — добавить пару условий в метод build и успокоиться на этом. Однако, душа просит большего. Заполненность всех полей объекта должна быть обязательным условием типа Musician, и хорошо было бы проверять это ограничение ещё до компиляции, статически. Тогда в дело вступают... дженерики.
Типобезопасность и все, все, всеВажная для нас особенность дженериков — вывод типов. Это инструмент невероятной мощи, что смогли доказать особо извращённые умы, смастерившие машину Тьюринга на одних лишь дженериках (причём формальным методом). Нам-то оно и нужно.
Прежде всего создадим несколько пустых интерфейсов Ok и Err:
- public interface Ok { }
- public interface Err { }
Они могут быть как внешними, так и внутренними — всё зависит от ваших намерений использовать данный способ построения Builder'ов в проекте в дальнейшем.
И класс самого «строителя»:
- public class Builder<TName, TRoles, TInstr> {
- private String name;
- private List<String> roles;
- private String mainInstrument;
- public static Builder<Err, Err, Err> create() {
- return new Builder<>();
- }
- private Builder() { }
- private Builder(String name, List<String> roles, String mainInstrument) {
- this.name = name;
- this.roles = roles;
- this.mainInstrument = mainInstrument;
- }
- public Builder<Ok, TRoles, TInstr> withName(String name) {
- return new Builder<>(name, roles, mainInstrument);
- }
- public Builder<TName, Ok, TInstr> withRoles(List<String> roles) {
- return new Builder<>(name, roles, mainInstrument);
- }
- public Builder<TName, TRoles, Ok> withMainInsturment(String mainInstrument) {
- return new Builder<>(name, roles, mainInstrument);
- }
- }
Каждому обязательному для инициализации полю соответствует свой тип-переменная:
- 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 не обязательно делать внутренним классом — достаточно изменить уровень видимости его полей. Суть метода проста — передаём в конструктор класса создаваемых объектов его «строителя». Чтобы не портить исходный класс, расширим его следующим образом:
- class MusicianCtor extends Musician {
- public MusicianCtor(Builder<Ok, Ok, Ok> builder) {
- super(builder.name, builder.roles, builder.mainInstrument)''
- }
- public static class Builder<TName, TRoles, TInstr> {
- // ...
- }
- }
- // Пример использования:
- Musician ritsu = new MusicianCtor(MusicianCtor.Builder.create()
- .withName("Ritsu Tainaka")
- .withRoles(Arrays.asList("Drummer"))
- .withMainInstrument("Yamaha Rick Marotta"));
Нужно больше дженериковНо раз мы начали активно использовать дженерики, то почему бы не продолжить это делать? Чем больше, тем лучше.
Для начала добавим метод build в Builder:
- class MusicianBuilderGen<TName, TRoles, TInstr> {
- // Те же самые поля и методы, что и ранее
- public Musician build() {
- return new Musician(name, roles, mainInstrument);
- }
- }
Всё, что нам нужно — каким-то образом при вызове метода build проверять были ли все обязательные поля полностью инициализированы. Если размышлять на уровне типов, то build должен вызываться только у объекта с типом <Ok, Ok, Ok>. Для этого реализуем класс, позволяющий сравнивать типы на уровне дженериков:
- public class Guard<T, U> {
- private Guard() { }
- public static <V> Guard<V, V> check() {
- return new Guard<>();
- }
- }
Тогда метод build можно будет модифицировать следующим образом:
- public Musician build(Guard<MusicianBuilderGen<Ok, Ok, Ok>, MusicianBuilderGen<TName, TRoles, TInstr>> guard) {
- return new Musician(name, roles, mainInstrument);
- }
Пример использования:
- Musician mio = MusicianBuilderGen.create()
- .withName("Mio Akiyama")
- .withRoles(Arrays.asList("Bassist", "Backup vocalist", "Main lyrics composer"))
- .withMainInstrument("Fender Japan '62 Reissue Jazz Bass")
- .build(Guard.check());
Дополнение. Реализация на C#
- public class Musician {
- public string Name { get; }
- public IReadOnlyList<string> Roles { get; }
- public string MainInstrument { get; }
- public Musician(string name, IReadOnlyList<string> roles, string mainInstrument) {
- Name = name;
- Roles = roles;
- MainInstrument = mainInstrument;
- }
- }
- public class MusicianBuilder<TName, TRoles, TInstr> {
- public string Name { get; }
- public string IReadOnlyList<string> Roles { get; }
- public string MainInstrument { get; }
- public MusicianBuilder() { }
- private MusicianBuilder(string name, IReadOnlyList<string> roles, string mainInstrument) {
- Name = name;
- Roles = roles;
- MainInstrument = mainInstrument;
- }
- public MusicianBuilder<Ok, TRoles, TInstr> WithName(string name) {
- return new MusicianBuilder<Ok, TRoles, TInstr>(name, Roles, MainInstrument);
- }
- // дальше по аналогии с кодом на Java
- }
- public static class MusicianBuilder { // 0
- public static MusicianBuilder<Err, Err, Err> Create() { // 1
- return new MusicianBuilder<Err, Err, Err>();
- }
- public static Musician Build(this MusicianBuilder<Ok, Ok, Ok> builder) { // 2
- return new Musician(builder.Name, builder.Roles, builder.MainInstrument);
- }
- }
- // Пример использования:
- MusicianBuilder.Create()
- .WithName("Azusa Nakano")
- .WithRoles(new List<string> { "Rhythm guitarist", "Lead guitarist", "Lead vocalist" })
- .WithMainInstrument("Electric guitar")
- .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().