Когда мы, разработчики, говорим про стоимость мы прежде всего имеем ввиду 2 вещи:

  • Трудозатраты на проектирование, разработку, тестирование, документирование, подержку, развитие;

  • Затраты вычислительных ресурсов при работе “нашей прелести” (с) Горлум;

Люди из бизнеса стремятся все перевести в какие-то деньги, но что они понимают в истинных ценностях…

Так вот мы, как разработчики, и давайте посмотрим на ценности, которые нас интересуют больше всего.

Трудозатраты

На проектирование

Казалось бы, что в данной части все очевидно - куда проще взять и объявить один единственный метод с несколькими параметрами, чем городить целую цепочку вызовов.

И действительно, в примитивных случаях проще сделать метод с парой параметров и все будет отлично. Но стоит ли применять Fluent подход в примитивных случаях? Подвесим его до поры - на это заложена отдельная статья.

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

Ну хорошо, тут перегнул, не совсем естественном, но все же легко читаемом, а главное понимаемом.

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

Как можно назвать метод перевода с использованием идентификатора счета-источника, идентификатора карты-приемника и комиссией, включенной в стоимость? 

Например так:

    transferFromAccountToCardFeeInclued(long accountId, 
                                        long cardId, 
                                        BigDecimal amount, 
                                        BigDecimal fee)

И если присмотреться повнимательнее то в имени метода можно увидеть зародыш Fluent вызова, ведь простым изменением положения параметров в строке получаем:

    transfer(BigDecimal amount)
      .fromAccount(long accountId)
      .toCard(long cardId)
      .fee(BigDecimal fee)
      .inclued()

Та-дам! Полноценный Fluent вызов. Насколько больше мозговых усилий мы потратили при этом? Кажется что в пределах погрешности.

В общем случае вывод по данному пункту можно сформулировать так:

Хороший API спроектировать одинаково непросто как в классическом подходе так и во Fluent варианте.

На разработку

Тут никакого сюрприза - для того чтобы такой API заработал придется реализовать некоторое количество инфраструктурного кода. Он не сложный, шаблонный но его в отдельных случаях получается достаточно много. (Подходы к его реализации будут рассмотрены в дальнейших статьях.)

Для разработки Fluent API приходится тратить дополнительные усилия на реализацию инфраструктурного кода.

На тестирование

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

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

Тестирование кода, реализованного с применением Fluent подхода не требует дополнительных трудозатрат, в некоторых случаях добавляя удобства за счет прозрачности сценариев использования.

На документирование

Тут можно было бы сказать что то типа: “За счет того что Fluent API предполагает организацию кода в легко читаемую последовательность вызовов… ” И в результате свести к каким-то бенефитам подхода при документировании, но я так делать не буду, ибо оно не соответствует действительности.

Да, действительно, в некоторых случаях пользователям Fluent API не потребуется заглядывать в документацию, потому что и так все понятно. Но это не значит что документации не должно быть.

Качество и количество документации обычно зависит от 2-х вещей:

  1. Сознательности разработчика. Если человек ответственно подходит к тому что он делает, то в обязательном порядке позаботится о том, чтобы документация позволила другим людям без проблем использовать и поддерживать код. 

  2. Требований ТЗ. Если идет заказная разработка, то как минимум количество документов будет точно соответствовать требованиям, зафиксированным в проектной документации.

Fluent API не облегчает процесс документирования.

На поддержку

Под поддержкой мы обычно понимаем исправление дефектов, возникших после выпуска функционала “в свет”. 

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

С другой стороны, как говорилось в пункте про тестирование, сама структура Fluent API задает все возможные сценарии использования, и в процессе тестирования они обычно покрываются все, так что покрытие инфраструктурного кода тестами автоматически оказывается достаточно высоким.

Также не стоит забывать что сложный API нужен для сложной логики, и зачастую количество инфраструктурного кода не превышает 3-5% от кода логики.

Принимая все вышесказанное во внимание можно утверждать что само наличие Fluent API не добавляет существенного оверхеда на поддержку.

На развитие

Давайте сразу расссмотрим пару примеров.

Пример №1: Рефакторинг.

Вспомним банковскую тематику - мы, как всякий уважающий себя банк, обслуживаем как физических (далее физики), так и юридических (далее юрики) лиц. Соответственно у нас есть и 2 команды разработки, каждая отвечает за свои “лица”. Обе команды реализовали метод перевода средств со счета на счет:

Физики:

transferAmountIndividual(long accountFrom, long accountTo, BigDecimal amount);

Юрики:

transferAmountCompany(long accountTo, long accountFrom, BigDecimal amount);

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

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

Предположим поменяли реализацию для юриков:

transferAmountCompany(long accountFrom, long accountTo, BigDecimal amount);

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

Теперь рассмотрим ту же самую проблему, но во Fluent реалиазции. Итак изначально было:

Физики:

individuals.from(long accountFrom)
           .to(long accountTo)
           .amount(BigDecimal amount)
           .transfer();

Юрики:

companies.to(long accountTo)
         .from(long accountFrom)
         .amount(BigDecimal amount)
         .transfer();

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

Пример №2: Расширение функциональности.

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

Реализуем соответствующий метод в классическом подходе:

    class Arithmetics {

        static int sum(int... values) {
            return IntStream.of(values).sum();
        }
   }

И Fluent аналог:

    static class Value {

        private final int value;

        //-- Приватный конструктор для создания только через Value.of
        private Value(int value) {
            this.value = value;
        }

        int get() {
            return value;
        }

        Value plus(int value) {
            return new Value(this.value + value);
        }

        //-- Фабричный метод
        static Value of(int value) {
            return new Value(value);
        }
    }

Все как обещал - кода больше.

Примеры использования обоих вариантов:

    Int sum1 = Arithmetics.sum(1, 2, 3);

    Int sum2 = Value.of(1).plus(2).plus(3).get();

Как не сложно заметить Fluent вариант и при использовании заставляет писать больше кода.

So far so good, как говорят наши друзья англичане. Но вдруг, как это часто бывает, оказывается что иногда, нам нужно не только складывать, но и вычитать.

Как мы это сделаем во Fluent варианте - добавим метод:

        Value minus(int value) {
            return new Value(this.value - value);
        }

С классическим вариантом придется подумать. На самом деле можно ничего и не делать. Просто передавать отрицательные значения когда надо вычесть да и все.

Теперь получается так:

    Int result1 = Arithmetics.sum(1, 2, 3, -4);

    Int result2 = Value.of(1).plus(2).plus(3).minus(4).get();

Справились малыми силами, но не сегодня, так завтра нам придется добавлять функционал умножения, деления, скобок…

В варианте Fluent API мы просто будем добавлять соответствующие методы для операций. Как развивать утилитарный класс, реализованный по классике - домашнее задание для всех желающих. 

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

Грамотно спроектированный Fluent API не создает препятствий на пути развития системы.

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

Вычислительные ресурсы

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

Окружение для бенчмарков:

  • MacBook PRO 2020, i5, 16 GB RAM;

  • JDK 22 с настройками по-умолчанию;

И для начала посмотрим на стоимость различных вызовов на следующем примере:

public class Sum {

    private final InstanceAdder instanceAdder = new InstanceAdder();

    public int instanceMethod(int a, int b) {
        return a + b;
    }

    public static int staticMethod(int a, int b) {
        return a + b;
    }

    public Adder instanceMethodLambda(int a) {
        return b -> a + b;
    }

    public static Adder staticMethodLambda(int a) {
        return b -> a + b;
    }

    public Adder instanceMethodInstanceAdder(int a) {
        return instanceAdder.initialize(a);
    }

    public Adder instanceMethodStaticAdder(int a) {
        return new StaticAdder(a);
    }

    public static Adder staticMethodStaticAdder(int a) {
        return new StaticAdder(a);
    }

    public interface Adder {
        int and(int b);
    }

    public record StaticAdder(int a) implements Adder {

        @Override
        public int and(int b) {
            return a + b;
        }
    }

    private class InstanceAdder implements Adder {

        private int a;

        public InstanceAdder initialize(int a) {
            this.a = a;
            return this;
        }

        @Override

        public int and(int b) {
            return a + b;
        }
    }
}
Подробное описание вариантов вызовов

1. Экземплярный метод

    int instanceMethod(int a, int b) {
        return a + b;
    }

2. Статический метод

    static int staticMethod(int a, int b) {
        return a + b;
    }

3. Экземплярный метод, возвращающий лямбду

Тут впервые появляется интерфейс Adder:

    interface Adder {
        int and(int b);
    }

Он предназначен для приема второго слагаемого и выдачи результата.

И собственно сам метод, он принимает первое слагаемое и возвращает экземпляр интерфейса Adder для завершения операции сложения:

    Adder instanceMethodLambda(int a) {
        return b -> a + b;
    }

4. Статический метод, возвращающий лямбд

Делает то же самое что и метод, описанный в предыдущем пункте, но из статического метода:

    static Adder staticMethodLambda(int a) {
        return b -> a + b;
    }

5. Экземплярный метод, инициализирующий и возвращающий внутренний объект фактически выполняющий сложение

Для сложения мы используем вложенный класс:

    class InstanceAdder implements Adder {

        private int a;

        public InstanceAdder initialize(int a) {
            this.a = a;
            return this;
        }

        @Override
        public int and(int b) {
            return a + b;
        }
    }

Экземпляр этого класса создается при инициализации класса Sum:

    final InstanceAdder instanceAdder = new InstanceAdder();

Сам метод инициализирует экземпляр InstanceAdder первым слагаемым и возвращает его для выполнения сложения.

    Adder instanceMethodInstanceAdder(int a) {
        return instanceAdder.initialize(a);
    }

6. Экземплярный метод, создающий и возвращающий независимый объект фактически выполняющий сложение

В данном случае для сложения мы также используем отдельный класс (вернее record):

    record StaticAdder(int a) implements Adder {

        @Override
        public int and(int b) {
            return a + b;
        }
    }

И метод, принимающий первое слагаемое и возвращающий новый экземпляр StaticAdder:

    Adder instanceMethodStaticAdder(int a) {
        return new StaticAdder(a);
    }

7. Статический метод, создающий и возвращающий независимый объект фактически выполняющий сложение

То же самое что и в предыдущем методе но из статического метода:

    static Adder staticMethodStaticAdder(int a) {
        return new StaticAdder(a);
    }

И, да, все варианты кроме первых двух инициируют цепочку вызовов, образуя таким образом Fluent API.

Бенчмаркать будем все методы из класса Sum плюс для калибровки:

  • Создание объекта - new Object();

  • Прямое сложение - a + b;

Сам бенчмарк
public class SumBenchmark {

    private Sum sum;

    @Setup(Level.Trial)
    public void setUp() {
        sum = new Sum();
    }

    @State(Scope.Benchmark)
    public static class Params {
        public int a = 1;
        public int b = 2;
    }

    @Benchmark
    public void createNewObject(Blackhole blackhole) {
        blackhole.consume(new Object());
    }

    @Benchmark
    public void inlineSum(Blackhole blackhole, Params params) {
        blackhole.consume(params.a + params.b);
    }

    @Benchmark
    public void instanceMethod(Blackhole blackhole, Params params) {
        blackhole.consume(sum.instanceMethod(params.a, params.b));
    }

    @Benchmark
    public void staticMethod(Blackhole blackhole, Params params) {
        blackhole.consume(Sum.staticMethod(params.a, params.b));
    }

    @Benchmark
    public void instanceMethodLambda(Blackhole blackhole, Params params) {
        blackhole.consume(sum.instanceMethodLambda(params.a).and(params.b));
    }

    @Benchmark
    public void staticMethodLambda(Blackhole blackhole, Params params) {
        blackhole.consume(Sum.staticMethodLambda(params.a).and(params.b));
    }

    @Benchmark
    public void instanceMethodInstanceAdder(Blackhole blackhole, Params params) {
        blackhole.consume(sum.instanceMethodInstanceAdder(params.a).and(params.b));
    }

    @Benchmark
    public void instanceMethodStaticAdder(Blackhole blackhole, Params params) {
        blackhole.consume(sum.instanceMethodStaticAdder(params.a).and(params.b));
    }

