Gleam і use.

Gleam logo

Я пачаў вывучаць мову праграмавання Gleam. Гэта функцыянальная мова, якая можа быць скампілявана пад дзве платформы — BEAM (Гэта, калі ведаеце, як erlang, elixir), або JavaScript (уласна мне здаецца што ў першую чаргу ў мэтах запускацца ў браўзары, але, тэхнічна, любы рухавік падыдзе). Але цяпер не пра гэта.

Gleam даволі моцна падобны на ўжо знаёмы мне Elm, але дазваляе рабіць на ім бэкэнды. І ёсць у ім адна адметная рыса. Гэта ключавое слова use.

Дазволю сабе вольны пераклад з дакументацыі.

use выкарыстоўваецца ў Gleam для таго, каб выклікаць функцыі, якія прымаюць функцыю зваротнага выкліку без павелічэння ўложанасці кода.

Увесь код ніжэй за use пераўтвараецца ў ананімную функцыю, што перадаецца апошнім аргументам у функцыю па правым баку ад <-, а пераменная, што атрымлівае значэнне становіцца аргументам гэтай ананімнай функцыі.
https://tour.gleam.run/advanced-features/use/

Складана гучыць. Магчыма праз пераклад стала яшчэ горш. Але не бяда, зараз пакажу што мяркуецца, гэта зусім не складана!

Уявім, што ў нас ёсць такія вось функцыі:

fn get_username() -> Result(String, Nil) {
  Ok("alice")
}

fn get_password() -> Result(String, Nil) {
  Ok("hunter2")
}

fn log_in(_username: String, _password: String) -> Result(String, Nil) {
  Ok("Welcome")
}

І цяпер мы хочам іх паслядоўна выклікаць:

pub fn without_use() -> Result(String, Nil) {
  result.try(get_username(), fn(username) {
    result.try(get_password(), fn(password) {
      result.map(log_in(username, password), fn(greeting) {
        greeting <> ", " <> username
      })
    })
  })
}

Нас цікавіць функцыя without_use. Не прыгожа, праўда? У Gleam ёсць выхад для гэтага.

pub fn with_use() -> Result(String, Nil) {
  use username <- result.try(get_username())
  use password <- result.try(get_password())
  use greeting <- result.map(log_in(username, password))
  greeting <> ", " <> username
}

Ну так жа нашмат прыгажэй, хіба не? Падсумуем.

Наступны код:

pub fn main() -> Nil {
  use a, b <- my_function
  next(a)
  next(b)
}

Эквівалентны коду:

pub fn main() -> Nil {
  my_function(fn(a, b) {
    next(a)
    next(b)
  })
}

З гэтым нібы разабраліся. Разабраліся ды не разабраліся. Усё гэта прыгожа на гэтым сінтэтычным прыкладзе. А як гэта адбываецца ў рэальнасці? А справа ў тым, што часцей за ўсё нам трэба пабудаваць паслядоўны выклік залежных функцый, але кожная функцыя будзе вяртаць сваю даменную памылку. Вось напрыклад мы хочам зрабіць http запыт, а пасля, распарсіць json рэспонса ў нейкі наш тып даных. Прыклад максімальна спрошчаны:

type User {
  User(id: String)
}

fn user_decoder() -> decode.Decoder(User) {
  // не выкарыстоўваем сапраўдны дэкодзінг, проста вяртаем юзера
  decode.success(User("example"))
}

fn get_user() -> Result(User, ???) {
  let assert Ok(req) = request.to(url)
  use resp <- result.try(httpc.send(req)))
  use user <- result.try(json.parse(resp.body, user_decoder()))
  Ok(user)
}

Здавалася б, вельмі распаўсюджаны кейс, зрабіць http запыт, пераўтварыць у даменую сістэму тыпаў і пагналі. Аднак давайце паспрабуем разабрацца што тут адбываецца. Перапішам код на адпаведны, без выкарыстоўвання use.

// Для спраўкі.
//
// Сігнатура функцыі result.try
// fn try(
//   result: Result(a, e),
//   callback: fn(a) -> Result(b, e)
// ) -> Result(b, e)
//
// Сігнатура функцыі http.send
// fn send(request: Request) -> Result(Response, HttpError)
//
// Сігнатура функцыі json.parse
// fn parse(
//   json_str: String,
//   decoder: Decoder(a)
// ) -> Result(a, DecodeError)

