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