Содержание
Когда-то в прошлом году я наткнулся на отличный пост из двадцати способов сделать FizzBuzz на JavaScript. Я спросил себя, как могут выглядеть некоторые функциональные решения в Java 8 и Vavr ( ранее называвшиеся Javaslang ). В этой статье я представляю шесть различных решений с использованием функциональных структур данных, функций высшего порядка и проверки свойств. Все эти решения соответствуют тестовой схеме, которую я представил в моей последней статье .
Прежде чем мы начнем
Моя мотивация в этой статье — экспериментировать с различными функциональными подходами, чтобы привыкнуть к более функциональному мышлению. Представленные решения не обязательно выстраиваются в линейную процессию от плохого к хорошему. Это просто разные способы одеть одну и ту же кошку и найти разные компромиссы, что я и хочу исследовать. Если вы знаете другое решение или хотите обсудить предложенное, оставьте комментарий.
Прежде чем мы начнем с реальных функциональных решений, мы рассмотрим императивное и обсудим его обязанности. Далее, мы делаем некоторые функциональные рассуждения и вводим структуру данных потока, которую мы будем широко использовать. Наконец, обсуждаются свойства, которые должна выполнять функция FizzBuzz.
Императивное решение и функциональное мышление
В простом императивном решении точно указано, как считать от 1 до 100, как вычислять по модулю и как решать, что и когда печатать. Это как следовать рецепту.
public void fizzBuzzFrom1to100(){ for(int i = 1; i <= 100; i++){ if (i % 3 == 0 && i % 5 == 0) { System.out.println("FizzBuzz"); } else if (i % 3 == 0) { System.out.println("Fizz"); } else if (i % 5 == 0) { System.out.println("Buzz"); } else { System.out.println(i); } } }
С внешней точки зрения этот метод не демонстрирует никакого поведения, кроме печати на консоль. Тестирование довольно утомительно, потому что для проверки вычисленных результатов нам нужно System.out
и подсчитать количество вызовов. Но для того, чтобы насмехаться, нам нужно знать внутренности.
Когда мы уже рискуем заглянуть внутрь метода, мы видим, что он отвечает за вычисление и отображение результата. Мы можем вынести логику вычислений в собственную функцию и использовать вторичный метод на внешнем уровне для вывода результата.
public List<String> fizzBuzz(int amount) { List<String> ret = new ArrayList<>(); for (int i = 1; i <= amount; i++) { if (i % 3 == 0 && i % 5 == 0) { ret.add("FizzBuzz"); } else if (i % 3 == 0) { ret.add("Fizz"); } else if (i % 5 == 0) { ret.add("Buzz"); } else { ret.add(String.valueOf(i)); } } return ret; } public void printList(List<String> list){ for(String element: list){ System.out.println(element); } } printList(fizzBuzz(100));
Тестирование функции fizzBuzz
становится легким, потому что все, что делает функция, представлено возвращенным списком. Мы можем проверить любой элемент в списке на его значение.
Для конкретного входа функция fizzBuzz
всегда будет выдавать один и тот же результат. Мы могли бы заменить приложение-функцию fizzBuzz(100)
на вычисляемый список, не меняя смысла нашей программы. То есть функция fizzBuzz
является чистой .
Причина заключается в том, что метод с побочными эффектами всегда может быть преобразован в чистую функцию и два побочных метода, обеспечивающих ввод и управление выходом. То есть мы выдвигаем побочные эффекты к границам нашего приложения. Все представленные решения следуют этому принципу, перенося ответственность за итерации и отображение на потоковую структуру данных Vavr. Код оболочки этих функций выглядит следующим образом.
public static void main(String... args){ fizzBuzzStream(100).forEach(System.out::println); } private static Stream<String> fizzBuzzStream(int amount){ return Stream.from(1) .map(fizzBuzz()) .take(amount); }
В следующем разделе мы более подробно рассмотрим потоки, но позвольте мне указать на важные части этого кода. Во-первых, в функции fizzBuzzStream
мы создаем поток целых чисел, который начинается from
1. Затем мы map
эти целые числа с помощью функции fizzBuzz
которая вычисляет элемент FizzBuzz
для заданного числа. Это та часть, которую будет реализовывать большинство решений. Затем мы преобразуем бесконечный поток в конечный, беря amount
элементов. В main
методе эти элементы печатаются на консоли.
Обратите внимание, как поток скрывает такие детали, как итерация и создание. Мы работаем на очень декларативном уровне и очень кратко выражаем нашу программу. Также обратите внимание, что побочным эффектом печати элементов является последнее действие, которое выполняется. По сути, это самый внешний слой в нашей программе.
Вавр Стрим
Функциональная структура данных Stream
может быть Empty
или Cons
. Cons
состоят из головного элемента и ленивого вычисленного хвостового Stream
. Бесконечный Stream
не содержит Empty
хвост.
Мы пишем Stream.cons(1, () -> Stream.empty())
чтобы определить поток, который содержит 1 в качестве Stream.cons(1, () -> Stream.empty())
и пустой хвост. Следуя этой записи, определение бесконечного потока из 1 может выглядеть следующим образом: Stream.cons(1, () -> Stream.cons(1,...))
. Однако это неверный синтаксис в Java и будет довольно утомительным. Поэтому мы используем рекурсивную функцию, которая вычисляет следующие 1 по требованию.
Stream<Integer> ones() { return Stream.cons(1, () -> ones()); }
Java оценивает аргументы метода перед вызовом метода. В случае бесконечного потока это обманывают с Supplier
, чтобы предотвратить переполнение стека. Это также позволяет эффективно использовать память для представления потока, потому что в голове хранится только голова. Это отличается от List
который содержит все элементы, и от потока Java 8, который представляет собой составную агрегированную операцию над последовательностью элементов данных из некоторого источника.
Test Harness
Существует три инварианта, которым должен удовлетворять поток элементов FizzBuzz:
- Каждый третий элемент должен начинаться с Fizz
- Каждый из 5 должен заканчиваться Buzz
- Каждый не третий и не пятый элемент должен оставаться числом
В тестировании на основе свойств (PBT) каждый инвариант объединяется с образцом генератора, формирующим свойство. Например, в случае инварианта Fizz, генератор создает кратные 3. Для каждого сгенерированного образца проверяется инвариант. Если одна проверка выдает false, свойство считается фальсифицированным . В противном случае это называется удовлетворенным .
Для более подробного ознакомления и создания тестового набора FizzBuzz ознакомьтесь с моей статьей о тестировании на основе свойств с помощью Vavr .
Шесть способов FizzBuzz
Первые три решения о написании чистых функций, вычисляющих элементы FizzBuzz. По своей сути каждая из этих функций отображает число в слово. Эта информация предоставляется по их типу: Function<Integer, String>
. Фактически, любая из трех реализаций может использоваться для замены любой другой. То есть они прозрачны по ссылкам .
В четвертом и пятом решениях мы рассмотрим, как можно использовать функции потоков для создания решения FizzBuzz. Мы всегда остаемся в контексте потока, и тип возвращаемого значения — Stream<String>
.
Шестое и последнее решение исследует другой подход, предложенный Пьером-Ивом .
1. Простая функция
Это вычислительное ядро императивного решения. В зависимости от операций по модулю возвращается соответствующий элемент FizzBuzz.
static Function<Integer, String> fizzBuzz(){ return i -> { if (i % 3 == 0 && i % 5==0) { return "FizzBuzz"; } else if (i % 3 == 0) { return "Fizz"; } else if (i % 5 == 0) { return "Buzz"; } else { return i.toString(); } } }
Что мне нравится в этом решении, так это то, что оно довольно простое и показывает, что чистая функция может быть выделена из побочного метода. Тем не менее, это решение все еще довольно негибко и выглядит как императивное решение. Например, если мы хотим добавить еще один случай для замены каждого кратного 4 на Bizz, нам нужно добавить еще одно предложение.
2. Шаблон соответствия
Это решение отличается от первого использованием сопоставления с образцом вместо операторов if. Представьте, что сопоставление с образцом — это оператор переключения на стероидах, который проверяет структуру выражения и не имеет сквозной семантики.
static Function<Integer, String> withPatternMatching(){ return e -> Match(Tuple.of(e % 3, e % 5)).of( Case(Tuple2($(0),$(0)), "FizzBuzz"), Case(Tuple2($(0), $()), "Fizz"), Case(Tuple2($(), $(0)), "Buzz"), Case($(), e.toString()) ); }
Мы сопоставляем кортеж вычислительного результата с шаблонами, описанными в случаях. Если регистр совпадает, возвращается строка справа. Например, если число делится на 3 и на 5, "FizzBuzz"
.
Это решение является более кратким, чем первое, и мы избавились от явных операторов if. Однако мы стали еще менее гибкими. Например, чтобы проверить другое число, нам нужно расширить предложение соответствия и добавить еще один случай.
3. Шаблон комбинатора
В этом решении я использовал шаблон комбинатора .
static Function<Integer, String> withCombinator(){ return fizzBuzz() .orElse(fizz()) .orElse(buzz()) // .orElse(word("Bizz").ifDivisibleBy(4)) .orElse(number()); } interface NumberToWord extends Function<Integer, String> { static NumberToWord fizzBuzz(){ return fizz().and(buzz()); } static NumberToWord fizz(){ return word("Fizz").ifDivisibleBy(3); } static NumberToWord buzz(){ return word("Buzz").ifDivisibleBy(5); } static NumberToWord word(String word){ return i -> word; } static NumberToWord number(){ return Object::toString; } default NumberToWord ifDivisibleBy(int modulo){ return i -> i % modulo == 0 ? apply(i) : ""; } default NumberToWord orElse(NumberToWord other){ return i -> { String result = apply(i); return result.isEmpty() ? other.apply(i) : result; }; } default NumberToWord and(NumberToWord other){ return i -> { String first = this.apply(i); String second = other.apply(i); return (first.isEmpty() || second.isEmpty()) ? ""; : (first + second); }; } }
Шаблон комбинатора помогает нам написать небольшой встроенный предметно-ориентированный язык с терминами из домена FizzBuzz. Более того, язык сформирован таким образом, что позволяет нам добавлять новые слова для чисел без изменения основного типа WordToNumber
.
Мне нравится, насколько декларативным является это решение, но есть потеря в краткости. Увеличение читабельности (при условии, что вы усвоили шаблон комбинатора) компенсирует это более чем достаточно.
4. Сжатые потоки
В этом решении мы имеем другую точку зрения на проблему FizzBuzz, которая позволяет нам вообще не вычислять элемент. Идея состоит в том, чтобы видеть каждую подпоследовательность FizzBuzz, например, ту, которая nothing, nothing, "Fizz"
, как свой собственный поток.
static Stream<String> withZippedStreams(){ return Stream.of("","","Fizz").cycle() // creates a Stream<Tuple2<String, String>> .zip(Stream.of("","","","","Buzz").cycle()) // creates a Stream<Tuple2<Tuple2<String, String>, Integer>> .zip(Stream.from(1)) // flattens Tuple2<Tuple2<String, String>, Integer> to String .map(flatten()); } private static Function<Tuple2<Tuple2<String, String>, Integer>, String> flatten() { return tuple -> { String fizzBuzz = tuple._1._1 + tuple._1._2; String number = tuple._2.toString(); return fizzBuzz.isEmpty() ? number : fizzBuzz; }; }
Каждый третий элемент должен быть Fizz, выраженным в виде потока, который циклически повторяет последовательность из двух пустых элементов, а третий элемент является «Fizz». Это приводит к потоку, который повторяет эту последовательность бесконечно. Та же идея относится и к элементу Buzz.
Оба потока объединяются с использованием функции zip
. Название функции указывает, что она делает: она связывает два потока в поток кортежей (см. Также zipper ).
Этот сжатый поток снова объединяется с другим потоком, содержащим целые числа от 1
. Функция flatten
отображает результат, решая, каким должен быть фактический элемент. Например, если оба элемента из потоков Buzz и Fizz пусты, элемент становится номером.
Мне нравится это решение, потому что оно следует за нестандартной мыслью. Что если все, что у меня есть, это поток?
5. Поток молнии трансформации
В отличие от предыдущего решения, мы теперь копируем потоки функций.
static Stream<String> withFunctions(){ return fizzBuzzTransformations() .zip(Stream.from(1)) .map(functionAndNumber -> functionAndNumber._1.apply(functionNumber._2)); } private static Stream<Function<Integer, String>> fizzBuzzTransformations() { Function<Integer, String> empty = i -> ""; return Stream.of(empty, empty, i -> "Fizz").cycle() // creates Stream<Tuple2<Function<Int, Str>, Function<Int, Str>>> .zip(Stream.of(empty, empty, empty, empty, i -> "Buzz").cycle()) // flattens Tuple2<Function<Int, Str>, Function<Int, Str>> // to Function<Int, Str> .map(flatten()); } private static Function< Tuple2<Function<Integer, String>, Function<Integer, String>>, Function<Integer, String>> flatten() { return functions -> i -> { String fizzBuzz = functions._1.apply(i) + functions._2.apply(i); return fizzBuzz.isEmpty() ? i.toString() : fizzBuzz; }; }
Например, элемент Fizz преобразуется в поток, который циклически повторяет последовательность из трех функций. Первые два элемента являются пустой функцией, которая ничего не возвращает для данного целого числа. Последний элемент — это функция Fizz, которая возвращает Fizz для заданного числа. Элемент Buzz переведен эквивалентно.
Интересная часть — как мы отображаем поток кортежей функций. Для данного кортежа мы возвращаем функцию, которая принимает число и применяет функции в кортеже. То есть для каждого кортежа в потоке мы составляем новую функцию, которая использует предыдущие функции для определения фактического элемента. Например, если оба элемента являются пустой функцией, фактический элемент становится номером. Это похоже на функцию flatten
из предыдущего решения.
Результирующий поток составных функций упакован в поток с числом от 1
. Осталось только применить функцию в каждом кортеже к числу.
Что мне очень нравится в этом решении, так это то, как оно показывает, что функции — это значения, которые можно передавать.
6. Колесо
Во время нашей внутренней обзорной сессии Пьер-Ив заметил и указал, что колесо является основной схемой двух потоковых решений. Колесо — это метафора периодической последовательности элементов. Повторная подпоследовательность начинается с 1 и заканчивается первым FizzBuzz . Когда вы распределяете элементы подпоследовательности равномерно по колесу, то определение следующего элемента продвигает колесо к следующему элементу. Пьер-Ив визуализировал эту идею с помощью следующего кода.
public static void main(String... args) { String[] wheelArray = new String[] { "", "", "Fizz", "", "Buzz", "Fizz", "", "", "Fizz", "Buzz", "", "Fizz", "", "", "FizzBuzz"}; Stream<String> fizzBuzz = fizzBuzzStream(wheelArray); fizzBuzz.take(30).forEach(System.out::println); } private static Stream<String> fizzBuzzStream(String[] wheelArray) { return Stream .iterate(new Wheel(wheelArray), Wheel::next) .map(Wheel::getValue); } static class Wheel { private final String[] wheel; private final int numValue; private final String value; private Wheel(String[] wheelArray) { this(wheelArray, 0); } private Wheel(String[] wheelArray, int n) { this.wheel = wheelArray; this.numValue = n + 1; int index = n % wheel.length; this.value = wheel[index].length() == 0 ? Integer.toString(numValue) : wheel[index]; } private String getValue() { return this.value; } private Wheel next() { return new Wheel(this.wheel, this.numValue); } }
Интересной частью является использование неизменяемого класса Wheel
для отслеживания состояния и ссылок на методы экземпляра метода произвольного типа . Колесо принимает начальную последовательность элементов. Затем он вычисляет числовое значение, индекс и значение, определяемое положением колеса. Если элемент в массиве колес в позиции индекса не пуст, он становится значением. В противном случае цифровое значение становится значением.
Построенный поток повторяет колеса, возвращаемые next
функцией, каждое из которых на один шаг дальше по сравнению с предыдущим. Этот бесконечный поток колес затем отображается на вычисленный элемент FizzBuzz с помощью Wheel::getValue
.
Резюме
Проблему FizzBuzz легко понять. Тем не менее, существует множество способов ее решения. В этой статье мы сосредоточились на функциональных решениях с использованием библиотеки Vavr .
Сначала мы обсудили императивное решение и то, как мы можем учесть функциональное ядро. Это привело нас к более общей дискуссии о том, как рассуждать с функциональными программами. Ключевым моментом здесь является то, что каждый нефункциональный метод может быть преобразован в чистую функцию и два метода, которые обеспечивают ввод и справляются с выводом. Побочные эффекты раздвигаются до границ нашей программы.
Во второй части статьи мы рассмотрели 6 различных решений проблемы FizzBuzz. Первые три решения были сосредоточены на вычислении элемента FizzBuzz для заданного числа, а последние два — на использовании функций в потоках. Все решения имели свои преимущества и недостатки. Ключевым моментом здесь является то, что нестандартное мышление может привести к неожиданным решениям.
Знаете ли вы другое функциональное решение FizzBuzz или простую проблему, которая может быть решена различными способами? Оставить комментарий!