За последние несколько лет значительно возрос интерес к языкам функционального программирования (ФП).

Многие программисты видят преимущества в написании кода, который предоставляет такие возможности, как функции, рассматриваемые как элементы первого класса. Они используют неизменяемость в конкурентной среде и выполняют сложные вычислительные задачи, не беспокоясь о каких-либо проблемах с реализацией конкурентности. Кроме того, такие ценители с удовольствием пишут обобщенный код по принципу DRY (рус. “Не повторяйся”) настолько, насколько возможно. 

Все это говорит о том, что ФП снова набирает обороты. Однако в создании кода ФП есть свои сложности. Они связаны с паттернами проектирования и антипаттернами, которые отличаются от обычных языков программирования. 

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

Чрезмерная вложенность функции обратного вызова 

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

Рассмотрим базу кода с предельно кратким и абстрактным методом. Код выглядит следующим образом: 

def buildRunner
((req,Resp) => ctx.TransactionContext)
((resp, ctx.rtx.Transaction) => Final[Context])
(resp => Unit): Runner[ctx.Transaction, rtx.TransacitonResponse] = new Runner[ctx.Transaction, rtx.TransactionResponse] {
override def run((ctx.Transaction, rtx.TransactionResponse) => Response): Req => Resp = ???
}


trait Runner[T, F] {
def run((T,F) => Response): Req => Resp
}

Что означает в этом примере определение buildRunner

Функция buildRunner задействуется во всех операциях, таких как авторизация, сбор данных и аннулирование в платежном процессоре. Два дня я к ней присматривался и наконец понял, что она пытается сделать. 

Она создает максимально возможную DRY-абстракцию для всех прописываемых функций. Однако наличие вложенной функции обратного вызова усложняет обычным программистам создание новой функциональности или обслуживание. Большинству из них потребуется немало времени, чтобы разобраться в действиях buildRunner.

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

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

Если вы намерены задействовать анонимную функцию, то настоятельно рекомендую сверху поместить type для более удобного чтения. http4s делает это сам, оборачивая свои типы в Kleisli. Kleisli сам по себе является анонимной функцией, т.е. A => F[B]. Однако обертывание анонимной функции определением type сверху улучшает читаемость кода. 

Сопоставление с образцом по полной  

Когда мы пишем код на языке ФП, в нашем распоряжении оказываются разные преимущества, и самое первое из них  —  функциональность сопоставления с образцом (англ. pattern matching). Она позволяет избавиться от уродливых инструкций if-else, широко применяемых в обычных языках программирования. 

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

def doSomething(res: Future[Either[Throwable, Option[A]]]) = res match {
case Success(a) =>
a match {
case Left(ex) =>
case Right(b) => b match {
case Some(c) =>
case None =>
}

}

case Failure(ex) =>
}

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

Наличие вложенного выражения case и рекурсия в реализации функции усложняют чтение и понимание кода. Много времени уходит на комментарии PR. Кроме того, становится труднее обнаруживать ошибки в случае их появления в реализации. 

При написании вложенных выражений case для сопоставления с образцом рекомендуется озаботится только успешным выполнением условий case, а ошибочные сценарии оставить вне реализации функции. Более того, можно воспользоваться встроенной функцией высшего порядка, предоставляемой библиотекой или языком: по возможности map и flatMap. Такое решение способствует оптимизации базы кода и позволяет быстро определить, где происходит обработка ошибки. 

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

Применение преобразователя монад в интерфейсе 

Столкнувшись с эффектом вложенности, целесообразно воспользоваться преобразователем монад (англ. Monad Transformer). Он представляет собой еще один вариант решения проблемы чрезмерной вложенности и позволяет сделать API компонуемым. Однако не следует прописывать преобразователь монад в интерфейсе, поскольку это привязывает API к конкретному преобразователю.

Рассмотрим на примере. Вместо представленного ниже интерфейса EitherT[Future, Throwable, String] может быть и Future[Either[Throwable, String]]:

trait SomeInterface {
def someFunction(): EitherT[Future, Throwable, String]
}

Все функции, которые намерены задействовать someFunction в качестве API, также должны будут использовать EitherT.

А что если мы имеем дело с последовательностью функций и видим, что какая-то из них возвращает OptionT?

Потребуется пару раз вызвать value, чтобы вернуться к результату Future. Ненужная обертка. 

Как вариант, мы должны сделать так, чтобы someFunction возвращала Future[Either[Throwable, String]] и позволить результату определять, какое ограничение вам потребуется в программе.  

trait SomeInterface {
def someFunction(): Future[Either[Throwable, String]]
}

Чистый результат предпочтительнее преобразователя монад. Он не замыкает сервисы, работающие с API, на применение преобразователя монад. 

Возвращение логического значения в API 

Многие API способны возвращать одно логическое значение для обозначения той или иной логики. Классическим примером, взятым из практического пособия Practical Fp in Scala, служит функция filter

trait List[A] {
def filter(p : A => Boolean): List[A]
}

Рассмотрим определение функции и разберемся, что она делает. 

Здесь мы сталкивается с двояким толкованием. Равенство предиката true может означать как исключение, так и сохранение элементов списка. 

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

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

sealed trait Predicate 
object Predicate {
case object Keep extends Predicate
case object Discard extends Predicate
}

ADT помогает создать более конкретную сигнатуру функции: 

def filter[A](p: A => Predicate): List[A]

При использовании этой функции становится понятно, на что именно она нацелена: на сохранение элементов или их исключение.  

List(1,2,4).filter{p => if(p > 2) Predicate.Keep else Predicate.Disacrd}

Для решения данной проблемы в классе filter всегда можно создать метод расширения filterBy из трейта (англ. trait) Scala List

implicit class ListOp[A](lst: List[A]) {
def filterBy(p: A => Predicate): List[A] = lst.filter{ p(_) match {
case Predicate.Keep => true
case Predicate.Discard => false
}
}
}

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

Излишне обертывать посредством ADT все API с возвращаемым логическим значением. Вы можете обернуть такой API в критическом компоненте и добиться гибкости в остальной части приложения. Это вопрос договоренности со всеми участниками командной разработки. 

Использование структуры обобщенных данных в трейте 

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

В качестве примера рассмотрим Seq, обобщенное представление, определенное в стандартной библиотеке Scala. От него наследуются List, Vector и Stream. Это создает проблему, поскольку эти структуры данных ведут себя по-разному. 

Возьмем трейт, который возвращает Future[Seq[String]]:

trait SomeTrait {
def fetchAll: Future[Seq[String]]
}

Некоторые программисты вызовут функцию fetchAll и преобразуют Seq в List с помощью функции toList.

Откуда нам знать, что вызов toList безопасен? Интерпретатор может определить Seq как Stream. В этом случае у него будет другая семантика, и он может выбросить исключение на стороне вызывающей программы. 

Чтобы уменьшить количество подобных сюрпризов, рекомендуется определять более конкретный тип, а именно List, Vector, Stream, в зависимости от целей и производительности приложения. 

Заключение 

Проблема с этими антипаттернами заключается в том, что в стандартных языках программирования они таковыми не являются. 

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

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

Слишком обобщенная структура данных может усложнить использование API из-за неоднозначных трактовок. Следовательно, рекомендуется создавать более конкретный тип и недвусмысленное объявление функции для вызывающей программы. 

Надеюсь, теперь вы освободите свой код на языке ФП от перечисленных антипаттернов. 

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Edward Huang: 5 Anti Patterns to Avoid When Writing Code in a Functional Programming Language

Предыдущая статьяКак работает алгоритм YouTube?
Следующая статьяКак писать функции: 8 советов от опытного разработчика