Что будет, если возвращать в setter-методе не void, а this, т.е. использовать прием Fluent Interface?

public class SimplePojo {
    private String value;

    public String getValue() {
        return value;
    }

    public SimplePojo setValue(String value) {
        this.value = value;
        return this;
    }

    // equals, hashCode, toString
}

Теперь перепишем типовой кусок кода и получим такое:

private static AssignmentGroupedActivitiesResource create() {
    return new AssignmentGroupedActivitiesResource()
            .setGrouping(new UserActivitiesGroupingResource()
                    .setAlignmentScore(1)
                    .setFocusScore(0)
                    .setAdvancedGroups(Arrays.asList(
                            new ProductivityGroupResource()
                                    .setSectionName("Development")
                                    .setColor("#2196f3")
                                    .setSpentTime(5L),
                            new ProductivityGroupResource()
                                    .setSectionName("Chat")
                                    .setColor("#E502FA")
                                    .setSpentTime(1L)
                    ))
                    .setPeriodLong(10L)
                    .setTotalTrackedTime(7L)
                    .setIntensityScore(2));
}

Для сравнения – как тот же код выглядит без использования приёма:

private static AssignmentGroupedActivitiesResource create() {
    ProductivityGroupResource group1 = new ProductivityGroupResource();
    group1.setSectionName("Development");
    group1.setColor("#2196f3");
    group1.setSpentTime(5L);

    ProductivityGroupResource group2 = new ProductivityGroupResource();
    group2.setSectionName("Chat");
    group2.setColor("#E502FA");
    group2.setSpentTime(1L);

    UserActivitiesGroupingResource grouping = new UserActivitiesGroupingResource();
    grouping.setAlignmentScore(1);
    grouping.setFocusScore(0);
    grouping.setAdvancedGroups(Arrays.asList(group1, group2));
    grouping.setPeriodLong(10L);
    grouping.setTotalTrackedTime(7L);
    grouping.setIntensityScore(2);

    AssignmentGroupedActivitiesResource assignmentGroupedActivities = new AssignmentGroupedActivitiesResource();
    assignmentGroupedActivities.setGrouping(grouping);
    return assignmentGroupedActivities;
}

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



А почему не Lombok Builder?


Программисты, знакомые с Lombok, могут резонно спросить — а почему бы не взять вместо этого обычный Builder? Конечно, можно. Тот же код будет выглядеть почти так же, но с большим количеством вызовов builder() и build():

private static AssignmentGroupedActivitiesResource create() {
    return AssignmentGroupedActivitiesResource.builder()
            .grouping(UserActivitiesGroupingResource.builder()
                    .alignmentScore(1)
                    .focusScore(0)
                    .advancedGroups(Arrays.asList(
                            ProductivityGroupResource.builder()
                                    .sectionName("Development")
                                    .color("#2196f3")
                                    .spentTime(5L)
                                    .build(),
                            ProductivityGroupResource.builder()
                                    .sectionName("Chat")
                                    .color("#E502FA")
                                    .spentTime(1L)
                                    .build()
                    ))
                    .periodLong(10L)
                    .totalTrackedTime(7L)
                    .intensityScore(2)
                    .build())
            .build();
}

В целом использование Builder может быть оправданно для immutable-объектов как альтернатива передачи большого множества параметров в конструктор, но для mutable POJO в этом нет особого смысла. Кроме того, совместимость Builder-подхода сильно ограничена по сравнению с fluent setter в ряде фреймворков и библиотек.

Кто поддерживает?


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

Lombok


Переделать @Data-класс на использование такого подхода очень просто.
Для одного класса — добавляем аннотацию @Accessors(chain = true):

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class DataPojo {
    private String value;
}

Для пакета/модуля/проекта — добавляем один параметр в lombok.config:
lombok.accessors.chain=true

Уточнение про Lombok fluent vs chain
Важно различать fluent и chain в терминологии Lombok. Параметр
lombok.accessors.fluent=true
(как и @Accessors(fluent = true)) имеет почти тот же эффект, только генерируемый setter-метод не имеет префикса set:

    public SimplePojo value(String value) {
        this.value = value;
        return this;
    }



IDEA (Lombok plugin)


Начиная с прошлого года Lombok plugin поставляется с IDEA по умолчанию, т.е. курируется самой JetBrains. Плагин прекрасно распознает подобные конфигурации (как через аннотации, так и через конфиги), анализ кода работает корректно:



IDEA (кодогенерация)


IDEA умеет генерировать методы доступа к полям класса, в т.ч. и fluent. Для этого задайте новое поле и вызовите контекстное меню «Code/Generate...» (соответствующие комбинации хот-кеев могут отличаться, например, Ctrl+N):

затем выберите «Template: Builder»:

В итоге будет сгенерирован метод доступа:
public class SimplePojo {
    private String value;

    public SimplePojo setValue(String value) {
        this.value = value;
        return this;
    }
}

Важно: паттерн Builder станет шаблоном по умолчанию. Для возврата к классическому варианту просто еще раз выберите генерацию setter-метода с «Template: IntelliJ Default».

