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

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

Вот ошибочный код, который должен проверять документ в соответствии с данным контрактом. Я использовал функциональную библиотеку под названием vavrвместе со стандартными потоками Java.

public record Document(Set<Section> sections) {

   //   Faulty code
   public Stream<ContractViolation> faultyIsValidAccordingTo(Contract contract) {
      return Try.of(() -> contract.sectionConstraints()
                  .stream()
                  .map(sectionConstraint -> sections().stream()
                        .filter(section -> section.name().equals(sectionConstraint.sectionName()))
                        .map(section -> Tuple.of(section, sectionConstraint.constraint()))
                        .findAny()
                        .orElseThrow())
                  .filter(sectionValidation -> !sectionValidation._1.isSpecValid(sectionValidation._2))
                  .map(invalidSectionValidation -> new ContractViolation("SECTION_CONSTRAINT_VIOLATION",
                        String.format("Specification %s not valid according to section constraint %s",
                              invalidSectionValidation._1, invalidSectionValidation._2))))
            .getOrElseGet(throwable -> Stream.of(new ContractViolation("SECTION_MISSING",
                  String.format("Provided document %s does not contain section required by contract %s", this,
                        contract))));

   }
}

Проверка корректности

Чтобы убедиться, что код работает должным образом, я добавил несколько модульных тестов. Первый тест проверяет сценарий «счастливого пути», когда данный документ успешно проходит контракт.

private static Stream<Arguments> validationMethods() {
  BiFunction<Document, Contract, Stream<ContractViolation>> faultyIsValidAccordingTo = Document::faultyIsValidAccordingTo;
  return Stream.of(
        Arguments.of(Named.of("Faulty semi-functional, non separated methods",faultyIsValidAccordingTo)),
  );
}

