[Перевод] 5 антипаттернов при написании кода на функциональном ЯП
![nswvisbehithta6yvyuvyz5wgpq.png](https://habrastorage.org/webt/ns/wv/is/nswvisbehithta6yvyuvyz5wgpq.png)
Антипаттерны в функциональных языках программирования могут показаться непривычными в силу отличия этих языков от других их видов, в связи с чем разработчики нередко пишут не самые удачные реализации, склонные к ошибкам и трудные в обслуживании. В статье мы разберём пять наиболее типичных антипаттернов, избегая которые вы сможете создавать более удобный в работе код при меньшем количестве ошибок.
Функциональные языки программирования за последние годы обрели значительную популярность. Многие видят плюсы написания кода, в котором функции выступают основными действующими компонентами. Программисты пользуются преимуществами немутабельности, позволяющей выполнять тяжёлые задачи, не беспокоясь о возможных проблемах с конкурентностью, а также любят писать обобщённый код, максимально соответствующий принципам DRY (Don«t Repeat Yourself, не повторяйся).
Для меня всё это стало явными признаком того, что функциональные языки снова набирают популярность. Тем не менее одной из сложностей написания кода в таком языке являются паттерны и антипаттерны проектирования, которые отличаются от стандартных языков программирования.
Я нередко вижу, как инженеры пишут огромную базу кода, используя, на мой взгляд, различные антипаттерны. Я также был причастен к созданию подобных нежелательных реализаций, когда только начинал писать готовые к продакшену приложения на функциональном языке. С тех пор я прочёл много книг о проектировании в функциональном программировании, которые помогли мне создавать более удобный в обслуживании код.
▍ Глубоко вложенные анонимные функции обратного вызова
Анонимная функция может удачно содействовать переиспользуемости кода. Однако при излишне глубокой вложенности такая функция станет сложной для восприятия разработчиками, которые захотят расширить её функциональность. Поэтому вопреки рекомендации следовать принципу 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
послужит лучшей читаемости.
▍ Сопоставление шаблонов
Первое из преимуществ, с которым мы знакомимся в функциональном программировании — это возможность сопоставления шаблонов. Она избавляет нас от уродливых инструкций if-else
, которые мы зачастую используем в более распространённых языках программирования.
Сопоставление шаблонов хорошо подходит только в случае небольшого их количества. Всё очень быстро может превратиться в «ад обратных вызовов», когда мы используем более двух слоёв сопоставления шаблонов.
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) =>
}
Зачастую разработчики, которые только начинают писать код на функциональном языке, не знают, сколько встроенных функций высшего порядка этот язык предоставляет. Следовательно, они по умолчанию прогоняют реализацию своих функций через сопоставление шаблонов и рекурсивную функцию.
Использование вложенного условного выражения и рекурсивной реализации в функции усложняет чтение и понимание кода. Больше времени уходит на комментарии к пул-реквестам и затрудняется поиск возможных багов.
Одно из решений в таком случае — опереться только на успешный кейс условного выражения, оставив ошибочный сценарий вне реализации функции. Более того, по возможности следует использовать встроенную функцию высшего порядка, предоставляемую библиотекой или языком, map
и flatMap
. Это сделает базу кода более удобной в использовании, и вы сможете быстро определять место возникновения ошибки.
Польза выражения ваших типов в определении функции заключается в том, что в её реализации не потребуется обрабатывать все сценарии ошибок — система типов будет перенаправлять их вызывающему, и обработка будет ложиться на вышестоящую функцию.
▍ Использование в интерфейсе монадных трансформеров
Монадные трансформеры могут оказаться очень кстати, когда вы сталкиваетесь с излишней вложенностью. В описанном выше сценарии такой трансформер представляет собой ещё одно решение проблемы чрезмерной вложенности — он позволяет сделать ваш API компонуемым. Тем не менее в интерфейсе использовать их не следует, поскольку это привяжет наш API к конкретному трансформеру.
Разберём пример. Приведённый ниже интерфейс может быть Future[Either[Throwable, String]]
вместо EitherT[Future, 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
, то она отбросит элементы списка. С другой стороны, когда предикат равен true
, это также может означать сохранение элементов списка.
Получается двусмысленность.
В Scala также есть filterNot
, которая имеет то же определение функции, но другое имя. Я зачастую встречал в этих функциях всяческие ошибки, возникающие из-за того, что программист упускал отличие между ними.
Этот нюанс можно поправить, обернув предикат в ADT (Algebraic Data Type, алгебраический тип данных) с несущими смысл значениями.
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}
Чтобы решить эту проблему для класса фильтра, всегда можно создать из трейта List
расширяющий метод filterBy
.
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. И хотя это приведёт к написанию лишнего рутинного кода, зато сократит путаницу и число ошибок при создании приложения.
Однако обёртывание всех возвращаемых API логических значений с помощью ADT может оказаться перебором. Поэтому желательно обёртывать их в критических компонентах, сохраняя гибкость в остальной части приложения. Здесь уже вопрос договорённости с членами вашей команды.
▍ Использование в трейте обобщённой структуры данных
В типичной практике разработки следующее утверждение может показаться противоречивым. «С целью поддержки расширяемости интерфейс должен быть максимально обобщённым». В теории звучит круто, но на деле это не так.
В качестве одного из примеров можно привести Seq
— обобщённое представление, определяемое в стандартной библиотеке Scala. Широкая универсальность этого представления демонстрируется тем, что от него происходят List
, Vector
и Stream
. И это является проблемой, поскольку каждая из этих структур данных действует по-разному.
Например, у нас есть трейт, возвращающий Future[Seq[String]]
:
trait SomeTrait {
def fetchAll: Future[Seq[String]]
}
Некоторые разработчики вызовут функцию fetchAll
и с помощью функции toList
преобразуют Seq
в List
.
А откуда вы знаете, что вызов toList
окажется безопасен? Интерпретатор может определить Seq
как Stream
, в случае чего она будет иметь иную семантику и, вероятно, выбросит исключение на стороне вызывающего.
Поэтому с целью сокращения числа сюрпризов с вызывающей стороны лучше всего в зависимости от целей и быстродействия приложения определять более конкретный тип, например, List
, Vector
, Stream
.
▍ Заключение
Проблема с описанными антипаттернами в том, что в привычных языках программирования они таковыми не считаются.
К примеру, нас учили, что писать абстракции — это хорошо, и это позволяет следовать принципу DRY. Тем не менее излишне вложенные анонимные функции трудно читать. Лучше всего в таких случаях для повышения читаемости прибегнуть к повторению кода.
API, возвращающий логическое значение, может не составлять проблемы, и таких примеров много, в том числе в приложениях. Однако реализация API, возвращающего логическое значение, не даёт нам ясности относительно смысла этого значения. Более того, человек зачастую может проглядеть мелкие детали в документации и в итоге допустить ошибки в реализации.
Сопоставление шаблонов является мощным инструментом в функциональном программировании, но оказывается излишне обобщённым. Если вы можете найти для реализации функции более подходящую функцию высшего порядка, то лучше так и сделать.
Излишне обобщённая структура данных может повысить двусмысленность в использовании API. Следовательно, лучше будет создавать более конкретные типы данных и максимально прояснять объявления функций для вызывающей стороны.
Надеюсь, вы сможете избегать перечисленных антипаттернов при написании кода на функциональном языке. А считаете ли вы их антипаттернами? Какие ещё примеры антипаттернов вы бы могли привести? Буду признателен, если поделитесь в комментариях своими мыслями по этому поводу.
Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх