В этой статье я хотел бы на примере простого чисто функционального кода показать, как он видоизменяется при добавлении требований к тестируемости и расширяемости, и что же получается в итоге. Надеюсь, это будет интересно всем, кто интересуется дизайном ПО и функциональным дизайном в частности. Также желательно немного понимать язык Scala, ну или уметь быстро разбираться.
Попробуем написать простую программу, вычисляющую выражение 4 * x^3 + 2 * x^2 + abs(x). Поскольку это пост про функциональное программирование, оформим всё в виде функций, вынеся операции возведения в степень и модуля:
Выглядит симпатично, не правда ли? Теперь добавим пару требований:
— мы хотим тестировать функцию fun(), используя свои реализации функций square, cube и abs вместо «зашитых» в текущую реализацию
— функция cube работает медленно — давайте её кешировать
Таким образом, fun должна принимать свои зависимости в виде аргументов, заодно можно сделать мемоизацию функции cube.
В принципе, решение рабочее, но всё портит уродливая сигнатура fun с четырьмя аргументами, раскиданными по двум спискам параметров. Давайте завернем первый список в trait:
И последнее, от чего можно избавиться — это частичное применение аргументов функции fun:
Таким образом, у нас получился классический ООП дизайн. Который гибче исходного варианта, который более типизирован (Int => Int уж точно менее понятен, чем MyFunctions.fun), который эффективен по быстродействию (ФП вариант не будет работать быстрее, а вот медленнее — легко), который просто понятнее.
Возможно, у читателей возникнет вопрос «Почему не монады?». Монады в 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.
Заголовок этой статьи заканчивается вопросительным знаком не просто так — я публикую мысли, которые у меня возникают при изучении ФП, в надежде помочь другим и в надежде, что другие поделятся своим видением и своим опытом в такой непростой сфере, как простой, понятный, устойчивый к ошибкам и расширяемый дизайн программного обеспечения.
Жду ваших комментариев )
Поделиться с друзьями
Комментарии (15)
4lex1v
18.06.2017 16:07Спасибо за статью. То что у Вас получилось это практически Tagless-Final Style, правда немного недоделано. Если я не ошибаюсь, то данный подход и правда растет из ООП. А заголовок конечно весьма толстый.
0xd34df00d
18.06.2017 22:09+1Непонятно, как бы тут помогли монады (ну не будете же вы Int'ы свои заворачивать в монаду с переопределёнными нужными вам операциями? да и аппликативного функтора будет достаточно).
И непонятно, причём тут ООП. У вас получился классический дизайн на тайпклассах, которые с ООП в каком-то смысле немножко пересекаются, но всё-таки разные вещи.
samsergey
Дело в том, что нет никакого антагонизма между ФП и ООП. Процедурное программирование тоже прекрасно с ними уживается и сотрудничает. Они друг другу не противоречат и не мешают. Они не лучше и не хуже друг друга. Их просто нужно использовать по делу. Правильно инкапсулированные классы и процедуры, не меняющие глобального состояния, вполне функциональны и прекрасно комбинируются в функциональном стиле. ООП возникает "само собой" в функциональной программе там, где мы мыслим на языке интерфейсов и сообщений. Недаром родились CLOS и O'Haskel. Если алгоритм чище и проще реализовать с
goto
мы, в рамках чистого ФП используемcall/cc
или монадуCont
, не теряя мощи вывода типов и возможности доказательства свойств программ. Когда в "чистом" ООП нам не нужны объекты, а только данные и процедуры, мы в C# или Java пишем ключевое словоstatic
и попадаем в мир процедурного программирования.Ваш пример со столь простыми функциями, и столь сложными к ним требованиями, действительно реализуется в объектной модели лучше. Если согласно ТЗ требуется много экземпляров функции
fun
с разной начинкой, то мы естественно приходим к объектам. Если функция будет одна и нужно только протестировать начинку, то первый вариант оптимальнее.И монады здесь вовсе ни к чему, разве что
Reader
, но и он не нужен.Scf
В моем примере ООП возникло из-за отсутствия другого удобного механизма инжектирования зависимостей. Я хотел показать, что в ФП стиле мы либо хардкодим зависимости (привет, все известные проблемы паттерна singleton), либо безбожно увеличиваем количество параметров функции.
Монады можно было бы использовать для управления зависимостями функции (монада[Int] с контекстом (square, cube, abs)), но думается мне, что красивого решения здесь не будет.
samsergey
Думаю, вы имели в виду не монаду
Int
, а класс. Да, для вашей задачи в Haskell я бы определённо определил полиморфную функцию с ограничением для классаNum
(увы, я не программирую на Scala).В своём комментарии я стремился снять полемичность заголовка. ФП в Scala нужен. Для того, хотя бы, чтобы можно было вообще передать функцию аргументом или каррировать. И, наконец, ФП нужен для вывода типов по Хиндли-Милнеру. При этом ваши программы могут использовать те парадигмы, какие больше подходят к задаче.
Scf
Вопрос терминологии — под ФП я понимаю программирование с использованием чистых функций и композиции чистых функций. Отдельные элементы ФП, несомненно, полезны, достаточно посмотреть на Kotlin — он перенял часть полезных идей из ФП языков.
Про монаду — конечно, обертку над типом Int
samsergey
Что есть, то есть, с терминологией вопросов много. Одни под ФП подразумевают наличие функций, как объектов первого класса, другие — чистоту и прозрачность по ссылкам, третьи — принцип композиции и декомпозиции программ. Было бы, наверное, неплохо иметь для этого специальные простые термины наподобие имён паттернов, чтобы одним чихом не утверждать, что ФП — оно такое-сякое. Но масса программистов, отчего-то, стремится не к математической точности, а к журналистским обобщениям. Я не про вас это говорю, не поймите меня неправильно.
senia
Причем здесь вообще монады?
Такое решается другими type class. Обратите внимание на Numeric — там как раз есть abs и times. Сделайте свой по аналогии.
На выходе должно получиться что-то вроде такого:
Ну а то, что вы написали — ручная реализации typeclass на OOP. Очень неудобно, если у вас много мест, принимающих Ops — везде передавать вручную придется, создавая много шума в коде.
Scf
typeclass не решение.
— Он подходит только для функций с одним аргументом. Что если в функции, к примеру, использовался max(Int, Int): Int?
— он должен быть доступным в имплиситном скоупе.
— синтаксис, приведенный в вашем примере, требует дополнительную аллокацию на каждое использование тайпкласса
— в теле функции извлечь тайпкласс можно либо через имплиситное преобразование типа к обертке тайпкласса, либо через `implicitly`. Этого недостаточно для пробрасывания произвольных зависимостей.
— абстрагирование от типа результата не нужно. Если моя программа работает только с интами, зачем запутывать читателей абстрактными типами?
— Смысл тайпкласса в добавлении свойств к типу, а проблема, изложенная в статье, — управление зависимостями.
samsergey
Вы недооцениваете концепцию класса типов. Классы не расширяют тип, а создают новое семантическое пространство, в котором вы можете создавать имеющие известный смысл сущности, используя, как новые, так и существующие типы.
У вас не просто
Int
, а такое целое число для которого существует какая-то нетривиальная реализация функций возведения в квадрат, куб и модуля (судя по ТЗ). Если бы это был простоInt
хватило бы первого решения. Для таких случаев вводят типы-обёртки (в Haskell этоnewtype
). Они без накладныхрасходов позволяют аккуратно менять поведение существующих типов.
Странно, судя по вашим пунктам, классы типов в Scala какие-то жутко неудобные, по сравнению, скажем с Haskell. Подозреваю, что это не так, и что задача, котоую вы для себя поставили — это отличный способ в них разобраться.
senia
Потрудитесь выучить основы языка, прежде чем писать статьи про него. И основы ФП, прежде чем писать статьи про ФП.
Scf
Ну зачем же так категорично
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?
И, прежде чем судить, покажите ваше решение.
senia
fuCtor
4) вот тут расписано https://github.com/anton-k/ru-neophyte-guide-to-scala/blob/master/src/p12-type-classes.md