Функциональное программирование в Scala — нужно ли оно вообще?

в 6:32, , рубрики: functional programming, scala, Программирование, функциональное программирование

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

Попробуем написать простую программу, вычисляющую выражение 4 * x^3 + 2 * x^2 + abs(x). Поскольку это пост про функциональное программирование, оформим всё в виде функций, вынеся операции возведения в степень и модуля:

object Main {

  def square(v: Int): Int = v * v
  def cube(v: Int): Int = v * v * v
  def abs(v: Int): Int = if (v < 0) -v else v

  def fun(v: Int): Int = {
    4 * cube(v) + 2 * square(v) + abs(v)
  }

  println(fun(42))
}

Выглядит симпатично, не правда ли? Теперь добавим пару требований:

— мы хотим тестировать функцию fun(), используя свои реализации функций square, cube и abs вместо «зашитых» в текущую реализацию
— функция cube работает медленно — давайте её кешировать

Таким образом, fun должна принимать свои зависимости в виде аргументов, заодно можно сделать мемоизацию функции cube.

object Main {

  def square(v: Int): Int = v * v
  def cube(v: Int): Int = v * v * v
  def abs(v: Int): Int = if (v < 0) -v else v

  // выносим все зависимости в аргументы функции
  // сразу делаем частичное каррирование (два списка аргументов), чтобы упростить частичное применение аргументов чуть ниже
  def fun( 
    square: Int => Int,
    cube: Int => Int,
    abs: Int => Int)
    (v: Int): Int = {
    4 * cube(v) + 2 * square(v) + abs(v)
  }

  // делает мемоизацию - по функции одного аргумента возвращает функцию того же типа,
  // которая умеет себя кешировать
  def memoize[A, B](f: A => B): A => B = new mutable.HashMap[A, B] {
    override def apply(key: A): B = getOrElseUpdate(key, f(key))
  }

  val cachedCube = memoize(cube)

  // cachedFun - это лямбда с одним аргументом, умеющая кешировать cube. Тип функции - как в первом примере
  val cachedFun: Int => Int = fun(
    square = square,
    cube = cachedCube,
    abs = abs)

  println(cachedFun(42))
}

В принципе, решение рабочее, но всё портит уродливая сигнатура fun с четырьмя аргументами, раскиданными по двум спискам параметров. Давайте завернем первый список в trait:

object Test3 {

  trait Ops {
    def square(v: Int): Int = v * v

    def cube(v: Int): Int = v * v * v

    def abs(v: Int): Int = if (v < 0) -v else v
  }

  def fun( //более симпатичная сигнатура, не так ли?
    ops: Ops)
    (v: Int): Int = {
    4 * ops.cube(v) + 2 * ops.square(v) + ops.abs(v)
  }

  // мемоизация уже не нужна - мы можем просто переопределить поведение методов
  // дополнительный бонус - мы управляем мутабельным состоянием явно
  // т.е. можем выбирать время жизни кеша - к примеру, не создавать Map здесь,
  // а использовать какую-то внешнюю реализацию. Из Guava к примеру.
  val cachedOps = new Ops {
    val cache = mutable.HashMap.empty[Int, Int]
    override def cube(v: Int): Int = cache.getOrElseUpdate(v, super.cube(v))
  }

  val realFun: Int => Int = fun(cachedOps)

  println(realFun(42))
}

И последнее, от чего можно избавиться — это частичное применение аргументов функции fun:

object Main {

  trait Ops {
    def square(v: Int): Int = v * v

    def cube(v: Int): Int = v * v * v

    def abs(v: Int): Int = if (v < 0) -v else v
  }

  class MyFunctions(ops: Ops) {
    def fun(v: Int): Int = {
      4 * ops.cube(v) + 2 * ops.square(v) + ops.abs(v)
    }
  }

  val cachedOps = new Ops {
    val cache = mutable.HashMap.empty[Int, Int]
    override def cube(v: Int): Int = cache.getOrElseUpdate(v, super.cube(v))
  }

  val myFunctions = new MyFunctions(cachedOps)

  println(myFunctions.fun(42))
}

Таким образом, у нас получился классический ООП дизайн. Который гибче исходного варианта, который более типизирован (Int => Int уж точно менее понятен, чем MyFunctions.fun), который эффективен по быстродействию (ФП вариант не будет работать быстрее, а вот медленнее — легко), который просто понятнее.

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

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

Жду ваших комментариев )

Автор: Scf

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js