    @Benchmark
    public void staticMethodStaticAdder(Blackhole blackhole, Params params) {
        blackhole.consume(Sum.staticMethodStaticAdder(params.a).and(params.b));
    }
}

Пара пояснений к коду:

  1. Слагаемые a и b помещаем в отдельный класс чтобы JIT не подставил результат сложения;

  2. Используем Blackhole для того чтобы JIT не выкинул код, результат работы которого не используется;

  3. Добавился калибровочный  тест inlineSum для оценки оверхеда на вызов методов;

  4. Внимательный читатель конечно заметил белую ворону среди рыжих котов - метод createNewObject, который не связан непосредственно с тестируемым функционалом, но он нам нужен чтобы получить привязку к стоимости создания объекта, ведь мы в некоторых методах используем создание инфраструктурных объектов для Fluent вызовов.

Спустя полчаса интенсивной работы получаем следующий результат:

createNewObject                  548 393 328,285 ± 18 765 361,999  ops/s
inlineSum                      1 252 795 764,180 ± 43 137 305,204  ops/s
instanceMethod                 1 070 496 160,635 ± 14 697 885,401  ops/s
instanceMethodInstanceAdder      884 496 468,387 ± 14 614 651,387  ops/s
instanceMethodLambda             888 679 157,473 ± 17 101 551,890  ops/s
instanceMethodStaticAdder      1 012 036 557,549 ± 36 865 740,170  ops/s
staticMethod                   1 242 652 194,175 ± 21 69 8870,418  ops/s
staticMethodLambda             1 027 836 397,990 ± 14 534 812,030  ops/s
staticMethodStaticAdder        1 236 086 021,567 ± 14 835 889,108  ops/s

Первым бросается в глаза то что самый медленный метод в нашем бенчмарке createNewObject. Как так? Ведь мы в большинстве методов создаем объекты да не пустые а с состоянием. Давайте разбираться.

Прежде всего заметим что все методы у нас дружно побились на 3 группы, примерно одинаковые по производительности:

1. inlineSum, staticMethod и staticMethodStaticAdder

inlineSum ожидаемый лидер. Никаких накладных, только сложение.

Со staticMethod в целом тоже понятно - JIT просто обязан в данном случае заинлайнить метод сложения.

Но вот staticMethodStaticAdder - непонятно на первый взгляд. На второй становится чуть более понятно. Так как экземпляр StaticAdder не покидает области видимости метода JIT его скаляризировал то есть заменил локальными переменными и заинлайнил его метод сложения. В результате получилось то же самое что и в случае статического метода.

Неплохая новость для нас, как разработчиков API, правда же?

2. instanceMethod, instanceMethodStaticAdder, staticMethodLambda

С instanceMethod и instanceMethodStaticAdder история примерно такая же как и с их статическими братьями. Разница в производительности обусловлена доступом к this и на этом теряется производительность.

Немного удивительно что в эту группу попал и метод staticMethodLambda. Казалось бы он должен быть по производительности в первой группе, так как ничего не мешает скаляризировать лямбду. И, судя по тому, что этот метод все еще быстрее createNewObject аллокация исключена JIT компилятором, но видимо оптимизированный код получился сложнее чем в предыдущих случаях.

3. instanceMethodInstanceAdder и instanceMethodLambda

Метод instanceMethodLambda опять-таки не настолько успешно оптимизирован компилятором как и в случае с staticMethodLambda + она создается в не-статическом контексте что и сказывается негативно на производительности.

Метод instanceMethodInstanceAdder аутсайдер по праву. Каждый его вызов это чтение + запись переменной в куче, что дороже чем операции со стековыми переменными как во всех предыдущих методах.

Еще необходимо отметить что instanceMethodInstanceAdder единственный из вариантов, который нельзя применять в многопоточной среде, так как его состояние будет предметом гонок.

На данном этапе можно сделать осторожное предположение что в некоторых случаях использования Fluent API для нас не стоит, как ни странно, ничего.

Давайте посмотрим еще один пример. Возьмем в качестве подопытных классы Arithmetics и Value, которые мы рассматривали ранее с небольшими доработками:

class Value {

    private final int value;

    private Value(int value) {
        this.value = value;
    }

