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

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

Жду ваших комментариев )
Поделиться с друзьями
-->

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


  1. samsergey
    18.06.2017 10:07
    +8

    Дело в том, что нет никакого антагонизма между ФП и ООП. Процедурное программирование тоже прекрасно с ними уживается и сотрудничает. Они друг другу не противоречат и не мешают. Они не лучше и не хуже друг друга. Их просто нужно использовать по делу. Правильно инкапсулированные классы и процедуры, не меняющие глобального состояния, вполне функциональны и прекрасно комбинируются в функциональном стиле. ООП возникает "само собой" в функциональной программе там, где мы мыслим на языке интерфейсов и сообщений. Недаром родились CLOS и O'Haskel. Если алгоритм чище и проще реализовать с goto мы, в рамках чистого ФП используем call/cc или монаду Cont, не теряя мощи вывода типов и возможности доказательства свойств программ. Когда в "чистом" ООП нам не нужны объекты, а только данные и процедуры, мы в C# или Java пишем ключевое слово static и попадаем в мир процедурного программирования.


    Ваш пример со столь простыми функциями, и столь сложными к ним требованиями, действительно реализуется в объектной модели лучше. Если согласно ТЗ требуется много экземпляров функции funс разной начинкой, то мы естественно приходим к объектам. Если функция будет одна и нужно только протестировать начинку, то первый вариант оптимальнее.
    И монады здесь вовсе ни к чему, разве что Reader, но и он не нужен.


    1. Scf
      18.06.2017 10:23

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

      Монады можно было бы использовать для управления зависимостями функции (монада[Int] с контекстом (square, cube, abs)), но думается мне, что красивого решения здесь не будет.


      1. samsergey
        18.06.2017 11:07

        Думаю, вы имели в виду не монаду Int, а класс. Да, для вашей задачи в Haskell я бы определённо определил полиморфную функцию с ограничением для класса Num (увы, я не программирую на Scala).


        В своём комментарии я стремился снять полемичность заголовка. ФП в Scala нужен. Для того, хотя бы, чтобы можно было вообще передать функцию аргументом или каррировать. И, наконец, ФП нужен для вывода типов по Хиндли-Милнеру. При этом ваши программы могут использовать те парадигмы, какие больше подходят к задаче.


        1. Scf
          18.06.2017 11:22

          Вопрос терминологии — под ФП я понимаю программирование с использованием чистых функций и композиции чистых функций. Отдельные элементы ФП, несомненно, полезны, достаточно посмотреть на Kotlin — он перенял часть полезных идей из ФП языков.

          Про монаду — конечно, обертку над типом Int


          1. samsergey
            18.06.2017 11:31

            Что есть, то есть, с терминологией вопросов много. Одни под ФП подразумевают наличие функций, как объектов первого класса, другие — чистоту и прозрачность по ссылкам, третьи — принцип композиции и декомпозиции программ. Было бы, наверное, неплохо иметь для этого специальные простые термины наподобие имён паттернов, чтобы одним чихом не утверждать, что ФП — оно такое-сякое. Но масса программистов, отчего-то, стремится не к математической точности, а к журналистским обобщениям. Я не про вас это говорю, не поймите меня неправильно.


      1. senia
        18.06.2017 11:31

        Причем здесь вообще монады?

        Такое решается другими type class. Обратите внимание на Numeric — там как раз есть abs и times. Сделайте свой по аналогии.
        На выходе должно получиться что-то вроде такого:

        def fun[T: Math](v: T): T = 4 * v.cube + 2 * v.square + v.abs
        


        Ну а то, что вы написали — ручная реализации typeclass на OOP. Очень неудобно, если у вас много мест, принимающих Ops — везде передавать вручную придется, создавая много шума в коде.


        1. Scf
          18.06.2017 12:16

          typeclass не решение.
          — Он подходит только для функций с одним аргументом. Что если в функции, к примеру, использовался max(Int, Int): Int?
          — он должен быть доступным в имплиситном скоупе.
          — синтаксис, приведенный в вашем примере, требует дополнительную аллокацию на каждое использование тайпкласса
          — в теле функции извлечь тайпкласс можно либо через имплиситное преобразование типа к обертке тайпкласса, либо через `implicitly`. Этого недостаточно для пробрасывания произвольных зависимостей.
          — абстрагирование от типа результата не нужно. Если моя программа работает только с интами, зачем запутывать читателей абстрактными типами?
          — Смысл тайпкласса в добавлении свойств к типу, а проблема, изложенная в статье, — управление зависимостями.


          1. samsergey
            18.06.2017 12:45

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


            У вас не просто Int, а такое целое число для которого существует какая-то нетривиальная реализация функций возведения в квадрат, куб и модуля (судя по ТЗ). Если бы это был просто Int хватило бы первого решения. Для таких случаев вводят типы-обёртки (в Haskell это newtype). Они без накладных
            расходов позволяют аккуратно менять поведение существующих типов.


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


          1. senia
            18.06.2017 14:50
            +1

            1. Math[T].max(a, b, c, ...)
            2. Импортируйте или поместите туда, где он всегда виден.
            3. AnyVal
            4. См. выше Math[T]. Подсказать как такое реализовать?
            5. http://degoes.net/articles/insufficiently-polymorphic, но если хотите сделать метод только для Int, то это сделать не проблема. Учите синтаксис.
            6. Вы, мягко говоря, не правы. What to Leave Implicit by Martin Odersky

            Потрудитесь выучить основы языка, прежде чем писать статьи про него. И основы ФП, прежде чем писать статьи про ФП.


            1. Scf
              18.06.2017 15:13

              Ну зачем же так категорично
              1. А экземпляр Math[T] где взять? см п.4
              2. импортирование или размещение синглтона в скоупе делает код неконфигурабельным. С тем же успехом можно использовать код из первого примера
              3. имплиситная обертка для тайпклассов не может наследовать AnyVal, т.к. содержит ДВА поля — экземпляр тайпкласса и оборачиваемый тип. Пример тут: https://github.com/mpilquist/simulacrum
              4. Ну, желательно. На вашем же примере `def fun[T: Math](v: T): T` Я знаю только один способ получить экземпляр Math[T] в теле функции — implicitly[Math[T]].
              5. Статья выглядит несколько… фанатично. И не сказал бы, что его решение понятнее.
              6. Видео посмотрю когда-нибудь, всё таки целый час нужно потратить. Но имплиситы в качестве средства инжекта зависимостей… пытался. не раз. не работает. Надеюсь, видео не про cake pattern?

              И, прежде чем судить, покажите ваше решение.


              1. senia
                18.06.2017 15:25
                +2

                1. Стандартный паттерн в scala. object Math { def apply[T](implicit m: Math[T]) = m }
                2. Вы о чем? Импортировать надо пере вызовам foo. В каждом новом месте можно вызывать foo с другим инстансом type class. Если же инстанс в компаньоне, то его все равно можно перекрыть в тестах явным вызовом.
                3. Вы не правы. Никто не говорил, что надо передавать инстанс тайпкласса в конструктор обертки.
                4. См. 1
                5. Оно гораздо понятнее, если понимать ФП. Оперирует исключительно широко распространенными в ФП понятиями. Если не знать ФП, да не понятно. Если не знать scala еще менее понятно.
                6. Про тортик там только упоминание bakery of doom. А доклад автора языка по ключевым моментам идеологии языка посмотреть стоит.



  1. 4lex1v
    18.06.2017 16:07

    Спасибо за статью. То что у Вас получилось это практически Tagless-Final Style, правда немного недоделано. Если я не ошибаюсь, то данный подход и правда растет из ООП. А заголовок конечно весьма толстый.


  1. 0xd34df00d
    18.06.2017 22:09
    +1

    Непонятно, как бы тут помогли монады (ну не будете же вы Int'ы свои заворачивать в монаду с переопределёнными нужными вам операциями? да и аппликативного функтора будет достаточно).


    И непонятно, причём тут ООП. У вас получился классический дизайн на тайпклассах, которые с ООП в каком-то смысле немножко пересекаются, но всё-таки разные вещи.


  1. basnopisets
    19.06.2017 10:51

    Отличный вариант использования trait