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

Функциональные языки программирования за последние годы обрели значительную популярность. Многие видят плюсы написания кода, в котором функции выступают основными действующими компонентами. Программисты пользуются преимуществами немутабельности, позволяющей выполнять тяжёлые задачи, не беспокоясь о возможных проблемах с конкурентностью, а также любят писать обобщённый код, максимально соответствующий принципам 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.Discard}

Чтобы решить эту проблему для класса фильтра, всегда можно создать из трейта 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 и постами о ретроиграх ????️

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


  1. Anvano
    21.05.2023 14:27
    +24

    Я иногда не понимаю зачем в языки вводят все эти "упрощения" (которые по факту являются усложнениями).

    Если для понимания какого-то фрагмента кода рядовому (подчеркиваю, не лиду, не сеньору, а рядовому миддлу или джуну) требуется потратить времени БОЛЬШЕ, чем для понимания аналогичного по функционалу кода, но написанного через базовые конструкции, общие для всех языков, то грош цена такому "упрощению".

    Ну и что, что у кого-то получилось избавиться от трёх процедур по 50 строк с ветвлениями и завернуть всё это в три строки кода с "заумными" конструкциями, лямбдами, монадами и т.п.?

    Общая эффективность такого кода только падает, а не растёт. Поскольку эффективность кода заключается не только в количестве строк, но и во времени, затрачиваемом на его сопровождение НЕ АВТОРОМ данного кода (а это очень важно в больших командах, сопровождение НЕ АВТОРОМ).

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

    На код ревью у меня даже уже чутьё выработалось на такие строки. Я читаю код и если у меня глаз зацепился за конструкцию и мозгу потребовалась лишняя "итерация", чтобы понять, что и как этот код делает - то данная конструкция - первый кандидат на рефакторинг в более простом виде.

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


    1. Rive
      21.05.2023 14:27
      +10

      Мне тоже нравится простой для понимания код без вовлечения высокоуровневых паттернов, но сравнительно новый синтаксический сахар типа лямба-функций, элвис-оператора или функциональных методов типа .map() делают код гораздо проще для восприятия за счёт того, что выкидывается рутинный бойлерплейт типа циклов или проверок на null. Да, эта работа кода всё ещё есть под капотом и при расширении код может понадобится как-нибудь извращённо переписать, но часто этого сокращения бывает достаточно.
      (Что, разумеется, не отменяет того, что при усердии можно соорудить действительно адский код в функциональной парадигме тоже.)


    1. 0xd34df00d
      21.05.2023 14:27
      +16

      Если для понимания какого-то фрагмента кода рядовому (подчеркиваю, не лиду, не сеньору, а рядовому миддлу или джуну) требуется потратить времени БОЛЬШЕ, чем для понимания аналогичного по функционалу кода, но написанного через базовые конструкции, общие для всех языков, то грош цена такому "упрощению".

      Это вопрос знакомства с языком и терминами. Мне (а я вроде сеньор) потребуется времени больше, чтобы понять код с всеми этими реактами, чем с голым HTML — значит ли это, что реакт не нужен?


      1. ValentinAndreev
        21.05.2023 14:27
        +1

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


        1. 0xd34df00d
          21.05.2023 14:27
          +12

          Тогда это замкнутое на себя же утверждение — если в проекте используется ФП-стиль, то от джуна можно и его знание ожидать. Просто скала — она ж мультипарадигменная, поэтому «знает скалу» не означает «знает ФП-стиль в скале».


          Так-то у меня вполне себе рядовые джуны в хаскеле нормально понимали достаточно продвинутый хаскель-код, и вопросов обычно было больше к системе типов и прочему, чем к монадам и тем более лямбдам.


    1. maeris
      21.05.2023 14:27
      +1

      Потому что иногда лидам и сеньорам нужно написать тонну кода, который джун вообще не напишет, а времени в обрез. Поэтому лид или сеньор берёт Scala / Haskell / нужное подчеркнуть и пишет софтину за сравнительно короткий промежуток времени, полагаясь на parametricity и прочие результаты CS последних 50 лет, чтобы в этой разработке не утонуть. Иногда даже бывает, что такой весь проект, и джунов на него просто не нанимают.

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


      1. vkni
        21.05.2023 14:27
        +2

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

        На самом деле, времени всегда в обрез. Просто часть программ (которые могут написать и джуны), писать вообще не надо.


    1. vkni
      21.05.2023 14:27
      +7

      Я иногда не понимаю зачем в языки вводят все эти "упрощения" (которые по факту являются усложнениями).

      Потому, что если ими правильно пользоваться, то они упрощают жизнь. А если неправильно, то усложняют. Примерно как бензопила, автомобиль, топор.


    1. sheshanaag
      21.05.2023 14:27
      +5

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

      А вы что, истина в последней инстанции? Может вы вообще ничего не знаете и в лидах оказались за выслугу лет? Уж если код в тыщу раз компактнее, да ещё и оптимальнее, то именно его и надо использовать. И если лид режет такой код, то нафига такой лид нужен в проекте.


      1. TuzSeRik
        21.05.2023 14:27
        +2

        95% времени программист это не художник, это инженер.
        Инженерная задача подразумевает не просто решение, но решение, удовлетворяющее условиям, разумной стоимости и поддерживаемое другими инженерами или даже простыми механиками.

        Сейчас источник не найду, но ЕМНИП видел цитату "реактивный самолёт должен быть настолько прост, чтобы обычный механик мог провести его ремонт в поле стандартными инструментами".
        Если в команде поддержать написанный кусок кода может исчезающе малое количество людей, то это, скорее всего, инженерно плохой кусок кода, насколько бы он не был гениален по своей сути.

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

        P.S. Я сам люблю завернуть каких-нибудь абстракций лютых, но я не лид, так что выливается это в марафонные код-ревью, где приходится доносить свой "сумрачный гений" до остальных, что в сумме медленнее, чем писать тупой код и проходить ревью сходу


    1. fishHook
      21.05.2023 14:27
      +2

      Ну и что, что у кого-то получилось избавиться от трёх процедур по 50
      строк с ветвлениями и завернуть всё это в три строки кода с "заумными"
      конструкциями, лямбдами, монадами и т.п.?

      Я часто в своих проектах бью джунов по рукам ...

      Однажды у вас это обязательно случится, с гарантией. В вам придут новые требования, для соответствия которым вам будет необходимо изменить логику всех трех процедур. Две процедуры вы измените, а про третью забудете (хорошо, не вы, а ваш джун с набитыми руками). И будете потом полгода ловить странный сложно воспроизводимый баг. Ну, дебажте, конечно, если вам так нравится, только, пожалуйста, других глупостям не учите. Техника безопасности пишется кровью, а Clean code - нервами, временем, соравнными дедлайнами и деньгами.


    1. acordell
      21.05.2023 14:27
      +1

      Согласен. В "Чистом коде" Мартина это называет принципом наименьшего удивления. То есть, при чтении кода не должно возникать вопроса "а это что?"

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


  1. shuhray
    21.05.2023 14:27

    : <value> ( -- value ) \ <value> counter ;

    Кусочек кода на языке Factor. Определяет функцию <value>, которая при каждом запуске выдаёт новое натуральное число. В скобках что-то вроде типа (из ничего получается одно значение, в Factor функция может выдавать несколько значений, которые кладутся в стек, оттуда же берутся аргументы).


    1. shuhray
      21.05.2023 14:27
      -1

      Пестов, кажется, обиделся (никто не оценил), на письма с вопросами не отвечает. А мне нравится!


  1. vkni
    21.05.2023 14:27
    +5

    Вложенный match можно же переписать (замечание справедливо, но подъязык match'ей обычно вывозит очень и очень сложные конструкции с guard'ами и т.д.):

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


  1. GeorgeII
    21.05.2023 14:27
    +5

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


    1. 0xd34df00d
      21.05.2023 14:27
      +2

      С filter пример плохой, но только потому, что filter известная. Так-то это лишь один из признаков boolean blindness.


      1. AnthonyMikh
        21.05.2023 14:27
        +1

        Можно было бы решить, кстати, и просто неймингом: вместо filter и filterNotkeepWhich и skipWhich.


        1. 0xd34df00d
          21.05.2023 14:27
          +2

          Можно даже без which. keep isEven numbers и skip isOdd numbers вполне читаемо.


  1. Akon32
    21.05.2023 14:27
    +1

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

    Вот тут понятнее точно не стало. Предикат - это функция, возвращающая boolean. Например, имеющая тип A => Boolean. Именно отсюда название "p" в определении

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

    Хотя и это определение понятно только тем, кто уже в курсе, что делает filter(). Но тогда уж надо назвать функцию не filter(), а например retainWhere().


  1. brake
    21.05.2023 14:27
    +5

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

    Пойдёт в копилку заумных фраз.


  1. aegisql
    21.05.2023 14:27
    +4

    Да простит меня автор, но Скала - просто жуткий язык. Один из неудачных примеров языков созданных из академического любопытства, а не по необходимости. Такого безумного количества конструкций ради попыток впихнуть все известные парадигмы программирования я в других современных языках не знаю.Не удивительно, что после появления Котлина и фейслифтинга Java, от Скала стали по возможности отказываться. В конце концов, если вам нужен ФП в какой-то части проекта можно задействовать Кложур. Уж с ним-то точно все ясно.