    int get() {
        return value;
    }

    Value plus(int value) {
        return new Value(this.value + value);
    }

    static Value of(int value) {
        return new Value(value);
    }
}
class Arithmetics {

    static int sum(int... values) {

        int result = values[0];

        for (int i = 1; i < values.length; i++) {
            result += values[i];
        }

        return result;
    }

    static int sum10(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) {
        return a + b + c + d + e + f + g + h + i + j;
    }

    static int sumStream(int... values) {
        return IntStream.of(values).sum();
    }
}

В частности в класс Arithmetics добавлены два дополнительных метода:

  1. sum10 чтобы понять скорость работы метода с простым сложением фиксированного количества аргементов;

  2. sumStream в который перенесена логика сложения с использованием стримов, чтобы понять что нам стоит их использование.

Также в самом же методе sum сложение через стрим заменено на цикл чтобы снизить накладные.

В бенчмарк кроме методов классов Arithmetics и Value добавим калибровочный метод простого сложения inlineSum.

Сам бенчмарк
public class ArithmeticsBenchmark {

    @State(Scope.Benchmark)
    public static class Params {
        public int a = 1;
        public int b = 2;
        public int c = 3;
        public int d = 4;
        public int e = 5;
        public int f = 6;
        public int g = 7;
        public int h = 8;
        public int i = 9;
        public int j = 10;
    }

    @Benchmark
    public void inlineSum(Blackhole blackhole, Params params) {
        blackhole.consume(params.a + params.b + params.c + params.d + params.e + params.f + params.g + params.h + params.i + params.j);
    }

    @Benchmark
    public void arithmeticsSum(Blackhole blackhole, Params params) {
        blackhole.consume(Arithmetics.sum(params.a, params.b, params.c, params.d, params.e, params.f, params.g, params.h, params.i, params.j));
    }

    @Benchmark
    public void arithmeticsSumStream(Blackhole blackhole, Params params) {
        blackhole.consume(Arithmetics.sumStream(params.a, params.b, params.c, params.d, params.e, params.f, params.g, params.h, params.i, params.j));
    }

    @Benchmark
    public void arithmeticsSum10(Blackhole blackhole, Params params) {
        blackhole.consume(Arithmetics.sum10(params.a, params.b, params.c, params.d, params.e, params.f, params.g, params.h, params.i, params.j));
    }

    @Benchmark
    public void value(Blackhole blackhole, Params params) {
        blackhole.consume(Value.of(params.a).plus(params.b).plus(params.c).plus(params.d).plus(params.e).plus(params.f).plus(params.g).plus(params.h).plus(params.i).plus(params.j).get());
    }
}

И, не откладывая в долгий ящик, результат:

arithmeticsSum          174 044 639,505 ±  8 422 878,364  ops/s
arithmeticsSum10        476 123 361,287 ± 31 064 349,587  ops/s
arithmeticsSumStream     35 113 483,209 ±    919 274,397  ops/s
inlineSum               483 050 381,876 ± 27 187 133,724  ops/s
value                   492 497 625,028 ±  6 760 896,016  ops/s

Можно заметить несколько моментов:

  • Самый медленный вариант это arithmeticsSumStream. И тут несколько причин:

    • Создание массива аргументов на каждый вызов;

    • Накладные на создание и работу стрима. И они судя по всему достаточно большие, так как arithmeticsSum, который также принимает переменное количество аргументов, работает в разы быстрее. Однако это нисколько не умаляет преимущества стримов как непревзойденного инструмента работы с потоками данных;

  • На втором месте с конца arithmeticsSum. Он страдает также от двух моментов:

    • Создание массива аргументов как и в предыдущем случае;

    • Накладные на работу с массивом - проверка индекса + доступ к элементу массива по индексу против обращения к локальной переменной на стеке;

  • Ну и тройка лидеров inlineSum, arithmeticsSum10 и value:

    • inlineSum очевидный лидер, никаких накладных;

    • arithmeticsSum10 полностью заинлайненный статический метод как и в случае с двумя слагаемыми;

    • А вот производительность value заставляет снять шляпу и низко поклониться разработчикам JIT. Достаточно длинная цепочка вызовов, судя по всему, была полностью преобразована в код, эквивалентный простому сложению и работает на уровне с ним.