@ParameterizedTest
@MethodSource("validationMethods")
void shouldFulfillContract(BiFunction<Document, Contract, Stream<ContractViolation>> validationMethod) {
  //  given
  var contract = new Contract(Set.of(new SectionConstraint("Introduction", new SizeLimit(20)),
        new SectionConstraint("Body", new SizeLimit(500)), new SectionConstraint("Conclusion", new SizeLimit(20))));
  var document = new Document(
        Set.of(new Section("Introduction", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
              new Section("Body",
                    "Sollicitudin tempor id eu nisl nunc mi. Ut ornare lectus sit amet est placerat. Viverra maecenas accumsan lacus vel facilisis volutpat est velit egestas."),
              new Section("Conclusion", "Et magnis dis parturient montes nascetur ridiculus mus mauris. ")));
  //  when
  Stream<ContractViolation> validationResult = validationMethod.apply(document, contract);

  //  then
  assertThat(validationResult).isEmpty();
}

Тест пройден, что является хорошим началом. Теперь, что насчет случаев, когда валидация не проходит?

@ParameterizedTest
@MethodSource("validationMethods")
void shouldRejectWhenSectionDoesNotFulfillConstraint(
     BiFunction<Document, Contract, Stream<ContractViolation>> validationMethod) {
  //      given
  var contract = new Contract(Set.of(new SectionConstraint("Introduction", new SizeLimit(20)),
        new SectionConstraint("Body", new SizeLimit(10)), new SectionConstraint("Conclusion", new SizeLimit(20))));
  var document = new Document(
        Set.of(new Section("Introduction", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
              new Section("Body",
                    "Sollicitudin tempor id eu nisl nunc mi. Ut ornare lectus sit amet est placerat. Viverra maecenas accumsan lacus vel facilisis volutpat est velit egestas."),
              new Section("Conclusion", "Et magnis dis parturient montes nascetur ridiculus mus mauris. ")));
  //  when
  Stream<ContractViolation> validationResult = validationMethod.apply(document, contract);

  //      then
  assertThat(validationResult).map(ContractViolation::id).containsExactlyInAnyOrder("SECTION_CONSTRAINT_VIOLATION");
}

В этом случае логика также ведет себя так, как ожидалось.

@ParameterizedTest
@MethodSource("validationMethods")
void shouldRejectWhenSectionRequiredByContractIsMissing(
     BiFunction<Document, Contract, Stream<ContractViolation>> validationMethod) {
  //      given
  var contract = new Contract(Set.of(new SectionConstraint("Introduction", new SizeLimit(20)),
        new SectionConstraint("Body", new SizeLimit(100)), new SectionConstraint("Conclusion", new SizeLimit(20))));
  var document = new Document(
        Set.of(new Section("Introduction", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
              new Section("Body",
                    "Sollicitudin tempor id eu nisl nunc mi. Ut ornare lectus sit amet est placerat. Viverra maecenas accumsan lacus vel facilisis volutpat est velit egestas.")));
  //  when
  Stream<ContractViolation> validationResult = validationMethod.apply(document, contract);

  //      then
  assertThat(validationResult).map(ContractViolation::id).containsExactlyInAnyOrder("SECTION_MISSING");
}

К сожалению, в этом случае тест провалился. Мы ожидали получить нарушение SECTION_MISSING, потому что поместили в код try catch. Итак, что случилось? Почему код выдал исключение только на этапе утверждения, минуя перехват?

@ParameterizedTest
@MethodSource("validationMethods")
void shouldRejectWhenSectionRequiredByContractIsMissingAndSectionDoesNotFulfillConstraint(
     BiFunction<Document, Contract, Stream<ContractViolation>> validationMethod) {
  //      given
  var contract = new Contract(Set.of(new SectionConstraint("Introduction", new SizeLimit(20)),
        new SectionConstraint("Body", new SizeLimit(10)), new SectionConstraint("Conclusion", new SizeLimit(20))));
  var document = new Document(
        Set.of(new Section("Introduction", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
              new Section("Body",
                    "Sollicitudin tempor id eu nisl nunc mi. Ut ornare lectus sit amet est placerat. Viverra maecenas accumsan lacus vel facilisis volutpat est velit egestas.")));
  //  when
  Stream<ContractViolation> validationResult = validationMethod.apply(document, contract);

  //      then
  assertThat(validationResult).map(ContractViolation::id)
        .containsExactlyInAnyOrder("SECTION_MISSING", "SECTION_CONSTRAINT_VIOLATION");
}

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

Процедурный подход

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

public Stream<ContractViolation> proceduralIsValidAccordingTo(Contract contract) {
      Set<ContractViolation> violations = new HashSet<>();

      for (var sectionConstraint : contract.sectionConstraints()) {
         try {
            var section = findSectionInDocumentBy(sectionConstraint);
            if (filterInvalidSection(section)) {
               violations.add(sectionConstraintViolation(section));
            }
         } catch (Exception throwable) {
            violations.add(missingSectionViolation(contract));
         }
      }
      return violations.stream();
   }

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

Этот новый код намного легче читать и следует соглашениям собственной парадигмы. Что еще более важно, он работает так, как ожидалось. Мы можем даже улучшить его, удалив генерацию исключений, которая обычно не рекомендуется для обработки доменной логики.

public Stream<ContractViolation> refactoredProceduralIsValidAccordingTo(Contract contract) {
      Set<ContractViolation> violations = new HashSet<>();

      for (var sectionConstraint : contract.sectionConstraints()) {

         var sectionOpt = findSectionInDocBy(sectionConstraint);

         if (sectionOpt.isEmpty()) {
            violations.add(missingSectionViolation(contract));
         } else {
            var section = sectionOpt.get();

            if (filterInvalidSection(section)) {
               violations.add(sectionConstraintViolation(section));
            }
         }
      }
      return violations.stream();
   }

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

Функциональный подход

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

Кроме того, функциональное программирование не ограничивается API Java Streams и может применяться в различных средах и контекстах, таких как написание реактивного кода с помощью библиотеки Reactor, потоковые системы с API Kafka Streams или конвейеры Spark. Понимание концепции монад, мощного инструмента, используемого в функциональном программировании, может оказаться неоценимым в этих контекстах.

При этом давайте попробуем переписать код в полностью функциональной и чистой версии.

Глядя на исходный код, мы видим, что он должен был быть функциональным. Однако он ошибочен и довольно трудно читается. Основная проблема этого кода в том, что он смешивает две парадигмы: процедурную и функциональную. Легче, когда мы можем предположить, что код, который кажется функциональным, следует функциональным правилам, но в этом случае именно смешение парадигм является причиной того, что код не работает и его трудно читать.

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

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

public Stream<ContractViolation> faultyRefactoredIsValidAccordingTo(Contract contract) {
  return Try.of(() -> contract.sectionConstraints()
              .stream()
              .map(this::findSectionInDocumentBy)
              .filter(this::filterInvalidSection)
              .map(this::sectionConstraintViolation))
        .getOrElseGet(throwable -> Stream.of(missingSectionViolation(contract)));

}

 private Tuple2<Section, Constraint> findSectionInDocumentBy(SectionConstraint sectionConstraint) {
  return sections().stream()
        .filter(sectionConstraint::matches)
        .map(section -> Tuple.of(section, sectionConstraint.constraint()))
        .findAny()
        .orElseThrow();
}

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

Однако наш код по-прежнему не работает. Причина этого в том, что потоки оцениваются лениво. Вся логика внутри наших лямбда-выражений выполняется только тогда, когда мы вызываем терминальную операцию в нашем потоке. Это происходит спустя долгое время после вызова нашего метода проверки, поэтому блок try-catch полностью пропускается, а исключение выдается на этапе утверждения, когда мы пытаемся использовать поток.

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

public Stream<ContractViolation> isValidAccordingTo(Contract contract) {
      return contract.sectionConstraints()
            .stream()
            .flatMap(
                  constraint -> findSectionInDocument(constraint)
                        .map(this::validateSection)
                        .getOrElseGet(Stream::of));
   }
   
 private Either<ContractViolation, Tuple2<Section, Constraint>> findSectionInDocument(
         SectionConstraint sectionConstraint) {
      return sections().stream()
            .filter(sectionConstraint::matches)
            .map(section -> Tuple.of(section, sectionConstraint.constraint()))
            .findAny()
            .map(Either::<ContractViolation, Tuple2<Section, Constraint>>right)
            .orElseGet(() -> Either.left(missingSectionViolation(sectionConstraint)));
   }

Мы больше не выбрасываем исключения в этом коде. Вместо этого мы обрабатываем исключения так же, как правильные данные, возвращая их с помощью операторов return в методах. Это делает код более читаемым и избавляет от необходимости беспокоиться о каких-либо побочных эффектах.

Сигнатура возврата функции — единственное, о чем нам нужно беспокоиться. В приведенных выше примерах я использовал библиотеку vavr, которая расширяет функциональный Java API. К сожалению, в текущем стандартном API отсутствуют некоторые полезные функциональные конструкции, поэтому я рекомендую использовать его.

Заключение

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

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

Код, показанный в этой статье, можно найти на моем GitHub, доступ к которому вы можете получить здесь, https://github.com/djurasze/JavaExamples.

Кроме того, я рекомендую эти материалы, если вы хотите больше узнать о функциональном программировании.

  1. https://betterprogramming.pub/functional-programming-with-java-streams-f930e0e4d184
  2. https://betterprogramming.pub/functional-programming-with-java-exception-handling-67f40b1f0330
  3. https://www.youtube.com/watch?v=t1e8gqXLbsU