Jackson


Jackson, библиотека для сериализации JSON, XML и не только, не требует дополнительных настроек для работы с бинами, сеттеры которых возвращают this. Сериализация и десериализация работает без каких-либо проблем.

jOOQ


jOOQ, фреймворк, который генерирует классы модели из схемы базы данных, может генерировать классы с fluent setter'ами. Требуется явное выставление параметра конфигурации, т.к. это генерация, а не анализ.

Hibernate


Hibernate, фреймворк доступа к базам данных, позволяет использовать классы с предложенной нотацией как Entity. Не требуется дополнительная настройка.

Spring и Spring Boot


Spring и Spring Boot (а также производные проекты вроде spring-data, spring-data-rest) содержат тонны логики, построенной на reflection и анализе структур классов. Здесь и классы конфигурации, и разбор ResultSet'ов через BeanPropertyRowMapper, и прочее. Основной класс, который отвечает за анализ свойств бинов в spring — BeanUtils, поддерживает setter-методы, возвращающие как void, так и this. Т.е. всё работает, что называется, из коробки.

Mapstruct


Mapstruct, библиотека для авто-генерации классов конвертации из одного класса в другой, делает анализ соответствующих структур классов. Не требует дополнительных настроек, распознает setter методы, возвращающие this.

Kotlin


Разработчики языка Kotlin решили все те проблемы, которые пытается разрешить предложенный подход в Java. Там есть и data-классы, и именованная передача множества параметров в конструкторы и в методы. Но, кроме этого, в Kotlin есть синтаксический сахар для доступа к свойствам объектов, которые при обращении (как чтении, так и записи) фактически являются вызовами соответствующих getter и setter-методов. Kotlin лояльно относится к setter-методам, которые возвращают this вместо void (сиреневый курсив):



Т.е. мы в Kotlin коде можем использовать вызовы setter'ов в Java-моделях как присваивания свойств. Слава Kotlin!

Что говорит спека?


Есть спека JavaBeans, которая явным образом нигде не говорит, что setter-методы должны возвращать именно void, хотя все примеры, конечно, написаны именно так.

Но, на самом деле, далеко не все так радужно. Основной класс в JDK, который отвечает за анализ структуры Java Beans — java.beans.Introspector, не распознает setter-методы, если они возвращают не void.

Что можем сломать?


Выше упомянутый java.beans.Introspector активно используется в Swing и элементах Java EE. Например, если класс JSP тега пытается возвращать this в своих методах, – это вызовет ошибку в Runtime. И это, пожалуй, самая большая проблема с этим подходом — подобный баг не предотвратит ни компиляция, ни юнит-тест, разве что если это хороший интеграционный или e2e-тест.

Из тихих ошибок также могут быть проблемы, если вы используете apache common-beanutils, библиотеку работы с бинами (например, копирование свойств из одного бина в другой). По умолчанию, копирование свойств бина просто игнорирует свойства с setter'ами возвращающими this, но есть класс расширения FluentPropertyBeanIntrospector, который добавляет такой анализ. Засада в том, что в этом классе есть баг, к которому предложены два решения: 1, 2. Но даже после попытки обсуждения этой темы в списках рассылки этот вопрос так и остается открытым. Сам проект в его нынешнем виде похож на заброшенный даже несмотря на его относительно высокую популярность.

Когда использовать


Я использую этот подход в течение нескольких последних лет работы. Преимущественно это были REST-API, ведь они полны классов моделей данных и DTO-шек, а именно в таких классах сильнее всего чувствуется эффект от приема. Если проект на этапе разработки – тоже хорошо, меньше шанс сломать то, что работало правильно. Фреймворки вроде Spring Boot — отлично, но если вы работаете на традиционном стеке Java EE / swing, скорее всего это вам не подойдет.

Как мигрировать


Если нет хороших интеграционных тестов — очень осторожно. В случае Lombok есть гибкость настройки — можно начать с одного класса, пакета или, к примеру, нового модуля. Можно поступить и наоборот — определить режим chained-setter'ов на уровень всего проекта, а для конкретного пакета (типа JSP-тегов, которые не дружат с концепцией) – отключить:



В любом случае в существующем проекте лучше не делать это одним махом, а действовать итеративно.

Проблема с наследованием


В случае, если мы наследуем setter'ы из базового класса, возникает проблема.

public class IdPojo {
    private long id;

    public long getId() {
        return id;
    }

    public IdPojo setId(long id) {
        this.id = id;
        return this;
    }

    // equals, hashCode, toString
}

public class SubPojo extends IdPojo {
    private String value;

    public String getValue() {
        return value;
    }

    public SubPojo setValue(String value) {
        this.value = value;
        return this;
    }

    // equals, hashCode, toString
}


Мы теперь не можем написать так:

    public static SubPojo create() {
        return new SubPojo()
                .setId(1) // returns IdPojo
                .setValue("value"); // compilation failure
    }