Получается что в некоторых случаях реализация с Fluent API может оказаться даже производительнее классического кода.

И напоследок третий пример:

@AllArgsConstructor
@Builder
public class Entity {
    private int           id;
    private String        name;
    private LocalDateTime timestamp;
    private boolean       active;
}

Да, да. Это всеми любимый паттерн Builder в исполнении не менее любимого помощника любого Java программиста - Lombok.

В результате разворачивается в:
public class Entity {

    private int           id;
    private String        name;
    private LocalDateTime timestamp;
    private boolean       active;

    public Entity(int id, String name, LocalDateTime timestamp, boolean active) {
        this.id        = id;
        this.name      = name;
        this.timestamp = timestamp;
        this.active    = active;
    }

    public static EntityBuilder builder() {
        return new EntityBuilder();
    }

    public static class EntityBuilder {
        private int           id;
        private String        name;
        private LocalDateTime timestamp;
        private boolean       active;

        EntityBuilder() {
        }

        public EntityBuilder id(int id) {
            this.id = id;
            return this;
        }

        public EntityBuilder name(String name) {
            this.name = name;
            return this;
        }

        public EntityBuilder timestamp(LocalDateTime timestamp) {
            this.timestamp = timestamp;
            return this;
        }

        public EntityBuilder active(boolean active) {
            this.active = active;
            return this;
        }

        public Entity build() {
            return new Entity(this.id, this.name, this.timestamp, this.active);
        }
    }
}

В бенчмарке просто сравниваем производительность конструктора с билдером.

Сам бенчмарк
public class EntityBenchmark {

    @State(Scope.Benchmark)
    public static class Params {
        public int           id        = 34;
        public String        name      = "entity";
        public LocalDateTime timestamp = LocalDateTime.now();
        public boolean       active    = true;
    }

    @Benchmark
    public void constructor(Blackhole blackhole, Params params) {
        blackhole.consume(new Entity(params.id, params.name, params.timestamp, params.active));
    }

    @Benchmark
    public void builder(Blackhole blackhole, Params params) {
        blackhole.consume(Entity.builder().id(params.id).name(params.name).timestamp(params.timestamp).active(params.active).build());
    }
}

В данном случае у нас есть объект с внутренним состоянием, которое еще и не immutable. Очевидно же что Builder должен быть как минимум в 2 раза медленнее чем простой вызов конструктора. Или нет?

Так вот оказывается что:

builder        240 457 254,903 ± 11 103 385,856  ops/s
constructor    237 863 111,982 ± 11 403 099,609  ops/s

HotSpot C2 компилятор — невероятно мощный оптимизатор. Он смог полностью устранить накладные расходы Builder паттерна, преобразовав его в эквивалент конструктора. Это демонстрирует, что читаемый код может быть таким же быстрым, как "оптимизированный".

На этой позитивной ноте и закончить бы сделав очевидный из всего вышесказанного однозначный вывод что никаких накладных на Fluent API нет. Но будем честными:

  1. Не смотря на то что в определенных случаях накладных действительно нет, так происходит далеко не всегда. Достаточно нарушить достаточно хрупкие условия, позволяющие JIT эффективно оптимизировать код и результаты будут не такие впечатляющие;

  2. Одним из самых показательных был тест instanceMethodInstanceAdder, показавшую самую худшую производительность, которая, однако, составила порядка 900 млн операций в секунду. Вдумайтесь в эту цифру. Если разрабатывается достаточно сложная логика, которая включает в себя работу с БД, общение по сети, чтение с диска и работает порядка 10 - 100 мс. то даже если ваш API будет перформить на уровне 1 млн операций в секунду это будут крайне незначительные накладные;

Стало быть правильный вывод должен быть таким:

Дополнительные затраты умственных и аппаратных ресурсов, возникающие при реализации Fluent API при разумном подходе не оказывают существенного влияния на скорость разработки или аппаратные требования итогового продукта.

А вот на тему разумного подхода мы будем говорить в последующих статьях.

Спасибо за уделенное время.

PS: код из статьи доступен тут.

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