Доброе утро, дорогие читатели, в этом выпуске мы рассмотрим, как может выглядеть функциональный язык, основанный на Dependency Injection. Хочу заметить, что последнее может быть особенно полезно в функциональном языке. Потому что - это в обычном языке у нас может быть цепь взаимосвязанных объектов. А в функциональном языке структуры обычно плоские, неизменяемые, не связанные друг с другом.
На обложке я приводил пример про хипстера в средневековье:
MiddleAges(status, lifespan) = hipster
Хорошо бы, кстати, сделать опрос про хипстера в конце статьи — к сожалению, у меня было не очень в школе с историей, так что я даже годные варианты не смогу предложить. Так вот — с одной стороны, эта строчка похоже на паттерн‑матчинг, а с другой стороны — идея в том, чтобы сделать MiddleAges
обычной функцией, без всякой магии.
В функциональных языках, как вы знаете, паттерн‑матчинг не позволяет подобных вольностей: значения можно матчить либо по структуре, либо по содержанию. Кроме этого, можно использовать очень ограниченный набор проверок — так называемые guards. И этому есть простое объяснение — почему нельзя использовать дорогостоящие вычисления: такие вычисления нужно иметь возможность переиспользовать.
Пусть у нас есть проблема:
case problem:
PlanA(timeline, benefits):
# actions
PlanB(timeline, cost):
# other actions
_:
# give up
Допустим, мы попробовали выбрать план A — и выяснили, что, в данном случае, он неприменим. Попутно мы сделали массу вычислений, некоторые из которых можно было бы использовать и для анализа плана B — и которые теперь пропадут. Именно поэтому сложные вычисления не используются при паттерн матчинге. Мы также их не будем использовать — то есть, конструкцию case трогать не будем. Если что, здесь и дальше я буду использовать псевдокод — чего вы ещё ожидали от статьи о вымышленном языке? ()
означает кортежи, {}
— словари.
Паттерн‑матчинг, таким образом, остаётся традиционный, как ни крути — но, может, что‑нибудь мы всё‑таки сможем изменить. Об этом, конечно, читайте дальше.
Вообще, мой личный опыт в функциональном программировании связан с платформой BEAM и языками Erlang и Elixir (поэтому комментарии пишуших на других языках лично для меня интересны вдвойне). Некоторое время назад я был приятно удивлён, наткнувшись на язык gleam — так, что даже написал статью о нём. Если Вы о нём не слышали, gleam — это язык со статической типизацией для BEAM с С‑подобным синтаксисом (то есть, он скорее похож на Scala, чем на Haskell).
Забавно, но идея этого поста навеяна оператором try из gleam. Забавно то, что его сделали deprecated в последнем релизе — так что, он, увы, доживает свой последний месяц. Выглядел он так:
fn even_number(x) {
case x % 2 {
0 -> Ok(x)
1 -> Err("something odd")
}
}
fn main(x: Int) {
try x = even_number(x)
// ...
}
// equivalent code
fn main(x: Int) {
val = even_number(x)
case val {
Err(err) -> val
Ok(x) -> {
// ...
}
}
}
Это уже не псевдокод, а синтаксис gleam. Функция even_number
возвращает Ok(x)
, если x чётное, в противном случае — ошибку. Оператор try действует так, что, в случае чётного числа, мы получаем снова его значение, в случае же нечётного — сама функция main возвращает ошибку.
Другими словами — концепция, хорошо известная разработчикам на Go:
Но вернёмся к нашим баранам. Именно вышеупомянутые ошибки в стиле Go я решил использовать для нашего случая — Dependency Injection. Мы сделаем так: будем делать паттерн‑матчинг всего одного выражения. При этом, если матчинг не удался, но есть какие‑то результаты, которые могут нам быть интересны, они возвращаются нам в качестве ошибки.
Функции оператора try возьмёт на себя оператор pop:
pop MiddleAges(status) = hipster
pop
означает — выплюнуть ошибку, если она есть. В случае, когда нет блока обработки ошибок (как здесь) — то в родительскую функцию. Если такой блок есть, в нём происходит матчинг ошибок:
pop MiddleAges(status) = hipster:
Err("Coffee is not known in the kingdom"):
Err("Wrong place, try Turkey or Africa")
# They have coffee
print((hipster, status))
В этом примере блок обработки ошибок есть, и результат этого блока (тоже ошибка) возвращается в родительскую функцию. Поскольку после pop идёт перечисление ошибок, то как бы, интуитивно понятно, что выплёвываются ошибки, а не что‑нибудь ещё.
Мне кажется, название pop лучше, чем try, потому что после try обычно идёт оптимистичный сценарий и только потом — обработка ошибок. А у нас — наоборот. Кроме того, блок try‑catch обычно используется (в том числе, в Erlang) для обработки настоящих исключений (и язык gleam, между прочим, их никак не поддерживает).
Констукция pop может иметь и блок else, который позволяет обрабатывать успехи, а не только ошибки, и в целом, больше соответствует функциональному стилю:
result = pop MiddleAges(status) = hipster:
err:
err
else:
print((hipster, status))
Ok(status)
В этом примере result - это Ok()
или Err()
. По аналогии с try, pop можно также использовать с функциями, возвращающими Ok()
или Err()
:
val = pop my_risky_function()
Теперь перейдём к следущей части. Читатель, возможно, задаётся вопросом, как, собственно, реализовать Dependency Injection — то есть, как сделать MiddleAges
функцией. Сделать это несложно. Например, так:
fn middle_ages("match", person):
True
fn middle_ages("status", person):
...
some_status
fn middle_ages("lifespan", person):
...
days
Пусть функция middle_ages отвечает за конструкцию MiddleAges. Функция, принимающая :match делает сначала общий матч — определяет, можно ли вообще хипстеру в средние века. В нашем случае — видим, что можно (True). Две другие функции уже вычисляют нужные значения.
Вы можете возразить, что функции могут зависеть от результатов друг друга или иметь общие зависимости. И что в приведённом примере они могут дублировать работу друг друга. Это так, но это тоже решается несложно: можно передавать функциям ещё один параметр — контекст, в котором они будут сохранять полезные результаты своей работы:
fn middle_ages("match", person):
(True, {})
fn middle_ages("status", person, ctx):
...
new_ctx = probably_change(ctx)
(some_status, new_ctx)
fn middle_ages("lifespan", person, ctx):
...
new_ctx = probably_change(ctx)
(days, new_ctx)
Каждая функция, таким образом, возвращает 2 значения — своё и аргумент, который нужно передать в следующую функцию (контекст). Не хочу особенно на этом останавливаться — просто поверьте, что это реализуемо, и относительно несложно.
Каждая из 3х функций (принимающих :match, :status, :lifespan) может вернуть как ошибку, так и значение. Ошибки можно сматчить при помощи оператора pop — вы уже видели как.
Это — что касается Dependency Injection. Но, как говорится, мы сделали только два первых шага — давайте теперь дорисуем остальную часть совы. Такой кастомный паттерн‑матчинг можно довольно легко встроить в систему типов.
Вот, например — один из вариантов, как можно указывать типы параметров в функции:
fn my_function(MyType() = val, Int() = num, x):
...
Причём, MyType может быть определён как структурный тип — точно так же, как это делает gleam:
type Cat {
Cat(name: String, cuteness: Int, age: Int)
}
Концепция структурных типов простая и понятная, и отказываться от неё нет смысла. Кроме этого, тип может использовать паттерн‑матчинг а‑ля Dependency Injection:
fn my_handler(JwtAuth(user, permissions, info) = request):
...
Вообще, если наши матчеры выполняют функцию типов, то должны и называться как типы — что‑то вроде Request()
, в данном случае. Но, учитывая, что и JwtAuth и рассмотренный ранее MiddleAges — всё‑таки, абстрактные сущности, наверно, для них это не так критично.
Подобная запись позволяет использовать как аннотацию типами, так и структурный матчинг значений — одновременно. gleam так не умеет, кроме того, в нём нельзя объявлять несколько функций под одним именем, как это можно в эрланге. Как в примере ниже:
fn my_fun(MyMap() = {height, width}):
...
fn my_fun("description"):
"description here"
Типы не должны принимать участия в матчинге нужной функции при вызове — если их объявлено несколько. Должна сматчиться функция, которая сматчилась бы и без аннотации типами. Возможно, какая‑то функция сматчится, а затем вычисления Dependency Injection приведут к ошибке. Что ж — в этом случае, вернём ошибку в родительскую функцию.
Вот, в принципе — всё, что я хотел вам рассказать о том, как можно построить функциональный язык вокруг Dependency Injection и как это может быть связано с типами.
В конце, по традиции, небольшой опрос. Только, большая просьба: голосовать не за название идеи (POP), а за её содержание! Название — я и так знаю, что удачное: и с церковью есть ассоциации, и с эстрадой, и даже, простите, с пятой точкой. Что же касается содержания — я вот думаю, может в хабах по фронтенд‑разработке её ещё опубликовать и в хабах, посвящённых Java и Kotlin? Там поклонников Dependency Injection — не то что каждый второй, а каждый второй и третий, как минимум. Ладно — сами найдут.
UPDATE: Возможно, предложенный синтаксис - не предел совершенства. Например, мне пришёл в голову ещё такой:
let Env(host, vars, pipeline) = env
catch:
# errors
Или, в полном варианте:
result = let Env(host, vars, pipeline) = env
catch:
err: err
else:
(host, vars, pipeline)
Комментарии (3)
ijsgaus
00.00.0000 00:00+2Попахивает велосипедом... Автор спрашивал мнение пользователей других языков:
оператор pop реализован в Rust макросом ?. Для его использования достаточно, чтобы выражение возвращало Result<T, E> или Option<T>. Конечно нужно контролировать тип ошибки.
мечта о произвольном pattern matching реализована, например, в FSharp, как Active Patterns - любая функция, принимающая на вход сопоставляемое выражение и возвращающая Option<T> - матчер сопоставляемого выражения с T.
Pattern Dependency Injection реализуется в ФП с поимощью монады Reader. Собственно монада и предполагает некоторый контекст над типом, так что и мысль о таскаемом за собой контексте что то очень напоминает монаду State.
abetkin Автор
00.00.0000 00:00+1Действительно, в Active Patterns из F# есть что-то подобное. Большое спасибо!
tzlom
Такие трюки превращают любую идею в "ах если бы мечта сбылась"