Functional FizzBuzz на Scala

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


    Предлагаю вашему вниманию еще один вариант, не совсем пятничный, а скорее субботний: FizzBuzz на Scala, functional style.


    Задача


    Для чисел от 1 до 100 нужно выводить на экран


    • Fizz, если число делится на 3;
    • Buzz, если число делится на 5;
    • FizzBuzz, если число делится и на 3 и на 5;
    • в противном случае само число.

    Решение


    Программист должен не столько решать задачу, сколько создавать инструмент для ее решения

    Начнем с делимости


    def divisibleBy(n: Int, d: Int): Boolean = n % d == 0
    
    divisibleBy(10, 5) // => true

    Нет, это нас не устроит — ведь делимость это свойство не только чисел типа Int, опишем делимость в общем виде, а за одно сделаем ее инфиксным оператором (Тут и далее используются некоторые возможности библиотеки cats):


    import cats.implicits._
    import cats.Eq
    
    implicit class DivisionSyntax[T](val value: T) extends AnyVal {
      def divisibleBy(n: T)(implicit I: Integral[T], ev: Eq[T]): Boolean = {
        import I._
        (value % n) === zero
      }
      def divisibleByInt(n: Int)(implicit I: Integral[T], ev: Eq[T]): Boolean =
        divisibleBy(I.fromInt(n))
    }
    
    10 divisibleBy 5 // => true
    BigInt(10) divisibleBy BigInt(3) // => false
    BigInt(10) divisibleByInt 3 // => false

    Тут используются:


    • type class "Integral" требующий от типа "T" возможности вычислять остаток от деления и иметь значение "zero"
    • type class "Eq" требующий от типа "T" возможности сравнивать его элементы (оператор "===" это его синтаксис)
    • расширение типа "T" с помощью extension methods & value classes, которое не имеет рантайм-оверхеда (ждем dotty, который принесет нам нормальный синтаксис экстеншен методов)

    Строго говоря метод divisibleByInt не совсем тут нужен, но он пригодится нам позже, если мы захотим использовать литералы целочисленного типа 3 и 5.


    FizzBuzz


    Отлично! Перейдем к вычислению того, что нужно вывести на экран, напомню, что это может быть "Fizz", "Buzz", "FizzBuzz" либо само число. Тут есть общий паттерн — некоторое значение участвует в результате, только если выполняется определенное условие. Для этого подойдет Option, который будет определять используется значение или нет:


    def useIf[T](value: T, condition: Boolean) = if (condition) Some(value) else None

    Как и в случае с "divisibleBy(10, 5)" и "10 divisibleBy 5" задача решается, но как-то некрасиво. Мы ведь хотим не только решить задачу, но и создать инструмент для ее решения, DSL! По-сути, большая часть работы программиста и есть создание DSL разного рода, когда мы отделяем "как сделать" от "что сделать", "10 % 5 == 0" от "10 divisibleBy 5".


    implicit class WhenSyntax[T](val value: T) extends AnyVal {
      def when(condition: Boolean): Option[T] = if (condition) Some(value) else None
    }
    
    "Fizz" when (6 divisibleBy 3) // => Some("Fizz")
    "Buzz" when (6 divisibleBy 5) // => None

    Осталось собрать все вместе! Мы могли бы использовать orElse и получили бы 3 правильных ответа из 4, но когда мы должны вывести "FizzBuzz" это не сработает, нам нужно получить Some("Fizz") ? Some("Buzz") => Some("FizzBuzz"). Просто строки можно складывать, но как сложить Option[String]? Тут на помощь нам приходят монады моноиды, cats предоставляет нам все нужные инстансы и даже удобный синтаксис:


      def fizzBuzz[T: Integral: Eq: Show](number: T): String =
        ("Fizz" when (number divisibleByInt 3)) |+|
        ("Buzz" when (number divisibleByInt 5)) getOrElse
        number.show

    Тут type class Show дает типу T возможность превращения в строку, |+| синтаксис моноида для сложения и getOrElse задает значение по-умолчанию. Все в общем виде и для любых типов, мы могли бы и от строк "Fizz" & "Buzz" абстрагироваться, но это лишнее на мой взгляд.


    Конец


    Все, что нам осталось сделать это (1 to 100) map fizzBuzz[Int] и куда-нибудь вывести результат. Но это уже совсем другая история...

    Комментарии 14

      +5

      Отлично написано. Особенно концовка:


      Интервьювер: Ну так, и что? Мы вот запускаем, ничего не печатается по заданию
      Соискатель: Да ладно, я вам весь инструментарий дал, решение дал, вы просто вывести куда-нибудь не можете?


      Замечательный пятничный пост (хотя и в субботу)

        –4
        Вывод: абстракции позволяют очень долго ходить вокруг да около решения, и пока жители Виллабаджо…
        0
        Вот чего не хватило, честно говоря, так это понимания, сколько же всего кода в итоге получилось?
          0

          Смотря как считать, все функции кроме собственно fizzBuzz полезны в широком контексте и в проекте были бы общим кодом. Так что самого решения — одна функция в три строки :-)

            0
            Так а всего — примерно 20-30?
          +1
          В принципе, остаток от деления не обязан быть того же типа, что и делимое. Тогда, соответственно, и zero для Т не требуется?
            0

            Да, остаток от деления определяется строго говоря делителем, так что нужен zero для типа делителя, да и отрицательные числа из Integral тут не нужны, но это стандартный тайпкласс, проще использовать его, чем писать свой.

            0

            возможно глупый вопрос, а зачем писать обобщенный isdivisibleBy, если в итоге для вычисления используется стандартный оператор %?

              0

              На самом деле нет, в коде:


                def divisibleBy(n: T)(implicit I: Integral[T], ev: Eq[T]): Boolean = {
                  import I._
                  (value % n) === zero
                }

              Оператор % это синтаксис для I.rem, который импортируется из I, так что это будет работать для любого типа, не только стандартных числовых.

              0
              Вспомнилось: habr.com/ru/post/301536
                0

                Я бы не стал так делать, очень много букв, очень хрупкий код. Вот мой вариант, на Tagless Final:


                import simulacrum._
                import scala.language.implicitConversions
                
                // define operations on A required by FizzBuzz business logic
                @typeclass
                trait FizzBuzzNumber[A] {
                  // return true if a is divisible by v
                  def divisibleBy(a: A, v: Int): Boolean
                
                  // format A according to FizzBuzz requirements
                  def show(a: A): String
                }
                // import simulacrum-generated ops on A
                import FizzBuzzNumber.ops._
                
                // totally abstract business logic
                def row[A: FizzBuzzNumber](a: A): String = {
                  val by3 = a.divisibleBy(3)
                  val by5 = a.divisibleBy(5)
                
                  if (by3 && by5) "FizzBuzz" 
                    else if (by3) "Fizz"
                    else if (by5) "Buzz"
                    else a.show
                }
                
                //
                // implement FizzBuzz on ints
                //
                implicit val intInstance: FizzBuzzNumber[Int] = new FizzBuzzNumber[Int] {
                  def divisibleBy(a: Int, v: Int): Boolean = a % v == 0
                  def show(a: Int): String = a.toString
                }
                
                // lazy rows, to do not allocate memory for all 100 rows at once
                val rows: Iterable[String] = (1 to 100).view.map(v => row(v))
                
                rows.foreach(println)

                Запускаемый вариант: https://scastie.scala-lang.org/PlKt79BOQ6afkgHveSjyIA

                  0

                  Я бы не назвал это tagless final, просто тайпкласс под специфичную проблему, что в целом не очень хорошо, так как такой тайпкласс не будет повторно использован.

                    0

                    В вашем коде (как и в моем) нет повторно используемых компонентов, помимо, возможно, функции fizzBuz/row


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


                    Ваш вариант fizzBuz нарушает I в SOLID — передаваемые тайпклассы избыточны и если клиент захочет использовать эту функцию со сторонним типом, у него будут проблемы. С другой стороны, эти тайпклассы не делают бизнес-логику более понятной — идеально написанный код по требованиям должен совпадать с требованиями построчно, вы же используете "особенность" — что FizzBuzz состоит из Fizz и Buzz

                      0

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


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


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

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

                Самое читаемое