Здесь есть 4 возможных решения:

  1. Зачастую нам нужно получить на выходе объект супер-класса, а не самого класса, поэтому можно написать в обратном порядке и вернуть супер-тип:
        public static IdPojo create() {
            return new SubPojo()
                    .setValue("value")
                    .setId(1);
        }
    
  2. В супер-классе объявить конструктор, который в виде исключения принимает базовые поля — подходит, если полей не много. Не забываем, что нам также может требоваться конструктор без параметров для десериализации.
        public IdPojo() {
        }
    
        public IdPojo(long id) {
            this.id = id;
        }
    ...
        public SubPojo() {
        }
    
        public SubPojo(long id) {
            super(id);
        }
    
    
  3. Объявить generic-тип на самого себя и возвращать T вместо this:
    public class IdPojo<T extends IdPojo<T>> {
    ...
        public T setId(long id) {
            this.id = id;
            return self();
        }
    
        private T self() {
            return (T) this;
        }
    
        // equals, hashCode, toString
    }
    
    public class SubPojo extends IdPojo<SubPojo> {
    ...
    }
    
  4. Переопределить методы в наследнике
    public class SubPojo extends IdPojo {
    ...
        @Override
        public SubPojo setId(long id) {
            return (SubPojo) super.setId(id);
        }
    


Выводы


  • ✅ Снижение когнитивной нагрузки
  • ✅ Меньше локальных переменных (придумывание названий локальных переменных считается одной из главных проблем программирования)
  • ✅ Иерархичность кода, компактный вид
  • ❌ Несовместимость с консервативным стеком
  • ❌ Тихие ошибки

Возможно, слишком смело назвать использование fluent setter'ов новым трендом. Но очевидно формируется лагерь технологий, которые идут вразрез с традиционной конвенцией. Я свой выбор сделал. И не случалось еще такого, чтобы после внедрения подхода он выпиливался назад.

Примеры кода

Комментарии (11)


  1. MentalBlood
    16.09.2021 09:40

    Послушайте, у меня совсем не много опыта в программировании, но разве в примере про JSON-сериализуемый объект не естественней формировать его, передавая JSON в конструктор явно?


    Конструктор может сформировать и сохранить в полях класса нужные метаданные (если такие вообще есть). Если данные нужно формировать в определенном порядке, поможет описание и автоматическое разрешение зависимостей. Будет и снижение когнитивной нагрузки, и более компактный вид


    1. seregamorph Автор
      16.09.2021 10:19

      передавая JSON в конструктор явно

      Не совсем понял вопрос. Можете, пожалуйста, дать поясняющий пример с кодом (условно-примерный вид)?


      1. MentalBlood
        16.09.2021 12:04
        -1

        Что-то вроде
        return new AssignmentGroupedActivitiesResource().setFromJson(jsonParsed)
        Тогда устанавливаемые значения можно будет хранить в JSON-файле, а не в коде. Сеттеры для отдельных полей можно оставить, но использовать только внутри setFromJson


  1. aaabramenko
    16.09.2021 10:17
    +2

    В Dart для такого есть cascade notation


  1. timbaraccoon
    16.09.2021 10:32

    Хотел бы уточнить, в чем преимущество перед стандартными fluent ломбока? Т.е почему не добавить fluent помимо стандартного @data?


    1. seregamorph Автор
      16.09.2021 10:34

      Под fluent имеется в виду сеттер без префикса set? Не так много технологий из перечисленных будут поддерживать (jackson, spring, hibernate, etc.). Т.е. тут же речь не только про code-style, но и про поддержку фреймворками.


      1. timbaraccoon
        16.09.2021 10:44

        Да, я понимаю, больше вопрос про использование одновременно и стандартных сеттеров для поддержки всех фреймворков и флюент для удобства восприятия кода.


        1. seregamorph Автор
          16.09.2021 10:50

          Lombok сгенерирует либо одни, либо другие. А если писать и те и те вручную (либо одни генерить, вторые вручную) - разве код бина не получится перегружен методами? Плюс это может вызвать сомнения у клиента этого кода - какой метод выбрать. Но вообще это возможно, да.


          1. timbaraccoon
            16.09.2021 11:05

            ну да, про лишнюю перегрузку методами после вопроса не сразу подумал, повторы set чуть мозолят глаз, но не настолько, чтобы перегружать все методами)

            по удобству восприятия конечно штука хорошая.


  1. Throwable
    25.09.2021 11:02

    Важно различать fluent и chain в терминологии Lombok. Параметр
    lombok.accessors.fluent=true(как и @Accessors(fluent = true)) имеет почти тот же эффект, только генерируемый setter-метод не имеет префикса set:

    Если уж нарушать конвенцию, то именно fluent, особенно в Data- и Value-классах. Префиксы get/set -- это бесполезный рудимент, который только добавляет визуального шума. Даже в records от него отказались. К сожалению JPA (Hibernate) требует наличие get/set.


    1. seregamorph Автор
      25.09.2021 16:47
      +1

      Вы верно заметили про поддержку hibernate. То же касается и jackson, spring BeanUtils и пр., т.е. здесь все упирается в совместимость.