fn get_user() -> Result(User, ???) {
  let assert Ok(req) = request.to(url)
  result.try(httpc.send(req), fn(resp) {
    json.parse(resp.body, user_decoder())
  })
}

Я не дарма паставіў у сігнатуры функцыі get_user пытальнікі ў вяртаемым тыпе. Які другі параметр тыпу Result мусіць быць у такім выпадку? Давайце разбірацца. httpc.send паверне нам Result(Response, HttpError). json.parse павярне нам Result(User, DecodeError). result.try чакае ад нас функцыю, якая зробіць нешта з першым параметрам і павярне іншы параметр, але не чакаецца што тып памылкі зменіцца. Такім чынам у нас неадпаведнасць тыпаў, функцыя result.try чакае колбэк функцыю тыпу fn(Response) -> Result(User, HttpError), а мы перадалі функцыю тыпу fn(Response) -> Result(User, DecodeError). Давайце глядзець, што мы можам з гэтым зрабіць.

type GetUserError{
  HttpError(httpc.HttError)
  DecodeError(json.DecodeError)
}

fn get_user() -> Result(User, GetUserError) {
  let assert Ok(req) = request.to(url)
  httpc.send(req)
  |> result.map_error(HttpError)
  |> result.try(fn(resp) {
    json.parse(resp.body, user_decoder())
    |> result.map_error(DecodeError)
  })
}

Што робіць result.map_error? Зазірнём у сігнатуру:

fn map_error(
    result: Result(a, e1),
    callback: fn(e1) -> e2
) -> Result(a, e2)

То-бок пераўтварае адну памылку, на другую. Якраз тое самае што робіць аб'яўленыя намі канструктары GetUserError. Напрыклад HttpError гэта па факце fn(httpc.HttpError) -> GetUserError. Так. З гэтым нібы таксама разабраліся. Але як бы нам атрымаць "плоскасць" кода, як калі мы выкарыстоўваем use? Вось для гэтага, гэты артыкул і існуе! А тут у нас, як мне на гэты момант здаецца, ёсць два выхады.

fn get_user() -> Result(User, GetUserError) {
  let assert Ok(req) = request.to("")
  use resp <- result.try(
    httpc.send(req)
    |> result.map_error(HttpError)
  )
  use user <- result.try(
    json.parse(resp.body, user_decoder())
    |> result.map_error(DecodeError)
  )
  Ok(user)
}

Не ведаю як вам, але на мой густ гэта неяк усё роўна не прыгожа. Мне здаецца больш прыгожым рашэннем будзе простая дадатковая функцыя:

fn map_error_and_try(
  error_tagger: fn(e1) -> e2,
  res: Result(a, e1),
  callback: fn(a) -> Result(b, e2),
) -> Result(b, e2) {
  res
  |> result.map_error(error_tagger)
  |> result.try(callback)
}

З дапамогай яе, мы можам перапісаць наш прыклад наступным чынам:

fn get_user() -> Result(User, GetUserError) {
  let assert Ok(req) = request.to("")
  use resp <- map_error_and_try(HttpError, httpc.send(req))
  use user <- map_error_and_try(DecodeError, json.parse(resp.body, user_decoder())
  Ok(user)
}

Вуаля! Ну хіба не прыгажосць?


Я разумею што гэты артыкул атрымаўся не вельмі падыходзячым для людзей, якія маглі проста зацікавіцца, што за звер такі гэты Gleam вачамі іншага чалавека, бо тут ідзе гутарка пра так званыя advanced тэмы. Але хачу крыху растлумачыць: учора, чытаючы тое-сёе пра Gleam у мяне ўсё ніяк не складвалася ментальная мадэль. Накшталт — так, гэтая мова мае use, але ён нібы працуе толькі на сінтэтычных прыкладах. Гэтае пачуццё, нібы не хапае нейкай маленькай дэталі, якая ад мяне ўцякае. Калі ў нейкі момант да мяне дайшло рашэнне з map_error_and_try (магчыма мацёрыя функцыянальшчыкі будуць тыкаць пальцам і смяяцца з гэтага), у мяне настолькі клацнула ў галаве, што мне вельмі моцна захацелася гэтым падзяліцца. Гэта і стала нагодай для стварэння гэтага блога. Дзякуй за ўвагу!

Каментары