Введение
Эта глава знакомит с языком Gleam и его местом в мире функционального программирования.
- Цели главы
- Зачем ещё один ФП-язык?
- BEAM: виртуальная машина, проверенная временем
- Двойной таргет
- Философия Gleam
- Сравнение с другими языками
- Структура книги
- Заключение
Цели главы
В этой главе мы:
- Поймём, зачем нужен ещё один функциональный язык
- Познакомимся с BEAM — виртуальной машиной Erlang
- Увидим, чем Gleam отличается от других ФП-языков
- Узнаем, как устроена эта книга
Зачем ещё один ФП-язык?
Функциональное программирование давно вышло за пределы академии. map, filter и reduce стали привычными для JavaScript, Python и даже Java программистов. Но между «использовать пару приёмов из ФП» и «писать на функциональном языке» — пропасть.
Elixir и Erlang работают на легендарной BEAM-машине с отказоустойчивостью и конкурентностью из коробки, но лишены статической типизации — ошибки типов обнаруживаются только в рантайме. Haskell и OCaml дают мощные системы типов, со своей рантайм-средой, но без встроенной такой же мощной конкурентности.
Gleam занимает уникальную нишу: это строго типизированный минималистичный язык на BEAM. Он сочетает:
- Безопасность типов — ошибки ловятся компилятором, а не пользователем в продакшене
- Отказоустойчивость BEAM — процессы, супервизоры
- Намеренную простоту — нет классов типов, нет макросов, нет GADTs
BEAM: виртуальная машина, проверенная временем
BEAM (Bogdan/Björn's Erlang Abstract Machine) — виртуальная машина, созданная в Ericsson для телекоммуникаций. Её ключевые свойства:
- Лёгкие процессы — миллионы конкурентных процессов в одной VM, каждый с собственной кучей
- Изоляция сбоев — падение одного процесса не затрагивает другие
- Горячая перезагрузка — обновление кода без остановки системы (на 2026 год не актуально для Gleam)
- Распределённость — процессы общаются по сети прозрачно
На BEAM работают системы, обслуживающие сотни миллионов пользователей:
| Система | Масштаб |
|---|---|
| 2 млрд пользователей, ~50 инженеров | |
| Discord | Миллионы конкурентных подключений |
| RabbitMQ | Один из самых популярных брокеров сообщений |
| Ericsson | Телекоммуникации с 99.9999999% uptime |
Gleam компилируется в Erlang-байткод и получает все эти свойства бесплатно. Код на Gleam вызывает Erlang- и Elixir-библиотеки напрямую, без обёрток и накладных расходов.
Двойной таргет
У Gleam есть еще одно полезное свойство: один и тот же код компилируется в Erlang (для сервера) и в JavaScript (для браузера и Node.js).
┌─── Erlang (BEAM) ───► сервер, CLI, IoT
gleam build ──┤
└─── JavaScript ──────► браузер, Node.js
А для переключения между таргетами достаточно изменить одну строку в gleam.toml:
target = "erlang" # или "javascript"
Стандартная библиотека Gleam работает на обоих таргетах. Для платформо-специфичного кода используется FFI — вызов Erlang- или JavaScript-функций из Gleam-кода.
Философия Gleam
Gleam — намеренно минималистичный язык. Его создатель Louis Pilfold следует принципу: если фичу можно не добавлять — её не добавляют.
В Gleam нет:
- Классов типов (type classes)
- Макросов
- GADTs и зависимых типов
- Исключений (в привычном смысле)
- REPL
- Перегрузки операторов
- Неявных приведений типов
Это не ограничение, а осознанный выбор. Каждая строка кода на Gleam читается однозначно: нет скрытой диспетчеризации, нет магии макросов, нет неявных преобразований. Код делает ровно то, что написано.
Дружелюбные ошибки
Gleam известен исключительно понятными сообщениями об ошибках. Компилятор не просто говорит «type mismatch» — он объясняет, что пошло не так, и часто предлагает исправление:
error: Type mismatch
┌─ src/main.gleam:4:17
│
4 │ string.length(42)
│ ^^
Expected type:
String
Found type:
Int
Hint: try using `int.to_string` to convert the value.
Компилятор указывает точную строку и столбец ошибки, показывает ожидаемый и найденный тип, и сразу предлагает исправление. Это делает работу с Gleam особенно приятной для новичков.
Hex — единая экосистема
Gleam использует Hex — менеджер пакетов, общий с Erlang и Elixir. Это означает доступ к Erlang-библиотекам напрямую, а также к растущей экосистеме собственных Gleam-пакетов: Wisp (веб), Lustre (фронтенд), Mist (HTTP-сервер) и многие другие. Подробнее о совместимости с BEAM-экосистемой — в главе 8.
Сравнение с другими языками
| Gleam | Elm | Rust | Elixir | Haskell | OCaml | |
|---|---|---|---|---|---|---|
| Статическая типизация | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ |
| BEAM | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ |
| Простота | ✓✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
| Конкурентность (акторы) | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ |
| JS-таргет | ✓ | ✓ | ✗ | ✗ | ✓* | ✓* |
| Зрелая экосистема | Растёт | Средняя | ✓✓ | ✓ | ✓ | ✓ |
* Haskell компилируется в JS через GHCJS/Asterius, OCaml — через js_of_ocaml/Melange. Однако в обоих случаях это отдельные инструменты, а не встроенная возможность компилятора. В Gleam и Elm JS-таргет — штатный режим работы.
- Elm — ближайший родственник по философии (типы + простота), но работает только в браузере
- Rust — похожий подход к безопасности, но другой уровень сложности и нет BEAM
- Elixir — тот же BEAM, но без статических типов
- Haskell/OCaml — мощные системы типов, есть JS-таргеты через сторонние инструменты, но нет BEAM-конкурентности
Структура книги
Книга состоит из 14 глав и приложений:
| Глава | Тема |
|---|---|
| 1 | Введение |
| 2 | Начало работы |
| 3 | Функции и пайплайны |
| 4 | Типы данных и коллекции |
| 5 | Рекурсия, свёртки и обработка ошибок |
| 6 | Строки, битовые массивы и стандартная библиотека |
| 7 | Type Safety и Parse Don't Validate |
| 8 | Erlang FFI и системное программирование |
| 9 | JavaScript FFI и фронтенд интеграция |
| 10 | Процессы и OTP |
| 11 | Тестирование |
| 12 | Веб-разработка с Wisp |
| 13 | Фронтенд с Lustre |
| 14 | Заключение и следующие шаги |
Приложения:
- Приложение A: Telegram-бот с Telega
Каждая глава содержит:
- Объяснение концепций с примерами кода
- Сквозной проект, демонстрирующий концепции на практике
- Упражнения с подсказками (кроме этой вводной главы)
Как читать эту книгу
Начинающим рекомендуется читать главы последовательно, так как ранний материал закладывает фундаментальные концепции. Однако тем, кто уже знаком с функциональным программированием — особенно в строго типизированных языках — можно пропускать известные темы.
Каждая глава (со 2-й по 13-ю) построена вокруг практического примера, который служит мотивацией для новых идей. Главы 2–7 образуют базовый курс и лучше изучать последовательно. Главы 8–13 более независимы и покрывают специализированные темы: FFI, OTP, тестирование, веб-разработку.
Код из репозитория следует изучать вместе с текстом для полного понимания:
exercises/
├── chapter02/ # Начало работы
├── chapter03/ # Функции и пайплайны
├── ...
├── chapter13/ # Фронтенд с Lustre
└── appendix_a/ # Telegram-бот
Каждая директория содержит:
src/chapterXX.gleam— примеры кода из текста главыtest/my_solutions.gleam— шаблоны для ваших решенийno-peeking/solutions.gleam— референсные решения
Это не справочник с готовыми шаблонами. Для максимального эффекта от обучения настоятельно рекомендуется:
- Читать главу и изучать примеры кода
- Решать упражнения самостоятельно — каждое упражнение снабжено подсказками
- Запускать тесты:
cd exercises/chapterXX && gleam test - Только после попыток решения смотреть в
no-peeking/solutions.gleam
Упражнения — неотъемлемая часть понимания материала. Проработка их критически важна для усвоения концепций.
О читателе
Книга предполагает:
- Опыт программирования на любом языке (Python, JavaScript, Go, Java — неважно)
- Базовое понимание командной строки (терминал,
cd, запуск команд) - Знакомство с Git (на уровне
clone,commit,push)
Опыт функционального программирования не требуется — мы объясняем все концепции с нуля.
Ресурсы
- Gleam — официальный сайт
- Gleam Language Tour — интерактивный тур по языку
- Exercism — Gleam Track — упражнения с менторингом
- Awesome Gleam — каталог библиотек
- Gleam Discord — сообщество
Заключение
Gleam — молодой, но быстро растущий язык. Он берёт лучшее из двух миров: надёжность статической типизации и промышленную конкурентность BEAM. При этом он остаётся простым — выучить весь язык можно за выходные.
В следующей главе мы установим Gleam, создадим первый проект и напишем первую программу.
Начало работы
В этой главе мы установим Gleam, создадим первый проект и познакомимся с базовыми типами.
- Цели главы
- Установка
- Первый проект
- Базовые типы
- let-привязки
- Функции
- Модули и импорты
- Проект: задача Эйлера №1
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Установим Gleam и Erlang/OTP
- Создадим первый проект и разберём его структуру
- Познакомимся с базовыми типами:
Int,Float,String,Bool - Научимся объявлять функции и использовать модули
- Решим задачу Эйлера №1
Установка
Gleam
Gleam можно установить несколькими способами:
# macOS (Homebrew)
$ brew install gleam
# С помощью asdf
$ asdf plugin add gleam
$ asdf install gleam latest
$ asdf global gleam latest
# Из исходников (нужен Rust)
$ cargo install gleam
asdf — универсальный менеджер версий, который позволяет переключаться между версиями Gleam в разных проектах. cargo install собирает Gleam из исходников — занимает больше времени, но не требует отдельного инструмента.
Erlang/OTP
Gleam компилирует код в Erlang, поэтому для запуска нужна виртуальная машина BEAM:
# macOS (Homebrew)
$ brew install erlang
# С помощью asdf
$ asdf plugin add erlang
$ asdf install erlang latest
$ asdf global erlang latest
Проверим, что всё установлено:
$ gleam --version
gleam 1.14.0
$ erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell
"27"
Если оба вывода показывают версию — окружение настроено правильно. Gleam требует OTP 27+ для полноценной работы.
Первый проект
Создадим новый проект:
$ gleam new hello_gleam
Your Gleam project hello_gleam has been successfully created.
The project is in the hello_gleam directory.
$ cd hello_gleam
gleam new создаёт готовую структуру проекта с конфигурацией, пустым модулем и тестовым файлом. Имя проекта используется как имя пакета в Hex.
Структура проекта
hello_gleam/
├── gleam.toml # Конфигурация проекта
├── src/
│ └── hello_gleam.gleam # Исходный код
└── test/
└── hello_gleam_test.gleam # Тесты
Файл gleam.toml — сердце проекта:
name = "hello_gleam"
version = "1.0.0"
target = "erlang"
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
Здесь указано имя проекта, таргет (Erlang по умолчанию) и зависимости. gleam_stdlib — стандартная библиотека, gleeunit — тестовый фреймворк.
Запуск и тестирование
# Скомпилировать и запустить
$ gleam run
Hello from hello_gleam!
# Запустить тесты
$ gleam test
Compiled in 0.02s
Running hello_gleam_test.main
1 tests, 0 failures
gleam run компилирует проект и вызывает функцию main из основного модуля. gleam test компилирует тесты и запускает gleeunit.main().
Другие полезные команды
# Добавить зависимость
$ gleam add gleam_json
# Отформатировать код
$ gleam format
# Собрать без запуска
$ gleam build
# Проверить типы
$ gleam check
# Сгенерировать документацию
$ gleam docs build
gleam format — не опция, а стандарт: весь Gleam-код форматируется единообразно.
Базовые типы
В Gleam четыре примитивных типа:
Int
Целые числа произвольной точности (на BEAM-таргете):
let x = 42
let big = 1_000_000 // подчёркивания для читаемости
let hex = 0xFF // шестнадцатеричный литерал
let oct = 0o77 // восьмеричный
let bin = 0b1010 // двоичный
Арифметические операторы для целых чисел: +, -, *, / (целочисленное деление), % (остаток от деления).
let sum = 2 + 3 // 5
let diff = 10 - 4 // 6
let prod = 3 * 7 // 21
let quot = 10 / 3 // 3 (целочисленное деление)
let rem = 10 % 3 // 1
/ в Gleam — всегда целочисленное деление для Int. Деление на ноль не выбросит исключение — на BEAM оно вернёт ошибку в рантайме.
Float
Числа с плавающей точкой (64-бит IEEE 754):
let pi = 3.14159
let negative = -0.5
let scientific = 1.0e10
Важно: операторы для Float отличаются от операторов для Int. К ним добавляется точка:
let sum = 2.0 +. 3.0 // 5.0
let diff = 10.0 -. 4.0 // 6.0
let prod = 3.0 *. 7.0 // 21.0
let quot = 10.0 /. 3.0 // 3.333...
Это принципиальное решение: Gleam не допускает неявного приведения типов. Нельзя сложить Int и Float напрямую:
// ✗ Ошибка компиляции!
let result = 2 + 3.0
// ✓ Нужно явно сконвертировать
import gleam/int
let result = int.to_float(2) +. 3.0
Отсутствие неявных приведений — осознанное решение: ошибки типов обнаруживаются компилятором, а не в рантайме. Это устраняет целый класс трудноуловимых багов.
String
Строки в Gleam — последовательности UTF-8, заключённые в двойные кавычки:
let greeting = "Привет, мир!"
let empty = ""
Конкатенация строк — оператор <>:
let name = "Gleam"
let message = "Привет, " <> name <> "!"
// "Привет, Gleam!"
Gleam не поддерживает интерполяцию строк — только конкатенацию. Чтобы вставить число в строку, нужно сначала сконвертировать:
import gleam/int
let age = 25
let message = "Мне " <> int.to_string(age) <> " лет"
int.to_string — стандартный способ вставить число в строку. Отсутствие интерполяции компенсируется явностью: всегда видно, что именно конвертируется.
Bool
Логические значения — True и False:
let is_active = True
let is_admin = False
Операторы сравнения: ==, !=, <, >, <=, >=. Логические операторы: && (И), || (ИЛИ), ! (НЕ).
let a = True && False // False
let b = True || False // True
let c = !True // False
Операторы && и || — ленивые: правая часть не вычисляется, если результат уже определён по левой.
let-привязки
В Gleam все значения неизменяемые. Ключевое слово let создаёт привязку имени к значению:
let x = 42
let y = x + 1 // 43
Повторная привязка (shadowing) допустима:
let x = 10
let x = x + 1 // x = 11, предыдущее значение затенено
Gleam требует, чтобы все привязки использовались. Для неиспользуемых значений есть два варианта:
// Полностью отбросить значение
let _ = some_function()
// Дать имя с подчёркиванием — значение не используется,
// но имя документирует намерение
let _result = some_function()
_ — анонимный discard, он не создаёт привязки и не попадет в рантайм. _result — именованный discard: компилятор не будет предупреждать о неиспользовании, но значение доступно (например, для отладки).
Блоки
Блок { ... } — группа выражений, результат блока — последнее выражение:
let result = {
let a = 10
let b = 20
a + b
}
// result = 30
Блоки полезны для промежуточных вычислений внутри let.
Аннотации типов
Gleam выводит типы автоматически, но аннотации можно указать явно:
let name: String = "Gleam"
let count: Int = 42
let pi: Float = 3.14159
Аннотации не меняют поведение — требуются крайне редко, лишь документируют намерение и помогают компилятору выдавать более понятные ошибки.
Функции
Объявление функций
Функции объявляются с помощью fn. Ключевое слово pub делает функцию публичной (видимой из других модулей):
// Приватная функция (видна только в этом модуле)
fn square(x: Int) -> Int {
x * x
}
// Публичная функция
pub fn greet(name: String) -> Nil {
io.println("Привет, " <> name <> "!")
}
Тело функции — блок. Возвращаемое значение — последнее выражение, ключевое слово return отсутствует.
Типы параметров и возвращаемого значения указываются явно в сигнатуре.
Тип Nil
Nil — аналог void/unit в других языках. Используется, когда функция выполняет побочный эффект (например, печать) и не возвращает полезного значения:
pub fn greet(name: String) -> Nil {
io.println("Привет, " <> name <> "!")
}
Функция с возвращаемым типом Nil выполняет побочный эффект (вывод на экран) и не несёт полезного значения. Nil — единственное значение типа Nil, аналог unit в Haskell или void в Rust.
Модули и импорты
Каждый файл .gleam — отдельный модуль. Имя модуля соответствует пути к файлу: src/math/utils.gleam → модуль math/utils.
Импорт модулей
// Импорт модуля — используем через квалифицированное имя
import gleam/io
io.println("Привет!")
// Импорт конкретных функций — используем без префикса
import gleam/io.{println}
println("Привет!")
// Импорт с переименованием
import gleam/io as console
console.println("Привет!")
Квалифицированный импорт (gleam/io) — самый распространённый: функции вызываются через io.println. Неквалифицированный ({println}) удобен для часто используемых функций. Псевдоним (as) полезен при конфликте имён.
Импорт типов
Типы из других модулей можно использовать через квалифицированное имя (option.Option) или импортировать напрямую с ключевым словом type:
import gleam/option.{type Option}
// Теперь можно писать Option вместо option.Option
pub fn default_name(name: Option(String)) -> String {
option.unwrap(name, "Аноним")
}
В Gleam принято импортировать типы неквалифицированно ({type Option}), а функции вызывать через имя модуля (option.unwrap). Это позволяет сразу видеть, откуда пришла функция, при этом типы не перегружают сигнатуры.
Стандартная библиотека
Стандартная библиотека Gleam (gleam_stdlib) содержит модули для работы с основными типами:
gleam/io— вывод в консоль (println,debug)gleam/int— операции с целыми числами (to_string,to_float,parse,sum,max,min,is_even,is_odd,absolute_value)gleam/float— операции с числами с плавающей точкой (to_string,parse,round,floor,ceiling,power,square_root)
Пример использования:
import gleam/int
import gleam/float
import gleam/io
pub fn main() {
// gleam/io
io.println("Привет!")
io.debug(42) // выводит значение любого типа
// gleam/int
let n = 42
io.println(int.to_string(n)) // "42"
io.debug(int.is_even(n)) // True
io.debug(int.max(10, 20)) // 20
// gleam/float
let x = 9.0
io.debug(float.square_root(x)) // Ok(3.0)
io.debug(float.round(3.7)) // 4
io.debug(float.to_precision(3.14159, 2)) // 3.14
}
io.debug выводит значение любого типа — полезно для отладки. В продакшене предпочтительнее io.println с явным to_string.
Конвертация типов
В Gleam нет неявных приведений типов. Все конвертации — явные:
import gleam/int
import gleam/float
// Int → Float
let x = int.to_float(42) // 42.0
// Float → Int
let y = float.round(3.7) // 4
let z = float.floor(3.7) // 3
let w = float.ceiling(3.2) // 4
let t = float.truncate(3.9) // 3
// Int → String
let s = int.to_string(42) // "42"
// String → Int
let n = int.parse("42") // Ok(42)
let e = int.parse("abc") // Error(Nil)
Обратите внимание: int.parse возвращает Result(Int, Nil), а не Int. Если строка не является числом, мы получаем Error(Nil) вместо исключения. Подробнее о Result — в главе 5.
Проект: задача Эйлера №1
Найти сумму всех натуральных чисел меньше 1000, которые делятся на 3 или 5.
Решим эту задачу, используя изученные концепции:
import gleam/int
import gleam/io
import gleam/list
pub fn euler1(limit: Int) -> Int {
list.range(1, limit)
|> list.filter(fn(x) { x % 3 == 0 || x % 5 == 0 })
|> int.sum
}
pub fn main() {
let answer = euler1(1000)
io.println("Ответ: " <> int.to_string(answer))
// Ответ: 233168
}
Не беспокойтесь, если вам пока незнакомы list.range, list.filter или оператор |> — мы подробно разберём их в следующих главах. Пока достаточно понять общую идею:
list.range(1, limit)создаёт список чисел от 1 доlimit(не включаяlimit)list.filter(...)оставляет только числа, делящиеся на 3 или 5int.sumскладывает все оставшиеся числа|>передаёт результат одной функции на вход следующей
Упражнения
Решения упражнений пишите в файле exercises/chapter02/test/my_solutions.gleam. Запускайте тесты командой:
cd exercises/chapter02
gleam test
Команды выше позволяют перейти в папку упражнения и запустить тесты для проверки ваших решений.
1. Диагональ прямоугольника (Лёгкое)
Напишите функцию diagonal, которая вычисляет длину диагонали прямоугольника по двум сторонам.
pub fn diagonal(a: Float, b: Float) -> Float
Формула: \( d = \sqrt{a^2 + b^2} \)
Примеры:
diagonal(3.0, 4.0) == 5.0
diagonal(5.0, 12.0) == 13.0
Подсказка: используйте float.square_root из gleam/float. Функция возвращает Result(Float, Nil) — используйте let assert Ok(result) = ... для извлечения значения (мы подробно разберём Result позже).
2. Конвертация температуры (Лёгкое)
Напишите функцию celsius_to_fahrenheit, которая переводит градусы Цельсия в Фаренгейт.
pub fn celsius_to_fahrenheit(c: Float) -> Float
Формула: \( F = C \times \frac{9}{5} + 32 \)
Примеры:
celsius_to_fahrenheit(0.0) == 32.0
celsius_to_fahrenheit(100.0) == 212.0
Примеры выше показывают ожидаемые результаты конвертации: 0 °C соответствует 32 °F, а 100 °C — 212 °F.
3. Обратная конвертация (Лёгкое)
Напишите функцию fahrenheit_to_celsius, обратную к предыдущей.
pub fn fahrenheit_to_celsius(f: Float) -> Float
Примеры:
fahrenheit_to_celsius(32.0) == 0.0
fahrenheit_to_celsius(212.0) == 100.0
Примеры выше демонстрируют обратное преобразование: 32 °F — это 0 °C, а 212 °F — 100 °C.
4. Задача Эйлера (Среднее)
Напишите функцию euler1, которая находит сумму всех натуральных чисел меньше n, делящихся на 3 или 5.
pub fn euler1(n: Int) -> Int
Примеры:
euler1(10) == 23 // 3 + 5 + 6 + 9 = 23
euler1(1000) == 233168
Подсказка: используйте list.range, list.filter и int.sum.
Заключение
В этой главе мы:
- Установили Gleam и Erlang
- Создали проект и разобрались в его структуре
- Познакомились с четырьмя базовыми типами и их операторами
- Научились объявлять функции и импортировать модули
- Увидели, что Gleam не допускает неявных приведений типов
В следующей главе мы глубже изучим функции: анонимные функции, pipe-оператор, use-выражения и pattern matching.
Функции и пайплайны
Эта глава посвящена функциям как значениям первого класса, pipe-оператору и use-выражениям.
- Цели главы
- Функции как значения первого класса
- Нет каррирования
- Анонимные функции
- Именованные аргументы
- Pipe-оператор
- Отладка пайплайнов с
echo - use-выражения
- Case-выражения и pattern matching
- Модуль gleam/function
- Модуль gleam/bool
- Модуль gleam/order
- Проект: адресная книга
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Научимся работать с функциями как со значениями
- Поймём, почему в Gleam нет каррирования
- Освоим pipe-оператор
|>— основной инструмент композиции - Разберём use-выражения — синтаксический сахар для callback-ов
- Познакомимся с pattern matching и case-выражениями
Функции как значения первого класса
В Gleam функции — такие же значения, как числа или строки. Их можно сохранять в переменные, передавать в другие функции и возвращать из функций.
import gleam/io
pub fn main() {
// Сохраняем функцию в переменную
let add_one = fn(x) { x + 1 }
// Вызываем через переменную
io.debug(add_one(5)) // 6
// Передаём функцию как аргумент
io.debug(apply_twice(add_one, 3)) // 5
}
pub fn apply_twice(f: fn(a) -> a, x: a) -> a {
f(f(x))
}
Функция apply_twice принимает функцию f и значение x, а затем применяет f к x дважды. Тип fn(a) -> a означает: «функция, которая принимает значение типа a и возвращает значение того же типа». Здесь a — параметр типа (generic), который будет подставлен конкретным типом при вызове.
Нет каррирования
В Haskell, OCaml и PureScript каждая функция принимает ровно один аргумент, а многоаргументные функции — это цепочки функций, возвращающих функции:
-- Haskell: add :: Int -> Int -> Int
-- На самом деле: add :: Int -> (Int -> Int)
add x y = x + y
add3 = add 3 -- частичное применение
В Gleam каррирования нет. Каждая функция принимает все аргументы сразу:
pub fn add(x: Int, y: Int) -> Int {
x + y
}
// ✗ Ошибка! Нельзя вызвать add(3) — нужны оба аргумента
// let add3 = add(3)
// ✓ Для частичного применения используйте анонимную функцию
let add3 = fn(y) { add(3, y) }
Это осознанное решение: код без каррирования проще читать, а сообщения об ошибках — понятнее.
Захват функций (function capture)
Вместо анонимных функций-обёрток Gleam предлагает сокращённый синтаксис — function capture. Символ _ обозначает место для аргумента:
// Полная запись
let add3 = fn(y) { add(3, y) }
// Захват функции — то же самое, но короче
let add3 = add(3, _)
add(3, _) создаёт новую функцию, которая принимает один аргумент и подставляет его вместо _. Это единственный способ частичного применения в Gleam — явный и наглядный.
Анонимные функции
Анонимные (лямбда) функции создаются ключевым словом fn:
let double = fn(x) { x * 2 }
let greet = fn(name) { "Привет, " <> name <> "!" }
let add = fn(a, b) { a + b }
Анонимные функции часто используются как аргументы функций высшего порядка:
import gleam/list
pub fn main() {
let numbers = [1, 2, 3, 4, 5]
// Удвоить каждый элемент
list.map(numbers, fn(x) { x * 2 })
// [2, 4, 6, 8, 10]
// Оставить только чётные
list.filter(numbers, fn(x) { x % 2 == 0 })
// [2, 4]
}
Пример демонстрирует передачу анонимных функций в list.map и list.filter для трансформации и фильтрации списков.
Замыкания
Анонимные функции захватывают переменные из окружающего контекста:
pub fn make_adder(n: Int) -> fn(Int) -> Int {
fn(x) { x + n } // n захвачена из внешней области
}
pub fn main() {
let add5 = make_adder(5)
add5(3) // 8
add5(10) // 15
}
Захваченные значения неизменяемы — замыкание видит тот n, который существовал на момент создания функции.
Именованные аргументы
Gleam поддерживает именованные (labelled) аргументы. Они позволяют указывать имя параметра при вызове для ясности:
pub fn greet(greeting greeting: String, name name: String) -> String {
greeting <> ", " <> name <> "!"
}
В сигнатуре greeting greeting: String первое слово — внешняя метка (для вызова), второе — внутреннее имя (для тела функции). Если они совпадают, можно указать имя один раз:
pub fn greet(greeting greeting: String, name name: String) -> String
// то же самое, что:
pub fn greet(greeting: String, name: String) -> String
При вызове именованные аргументы можно передавать в любом порядке:
greet(greeting: "Привет", name: "Алиса")
// "Привет, Алиса!"
greet(name: "Боб", greeting: "Здравствуй")
// "Здравствуй, Боб!"
Именованные аргументы особенно полезны для функций с несколькими параметрами одного типа, где порядок неочевиден:
// Без меток — что есть что?
send("alice@example.com", "bob@example.com", "Привет!")
// С метками — ясно
send(from: "alice@example.com", to: "bob@example.com", body: "Привет!")
Именованные аргументы устраняют неоднозначность при нескольких параметрах одного типа: по меткам сразу видно, что означает каждый аргумент.
Сокращённый синтаксис меток
Если локальная переменная называется так же, как именованный аргумент, имя можно не повторять:
let name = "Алиса"
let greeting = "Привет"
// Полная запись
greet(greeting: greeting, name: name)
// Сокращённая — то же самое
greet(greeting:, name:)
Запись name: без значения означает: «подставь переменную name». Это работает и при создании записей (custom types с labelled fields).
Pipe-оператор
Pipe-оператор |> — главный инструмент композиции в Gleam. Он передаёт результат левого выражения как первый аргумент правого:
// Без pipe
string.uppercase(string.trim(" hello "))
// С pipe — читается слева направо, сверху вниз
" hello "
|> string.trim
|> string.uppercase
Оба варианта делают одно и то же, но второй читается как последовательность шагов: «взять строку, обрезать пробелы, перевести в верхний регистр».
Pipe с дополнительными аргументами
Если функция принимает больше одного аргумента, pipe подставляет значение как первый:
import gleam/string
"hello"
|> string.append(", world") // string.append("hello", ", world")
|> string.uppercase // string.uppercase("hello, world")
// "HELLO, WORLD"
Pipe-оператор автоматически подставляет предыдущее значение первым аргументом, поэтому string.append получает "hello" как базу и добавляет ", world".
Pipe с function capture
По умолчанию |> подставляет значение как первый аргумент. Если нужно подставить в другую позицию, используйте function capture с _:
import gleam/string
// _ указывает, куда подставить значение из pipe
"world"
|> string.append("hello, ", _)
|> string.uppercase
// "HELLO, WORLD"
Здесь string.append("hello, ", _) создаёт функцию, которая подставляет входящее значение вторым аргументом. Без _ получилось бы string.append("world", "hello, ") — не то, что нужно.
Ещё пример — построение строки справа налево:
"1"
|> string.append("2") // string.append("1", "2") → "12"
|> string.append("3", _) // string.append("3", "12") → "312"
Символ _ в function capture позволяет передать значение из pipe в любую позицию аргумента, а не только в первую.
Практический пример
Pipe-оператор позволяет строить читаемые конвейеры обработки данных:
import gleam/int
import gleam/list
import gleam/string
pub fn format_scores(scores: List(Int)) -> String {
scores
|> list.sort(int.compare)
|> list.reverse
|> list.map(int.to_string)
|> string.join(", ")
}
// format_scores([42, 17, 88, 5]) == "88, 42, 17, 5"
Функция format_scores сортирует результаты по убыванию и объединяет их в строку через запятую — весь конвейер записан без промежуточных переменных.
Отладка пайплайнов с echo
Проблема отладки в пайплайнах
При работе с длинными цепочками pipe-операторов бывает сложно понять, какое значение имеет выражение на промежуточном этапе. Классический подход — разбить pipeline на шаги с промежуточными переменными или вставить вызов io.debug:
import gleam/int
import gleam/list
import gleam/string
pub fn format_scores(scores: List(Int)) -> String {
scores
|> list.sort(int.compare)
|> io.debug // выводит отсортированный список
|> list.reverse
|> io.debug // выводит перевёрнутый список
|> list.map(int.to_string)
|> string.join(", ")
}
Проблема этого подхода:
io.debugвыводит только значение, без контекста (где именно это напечатано?)- Приходится вручную добавлять и удалять вызовы
io.debug - Нет информации о файле и номере строки
Решение: ключевое слово echo
Ключевое слово echo было добавлено в Gleam v1.9.0 специально для удобной отладки пайплайнов. Синтаксис:
value |> echo
// или с меткой
value |> echo("после сортировки")
echo печатает значение в консоль и возвращает его без изменений, позволяя продолжить цепочку pipe. Но в отличие от io.debug, echo автоматически показывает:
- Имя файла
- Номер строки
- Опциональную метку
- Само значение
Пример с echo:
import gleam/int
import gleam/list
import gleam/string
pub fn format_scores(scores: List(Int)) -> String {
scores
|> list.sort(int.compare)
|> echo("после сортировки")
|> list.reverse
|> echo("после разворота")
|> list.map(int.to_string)
|> string.join(", ")
}
Вывод в консоль:
src/chapter03.gleam:8: после сортировки
[5, 17, 42, 88]
src/chapter03.gleam:10: после разворота
[88, 42, 17, 5]
Сразу видно, где в коде произошёл вывод и какое значение было на этом этапе.
echo vs io.debug()
| Функция | Показывает файл:строку | Принимает метку | Возвращает значение |
|---|---|---|---|
echo | ✓ | ✓ (опционально) | ✓ |
io.debug | ✗ | ✗ | ✓ |
io.debug полезен для быстрого вывода в консоль, но для отладки сложных пайплайнов echo удобнее — не нужно гадать, в каком месте кода произошёл вывод.
Ещё одно отличие: echo можно использовать без импорта. Это встроенное ключевое слово языка, а не функция из модуля.
Практические примеры
Отладка трансформации данных
Представим, что нужно обработать список чисел: оставить только положительные, удвоить их и найти сумму. На каком этапе список стал пустым?
import gleam/int
import gleam/list
pub fn process_numbers(numbers: List(Int)) -> Int {
numbers
|> echo("исходный список")
|> list.filter(fn(n) { n > 0 })
|> echo("после фильтрации")
|> list.map(fn(n) { n * 2 })
|> echo("после удвоения")
|> int.sum
}
pub fn main() {
process_numbers([1, -2, 3, -4, 5])
|> io.debug
}
Вывод:
src/chapter03.gleam:6: исходный список
[1, -2, 3, -4, 5]
src/chapter03.gleam:8: после фильтрации
[1, 3, 5]
src/chapter03.gleam:10: после удвоения
[2, 6, 10]
18
Каждый шаг видимым — легко понять, как преобразуются данные.
Отладка строковых операций
Сложные преобразования строк тоже удобно отлаживать с echo:
import gleam/string
pub fn normalize_name(name: String) -> String {
name
|> echo("входная строка")
|> string.trim
|> echo("после trim")
|> string.lowercase
|> echo("после lowercase")
|> string.replace("_", " ")
|> echo("после замены _")
}
// normalize_name(" John_DOE ")
Вывод:
src/chapter03.gleam:5: входная строка
" John_DOE "
src/chapter03.gleam:7: после trim
"John_DOE"
src/chapter03.gleam:9: после lowercase
"john_doe"
src/chapter03.gleam:11: после замены _
"john doe"
Отладка вложенных трансформаций
echo работает на любом уровне вложенности:
import gleam/int
import gleam/list
import gleam/string
pub fn top_three_doubled(numbers: List(Int)) -> String {
numbers
|> echo("начальный список")
|> list.sort(int.compare)
|> list.reverse
|> echo("отсортировано по убыванию")
|> list.take(3)
|> echo("взято первые 3")
|> list.map(fn(n) {
let doubled = n * 2
echo(doubled, "удвоенное значение")
doubled
})
|> echo("после удвоения")
|> list.map(int.to_string)
|> string.join(", ")
}
Вывод показывает все промежуточные значения — даже внутри list.map.
Когда использовать echo
✓ Используйте echo, когда:
- Отлаживаете длинный pipeline
- Хотите проверить промежуточные значения
- Нужно понять, на каком шаге данные меняются неожиданным образом
- Изучаете новую функцию или библиотеку
✗ Не используйте echo для:
- Production-логирования (используйте библиотеки логирования)
- Вывода результатов пользователю (используйте
io.println) - Постоянной отладки (используйте debugger или тесты)
echo — временный инструмент для разработки. Перед коммитом кода удалите все вызовы echo (либо замените их на полноценное логирование, если нужно).
use-выражения
use-выражение — синтаксический сахар для callback-ов. Это не монадический do из Haskell — это простая перезапись вложенных callback-функций в плоский код.
Проблема: вложенные callback-и
Представьте цепочку операций, каждая из которых может завершиться ошибкой:
import gleam/result
pub fn process(input: String) -> Result(Int, String) {
result.try(parse_input(input), fn(value) {
result.try(validate(value), fn(valid) {
Ok(valid * 2)
})
})
}
С каждым шагом вложенность растёт. Код сложно читать.
Решение: use
use разворачивает callback в плоский стиль:
import gleam/result
pub fn process(input: String) -> Result(Int, String) {
use value <- result.try(parse_input(input))
use valid <- result.try(validate(value))
Ok(valid * 2)
}
Запись use x <- f(arg) означает: «вызови f(arg, fn(x) { ... }), где ... — весь оставшийся код блока». Другими словами, use захватывает остаток блока как callback.
Как use работает внутри
Компилятор преобразует:
use x <- f(a)
use y <- g(x)
h(y)
в:
f(a, fn(x) {
g(x, fn(y) {
h(y)
})
})
Никакой магии — только устранение вложенности.
use с несколькими значениями
use может связывать несколько переменных:
use first, rest <- list.pop(items)
Это эквивалентно list.pop(items, fn(first, rest) { ... }).
Case-выражения и pattern matching
Case-выражение — основной способ ветвления в Gleam:
pub fn describe(n: Int) -> String {
case n {
0 -> "ноль"
1 -> "один"
_ -> "другое число"
}
}
_ — подстановочный паттерн, совпадающий с любым значением.
Pattern matching на строках
pub fn greet(name: String) -> String {
case name {
"Алиса" -> "Привет, Алиса! Как дела?"
"Боб" -> "Здорово, Боб!"
name -> "Привет, " <> name <> "!"
}
}
В последней ветке name — новая переменная, привязанная к значению.
Сопоставление нескольких значений
Case может сопоставлять несколько значений одновременно:
import gleam/int
pub fn fizzbuzz(n: Int) -> String {
case n % 3, n % 5 {
0, 0 -> "FizzBuzz"
0, _ -> "Fizz"
_, 0 -> "Buzz"
_, _ -> int.to_string(n)
}
}
Пример показывает сопоставление с образцом по нескольким значениям одновременно с помощью кортежа в case.
Guards
Guards — дополнительные условия в ветках case:
pub fn classify_age(age: Int) -> String {
case age {
a if a < 0 -> "некорректный возраст"
a if a < 13 -> "ребёнок"
a if a < 18 -> "подросток"
a if a < 65 -> "взрослый"
_ -> "пенсионер"
}
}
Guards используют ключевое слово if после паттерна. В них допустимы сравнения и логические операторы.
Альтернативные паттерны
Несколько паттернов можно объединить оператором | (ИЛИ):
pub fn is_weekend(day: String) -> Bool {
case day {
"Saturday" | "Sunday" -> True
_ -> False
}
}
Оператор | в паттернах позволяет объединять несколько вариантов в одну ветку, избегая дублирования логики.
Модуль gleam/function
Модуль gleam/function содержит утилиты для работы с функциями:
import gleam/function
// identity — возвращает аргумент без изменений
function.identity(42) // 42
// compose — композиция двух функций
let double_then_add1 = function.compose(
fn(x) { x * 2 },
fn(x) { x + 1 },
)
double_then_add1(5) // 11
// flip — меняет порядок аргументов
let flipped_append = function.flip(string.append)
flipped_append("мир", "привет, ") // "привет, мир"
// tap — выполняет побочный эффект и возвращает исходное значение
42
|> function.tap(io.debug) // выводит 42, возвращает 42
|> int.to_string
Эти утилиты особенно полезны при построении конвейеров из pipe-операторов, когда нужно адаптировать порядок аргументов или вставить отладочный вывод без разрыва цепочки.
Модуль gleam/bool
Утилиты для работы с логическими значениями:
import gleam/bool
bool.to_int(True) // 1
bool.to_int(False) // 0
bool.to_string(True) // "True"
bool.negate(True) // False
// guard — раннее прерывание при выполнении условия
pub fn divide(a: Int, b: Int) -> Result(Int, String) {
use <- bool.guard(b == 0, Error("деление на ноль"))
Ok(a / b)
}
bool.guard(condition, return_value) — если condition равно True, немедленно возвращает return_value. Иначе выполняет оставшийся код. Это идиоматический способ «раннего возврата» в Gleam.
Модуль gleam/order
Тип Order используется для сравнения значений:
import gleam/order
// Тип Order имеет три значения: Lt, Eq, Gt
order.compare(1, 2) // Lt
order.compare(2, 2) // Eq
order.compare(3, 2) // Gt
// Обращение порядка
order.negate(order.Lt) // Gt
// Полезно для сортировки
order.reverse // функция для обратной сортировки
Тип Order и его функции часто используются совместно с сортировкой списков — например, передаётся в list.sort для управления порядком элементов.
Проект: адресная книга
Соберём изученные концепции в небольшом проекте. Представим адресную книгу как список записей:
import gleam/io
import gleam/list
import gleam/string
pub type Entry {
Entry(name: String, phone: String, city: String)
}
pub fn format_entry(entry: Entry) -> String {
entry.name <> " (" <> entry.city <> "): " <> entry.phone
}
pub fn find_by_city(
entries: List(Entry),
city: String,
) -> List(Entry) {
entries
|> list.filter(fn(e) { e.city == city })
}
pub fn format_book(entries: List(Entry)) -> String {
entries
|> list.map(format_entry)
|> string.join("\n")
}
pub fn main() {
let book = [
Entry(name: "Алиса", phone: "+7-900-111-22-33", city: "Москва"),
Entry(name: "Боб", phone: "+7-900-444-55-66", city: "Петербург"),
Entry(name: "Чарли", phone: "+7-900-777-88-99", city: "Москва"),
]
book
|> find_by_city("Москва")
|> format_book
|> io.println
// Алиса (Москва): +7-900-111-22-33
// Чарли (Москва): +7-900-777-88-99
}
Обратите внимание, как pipe-оператор превращает цепочку вызовов в читаемый конвейер. Мы ещё не знакомы с пользовательскими типами (они будут в следующей главе), но уже можем видеть, как Entry описывает структуру данных с именованными полями.
Упражнения
Решения пишите в файле exercises/chapter03/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter03
gleam test
Запускайте тесты после каждого упражнения, чтобы сразу видеть результат.
1. apply_twice (Лёгкое)
Напишите функцию apply_twice, которая применяет функцию f к значению x дважды.
pub fn apply_twice(f: fn(a) -> a, x: a) -> a
Примеры:
apply_twice(fn(x) { x * 2 }, 3) == 12
apply_twice(fn(x) { x + 1 }, 0) == 2
Примеры показывают ожидаемое поведение функции apply_twice при применении функции к значению дважды.
2. add_exclamation (Лёгкое)
Напишите функцию, которая добавляет восклицательный знак к строке.
pub fn add_exclamation(s: String) -> String
Примеры:
add_exclamation("hello") == "hello!"
add_exclamation("") == "!"
Примеры показывают ожидаемое поведение функции add_exclamation для строк разной длины.
3. shout (Среднее)
Напишите функцию, которая переводит строку в верхний регистр и добавляет "!". Используйте pipe-оператор.
pub fn shout(s: String) -> String
Примеры:
shout("hello") == "HELLO!"
shout("gleam") == "GLEAM!"
Подсказка: используйте string.uppercase и функцию add_exclamation из предыдущего упражнения (или оператор <>).
4. safe_divide (Среднее)
Напишите функцию безопасного целочисленного деления. При делении на ноль возвращайте ошибку.
pub fn safe_divide(a: Int, b: Int) -> Result(Int, String)
Примеры:
safe_divide(10, 3) == Ok(3)
safe_divide(10, 0) == Error("деление на ноль")
Подсказка: используйте case-выражение.
5. FizzBuzz (Среднее)
Напишите функцию FizzBuzz: если число делится на 15 → "FizzBuzz", на 3 → "Fizz", на 5 → "Buzz", иначе → строковое представление числа.
pub fn fizzbuzz(n: Int) -> String
Примеры:
fizzbuzz(15) == "FizzBuzz"
fizzbuzz(9) == "Fizz"
fizzbuzz(10) == "Buzz"
fizzbuzz(7) == "7"
Подсказка: используйте case с сопоставлением нескольких значений — case n % 3, n % 5 { ... }.
Заключение
В этой главе мы изучили:
- Функции как значения первого класса и анонимные функции
- Отсутствие каррирования в Gleam и способы частичного применения
- Pipe-оператор
|>для построения конвейеров - Ключевое слово
echoдля отладки пайплайнов - use-выражения для устранения вложенных callback-ов
- Case-выражения, pattern matching и guards
- Модули
gleam/function,gleam/boolиgleam/order
В следующей главе мы познакомимся с пользовательскими типами, generics и основными коллекциями Gleam.
Типы данных и коллекции
Пользовательские типы, generics, списки, словари, множества и итераторы.
- Цели главы
- Пользовательские типы
- Labelled fields
- Type aliases
- Generics
- Constants
- Кортежи
- Списки
- Модуль gleam/list
- Dict
- Set
- Queue
- Iterator
- Builder-паттерн
- Проект: модель файловой системы
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Научимся создавать пользовательские типы (custom types)
- Познакомимся с generics и type aliases
- Разберём основные коллекции: List, Dict, Set, Queue
- Изучим ленивые последовательности (Iterator)
- Познакомимся с builder-паттерном — стандартным идиомом Gleam-экосистемы
- Построим проект — модель файловой системы
Пользовательские типы
Пользовательские типы (custom types) — основной способ моделирования данных в Gleam. Они похожи на алгебраические типы данных (ADT) из Haskell и OCaml.
Типы-перечисления
Простейший вариант — перечисление:
pub type Color {
Red
Green
Blue
}
Тип Color имеет три значения: Red, Green, Blue. Используем case для работы с ними:
pub fn to_hex(color: Color) -> String {
case color {
Red -> "#FF0000"
Green -> "#00FF00"
Blue -> "#0000FF"
}
}
Функция to_hex использует case для сопоставления с образцом по значениям перечисления и возвращает соответствующий HTML-цвет.
Типы с данными
Варианты могут содержать данные:
pub type Shape {
Circle(radius: Float)
Rectangle(width: Float, height: Float)
}
Здесь Circle и Rectangle — конструкторы: функции, создающие значения типа Shape.
Конструкторы можно вызывать с метками или позиционно:
// С метками — порядок не важен
let c = Circle(radius: 5.0)
let r = Rectangle(height: 4.0, width: 3.0)
// Позиционно — порядок как в определении типа
let c = Circle(5.0)
let r = Rectangle(3.0, 4.0)
Можно комбинировать: часть аргументов позиционно, часть с метками.
Pattern matching на записях
Pattern matching извлекает данные из конструкторов:
pub fn area(shape: Shape) -> Float {
case shape {
Circle(radius:) -> 3.14159265358979 *. radius *. radius
Rectangle(width:, height:) -> width *. height
}
}
В паттерне Circle(radius:) используется сокращённый синтаксис: имя переменной совпадает с именем поля. Это эквивалентно Circle(radius: radius).
Чтобы игнорировать часть полей, используйте ..:
pub fn name(shape: Shape) -> String {
case shape {
Circle(..) -> "круг"
Rectangle(..) -> "прямоугольник"
}
}
// Извлечь только одно поле, остальные проигнорировать
case shape {
Circle(radius:, ..) -> radius
Rectangle(width:, ..) -> width
}
.. в паттерне означает «остальные поля не важны». Для отдельных полей можно использовать _:
case shape {
Rectangle(_, height) -> height // игнорируем width
Circle(radius) -> radius
}
Типы-записи
Тип с единственным вариантом работает как запись (struct/record):
pub type User {
User(name: String, email: String, age: Int)
}
let alice = User(name: "Алиса", email: "alice@example.com", age: 30)
// Доступ к полям через точку
alice.name // "Алиса"
alice.age // 30
Тип с одним вариантом работает как запись (struct): конструктор называется так же, как тип, а поля доступны через точечный синтаксис.
Доступ к полям (record accessors)
Точечный синтаксис record.field работает для типов с одним вариантом без ограничений. Для типов с несколькими вариантами поле доступно через точку только если оно имеет одинаковое имя, позицию и тип во всех вариантах:
pub type SchoolPerson {
Teacher(name: String, subject: String)
Student(name: String)
}
let t = Teacher("Иванов", "Физика")
let s = Student("Петров")
t.name // "Иванов" — поле name есть в обоих вариантах
s.name // "Петров" — ок
// t.subject — ✗ ошибка! subject есть только у Teacher
// Для доступа к таким полям используйте pattern matching
Точечный синтаксис работает для поля только тогда, когда оно присутствует во всех вариантах типа с одинаковым именем и типом; иначе необходимо использовать pattern matching.
Обновление записи
Для создания модифицированной копии записи используется синтаксис ..:
let alice = User(name: "Алиса", email: "alice@example.com", age: 30)
let older = User(..alice, age: 31)
// User(name: "Алиса", email: "alice@example.com", age: 31)
Исходная запись alice не изменяется — создаётся новая копия с обновлённым полем.
Labelled fields
Поля конструкторов в Gleam могут быть именованными (labelled). Это улучшает читаемость:
pub type Point {
Point(x: Float, y: Float)
}
// С метками — порядок не важен
let point = Point(y: 4.0, x: 3.0)
// Позиционно — порядок как в определении
let point = Point(3.0, 4.0)
// В pattern matching
case point {
Point(x:, y:) -> x +. y
}
Метки при вызове опциональны — можно передавать аргументы и позиционно.
Type aliases
Type alias — альтернативное имя для существующего типа:
pub type Name = String
pub type Age = Int
pub type Coordinate = #(Float, Float)
Alias не создаёт новый тип — это просто синоним для удобства. Name и String полностью взаимозаменяемы.
Generics
Типы могут быть параметризованы другими типами:
pub type Pair(a, b) {
Pair(first: a, second: b)
}
a и b — параметры типа. При использовании они подставляются конкретными типами:
let pair_of_ints: Pair(Int, Int) = Pair(first: 1, second: 2)
let mixed: Pair(String, Bool) = Pair(first: "hello", second: True)
Функции тоже могут быть обобщёнными:
pub fn swap(pair: Pair(a, b)) -> Pair(b, a) {
Pair(first: pair.second, second: pair.first)
}
Gleam выводит параметры типов автоматически — явно указывать их нужно редко.
Пример: тип Box
pub type Box(a) {
Box(value: a)
Empty
}
pub fn map_box(box: Box(a), f: fn(a) -> b) -> Box(b) {
case box {
Box(value:) -> Box(value: f(value))
Empty -> Empty
}
}
Box(a) — контейнер, который может содержать значение типа a или быть пустым. Функция map_box применяет функцию к содержимому, не раскрывая контейнер.
Constants
Константы объявляются на уровне модуля:
const pi = 3.14159265358979
const max_retries = 3
const greeting = "Привет!"
Константы вычисляются на этапе компиляции. В них можно использовать только литералы и другие константы — вызовы функций запрещены.
Константы можно использовать в паттернах:
const admin_email = "admin@example.com"
pub fn is_admin(email: String) -> Bool {
case email {
e if e == admin_email -> True
_ -> False
}
}
Здесь показано использование константы в охраннике (guard) паттерна: константа admin_email сравнивается со входным значением прямо в ветке case.
Кортежи
Кортежи (tuples) — упорядоченные коллекции фиксированного размера с элементами разных типов:
let point = #(3.0, 4.0) // #(Float, Float)
let person = #("Алиса", 30) // #(String, Int)
let triple = #(1, "hello", True) // #(Int, String, Bool)
Доступ к элементам — через .0, .1, .2:
point.0 // 3.0
point.1 // 4.0
person.0 // "Алиса"
Pattern matching на кортежах:
let #(x, y) = point
// x = 3.0, y = 4.0
case person {
#(name, age) if age >= 18 -> name <> " — взрослый"
#(name, _) -> name <> " — ребёнок"
}
Кортежи удобны для возврата нескольких значений из функции. Для более сложных структур лучше использовать custom types с именованными полями.
Списки
Списки — основная коллекция в Gleam. Это связные списки (linked lists): эффективное добавление/удаление в начало, но медленный доступ по индексу.
Создание списков
let empty = []
let numbers = [1, 2, 3, 4, 5]
let strings = ["привет", "мир"]
Все элементы списка должны быть одного типа.
Добавление в начало
Оператор .. добавляет элемент(ы) в начало списка:
let numbers = [1, 2, 3]
let more = [0, ..numbers] // [0, 1, 2, 3]
Это операция за O(1) — самый эффективный способ добавления.
Pattern matching на списках
pub fn describe(xs: List(Int)) -> String {
case xs {
[] -> "пустой список"
[x] -> "один элемент: " <> int.to_string(x)
[first, ..rest] -> {
"первый: " <> int.to_string(first)
<> ", остальных: " <> int.to_string(list.length(rest))
}
}
}
Паттерн [first, ..rest] разбивает список на голову (first) и хвост (rest). Паттерн [x] совпадает со списком из одного элемента.
Модуль gleam/list
Модуль gleam/list — один из самых богатых в стандартной библиотеке. Разберём основные функции по категориям.
Трансформация
import gleam/list
// map — применить функцию к каждому элементу
list.map([1, 2, 3], fn(x) { x * 2 })
// [2, 4, 6]
// filter — оставить элементы, удовлетворяющие предикату
list.filter([1, 2, 3, 4, 5], fn(x) { x % 2 == 0 })
// [2, 4]
// filter_map — map + filter за один проход
list.filter_map([1, 2, 3, 4], fn(x) {
case x % 2 == 0 {
True -> Ok(x * 10)
False -> Error(Nil)
}
})
// [20, 40]
// flat_map — map, возвращающий список, + flatten
list.flat_map([1, 2, 3], fn(x) { [x, x * 10] })
// [1, 10, 2, 20, 3, 30]
Блок демонстрирует основные операции преобразования списков: map применяет функцию к каждому элементу, filter оставляет только подходящие элементы, а flat_map сочетает отображение и выравнивание вложенных списков.
Свёртки
Свёртка (fold) — самая мощная операция на списках. Она проходит список, накапливая результат:
// fold — свёртка слева
list.fold([1, 2, 3, 4], 0, fn(acc, x) { acc + x })
// 10
// reduce — свёртка без начального значения
list.reduce([1, 2, 3], fn(acc, x) { acc + x })
// Ok(6)
// reduce на пустом списке возвращает Error(Nil)
list.reduce([], fn(acc, x) { acc + x })
// Error(Nil)
Разница между fold и reduce: fold принимает начальное значение аккумулятора, reduce использует первый элемент списка.
Поиск
// find — первый элемент, удовлетворяющий предикату
list.find([1, 2, 3, 4], fn(x) { x > 2 })
// Ok(3)
// contains — проверка наличия элемента
list.contains([1, 2, 3], 2) // True
// any / all — есть ли хотя бы один / все ли
list.any([1, 2, 3], fn(x) { x > 2 }) // True
list.all([1, 2, 3], fn(x) { x > 0 }) // True
Функции find, contains, any и all позволяют проверять наличие элементов в списке, возвращая Result или Bool в зависимости от задачи.
Сортировка
import gleam/int
// sort — сортировка с функцией сравнения
list.sort([3, 1, 4, 1, 5], int.compare)
// [1, 1, 3, 4, 5]
// unique — убрать дубликаты (сохраняя порядок)
list.unique([1, 2, 3, 2, 1, 4])
// [1, 2, 3, 4]
// reverse
list.reverse([1, 2, 3])
// [3, 2, 1]
Функции sort, unique и reverse позволяют упорядочивать список: sort требует функцию сравнения (например, int.compare), unique убирает дубликаты, сохраняя порядок первого вхождения.
Комбинирование
// append — соединить два списка
list.append([1, 2], [3, 4])
// [1, 2, 3, 4]
// flatten — развернуть список списков
list.flatten([[1, 2], [3], [4, 5]])
// [1, 2, 3, 4, 5]
// zip — попарно объединить элементы
list.zip([1, 2, 3], ["a", "b", "c"])
// [#(1, "a"), #(2, "b"), #(3, "c")]
// intersperse — вставить элемент между каждой парой
list.intersperse([1, 2, 3], 0)
// [1, 0, 2, 0, 3]
Функции комбинирования позволяют соединять несколько списков: append и flatten объединяют элементы в один список, zip создаёт пары из двух списков, а intersperse вставляет разделитель между элементами.
Разбиение
// take / drop — взять / отбросить N элементов
list.take([1, 2, 3, 4, 5], 3) // [1, 2, 3]
list.drop([1, 2, 3, 4, 5], 2) // [3, 4, 5]
// split — разделить на два списка
list.split([1, 2, 3, 4, 5], 3)
// #([1, 2, 3], [4, 5])
// partition — разделить по предикату
list.partition([1, 2, 3, 4, 5], fn(x) { x % 2 == 0 })
// #([2, 4], [1, 3, 5])
// chunk — группировка последовательных элементов
list.chunk([1, 1, 2, 2, 2, 3], fn(x) { x })
// [[1, 1], [2, 2, 2], [3]]
Функции разбиения позволяют делить список на части: take/drop отрезают N элементов, split возвращает кортеж из двух частей, partition делит по предикату, а chunk группирует подряд идущие равные элементы.
Key-value списки
Списки кортежей можно использовать как простые словари:
let entries = [#("name", "Алиса"), #("city", "Москва")]
list.key_find(entries, "name") // Ok("Алиса")
list.key_find(entries, "phone") // Error(Nil)
list.key_set(entries, "city", "Петербург")
// [#("name", "Алиса"), #("city", "Петербург")]
Функции key_find и key_set позволяют работать со списком кортежей как с ассоциативным массивом: искать значение по ключу и обновлять его, не прибегая к полноценному Dict.
group — группировка
list.group(["cat", "car", "dog", "day"], fn(w) {
string.slice(w, 0, 1)
})
// dict.from_list([
// #("c", ["cat", "car"]),
// #("d", ["dog", "day"]),
// ])
list.group возвращает Dict — словарь, который мы рассмотрим далее.
Dict
Dict(key, value) — словарь (хеш-таблица). Ключи уникальны.
import gleam/dict
// Создание
let d = dict.from_list([#("a", 1), #("b", 2), #("c", 3)])
// Вставка и получение
let d = dict.insert(d, "d", 4)
dict.get(d, "a") // Ok(1)
dict.get(d, "z") // Error(Nil)
// Проверка
dict.has_key(d, "a") // True
dict.size(d) // 4
// Удаление
let d = dict.delete(d, "b")
Блок показывает базовые операции над словарём: создание из списка пар, получение значений, проверку наличия ключа, получение размера и удаление элементов.
Трансформации Dict
// map_values — преобразовать значения
dict.map_values(d, fn(_key, value) { value * 10 })
// filter — отфильтровать пары
dict.filter(d, fn(_key, value) { value > 2 })
// merge — объединить два словаря (правый приоритетнее)
let d1 = dict.from_list([#("a", 1), #("b", 2)])
let d2 = dict.from_list([#("b", 20), #("c", 30)])
dict.merge(d1, d2)
// dict.from_list([#("a", 1), #("b", 20), #("c", 30)])
// fold — свёртка по парам ключ-значение
dict.fold(d, 0, fn(acc, _key, value) { acc + value })
// keys и values
dict.keys(d) // список ключей
dict.values(d) // список значений
// to_list — преобразование в список кортежей
dict.to_list(d)
// [#("a", 1), #("c", 3), #("d", 4)]
Модуль dict предоставляет функции для преобразования словаря: map_values и filter работают аналогично своим аналогам для списков, merge объединяет два словаря (при совпадении ключей побеждает правый), а fold позволяет свернуть все пары ключ-значение в одно значение.
upsert — вставка с обновлением
// Если ключ есть — обновить, если нет — вставить
dict.upsert(d, "a", fn(existing) {
case existing {
Some(n) -> n + 1
None -> 1
}
})
upsert удобен для подсчёта или накопления данных: функция получает Some(существующее_значение) или None, если ключа ещё нет, и возвращает новое значение.
Set
Set(a) — неупорядоченное множество уникальных элементов:
import gleam/set
let s1 = set.from_list([1, 2, 3, 4])
let s2 = set.from_list([3, 4, 5, 6])
set.contains(s1, 2) // True
set.size(s1) // 4
// Теоретико-множественные операции
set.union(s1, s2) // {1, 2, 3, 4, 5, 6}
set.intersection(s1, s2) // {3, 4}
set.difference(s1, s2) // {1, 2}
set.is_subset(
set.from_list([1, 2]),
s1,
) // True
// Добавление и удаление
let s = set.insert(s1, 5) // {1, 2, 3, 4, 5}
let s = set.delete(s1, 1) // {2, 3, 4}
Set полезен для проверки уникальности, удаления дубликатов и теоретико-множественных операций.
Queue
Queue(a) — двусторонняя очередь с эффективными операциями на обоих концах:
import gleam/queue
let q = queue.from_list([1, 2, 3])
// Добавление
let q = queue.push_back(q, 4) // [1, 2, 3, 4]
let q = queue.push_front(q, 0) // [0, 1, 2, 3, 4]
// Извлечение
queue.pop_front(q) // Ok(#(0, остаток))
queue.pop_back(q) // Ok(#(4, остаток))
// Проверки
queue.is_empty(q) // False
queue.length(q) // 5
Queue реализована на двух списках и обеспечивает амортизированное O(1) для всех операций. Она полезна, когда нужно эффективно добавлять и извлекать элементы с обоих концов.
Iterator
Iterator(a) — ленивая последовательность. В отличие от списка, элементы вычисляются по требованию:
import gleam/iterator
// Создание из списка
let it = iterator.from_list([1, 2, 3])
// Бесконечные итераторы
let ones = iterator.repeat(1) // 1, 1, 1, ...
let nats = iterator.range(1, 1000) // 1, 2, ..., 1000
// Ленивые трансформации (не вычисляются сразу!)
let doubled = iterator.map(nats, fn(x) { x * 2 })
let evens = iterator.filter(nats, fn(x) { x % 2 == 0 })
// Материализация — здесь элементы вычисляются
iterator.to_list(iterator.take(doubled, 5))
// [2, 4, 6, 8, 10]
Пример демонстрирует ленивые вычисления: итератор бесконечной последовательности натуральных чисел создаётся с помощью iterate, преобразуется через map и filter, и лишь при вызове to_list(take(...)) элементы фактически вычисляются.
unfold — создание итератора из функции
// Числа Фибоначчи
let fibs = iterator.unfold(
#(0, 1),
fn(state) {
let #(a, b) = state
iterator.Next(element: a, accumulator: #(b, a + b))
},
)
iterator.to_list(iterator.take(fibs, 8))
// [0, 1, 1, 2, 3, 5, 8, 13]
unfold принимает начальное состояние и функцию, которая на каждом шаге возвращает элемент и новое состояние. iterator.Next продолжает генерацию, iterator.Done завершает.
iterate — бесконечная итерация
// Степени двойки
let powers = iterator.iterate(1, fn(x) { x * 2 })
iterator.to_list(iterator.take(powers, 10))
// [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
iterator.iterate строит бесконечную последовательность, применяя функцию к предыдущему элементу; вместе с take это позволяет ленивo получить нужное количество значений без лишних вычислений.
Когда использовать Iterator вместо List
- Данные слишком большие, чтобы уместиться в памяти
- Нужны только первые N элементов из потенциально большой последовательности
- Вычисление каждого элемента дорогое
Builder-паттерн
Builder — один из самых распространённых паттернов в Gleam-экосистеме. Его популярность объясняется просто: в Gleam нет аргументов по умолчанию и нет перегрузки функций. Builder — стандартный способ создавать сложные объекты с опциональными параметрами.
Примеры из реальных библиотек:
// pog — PostgreSQL клиент
pog.default_config()
|> pog.host("db.example.com")
|> pog.port(5432)
|> pog.database("myapp")
|> pog.connect
// mist — HTTP сервер
mist.new(handler)
|> mist.port(8080)
|> mist.start
// telega — Telegram Bot
telega.new_for_polling(token: token)
|> telega.with_router(my_router)
|> telega.init_for_polling_nil_session()
Два вида builder
Конструирующий builder строит структуру данных пошагово. Каждый add_* / with_* возвращает новый builder с добавленным элементом, а финальный вызов build создаёт готовый объект:
let src =
new_dir("src")
|> add_file("main.gleam", 2048)
|> add_file("utils.gleam", 512)
|> build
// → Directory("src", [File("main.gleam", 2048), File("utils.gleam", 512)])
let project =
new_dir("project")
|> add_file("README.md", 1024)
|> add_subdir(src)
|> build
Сравните с прямым конструированием — при большом числе файлов это нечитаемо:
Directory("project", [
File("README.md", 1024),
Directory("src", [
File("main.gleam", 2048),
File("utils.gleam", 512),
]),
])
Конфигурирующий builder собирает набор параметров / фильтров без немедленного выполнения. Вместо данных он аккумулирует настройки, а финальный вызов (run, connect, start) применяет их:
// FsQuery — конфигурирующий builder для поиска файлов
new_query()
|> with_extension(".gleam")
|> with_min_size(1000)
|> run_query(root_dir)
// → ["main.gleam", "main_test.gleam"]
Ключевые свойства паттерна
- Начинается с
new_*()илиdefault_*()— создаёт builder с дефолтными значениями - Каждый
with_*/add_*возвращает новый builder (иммутабельно, через..) - Заканчивается
build/run/connect— создаёт финальный результат - Дружит с pipe
|>— код читается как пошаговый список действий
Тип DirBuilder и функции new_dir, add_file, add_subdir, build уже реализованы в src/chapter04.gleam как демонстрационный пример. В упражнении 7 вы реализуете конфигурирующий builder FsQuery.
Проект: модель файловой системы
Соберём изученные концепции, построив модель файловой системы. Определим рекурсивный тип — узел, который может быть файлом или директорией:
pub type FSNode {
File(name: String, size: Int)
Directory(name: String, children: List(FSNode))
}
Это рекурсивный тип: Directory содержит List(FSNode), то есть может хранить другие файлы и директории.
Создадим тестовое дерево:
let fs = Directory("project", [
File("README.md", 1024),
Directory("src", [
File("main.gleam", 2048),
File("utils.gleam", 512),
]),
Directory("test", [
File("main_test.gleam", 1536),
]),
])
Это соответствует структуре:
project/
├── README.md (1024 bytes)
├── src/
│ ├── main.gleam (2048 bytes)
│ └── utils.gleam (512 bytes)
└── test/
└── main_test.gleam (1536 bytes)
Тип FSNode определён в exercises/chapter04/src/chapter04.gleam — вы импортируете его в решениях. Все упражнения этой главы работают с этим типом, постепенно наращивая сложность: от простого обхода дерева до группировки данных с помощью Dict.
Упражнения
Решения пишите в файле exercises/chapter04/test/my_solutions.gleam. Тип FSNode уже импортирован. Запускайте тесты:
cd exercises/chapter04
gleam test
Команды для перехода в директорию упражнения и запуска тестов, проверяющих ваши решения.
1. total_size (Лёгкое)
Вычислите общий размер узла файловой системы. Для файла — его размер, для директории — сумма размеров всех вложенных узлов (рекурсивно).
pub fn total_size(node: FSNode) -> Int
Примеры:
total_size(File("a.txt", 100)) == 100
total_size(sample_fs) == 5120 // 1024 + 2048 + 512 + 1536
total_size(Directory("empty", [])) == 0
Подсказка: используйте case для разделения на File и Directory. Для директории пройдите children с помощью list.fold, рекурсивно вызывая total_size.
2. all_files (Лёгкое)
Соберите имена всех файлов в дереве в плоский список. Порядок — обход в глубину (depth-first).
pub fn all_files(node: FSNode) -> List(String)
Примеры:
all_files(sample_fs)
== ["README.md", "main.gleam", "utils.gleam", "main_test.gleam"]
all_files(File("only.txt", 10))
== ["only.txt"]
Подсказка: для файла верните список из одного имени. Для директории используйте list.flat_map — она применит all_files к каждому дочернему узлу и объединит результаты.
3. find_by_extension (Среднее)
Найдите все файлы с заданным расширением.
pub fn find_by_extension(node: FSNode, ext: String) -> List(String)
Примеры:
find_by_extension(sample_fs, ".gleam")
== ["main.gleam", "utils.gleam", "main_test.gleam"]
find_by_extension(sample_fs, ".md")
== ["README.md"]
find_by_extension(sample_fs, ".rs")
== []
Подсказка: похоже на all_files, но для файла проверяйте string.ends_with(name, ext). Если не совпадает — возвращайте пустой список.
4. largest_file (Среднее)
Найдите самый большой файл в дереве. Верните пару #(имя, размер) или Error(Nil), если файлов нет.
pub fn largest_file(node: FSNode) -> Result(#(String, Int), Nil)
Примеры:
largest_file(sample_fs) == Ok(#("main.gleam", 2048))
largest_file(Directory("empty", [])) == Error(Nil)
Подсказка: сначала соберите все файлы как пары #(имя, размер) (напишите вспомогательную функцию, аналогичную all_files). Затем используйте list.reduce для поиска максимума — она вернёт Error(Nil) для пустого списка.
5. count_by_extension (Среднее)
Подсчитайте количество файлов каждого типа (по расширению). Результат — список пар #(расширение, количество), отсортированный по расширению. Считайте, что все файлы имеют расширение формата .xxx.
pub fn count_by_extension(node: FSNode) -> List(#(String, Int))
Примеры:
count_by_extension(sample_fs)
== [#(".gleam", 3), #(".md", 1)]
Подсказка: используйте all_files для сбора имён, извлеките расширение через string.split(name, "."), сгруппируйте с list.group, посчитайте длины через dict.map_values, преобразуйте в отсортированный список.
6. group_by_directory (Сложное)
Сгруппируйте файлы по директории, в которой они непосредственно находятся. Результат — список пар #(имя_директории, список_файлов), отсортированный по имени директории. Директории без прямых файлов-потомков не включаются.
pub fn group_by_directory(node: FSNode) -> List(#(String, List(String)))
Примеры:
group_by_directory(sample_fs)
== [
#("project", ["README.md"]),
#("src", ["main.gleam", "utils.gleam"]),
#("test", ["main_test.gleam"]),
]
Подсказка: напишите вспомогательную рекурсивную функцию: для директории отфильтруйте прямых детей-файлов через list.filter_map, рекурсивно обработайте поддиректории, объедините результаты. В конце отсортируйте по имени.
7. FsQuery builder (Среднее)
Реализуйте конфигурирующий builder для поиска файлов. Тип FsQuery уже определён в src/chapter04.gleam — вам нужно реализовать пять функций:
pub fn new_query() -> FsQuery
pub fn with_extension(q: FsQuery, ext: String) -> FsQuery
pub fn with_min_size(q: FsQuery, size: Int) -> FsQuery
pub fn with_max_size(q: FsQuery, size: Int) -> FsQuery
pub fn run_query(q: FsQuery, node: FSNode) -> List(String)
Примеры:
new_query()
|> run_query(sample_fs)
== ["README.md", "main.gleam", "utils.gleam", "main_test.gleam"]
new_query()
|> with_extension(".gleam")
|> run_query(sample_fs)
== ["main.gleam", "utils.gleam", "main_test.gleam"]
new_query()
|> with_min_size(1000)
|> with_max_size(2000)
|> run_query(sample_fs)
== ["README.md", "main_test.gleam"] // 1024 и 1536 — в диапазоне
Подсказка: new_query создаёт FsQuery со всеми полями None. Каждый with_* возвращает FsQuery(..q, field: Some(value)). В run_query соберите пары #(name, size) (как в largest_file), затем пройдите их через list.filter_map, проверяя каждый фильтр через case.
Заключение
В этой главе мы изучили:
- Пользовательские типы — основу моделирования данных в Gleam
- Generics для создания обобщённых типов и функций
- Списки и богатый модуль
gleam/list - Dict, Set и Queue для различных задач
- Iterator для ленивых вычислений
- Builder-паттерн — стандартный идиом для объектов с опциональными параметрами
Эти инструменты покрывают подавляющее большинство задач по работе с данными. В следующей главе мы глубже разберём рекурсию, свёртки и обработку ошибок через Result и Option.
Рекурсия, свёртки и обработка ошибок
«Сделайте невозможные состояния невыразимыми» — Ричард Фельдман
- Цели главы
- Рекурсия
- Хвостовая рекурсия
- Свёртки как обобщение рекурсии
- Ошибки как значения
- Два вида ошибок
- Result(value, error) — подробно
- Option(a) — подробно
- use + result.try — идиоматические цепочки
- panic
- let assert
- Railway-Oriented Programming
- Накопление ошибок
- Сделайте невозможные состояния невыразимыми
- Проект: валидация формы регистрации
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Научимся писать рекурсивные функции и оптимизировать их хвостовыми вызовами
- Поймём свёртки как обобщение рекурсии
- Разберём философию «ошибки как значения»
- Изучим
ResultиOption— два основных типа обработки ошибок - Освоим
use+result.tryдля идиоматических цепочек - Узнаем про
panicиlet assert— когда их использовать - Познакомимся с Railway-Oriented Programming
- Научимся накапливать ошибки вместо остановки на первой
Рекурсия
Рекурсия — основной способ итерации в функциональных языках. Функция вызывает сама себя, обрабатывая на каждом шаге часть данных.
Базовая рекурсия
Простейший пример — факториал:
pub fn factorial(n: Int) -> Int {
case n {
0 -> 1
_ -> n * factorial(n - 1)
}
}
Каждая рекурсивная функция имеет:
- Базовый случай — условие завершения (
n == 0) - Рекурсивный случай — вызов с «уменьшенной» задачей (
n - 1)
Pattern matching на списках
Рекурсия особенно естественна для работы со списками. Список можно разобрать на голову и хвост:
pub fn sum(xs: List(Int)) -> Int {
case xs {
[] -> 0
[first, ..rest] -> first + sum(rest)
}
}
// sum([1, 2, 3])
// = 1 + sum([2, 3])
// = 1 + 2 + sum([3])
// = 1 + 2 + 3 + sum([])
// = 1 + 2 + 3 + 0
// = 6
Паттерн [first, ..rest] разбивает список: first — первый элемент, rest — остаток. Пустой список [] — базовый случай.
Множественный pattern matching
Можно рекурсивно обходить несколько списков одновременно:
pub fn zip_with(
xs: List(a),
ys: List(b),
f: fn(a, b) -> c,
) -> List(c) {
case xs, ys {
[], _ | _, [] -> []
[x, ..rest_x], [y, ..rest_y] -> [
f(x, y),
..zip_with(rest_x, rest_y, f)
]
}
}
zip_with([1, 2, 3], [10, 20, 30], fn(a, b) { a + b })
// [11, 22, 33]
Функция zip_with одновременно обходит два списка и применяет переданную функцию к парам элементов, останавливаясь когда один из списков заканчивается.
Хвостовая рекурсия
Обычная (body) рекурсия наращивает стек вызовов: каждый вызов ждёт возврата следующего и затем выполняет дополнительную операцию.
Хвостовая рекурсия — когда рекурсивный вызов стоит в хвостовой позиции: результат вызова сразу возвращается, без дополнительных операций. BEAM (Erlang VM) оптимизирует такие вызовы — они выполняются в постоянной памяти стека, как цикл.
Аккумуляторы
Для преобразования обычной рекурсии в хвостовую используют аккумулятор — параметр, накапливающий промежуточный результат:
// Обычная рекурсия (НЕ хвостовая)
pub fn sum(xs: List(Int)) -> Int {
case xs {
[] -> 0
[first, ..rest] -> first + sum(rest) // ← сложение ПОСЛЕ рекурсии
}
}
// Хвостовая рекурсия (TCO-оптимизируемая)
pub fn sum_tail(xs: List(Int)) -> Int {
sum_loop(xs, 0)
}
fn sum_loop(xs: List(Int), acc: Int) -> Int {
case xs {
[] -> acc
[first, ..rest] -> sum_loop(rest, acc + first) // ← хвостовой вызов
}
}
В sum_loop рекурсивный вызов — последняя операция. Сложение acc + first выполняется до вызова, а не после. BEAM превращает это в эффективный цикл.
Паттерн: публичная функция + приватный цикл с аккумулятором:
pub fn factorial_tail(n: Int) -> Int {
factorial_loop(n, 1)
}
fn factorial_loop(n: Int, acc: Int) -> Int {
case n {
0 -> acc
_ -> factorial_loop(n - 1, n * acc)
}
}
Паттерн «публичная функция + приватный цикл» скрывает аккумулятор от пользователя: factorial_tail предоставляет удобный интерфейс, а factorial_loop содержит хвостово-рекурсивную реализацию с накапливаемым результатом.
Мифы о хвостовой рекурсии
Распространённое заблуждение: «хвостовая рекурсия всегда быстрее обычной». На современном BEAM это не так. Согласно Erlang Efficiency Guide:
Body-recursive function generally uses the same amount of memory as a tail-recursive function. It is generally not possible to predict whether the tail-recursive or the body-recursive version will be faster.
Исторически (до Erlang R12B) хвостовая рекурсия давала значительный выигрыш. Современный компилятор устранил большую часть разницы.
Когда хвостовая рекурсия действительно быстрее:
- Аккумулирование простого значения без создания промежуточных структур (суммирование, подсчёт)
- Случаи, когда не нужен
list.reverseв конце (reverse сводит на нет экономию)
Когда разницы нет:
- Построение списка (хвостовая версия требует
list.reverseв конце — то же количество операций) - Обработка коротких и средних списков
Рекомендация из Erlang Efficiency Guide: используйте тот вариант, который делает код чище — обычно это body-рекурсия.
Когда хвостовая рекурсия обязательна?
Несмотря на развенчанные мифы, есть случаи, когда хвостовая рекурсия необходима:
- Бесконечные циклы — серверы, акторы, циклы обработки сообщений (глава 8). Без TCO стек будет расти бесконечно
- Потоковая обработка — чтение из файла или сокета строка за строкой, когда объём данных неизвестен заранее
- Аккумулирование скалярного результата — сумма, максимум, подсчёт — здесь хвостовая рекурсия действительно эффективнее
Для большинства функций над списками конечной длины выбирайте вариант, который проще читать и поддерживать.
Свёртки как обобщение рекурсии
Большинство рекурсивных функций на списках следуют одному паттерну: пройти список, накапливая результат. Этот паттерн абстрагирован в свёртку (fold).
list.fold
list.fold — свёртка слева с начальным значением:
import gleam/list
// Сумма элементов
list.fold([1, 2, 3, 4], 0, fn(acc, x) { acc + x })
// 10
// Конкатенация строк
list.fold(["hello", " ", "world"], "", fn(acc, s) { acc <> s })
// "hello world"
// Подсчёт элементов
list.fold([True, False, True, True], 0, fn(acc, x) {
case x {
True -> acc + 1
False -> acc
}
})
// 3
fold принимает начальное значение аккумулятора и функцию fn(acc, element) -> acc. По сути, fold — это хвостовая рекурсия, вынесенная в библиотеку.
list.reduce
list.reduce — свёртка без начального значения. Первый элемент становится аккумулятором:
list.reduce([1, 2, 3], fn(acc, x) { acc + x })
// Ok(6)
list.reduce([], fn(acc, x) { acc + x })
// Error(Nil) — пустой список!
reduce возвращает Result, потому что для пустого списка нет начального значения.
list.fold_right
list.fold_right — свёртка справа (от конца к началу):
list.fold_right([1, 2, 3], [], fn(x, acc) { [x * 10, ..acc] })
// [10, 20, 30]
Обратите внимание: у fold_right порядок аргументов функции — fn(element, acc), а не fn(acc, element).
list.try_fold
list.try_fold — свёртка, которая может остановиться при ошибке:
import gleam/int
pub fn sum_strings(xs: List(String)) -> Result(Int, Nil) {
list.try_fold(xs, 0, fn(acc, s) {
case int.parse(s) {
Ok(n) -> Ok(acc + n)
Error(_) -> Error(Nil)
}
})
}
sum_strings(["1", "2", "3"]) // Ok(6)
sum_strings(["1", "abc", "3"]) // Error(Nil)
try_fold останавливается на первом Error — не обрабатывая остальные элементы.
Ошибки как значения
В Gleam нет механизма исключений. Ошибки — это обычные значения, которые возвращаются из функций. Это осознанное решение, а не ограничение.
Почему не исключения?
Исключения (как в Java, Python, JavaScript) создают проблемы:
- Невидимость: по сигнатуре функции не видно, может ли она «упасть»
- Нелокальность: исключение летит сквозь стек вызовов, пока кто-то его не поймает
- Хрупкость: забытый
try/catch— и программа крашится
В Gleam каждая функция, которая может завершиться неудачей, явно возвращает Result:
// Сигнатура говорит ВСЁ о поведении функции
pub fn parse(s: String) -> Result(Int, String)
// Вызывающий код ОБЯЗАН обработать оба случая
case parse("42") {
Ok(n) -> io.println("Число: " <> int.to_string(n))
Error(msg) -> io.println("Ошибка: " <> msg)
}
Компилятор гарантирует: если функция может вернуть ошибку, вызывающий код это увидит и обработает.
Два вида ошибок
Gleam различает два вида ошибок:
Ожидаемые ошибки (expected errors)
Ситуации, которые нормальны для работы программы:
- Пользователь ввёл некорректные данные
- Файл не найден
- Сетевое соединение оборвалось
- Парсинг строки не удался
Для них используется Result(value, error):
pub fn parse_age(s: String) -> Result(Int, String) {
case int.parse(s) {
Ok(n) if n >= 0 && n <= 150 -> Ok(n)
Ok(_) -> Error("возраст должен быть от 0 до 150")
Error(_) -> Error("не удалось распознать число")
}
}
Функция parse_age демонстрирует типичный паттерн обработки ожидаемых ошибок через Result: каждая ветка case возвращает либо Ok с валидным значением, либо Error с понятным сообщением о причине отказа.
Неожиданные ошибки (unexpected errors, bugs)
Ситуации, которые никогда не должны произойти в корректной программе:
- Нарушение инварианта (список должен быть непустым, но пуст)
- Невозможная ветка кода
- Баг в логике
Для них используется panic или let assert:
pub fn head(xs: List(a)) -> a {
// Мы ЗНАЕМ, что список непуст —
// если он пуст, это баг в вызывающем коде
let assert [first, ..] = xs
first
}
let assert здесь сигнализирует: если список пуст — это баг в вызывающем коде, а не ожидаемая ситуация, поэтому возврат Result был бы избыточен.
Правило выбора
Используйте
Resultдля ожидаемых проблем. Используйтеpanic/let assertдля багов, которые сигнализируют об ошибке программиста.
Result(value, error) — подробно
Result(value, error) — тип с двумя вариантами:
// Определение из стандартной библиотеки
pub type Result(value, error) {
Ok(value)
Error(error)
}
Result — это тип-сумма с двумя вариантами: Ok(value) для успешного результата и Error(error) для ошибки; оба варианта параметризованы, что делает тип универсальным.
Основные функции
import gleam/result
// map — преобразовать значение внутри Ok
result.map(Ok(5), fn(x) { x * 2 }) // Ok(10)
result.map(Error("oops"), fn(x) { x * 2 }) // Error("oops")
// try (bind) — цепочка операций, каждая может вернуть ошибку
result.try(Ok(5), fn(x) { Ok(x * 2) }) // Ok(10)
result.try(Ok(5), fn(_) { Error("fail") }) // Error("fail")
result.try(Error("oops"), fn(x) { Ok(x) }) // Error("oops")
// unwrap — извлечь значение или вернуть значение по умолчанию
result.unwrap(Ok(5), 0) // 5
result.unwrap(Error("oops"), 0) // 0
// lazy_unwrap — значение по умолчанию вычисляется лениво
result.lazy_unwrap(Ok(5), fn() { expensive_default() }) // 5
Эти функции позволяют трансформировать и извлекать значения из Result, не разворачивая его вручную через case: map преобразует успешное значение, try строит цепочку зависимых операций, а unwrap извлекает значение с резервным вариантом.
Работа с ошибками
// map_error — преобразовать ошибку
result.map_error(Error("oops"), fn(e) { "Error: " <> e })
// Error("Error: oops")
// replace_error — заменить ошибку
result.replace_error(Error(Nil), "не найдено")
// Error("не найдено")
// map_error — преобразовать ошибку
result.map_error(Error("not found"), fn(_) { Nil })
// Error(Nil)
map_error и replace_error позволяют преобразовывать тип ошибки внутри Error, не затрагивая успешный вариант — это полезно при унификации разнотипных ошибок в одной цепочке.
Комбинирование
// all — собрать список Result в Result списка
result.all([Ok(1), Ok(2), Ok(3)])
// Ok([1, 2, 3])
result.all([Ok(1), Error("fail"), Ok(3)])
// Error("fail") — останавливается на первой ошибке
// partition — разделить на успешные и ошибочные
result.partition([Ok(1), Error("a"), Ok(3), Error("b")])
// #([1, 3], ["a", "b"])
// values — извлечь только успешные значения
result.values([Ok(1), Error("a"), Ok(3)])
// [1, 3]
// flatten — убрать вложенный Result
result.flatten(Ok(Ok(5))) // Ok(5)
result.flatten(Ok(Error("x"))) // Error("x")
result.flatten(Error("y")) // Error("y")
all превращает список результатов в результат списка (останавливаясь на первой ошибке), partition разделяет все результаты на успешные и ошибочные, а flatten убирает лишний уровень вложенности Result.
Проверки
result.is_ok(Ok(5)) // True
result.is_ok(Error("oops")) // False
result.is_error(Error("x")) // True
// or — вернуть первый Ok
result.or(Error("a"), Ok(5)) // Ok(5)
result.or(Ok(3), Ok(5)) // Ok(3)
result.or(Error("a"), Error("b")) // Error("b")
is_ok и is_error проверяют вариант результата без его разворачивания, а or возвращает первый успешный из двух результатов — удобно для задания резервных вариантов.
Option(a) — подробно
Option(a) представляет значение, которое может отсутствовать:
import gleam/option.{type Option, None, Some}
// Определение
pub type Option(a) {
Some(a)
None
}
Option — это по сути Result без информации об ошибке. Используйте его, когда отсутствие значения — нормальная ситуация и не требует пояснений:
import gleam/option
import gleam/list
// list.find возвращает Result — мы знаем, что элемент может не найтись
list.find([1, 2, 3], fn(x) { x > 5 })
// Error(Nil)
// Для пользовательского API Option более выразителен
pub fn safe_head(xs: List(a)) -> Option(a) {
case xs {
[] -> None
[first, ..] -> Some(first)
}
}
Option выразительнее Result(a, Nil) в ситуациях, когда причина отсутствия значения очевидна из контекста: None означает «нет значения», а не «произошла ошибка».
Основные функции
// map — преобразовать значение
option.map(Some(5), fn(x) { x * 2 }) // Some(10)
option.map(None, fn(x) { x * 2 }) // None
// flatten — убрать вложенность
option.flatten(Some(Some(5))) // Some(5)
option.flatten(Some(None)) // None
option.flatten(None) // None
// unwrap
option.unwrap(Some(5), 0) // 5
option.unwrap(None, 0) // 0
// Конвертация Option ↔ Result
option.to_result(Some(5), "не найдено") // Ok(5)
option.to_result(None, "не найдено") // Error("не найдено")
option.from_result(Ok(5)) // Some(5)
option.from_result(Error("oops")) // None
Функции map, flatten и unwrap для Option работают аналогично их аналогам для Result; функции to_result и from_result позволяют свободно переключаться между двумя типами в зависимости от того, нужна ли информация об ошибке.
Комбинирование
// all — список Option → Option списка
option.all([Some(1), Some(2), Some(3)]) // Some([1, 2, 3])
option.all([Some(1), None, Some(3)]) // None
// values — извлечь только Some
option.values([Some(1), None, Some(3)]) // [1, 3]
// or / lazy_or
option.or(None, Some(5)) // Some(5)
option.or(Some(3), Some(5)) // Some(3)
all преобразует список опциональных значений в опциональный список (возвращая None при первом отсутствующем значении), values извлекает только присутствующие значения, а or возвращает первое непустое из двух.
use + result.try — идиоматические цепочки
Когда нужно выполнить последовательность операций, каждая из которых может вернуть ошибку, use + result.try позволяет писать плоский код вместо вложенного:
import gleam/result
import gleam/int
// Без use — вложенность растёт
pub fn process_nested(input: String) -> Result(String, String) {
result.try(
int.parse(input) |> result.replace_error("не число"),
fn(n) {
result.try(validate(n), fn(valid) {
Ok(int.to_string(valid * 2))
})
},
)
}
// С use — плоский, читаемый код
pub fn process(input: String) -> Result(String, String) {
use n <- result.try(
int.parse(input)
|> result.replace_error("не число"),
)
use valid <- result.try(validate(n))
Ok(int.to_string(valid * 2))
}
fn validate(n: Int) -> Result(Int, String) {
case n > 0 {
True -> Ok(n)
False -> Error("число должно быть положительным")
}
}
use + result.try позволяет выстраивать цепочки зависимых операций в плоском линейном стиле: каждая строка use x <- result.try(...) извлекает значение из Ok или немедленно возвращает Error из всей функции.
Напомним, как use работает: use x <- f(arg) — это f(arg, fn(x) { ... }), где ... — весь остаток блока. При Error цепочка result.try останавливается и сразу возвращает ошибку.
Пример: парсинг и валидация
pub type User {
User(name: String, age: Int, email: String)
}
pub fn parse_user(
name: String,
age_str: String,
email: String,
) -> Result(User, String) {
use age <- result.try(
int.parse(age_str)
|> result.replace_error("возраст должен быть числом"),
)
use _ <- result.try(case age >= 0 && age <= 150 {
True -> Ok(Nil)
False -> Error("возраст должен быть от 0 до 150")
})
use _ <- result.try(case email {
"" -> Error("email не может быть пустым")
_ -> Ok(Nil)
})
Ok(User(name:, age:, email:))
}
Функция parse_user демонстрирует реальный сценарий использования use + result.try: парсинг строки в число, две независимые проверки значений и итоговое построение типа — всё в читаемой плоской форме без вложенности.
panic
panic немедленно завершает процесс с ошибкой:
pub fn divide(a: Int, b: Int) -> Int {
case b {
0 -> panic as "деление на ноль"
_ -> a / b
}
}
Синтаксис:
panic— завершает с сообщением по умолчаниюpanic as "сообщение"— с пользовательским сообщением
Когда использовать panic?
panic сигнализирует: «это баг, такого не должно было произойти». Используйте его только когда:
- Нарушен инвариант, который должен был поддерживаться вызывающим кодом
- Программа оказалась в «невозможном» состоянии
- Вы пишете прототип и хотите отложить обработку ошибки
BEAM и «let it crash»
На BEAM panic не так страшен, как в других языках. Каждый процесс BEAM изолирован — если один процесс крашится, остальные продолжают работать. Супервизор (глава 8) автоматически перезапустит упавший процесс.
Это философия Erlang — «let it crash»: не пытайтесь обработать каждую мыслимую ошибку, позвольте процессу упасть и перезапуститься в чистом состоянии.
Но это не означает «используйте panic вместо Result»! Result — для ожидаемых ошибок (валидация, парсинг). panic — для багов и невозможных состояний.
let assert
let assert — частичный pattern matching, который вызывает panic при несовпадении:
let assert Ok(value) = might_fail()
// Если might_fail() вернёт Error — процесс упадёт
Это краткая форма:
let value = case might_fail() {
Ok(v) -> v
Error(_) -> panic as "unexpected error"
}
let assert — синтаксический сахар для частичного сопоставления с образцом: он извлекает значение из единственного ожидаемого варианта и аварийно завершает процесс, если реальный вариант не совпадает.
Примеры использования
// Извлечение первого элемента (мы ЗНАЕМ, что список непуст)
let assert [first, ..rest] = non_empty_list
// Деструктуризация Result (мы ЗНАЕМ, что операция успешна)
let assert Ok(config) = load_config()
// Работа с кортежем
let assert #(x, y, _) = get_coordinates()
Типичные применения let assert: извлечение первого элемента из заведомо непустого списка, деструктуризация заведомо успешного Result и разбор кортежа с игнорированием ненужных полей.
let assert с as
Можно указать сообщение об ошибке:
let assert Ok(user) = find_user(id) as "пользователь должен существовать"
Клауза as после let assert задаёт пользовательское сообщение, которое будет выведено при аварийном завершении — это делает диагностику ошибок в логах значительно понятнее.
Когда использовать let assert?
- В тестах — для краткости
- В инициализации — загрузка конфигурации, которая обязана быть корректной
- Когда контекст гарантирует успех — например, после
list.filterвы знаете, что элементы удовлетворяют предикату
Не используйте let assert для обработки пользовательского ввода или данных из внешних источников — для этого есть Result.
Railway-Oriented Programming
Railway-Oriented Programming (ROP) — метафора для работы с цепочками Result. Представьте двухколейную железную дорогу:
- Верхняя колея (Ok) — данные проходят через трансформации
- Нижняя колея (Error) — ошибка «проваливается» вниз и пропускает все дальнейшие шаги
Input → [Parse] → [Validate] → [Transform] → Output
Ok ───✓──────────✓────────────✓──────────→ Ok(result)
Err ──✗─→─→─→─→─→─→─→─→─→─→─→─→─→─→─→──→ Error(err)
Каждый result.try — это «стрелка», которая переключает поезд на нижнюю колею при ошибке:
pub fn process_order(raw: String) -> Result(Order, String) {
use data <- result.try(parse_json(raw))
use order <- result.try(decode_order(data))
use validated <- result.try(validate_order(order))
use priced <- result.try(calculate_price(validated))
Ok(priced)
}
Если любой шаг возвращает Error, все последующие шаги пропускаются и ошибка возвращается сразу. Это позволяет писать линейный код без вложенных case.
Преобразование ошибок в цепочке
Разные функции в цепочке могут возвращать разные типы ошибок. Используйте result.map_error для унификации:
pub type ProcessError {
ParseError(String)
ValidationError(String)
PriceError(String)
}
pub fn process_order(raw: String) -> Result(Order, ProcessError) {
use data <- result.try(
parse_json(raw)
|> result.map_error(ParseError),
)
use validated <- result.try(
validate_order(data)
|> result.map_error(ValidationError),
)
use priced <- result.try(
calculate_price(validated)
|> result.map_error(PriceError),
)
Ok(priced)
}
При наличии разнотипных ошибок в цепочке result.map_error приводит каждую к общему типу-сумме ProcessError, что позволяет всей функции иметь единый тип возврата Result(Order, ProcessError).
Накопление ошибок
ROP останавливается на первой ошибке. Но иногда нужно собрать все ошибки — например, при валидации формы пользователь хочет увидеть все проблемы сразу.
result.partition
result.partition разделяет список результатов на успешные и ошибочные:
let results = [Ok(1), Error("a"), Ok(3), Error("b")]
let #(successes, errors) = result.partition(results)
// successes = [1, 3]
// errors = ["a", "b"]
result.partition обрабатывает весь список целиком и разделяет результаты на два списка: успешные значения и ошибки — в отличие от result.all, который останавливается на первой ошибке.
Паттерн: валидация с накоплением
import gleam/string
pub type FormError {
NameTooShort
EmailInvalid
AgeTooYoung
AgeTooOld
}
fn validate_name(name: String) -> Result(Nil, FormError) {
case string.length(name) >= 2 {
True -> Ok(Nil)
False -> Error(NameTooShort)
}
}
fn validate_email(email: String) -> Result(Nil, FormError) {
case string.contains(email, "@") {
True -> Ok(Nil)
False -> Error(EmailInvalid)
}
}
fn validate_age(age: Int) -> Result(Nil, FormError) {
case age {
a if a < 18 -> Error(AgeTooYoung)
a if a > 150 -> Error(AgeTooOld)
_ -> Ok(Nil)
}
}
pub fn validate_form(
name: String,
email: String,
age: Int,
) -> Result(#(String, String, Int), List(FormError)) {
let validations = [
validate_name(name),
validate_email(email),
validate_age(age),
]
let #(_, errors) = result.partition(validations)
case errors {
[] -> Ok(#(name, email, age))
errs -> Error(errs)
}
}
validate_form("A", "invalid", 10)
// Error([NameTooShort, EmailInvalid, AgeTooYoung])
Ключевая идея: мы запускаем все валидации независимо друг от друга, а затем собираем ошибки в список. Пользователь видит полную картину.
Сделайте невозможные состояния невыразимыми
Вместо проверки данных на каждом шаге можно спроектировать типы так, чтобы некорректные данные невозможно было создать. Это принцип «Make Illegal States Unrepresentable» (MISU).
Проблема: «сырые» типы
pub type User {
User(name: String, email: String, age: Int)
}
// Любой код может создать невалидного пользователя
let bad_user = User(name: "", email: "not-an-email", age: -5)
Когда поля типа представлены примитивами вроде String и Int, ничто в системе типов не мешает создать семантически некорректное значение: пустое имя, невалидный email или отрицательный возраст компилятор пропустит без предупреждений.
Решение: типы-обёртки
Вместо String для email создайте отдельный тип, который можно получить только через валидацию:
// Email нельзя создать напрямую — только через parse_email
pub opaque type Email {
Email(String)
}
pub fn parse_email(s: String) -> Result(Email, String) {
case string.contains(s, "@") {
True -> Ok(Email(s))
False -> Error("некорректный email")
}
}
Подробно мы разберём opaque type и smart constructors в главе 7. Пока запомните идею: если данные проходят через валидацию при создании, дальнейший код может доверять им без перепроверки.
Проект: валидация формы регистрации
Соберём изученные концепции в проекте. Представим форму регистрации пользователя:
import gleam/int
import gleam/list
import gleam/result
import gleam/string
pub type RegistrationError {
NameTooShort
NameTooLong
EmailMissingAt
PasswordTooShort
PasswordNoDigit
AgeTooYoung
AgeTooOld
}
pub type Registration {
Registration(
name: String,
email: String,
password: String,
age: Int,
)
}
fn validate_name(name: String) -> List(RegistrationError) {
let errors = []
let errors = case string.length(name) < 2 {
True -> [NameTooShort, ..errors]
False -> errors
}
let errors = case string.length(name) > 50 {
True -> [NameTooLong, ..errors]
False -> errors
}
errors
}
fn validate_email(email: String) -> List(RegistrationError) {
case string.contains(email, "@") {
True -> []
False -> [EmailMissingAt]
}
}
fn has_digit(s: String) -> Bool {
s
|> string.to_graphemes
|> list.any(fn(c) {
case int.parse(c) {
Ok(_) -> True
Error(_) -> False
}
})
}
fn validate_password(password: String) -> List(RegistrationError) {
let errors = []
let errors = case string.length(password) < 8 {
True -> [PasswordTooShort, ..errors]
False -> errors
}
let errors = case has_digit(password) {
True -> errors
False -> [PasswordNoDigit, ..errors]
}
errors
}
fn validate_age(age: Int) -> List(RegistrationError) {
case age {
a if a < 18 -> [AgeTooYoung]
a if a > 150 -> [AgeTooOld]
_ -> []
}
}
pub fn register(
name: String,
email: String,
password: String,
age: Int,
) -> Result(Registration, List(RegistrationError)) {
let errors =
list.flatten([
validate_name(name),
validate_email(email),
validate_password(password),
validate_age(age),
])
case errors {
[] -> Ok(Registration(name:, email:, password:, age:))
errs -> Error(errs)
}
}
Использование:
register("A", "bad", "short", 10)
// Error([NameTooShort, EmailMissingAt, PasswordTooShort, PasswordNoDigit, AgeTooYoung])
register("Алиса", "alice@example.com", "password123", 25)
// Ok(Registration("Алиса", "alice@example.com", "password123", 25))
Все ошибки собираются одним списком — пользователь видит полную картину и может исправить всё за один раз.
Упражнения
Решения пишите в файле exercises/chapter05/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter05
gleam test
Упражнения 1-3 закрепляют рекурсию и безопасную обработку отсутствующих значений. Упражнения 4-7 шаг за шагом строят систему валидации из проекта главы — от одного поля до полной формы с накоплением ошибок.
1. list_length (Лёгкое)
Вычислите длину списка через рекурсию (не используйте list.length).
pub fn list_length(xs: List(a)) -> Int
Примеры:
list_length([1, 2, 3, 4, 5]) == 5
list_length([]) == 0
Подсказка: используйте хвостовую рекурсию с аккумулятором. Базовый случай — пустой список.
2. list_reverse (Лёгкое)
Разверните список через хвостовую рекурсию (не используйте list.reverse).
pub fn list_reverse(xs: List(a)) -> List(a)
Примеры:
list_reverse([1, 2, 3]) == [3, 2, 1]
Подсказка: используйте аккумулятор-список, добавляя каждый элемент в его начало.
3. safe_head (Лёгкое)
Безопасно извлеките первый элемент списка. Для пустого списка верните None. Это первое знакомство с обработкой «возможно отсутствующего» значения — мост к Result в следующих упражнениях.
pub fn safe_head(xs: List(a)) -> Option(a)
Примеры:
safe_head([1, 2, 3]) == Some(1)
safe_head([]) == None
Примеры показывают безопасное извлечение первого элемента списка: для непустого списка возвращается Some с элементом, а для пустого — None.
4. validate_age (Среднее)
Первый шаг к форме регистрации: реализуйте валидатор возраста. Возраст корректен от 0 до 150 (включительно). Верните Ok(age) при успехе, Error с описанием проблемы — при ошибке.
pub fn validate_age(age: Int) -> Result(Int, String)
Примеры:
validate_age(25) == Ok(25)
validate_age(-1) == Error("возраст не может быть отрицательным")
validate_age(200) == Error("возраст слишком большой")
Примеры показывают, что функция возвращает Ok для корректного возраста и содержательные сообщения об ошибке для отрицательных значений и значений, превышающих максимально допустимое.
5. validate_password (Среднее)
Продолжаем строить валидаторы. Пароль должен быть не менее 8 символов и содержать хотя бы одну цифру. Используйте use + result.try для цепочки проверок — это паттерн ROP из раздела выше.
pub fn validate_password(password: String) -> Result(String, String)
Примеры:
validate_password("pass1234") == Ok("pass1234")
validate_password("short1") == Error("пароль должен быть не менее 8 символов")
validate_password("longpassword") == Error("пароль должен содержать хотя бы одну цифру")
validate_password("12345678") == Ok("12345678")
Подсказка: вам понадобится вспомогательная функция has_digit(s: String) -> Bool. Разберите строку на графемы через string.to_graphemes и проверьте каждый через int.parse. Обратите внимание: в отличие от validate_age, здесь две проверки — их нужно связать через use + result.try.
6. parse_and_validate (Среднее)
Представьте, что возраст приходит строкой из формы. Реализуйте полную ROP-цепочку: распарсьте строку в число, проверьте, что оно больше 0 и меньше 1000.
pub fn parse_and_validate(input: String) -> Result(Int, String)
Примеры:
parse_and_validate("42") == Ok(42)
parse_and_validate("abc") == Error("не удалось распознать число")
parse_and_validate("0") == Error("число должно быть больше 0")
parse_and_validate("-5") == Error("число должно быть больше 0")
parse_and_validate("1000") == Error("число должно быть меньше 1000")
parse_and_validate("999") == Ok(999)
Подсказка: используйте int.parse с result.replace_error, затем проверки через result.try. Это расширение паттерна из упражнения 5 — теперь в начале цепочки стоит парсинг.
7. validate_form (Сложное)
Финальное упражнение: соберите валидаторы в единую функцию с накоплением ошибок. В отличие от ROP-цепочки (упражнения 5-6), которая останавливается на первой ошибке, здесь нужно запустить все проверки и вернуть все найденные ошибки — как в проекте register.
Проверьте:
- Имя: длина ≥ 2
- Email: содержит
@ - Возраст: от 18 до 150
pub type FormError {
NameTooShort
EmailInvalid
AgeTooYoung
AgeTooOld
}
pub fn validate_form(
name: String,
email: String,
age: Int,
) -> Result(#(String, String, Int), List(FormError))
Примеры:
validate_form("Алиса", "alice@mail.com", 25) == Ok(#("Алиса", "alice@mail.com", 25))
validate_form("A", "bad", 10) == Error([NameTooShort, EmailInvalid, AgeTooYoung])
validate_form("Боб", "bob@mail.com", 200) == Error([AgeTooOld])
Подсказка: запустите каждую валидацию отдельно (как в проекте главы), соберите ошибки в список через result.partition.
Заключение
В этой главе мы изучили:
- Рекурсия — основной способ итерации, pattern matching на списках
- Хвостовая рекурсия — оптимизация через аккумуляторы; на современном BEAM разница с body-рекурсией минимальна, но обязательна для бесконечных циклов
- Свёртки —
fold,reduce,try_foldкак обобщение рекурсии - Ошибки как значения — философия Gleam: явные типы вместо исключений
- Result и Option — два основных типа для обработки ошибок и отсутствия значений
- use + result.try — идиоматические цепочки операций
- panic и let assert — для багов и невозможных состояний
- ROP — композиция через Result, «железнодорожная» метафора
- Накопление ошибок — сбор всех проблем вместо остановки на первой
- MISU — проектирование типов, делающих невозможные состояния невыразимыми
В следующей главе мы подробно изучим строки, битовые массивы и оставшиеся модули стандартной библиотеки.
Строки, битовые массивы и стандартная библиотека
«Строки — не байты. Байты — не строки.» — Эрланговская мудрость
- Цели главы
- Строки в Gleam
- gleam/string — полный обзор
- String builder (gleam/string_tree)
- Битовые массивы
- Pattern matching на bit arrays
- Bytes builder (gleam/bytes_tree)
- Регулярные выражения (gleam/regexp)
- URI (gleam/uri)
- gleam/pair
- Проект: HTML builder
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Детально изучим строки в Gleam: UTF-8, графемы, кодпоинты
- Разберём модуль
gleam/string— полный обзор функций - Познакомимся с
string_treeдля эффективной конкатенации - Изучим битовые массивы (bit arrays) и паттерн-матчинг на них
- Освоим
gleam/regexpдля работы с регулярными выражениями - Разберём
gleam/uriдля работы с URL - Познакомимся с
gleam/pair— утилитами для кортежей
Строки в Gleam
Строки в Gleam — это иммутабельные последовательности символов в кодировке UTF-8. На BEAM-таргете они реализованы как Erlang-бинарники (binary), на JavaScript — как JS-строки.
UTF-8 и графемы
UTF-8 — кодировка переменной длины: символ может занимать от 1 до 4 байтов. Это создаёт важное различие:
import gleam/string
// Длина в графемах (пользовательских символах)
string.length("hello") // 5
string.length("привет") // 6
// Размер в байтах
string.byte_size("hello") // 5 — ASCII: 1 байт на символ
string.byte_size("привет") // 12 — кириллица: 2 байта на символ
Графема (grapheme) — то, что пользователь воспринимает как один символ. Функция string.length считает графемы, а не байты. Это правильное поведение для большинства задач.
Кодпоинты
Кодпоинт (codepoint) — числовой код символа в Unicode:
// Строка → список кодпоинтов
let cps = string.to_utf_codepoints("Abc")
// [UtfCodepoint(65), UtfCodepoint(98), UtfCodepoint(99)]
// Кодпоинт → число
let assert [cp, ..] = cps
string.utf_codepoint_to_int(cp) // 65 — код буквы 'A'
// Число → кодпоинт
let assert Ok(cp) = string.utf_codepoint(97) // 97 = 'a'
string.from_utf_codepoints([cp]) // "a"
Кодпоинты полезны при работе с символами на уровне Unicode — например, для шифров или транслитерации.
gleam/string — полный обзор
Модуль gleam/string содержит около 40 функций. Разберём их по группам.
Базовые
string.length("hello") // 5
string.is_empty("") // True
string.is_empty("x") // False
string.reverse("hello") // "olleh"
string.compare("a", "b") // order.Lt
string.byte_size("привет") // 12
Базовые функции охватывают наиболее частые операции: проверку длины и пустоты строки, разворот символов и лексикографическое сравнение через order.Lt/order.Eq/order.Gt.
Регистр
string.lowercase("HELLO") // "hello"
string.uppercase("hello") // "HELLO"
string.capitalise("hello") // "Hello"
capitalise переводит первую букву в верхний регистр, остальные — в нижний.
Поиск
string.contains("hello world", "world") // True
string.starts_with("hello", "hel") // True
string.ends_with("file.gleam", ".gleam") // True
Функции поиска возвращают Bool и позволяют быстро проверить вхождение подстроки без создания лишних промежуточных значений.
Разбиение
// split — по разделителю
string.split("a,b,c", ",") // ["a", "b", "c"]
// split_once — только первое вхождение
string.split_once("a:b:c", ":") // Ok(#("a", "b:c"))
// to_graphemes — в список графем
string.to_graphemes("hello") // ["h", "e", "l", "l", "o"]
// pop_grapheme — отделить первый символ
string.pop_grapheme("hello") // Ok(#("h", "ello"))
string.pop_grapheme("") // Error(Nil)
Функции разбиения позволяют разложить строку на части: split и split_once делят по разделителю, а to_graphemes и pop_grapheme работают с отдельными символами.
Сборка
// append — конкатенация двух строк
string.append("hello", " world") // "hello world"
// concat — конкатенация списка строк
string.concat(["hello", " ", "world"]) // "hello world"
// join — конкатенация с разделителем
string.join(["a", "b", "c"], ", ") // "a, b, c"
// repeat — повторение строки
string.repeat("ha", 3) // "hahaha"
Оператор <> тоже конкатенирует строки: "hello" <> " world".
Обрезка
// trim — убрать пробелы с обоих сторон
string.trim(" hello ") // "hello"
string.trim_start(" hello ") // "hello "
string.trim_end(" hello ") // " hello"
// pad — дополнить до заданной длины
string.pad_start("42", 5, "0") // "00042"
string.pad_end("hi", 10, ".") // "hi........"
Функции обрезки убирают лишние пробелы с одного или обоих концов строки, а pad_start/pad_end дополняют строку до нужной длины заданным символом.
Слайсы
// slice — подстрока по позиции и длине
string.slice("hello world", 6, 5) // "world"
// crop — подстрока от первого вхождения до конца
string.crop("hello world", "world") // "world"
// drop_start / drop_end — отбросить символы
string.drop_start("hello", 2) // "llo"
string.drop_end("hello", 2) // "hel"
// first / last — первый/последний символ
string.first("hello") // Ok("h")
string.last("hello") // Ok("o")
string.first("") // Error(Nil)
Функции срезов позволяют извлекать подстроки по позиции и длине (slice), от первого вхождения до конца (crop), а также получать или отбрасывать крайние символы.
Замена
string.replace("hello world", "world", "gleam")
// "hello gleam"
// Заменяет ВСЕ вхождения
string.replace("abcabc", "a", "x")
// "xbcxbc"
string.replace заменяет все вхождения подстроки в строке — эквивалент глобального поиска и замены без регулярных выражений.
Утилиты
// inspect — строковое представление любого значения
string.inspect(42) // "42"
string.inspect([1, 2, 3]) // "[1, 2, 3]"
string.inspect(Ok("hi")) // "Ok(\"hi\")"
// to_option — пустая строка → None
string.to_option("") // None
string.to_option("hello") // Some("hello")
string.inspect полезна при отладке — она превращает любое значение Gleam в читаемую строку. string.to_option удобна для преобразования пустых строк в None при работе с опциональными значениями.
String builder (gleam/string_tree)
Конкатенация строк через <> создаёт копию на каждом шаге — это O(n) для каждой операции. Для сборки строк из множества частей используйте string_tree:
import gleam/string_tree
let tree =
string_tree.new()
|> string_tree.append("Hello")
|> string_tree.append(", ")
|> string_tree.append("world!")
|> string_tree.to_string
// "Hello, world!"
На BEAM string_tree реализован как IO-лист (iolist) — дерево строк, которое «разворачивается» в плоскую строку только при необходимости. Это O(1) для каждого append.
Когда использовать string_tree?
- Сборка HTML/JSON/шаблонов из множества частей
- Конкатенация в цикле/свёртке
- Генерация больших текстов
Для простой конкатенации 2-3 строк <> достаточно.
Основные функции
// Создание
string_tree.new() // пустой
string_tree.from_string("hello") // из строки
string_tree.from_strings(["a", "b"]) // из списка строк
// Добавление
string_tree.append(tree, " suffix") // добавить строку
string_tree.prepend(tree, "prefix ") // добавить в начало
string_tree.append_tree(tree1, tree2) // объединить деревья
// Преобразование
string_tree.to_string(tree) // → String
string_tree.byte_size(tree) // размер в байтах
string_tree.is_empty(tree) // пусто ли
// Утилиты
string_tree.join([tree1, tree2], ", ") // объединить с разделителем
string_tree.reverse(tree) // развернуть
string_tree предоставляет те же операции, что и gleam/string, но без промежуточных копий: каждый append и prepend работает за O(1), а итоговая строка собирается один раз при вызове to_string.
Битовые массивы
Битовые массивы (bit arrays) — последовательности байтов. Они используются для работы с бинарными данными: файлами, сетевыми протоколами, кодеками.
Создание
// Литерал
let bytes = <<1, 2, 3>> // три байта
// Из строки
let text = <<"hello":utf8>> // строка как UTF-8 байты
// Числа с указанием размера
let big = <<1024:16>> // 16-битное число (2 байта)
let small = <<42:8>> // 8-битное число (1 байт)
// Конкатенация
let combined = <<bytes:bits, text:bits>>
Литерал <<...>> позволяет задавать байты напрямую, встраивать строки как UTF-8, указывать разрядность чисел и конкатенировать несколько битовых массивов в один.
gleam/bit_array — API
import gleam/bit_array
// Конвертация строка ↔ байты
bit_array.from_string("hello") // <<"hello":utf8>>
bit_array.to_string(<<"hello":utf8>>) // Ok("hello")
// Размер
bit_array.byte_size(<<1, 2, 3>>) // 3
// Срез
bit_array.slice(<<1, 2, 3, 4, 5>>, 1, 3) // Ok(<<2, 3, 4>>)
// Конкатенация
bit_array.concat([<<1, 2>>, <<3, 4>>]) // <<1, 2, 3, 4>>
// Проверка UTF-8
bit_array.is_utf8(<<"hello":utf8>>) // True
bit_array.is_utf8(<<255, 254>>) // False
// Base64
bit_array.base64_encode(<<"hello":utf8>>, True)
// "aGVsbG8="
bit_array.base64_decode("aGVsbG8=")
// Ok(<<"hello":utf8>>)
// Base16 (hex)
bit_array.base16_encode(<<255, 0, 127>>)
// "FF007F"
bit_array.base16_decode("FF007F")
// Ok(<<255, 0, 127>>)
// Inspect — для отладки
bit_array.inspect(<<72, 101>>) // "<<72, 101>>"
Модуль gleam/bit_array предоставляет инструменты для преобразования бинарных данных: конвертацию строк, срезы, конкатенацию, кодирование в base64 и base16, а также проверку валидности UTF-8.
Pattern matching на bit arrays
Одна из самых мощных возможностей Gleam (унаследованная от Erlang) — паттерн-матчинг на битовых массивах:
import gleam/int
pub fn classify_ip(data: BitArray) -> String {
case data {
// IPv4: 4 байта
<<a:8, b:8, c:8, d:8>> -> {
"IPv4: "
<> int.to_string(a) <> "."
<> int.to_string(b) <> "."
<> int.to_string(c) <> "."
<> int.to_string(d)
}
_ -> "unknown"
}
}
classify_ip(<<192, 168, 1, 1>>)
// "IPv4: 192.168.1.1"
Паттерн-матчинг на битовых массивах позволяет деструктурировать бинарные данные прямо в case-выражении, указывая точный размер каждого сегмента в битах — без ручного смещения и побитовых операций.
Размеры сегментов
case data {
// Фиксированный размер в битах
<<header:16, payload:bytes>> -> ...
// Магические байты
<<0x89, "PNG":utf8, rest:bytes>> -> "PNG file"
// Конкретные значения байтов
<<0xCA, 0xFE, rest:bytes>> -> ...
}
Доступные спецификаторы сегментов:
:8,:16,:32,:64— размер в битах:bytes— остаток как байты:bits— остаток как биты:utf8— UTF-8 строка:float— 64-битное число с плавающей точкой
Пример: простой парсер бинарного формата
import gleam/bit_array
import gleam/result
pub type Packet {
Packet(version: Int, payload: BitArray)
}
pub fn parse_packet(data: BitArray) -> Result(Packet, Nil) {
case data {
<<version:8, len:16, payload:bytes>> if bit_array.byte_size(payload) >= len ->
Ok(Packet(version:, payload: bit_array.slice(payload, 0, len) |> result.unwrap(<<>>)))
_ -> Error(Nil)
}
}
Формат использует структуру TLV (Type-Length-Value):
- Первый байт (
version:8) — версия протокола - Следующие 2 байта (
len:16) — длина полезной нагрузки (big-endian, до 65535) - Остаток (
payload:bytes) — данные произвольной длины
Гард if bit_array.byte_size(payload) >= len защищает от обрезанных пакетов: если данных меньше, чем заявлено в заголовке, возвращаем Error(Nil). Затем bit_array.slice извлекает ровно len байтов — всё, что после, игнорируется (возможно, это следующий пакет).
Bytes builder (gleam/bytes_tree)
Аналог string_tree, но для байтов:
import gleam/bytes_tree
let tree =
bytes_tree.new()
|> bytes_tree.append(<<1, 2, 3>>)
|> bytes_tree.append(<<4, 5>>)
|> bytes_tree.to_bit_array
// <<1, 2, 3, 4, 5>>
bytes_tree используется при построении бинарных протоколов и HTTP-ответов (Wisp использует его для тел ответов).
Основные функции
bytes_tree.new() // пустой
bytes_tree.from_bit_array(<<1, 2>>) // из BitArray
bytes_tree.from_string("hello") // из строки (UTF-8)
bytes_tree.from_string_tree(st) // из StringTree
bytes_tree.append(tree, <<3, 4>>) // добавить байты
bytes_tree.append_string(tree, "text") // добавить строку
bytes_tree.prepend(tree, <<0>>) // добавить в начало
bytes_tree.concat([tree1, tree2]) // объединить список
bytes_tree.to_bit_array(tree) // → BitArray
bytes_tree.byte_size(tree) // размер в байтах
bytes_tree предлагает тот же набор операций, что и string_tree, но работает с BitArray вместо строк — удобно при сборке бинарных пакетов или HTTP-тел из нескольких фрагментов.
Регулярные выражения (gleam/regexp)
Модуль gleam/regexp предоставляет поддержку регулярных выражений:
import gleam/regexp
// Компиляция и проверка
let assert Ok(re) = regexp.from_string("^[a-z]+$")
regexp.check(re, "hello") // True
regexp.check(re, "Hello") // False
regexp.check(re, "123") // False
Функция regexp.check проверяет, совпадает ли строка с регулярным выражением: сначала регулярное выражение компилируется один раз через from_string, а затем многократно применяется без повторной компиляции.
Компиляция
// from_string — простая компиляция
regexp.from_string("[0-9]+") // Ok(Regexp) или Error(CompileError)
// compile — с опциями
regexp.compile("[a-z]+", regexp.Options(
case_insensitive: True,
multi_line: False,
))
// Ok(Regexp) — регистронезависимый поиск
from_string компилирует регулярное выражение и возвращает Result — ошибка компиляции (неверный синтаксис) обрабатывается явно, а compile позволяет задать дополнительные флаги, такие как регистронезависимость.
Поиск совпадений
let assert Ok(re) = regexp.from_string("(\\w+)@(\\w+)")
// scan — найти все совпадения
regexp.scan(re, "alice@example bob@test")
// [
// Match(content: "alice@example", submatches: [Some("alice"), Some("example")]),
// Match(content: "bob@test", submatches: [Some("bob"), Some("test")]),
// ]
Тип Match содержит:
content— полное совпадениеsubmatches— список захваченных групп (в скобках), каждаяOption(String)
Разбиение и замена
let assert Ok(re) = regexp.from_string("[,;\\s]+")
// split — разбить строку по регулярному выражению
regexp.split(re, "a, b; c d")
// ["a", "b", "c", "d"]
// replace — заменить совпадения
let assert Ok(digits) = regexp.from_string("[0-9]")
regexp.replace(digits, "h3ll0 w0rld", "*")
// "h*ll* w*rld"
regexp.split разбивает строку по регулярному выражению (удобно для разделителей переменной длины), а regexp.replace заменяет все совпадения на указанную строку.
Практический пример: парсинг логов
pub type LogEntry {
LogEntry(level: String, message: String)
}
pub fn parse_log(line: String) -> Result(LogEntry, Nil) {
let assert Ok(re) = regexp.from_string("\\[(\\w+)\\] (.+)")
case regexp.scan(re, line) {
[regexp.Match(_, [Some(level), Some(message)])] ->
Ok(LogEntry(level:, message:))
_ -> Error(Nil)
}
}
parse_log("[ERROR] connection timeout")
// Ok(LogEntry(level: "ERROR", message: "connection timeout"))
Разберём по шагам:
- Регулярное выражение
\[(\w+)\] (.+)содержит две группы захвата (в скобках):(\w+)— одно или более «словесных» символов (уровень лога), и(.+)— всё после пробела (сообщение) regexp.scanвозвращает списокMatch— мы ожидаем ровно одно совпадение- Паттерн
[regexp.Match(_, [Some(level), Some(message)])]деструктурирует результат:_— полное совпадение (нам не нужно),Some(level)иSome(message)— захваченные группы - Если строка не соответствует формату — попадаем в
_ -> Error(Nil)
URI (gleam/uri)
Модуль gleam/uri для работы с URL и URI:
import gleam/uri
// Парсинг
let assert Ok(u) = uri.parse("https://example.com:8080/path?key=value#section")
u.scheme // Some("https")
u.host // Some("example.com")
u.port // Some(8080)
u.path // "/path"
u.query // Some("key=value")
u.fragment // Some("section")
// Сборка обратно в строку
uri.to_string(u)
// "https://example.com:8080/path?key=value#section"
uri.parse разбирает URL на составные части (схема, хост, порт, путь, параметры, фрагмент), возвращая Result, а uri.to_string собирает URI обратно в строку из этих полей.
Query-параметры
// Парсинг query string
uri.parse_query("name=Alice&age=30")
// Ok([#("name", "Alice"), #("age", "30")])
// Сборка query string
uri.query_to_string([#("search", "gleam lang"), #("page", "1")])
// "search=gleam+lang&page=1"
uri.parse_query разбирает строку параметров в список пар ключ-значение, а uri.query_to_string выполняет обратное преобразование с автоматическим percent-кодированием пробелов и спецсимволов.
Кодирование
// Percent-encoding
uri.percent_encode("hello world!") // "hello%20world%21"
uri.percent_decode("hello%20world") // Ok("hello world")
// Сегменты пути
uri.path_segments("/api/v1/users")
// ["api", "v1", "users"]
uri.percent_encode и uri.percent_decode кодируют/декодируют спецсимволы в компонентах URI, а uri.path_segments разбивает путь на отдельные сегменты — удобно для маршрутизации.
Origin и merge
// origin — схема + хост + порт
let assert Ok(u) = uri.parse("https://example.com:8080/path")
uri.origin(u) // Ok("https://example.com:8080")
// merge — разрешение относительного URI
let assert Ok(base) = uri.parse("https://example.com/a/b")
let assert Ok(relative) = uri.parse("../c")
uri.merge(base, relative)
// Ok(Uri(... path: "/c" ...))
uri.origin извлекает схему, хост и порт из URI — именно эту часть проверяют при CORS-запросах. uri.merge разрешает относительный URI относительно базового по правилам RFC 3986.
gleam/pair
Утилиты для работы с кортежами из двух элементов:
import gleam/pair
pair.first(#("hello", 42)) // "hello"
pair.second(#("hello", 42)) // 42
pair.swap(#("a", 1)) // #(1, "a")
pair.map_first(#("hello", 42), string.uppercase)
// #("HELLO", 42)
pair.map_second(#("hello", 42), fn(n) { n * 2 })
// #("hello", 84)
pair.new("key", "value") // #("key", "value")
pair удобен в пайплайнах, когда нужно трансформировать один элемент кортежа:
entries
|> list.map(pair.map_second(_, int.to_string))
Такой стиль позволяет избежать анонимных функций вида fn(pair) { #(pair.0, transform(pair.1)) } — функции pair.map_first и pair.map_second передаются в list.map напрямую.
Проект: HTML builder
Соберём изученные модули в проекте — мини-библиотека для генерации HTML. Это практичный пример: такие билдеры используются внутри веб-фреймворков (например, Wisp отдаёт string_tree как тело ответа).
import gleam/list
import gleam/string
import gleam/string_tree
/// Экранирование спецсимволов HTML.
/// Без этого строка вроде "<script>" станет рабочим тегом — это XSS-уязвимость.
pub fn escape_html(text: String) -> String {
text
|> string.replace("&", "&")
|> string.replace("<", "<")
|> string.replace(">", ">")
|> string.replace("\"", """)
|> string.replace("'", "'")
}
/// Рендер одного атрибута: ` key="value"` (с экранированием значения)
fn render_attr(attr: #(String, String)) -> String {
" " <> attr.0 <> "=\"" <> escape_html(attr.1) <> "\""
}
/// Создание HTML-тега с атрибутами и содержимым
pub fn tag(
name: String,
attrs: List(#(String, String)),
children: String,
) -> String {
let attrs_str =
attrs
|> list.map(render_attr)
|> string.concat
"<" <> name <> attrs_str <> ">" <> children <> "</" <> name <> ">"
}
/// Сборка нескольких элементов через string_tree — O(1) на каждый append
pub fn render(elements: List(String)) -> String {
elements
|> list.fold(string_tree.new(), fn(tree, el) {
string_tree.append(tree, el)
})
|> string_tree.to_string
}
Пример использования:
let page =
render([
tag("h1", [], "Привет, Gleam!"),
tag("p", [#("class", "intro")], "Это параграф."),
tag("a", [#("href", "https://gleam.run")], "Сайт Gleam"),
])
// "<h1>Привет, Gleam!</h1><p class=\"intro\">Это параграф.</p><a href=\"https://gleam.run\">Сайт Gleam</a>"
Проект демонстрирует:
string.replace— цепочка замен для экранированияlist.map+string.concat— сборка атрибутовstring_tree— эффективная конкатенация множества элементов- Безопасность: экранирование пользовательского ввода для защиты от XSS
В упражнениях вы расширите этот билдер: добавите списки, таблицы и автолинковку.
Упражнения
Все упражнения продолжают проект HTML builder — вы будете расширять его новыми возможностями.
Решения пишите в файле exercises/chapter06/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter06
gleam test
Каждое упражнение добавляет новую функцию в HTML-билдер. Начните с первого и двигайтесь по порядку.
1. escape_html (Среднее)
Реализуйте экранирование HTML-спецсимволов. Это основа безопасного HTML — без экранирования пользовательский ввод вроде <script> превращается в рабочий код (XSS-атака).
pub fn escape_html(text: String) -> String
Нужно заменить 5 символов:
&→&(важно заменить первым, иначе сломаете остальные замены)<→<>→>"→"'→'
Примеры:
escape_html("<script>alert('xss')</script>")
// "<script>alert('xss')</script>"
escape_html("Tom & Jerry")
// "Tom & Jerry"
Подсказка: цепочка вызовов string.replace.
2. tag (Среднее)
Создайте HTML-тег с именем, атрибутами и содержимым. Значения атрибутов должны быть экранированы через вашу функцию escape_html.
pub fn tag(
name: String,
attrs: List(#(String, String)),
children: String,
) -> String
Примеры:
tag("p", [], "Hello")
// "<p>Hello</p>"
tag("a", [#("href", "https://gleam.run")], "Gleam")
// "<a href=\"https://gleam.run\">Gleam</a>"
tag("div", [#("class", "box"), #("id", "main")], "content")
// "<div class=\"box\" id=\"main\">content</div>"
Подсказка: для каждого атрибута #(key, value) сформируйте строку key="value", затем соедините через string.concat.
3. build_list (Среднее)
Постройте HTML-список из элементов. Параметр ordered определяет тип: <ol> или <ul>.
pub fn build_list(items: List(String), ordered: Bool) -> String
Примеры:
build_list(["яблоко", "банан", "вишня"], False)
// "<ul><li>яблоко</li><li>банан</li><li>вишня</li></ul>"
build_list(["первый", "второй"], True)
// "<ol><li>первый</li><li>второй</li></ol>"
Подсказка: оберните каждый элемент в <li>...</li>, соедините, оберните в <ul> или <ol>. Можете использовать вашу функцию tag.
4. linkify (Средне-сложное)
Найдите URL в тексте с помощью регулярного выражения и оберните их в <a> теги.
pub fn linkify(text: String) -> String
Считайте URL-ом любую подстроку, начинающуюся с https:// или http://, за которой следуют один или более непробельных символов.
Примеры:
linkify("Смотрите https://gleam.run — отличный язык")
// "Смотрите <a href=\"https://gleam.run\">https://gleam.run</a> — отличный язык"
linkify("нет ссылок тут")
// "нет ссылок тут"
linkify("два: http://a.com и https://b.org конец")
// "два: <a href=\"http://a.com\">http://a.com</a> и <a href=\"https://b.org\">https://b.org</a> конец"
Подсказка: используйте regexp.scan с паттерном https?://\\S+, чтобы найти все URL. Затем пройдите по совпадениям и замените каждый URL на <a href="...">...</a> через string.replace.
5. build_table (Сложное)
Постройте HTML-таблицу из заголовков и строк данных. Используйте string_tree для эффективной сборки.
pub fn build_table(
headers: List(String),
rows: List(List(String)),
) -> String
Примеры:
build_table(
["Имя", "Возраст"],
[["Алиса", "30"], ["Боб", "25"]],
)
// "<table><thead><tr><th>Имя</th><th>Возраст</th></tr></thead><tbody><tr><td>Алиса</td><td>30</td></tr><tr><td>Боб</td><td>25</td></tr></tbody></table>"
Подсказка: разбейте задачу на части — сначала функция для одной строки (<tr><td>...</td></tr>), затем соберите <thead> из заголовков (с <th>) и <tbody> из строк данных. Используйте string_tree и list.fold для сборки.
Заключение
В этой главе мы изучили:
- Строки — UTF-8, графемы, кодпоинты и богатый API
gleam/string - String builder (
gleam/string_tree) — эффективная конкатенация через IO-списки - Битовые массивы — бинарные данные,
<<>>синтаксис, base64/base16 - Pattern matching на bit arrays — мощный инструмент для парсинга бинарных протоколов
- Bytes builder (
gleam/bytes_tree) — эффективная сборка байтов - Regex (
gleam/regexp) — регулярные выражения для поиска и замены - URI (
gleam/uri) — парсинг и сборка URL - Pair (
gleam/pair) — утилиты для кортежей
В следующей главе мы перейдём к FFI, JSON и типобезопасному парсингу — научимся взаимодействовать с Erlang, обрабатывать JSON-данные и проектировать надёжные API через непрозрачные и фантомные типы.
Type Safety и Parse Don't Validate
«Parse, don't validate» — Алексис Кинг
- Цели главы
- Parse, Don't Validate
- Контрактное программирование через типы
- Фантомные типы
- gleam_json — кодирование и декодирование
- gleam/dynamic/decode — полный обзор
- Railway-Oriented Programming — практика
- Проект: PokeAPI клиент
- Упражнения
- 1. PokemonId — opaque type + smart constructor (Лёгкое)
- 2. pokemon_decoder — расширенный декодер (Среднее)
- 3. pokemon_to_json — кодирование в JSON (Среднее)
- 4. search_results_decoder — пагинированный список (Среднее)
- 5. DamageMultiplier — фантомные типы (Среднее-Сложное)
- 6. format_pokemon_card — CLI-вывод (Среднее)
- 7. build_pokedex_entry — ROP-цепочка (Сложное)
- Заключение
Цели главы
В этой главе мы:
- Поймём паттерн «Parse, Don't Validate»
- Изучим непрозрачные типы (opaque types) и smart constructors
- Разберём фантомные типы (phantom types) для типобезопасных контрактов
- Освоим
gleam_jsonдля кодирования и декодирования JSON - Детально изучим
gleam/dynamic/decode - Применим Railway-Oriented Programming для композиции парсинга и валидации
- Построим PokeAPI-клиент, объединив все концепции
Parse, Don't Validate
«Parse, Don't Validate» — паттерн проектирования, предложенный Алексис Кинг. Суть: вместо проверки данных с последующим использованием «сырых» типов, парсите неструктурированные данные в структурированные типы.
Проблема: валидация
// ❌ Плохо: проверяем, но тип остаётся String
pub fn send_email(to: String) -> Result(Nil, String) {
case string.contains(to, "@") {
True -> do_send(to)
False -> Error("invalid email")
}
}
// Ничто не мешает вызвать do_send с невалидным email
fn do_send(to: String) -> Result(Nil, String) { ... }
Проблема валидации: даже если мы проверили строку, тип String не несёт никакой информации о том, прошла ли она проверку — любой код может обойти валидацию и передать произвольную строку напрямую.
Решение: парсинг
// ✓ Хорошо: парсим String → Email, дальше работаем с Email
pub opaque type Email {
Email(String)
}
pub fn parse_email(s: String) -> Result(Email, String) {
case string.contains(s, "@") {
True -> Ok(Email(s))
False -> Error("некорректный email")
}
}
pub fn send_email(to: Email) -> Result(Nil, String) {
// Гарантированно валидный email!
do_send(email_to_string(to))
}
Разница: send_email принимает Email, а не String. Единственный способ получить Email — через parse_email, которая проверяет формат. Тип гарантирует валидность.
Контрактное программирование через типы
Непрозрачные типы (opaque types) реализуют контракты — предусловия, которые невозможно нарушить:
Непрозрачные типы (opaque types)
opaque type скрывает конструктор от внешнего кода. Значение можно создать только через функции модуля:
// В модуле positive.gleam
pub opaque type Positive {
Positive(Int)
}
pub fn new(n: Int) -> Result(Positive, String) {
case n > 0 {
True -> Ok(Positive(n))
False -> Error("число должно быть положительным")
}
}
pub fn value(p: Positive) -> Int {
let Positive(n) = p
n
}
pub fn add(a: Positive, b: Positive) -> Positive {
// Безопасно: сумма двух положительных всегда положительна
Positive(value(a) + value(b))
}
Внешний код не может создать Positive(-5) — только через new, который проверяет инвариант. Все функции внутри модуля могут доверять, что Positive содержит положительное число.
Smart constructors
Smart constructor — функция, создающая значение с проверкой:
pub opaque type Age {
Age(Int)
}
pub fn age(n: Int) -> Result(Age, String) {
case n {
_ if n < 0 -> Error("возраст не может быть отрицательным")
_ if n > 150 -> Error("возраст слишком большой")
_ -> Ok(Age(n))
}
}
pub fn age_value(a: Age) -> Int {
let Age(n) = a
n
}
Паттерн:
opaque typeскрывает конструктор- Smart constructor проверяет инварианты
- Аксессоры дают доступ к данным
- Все функции модуля доверяют инварианту
Когда использовать opaque types?
- Email, URL, телефонный номер — форматированные строки
- Положительные числа, проценты, возраст — числа с ограничениями
- Идентификаторы — UUID, OrderId, UserId
- Токены — JWT, API-ключи
Фантомные типы
Фантомный тип (phantom type) — параметр типа, который не используется в данных, но проверяется компилятором:
pub opaque type Currency(unit) {
Currency(amount: Int)
}
// Типы-метки (пустые, без значений)
pub type USD
pub type EUR
pub fn usd(amount: Int) -> Currency(USD) {
Currency(amount)
}
pub fn eur(amount: Int) -> Currency(EUR) {
Currency(amount)
}
pub fn add(a: Currency(unit), b: Currency(unit)) -> Currency(unit) {
Currency(a.amount + b.amount)
}
Обратите внимание: Currency(unit) содержит только Int, но параметр unit отличает доллары от евро на уровне типов:
let price = usd(100)
let tax = usd(20)
let total = add(price, tax) // ✓ Ok: Currency(USD) + Currency(USD)
let euros = eur(50)
// add(price, euros) // ✗ Ошибка компиляции!
// Expected Currency(USD), got Currency(EUR)
Компилятор не даст сложить доллары с евро — ошибка обнаруживается до запуска программы.
Ещё примеры фантомных типов
// Статус: проверенный vs непроверенный
pub type Verified
pub type Unverified
pub opaque type Document(status) {
Document(content: String)
}
pub fn create(content: String) -> Document(Unverified) {
Document(content:)
}
pub fn verify(doc: Document(Unverified)) -> Result(Document(Verified), String) {
// ... проверка ...
Ok(Document(content: doc.content))
}
pub fn publish(doc: Document(Verified)) -> Nil {
// Можно публиковать только проверенные документы!
...
}
Фантомный тип Document(status) кодирует состояние документа прямо в сигнатуре типа: publish принимает только Document(Verified), и компилятор не позволит передать непроверенный документ без явного вызова verify.
gleam_json — кодирование и декодирование
Библиотека gleam_json предоставляет функции для работы с JSON.
Кодирование (JSON → строка)
import gleam/json
// Примитивы
json.string("hello") // "hello"
json.int(42) // 42
json.float(3.14) // 3.14
json.bool(True) // true
json.null() // null
// Nullable — Some → значение, None → null
json.nullable(Some("hi"), json.string) // "hi"
json.nullable(None, json.string) // null
// Массивы
json.array([1, 2, 3], json.int) // [1, 2, 3]
json.preprocessed_array([json.int(1), json.string("hi")])
// [1, "hi"]
// Объекты
json.object([
#("name", json.string("Алиса")),
#("age", json.int(30)),
#("active", json.bool(True)),
])
// {"name":"Алиса","age":30,"active":true}
// Преобразование в строку
json.object([#("x", json.int(1))])
|> json.to_string
// "{\"x\":1}"
// С форматированием
json.object([#("x", json.int(1))])
|> json.to_string_tree
|> string_tree.to_string
Модуль gleam/json предоставляет строительные блоки для всех JSON-типов: примитивы конструируются отдельными функциями, объекты и массивы — через json.object и json.array, а итоговая строка получается вызовом json.to_string.
Кодирование пользовательских типов
pub type User {
User(name: String, age: Int, email: String)
}
pub fn user_to_json(user: User) -> json.Json {
json.object([
#("name", json.string(user.name)),
#("age", json.int(user.age)),
#("email", json.string(user.email)),
])
}
// Использование
User("Алиса", 30, "alice@example.com")
|> user_to_json
|> json.to_string
// {"name":"Алиса","age":30,"email":"alice@example.com"}
Для каждого пользовательского типа создаётся функция-кодировщик, которая отображает поля Gleam-структуры в пары ключ-значение JSON — такой подход легко масштабируется на вложенные типы.
Декодирование
Декодирование — преобразование JSON-строки обратно в Gleam-типы. Это двухшаговый процесс:
- Парсинг: строка → Dynamic (нетипизированные данные)
- Декодирование: Dynamic → типизированное значение
import gleam/dynamic/decode
import gleam/json
// Декодер для User
pub fn user_decoder() -> decode.Decoder(User) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use email <- decode.field("email", decode.string)
decode.success(User(name:, age:, email:))
}
// Парсинг JSON-строки
json.parse("{\"name\":\"Алиса\",\"age\":30,\"email\":\"a@b.com\"}", user_decoder())
// Ok(User("Алиса", 30, "a@b.com"))
json.parse("{\"name\":\"Алиса\"}", user_decoder())
// Error(...) — не хватает полей
json.parse объединяет парсинг строки и применение декодера в один шаг: если JSON некорректен или не соответствует структуре декодера, возвращается Error с описанием проблемы.
gleam/dynamic/decode — полный обзор
Модуль gleam/dynamic/decode предоставляет типобезопасные декодеры для работы с нетипизированными данными.
Примитивные декодеры
import gleam/dynamic/decode
decode.string // Decoder(String)
decode.int // Decoder(Int)
decode.float // Decoder(Float)
decode.bool // Decoder(Bool)
decode.bit_array // Decoder(BitArray)
decode.dynamic // Decoder(Dynamic) — пропускает как есть
Примитивные декодеры — это готовые значения типа Decoder(T), которые извлекают соответствующий тип из динамических данных и возвращают ошибку, если тип не совпадает.
Поля объектов
// field — обязательное поле
use name <- decode.field("name", decode.string)
// optional_field — может отсутствовать
use nickname <- decode.optional_field("nickname", None, decode.string)
// subfield — вложенное поле (путь)
use city <- decode.subfield(["address", "city"], decode.string)
// at — по индексу в массиве
use first <- decode.at([0], decode.string)
decode.field извлекает обязательное поле объекта, decode.optional_field возвращает None если поле отсутствует, а decode.subfield позволяет обращаться к глубоко вложенным полям по пути из ключей.
Коллекции
// list — массив
decode.list(decode.int) // Decoder(List(Int))
// dict — объект как словарь
decode.dict(decode.string, decode.int) // Decoder(Dict(String, Int))
// optional — nullable значение
decode.optional(decode.string) // Decoder(Option(String))
Декодеры коллекций оборачивают декодер элемента: decode.list применяет его к каждому элементу массива, decode.dict — к ключам и значениям объекта, decode.optional обрабатывает null как None.
Комбинаторы
// one_of — попробовать несколько декодеров
decode.one_of(decode.string, [
decode.int |> decode.map(int.to_string),
])
// Попробует string, если не получится — int → string
// then — зависимый декодер
use type_name <- decode.field("type", decode.string)
decode.then(fn() {
case type_name {
"circle" -> circle_decoder()
"rect" -> rect_decoder()
_ -> decode.failure(UnknownShape, "Shape")
}
})
// success — декодер, который всегда успешен
decode.success(42) // Decoder(Int) — всегда вернёт 42
// failure — декодер, который всегда неуспешен
decode.failure(MyError, "expected something else")
Комбинаторы позволяют строить гибкие декодеры: decode.one_of перебирает альтернативы по порядку, decode.then создаёт зависимые декодеры на основе уже декодированного значения, а decode.map трансформирует результат без изменения структуры.
Пример: вложенная структура
pub type Address {
Address(city: String, street: String)
}
pub type Person {
Person(name: String, age: Int, address: Address)
}
pub fn address_decoder() -> decode.Decoder(Address) {
use city <- decode.field("city", decode.string)
use street <- decode.field("street", decode.string)
decode.success(Address(city:, street:))
}
pub fn person_decoder() -> decode.Decoder(Person) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use address <- decode.field("address", address_decoder())
decode.success(Person(name:, age:, address:))
}
// Парсинг
let json_str = "{
\"name\": \"Алиса\",
\"age\": 30,
\"address\": {\"city\": \"Москва\", \"street\": \"Тверская\"}
}"
json.parse(json_str, person_decoder())
// Ok(Person("Алиса", 30, Address("Москва", "Тверская")))
Вложенные структуры декодируются путём передачи одного декодера в качестве аргумента другому: decode.field("address", address_decoder()) автоматически применит address_decoder к значению поля "address".
Рекурсивные декодеры
Для рекурсивных структур используйте decode.recursive:
pub type Tree {
Leaf(value: Int)
Node(left: Tree, right: Tree)
}
pub fn tree_decoder() -> decode.Decoder(Tree) {
decode.one_of(leaf_decoder(), [node_decoder()])
}
fn leaf_decoder() -> decode.Decoder(Tree) {
use value <- decode.field("value", decode.int)
decode.success(Leaf(value:))
}
fn node_decoder() -> decode.Decoder(Tree) {
use left <- decode.field("left", decode.recursive(tree_decoder))
use right <- decode.field("right", decode.recursive(tree_decoder))
decode.success(Node(left:, right:))
}
decode.recursive нужен, чтобы избежать бесконечной рекурсии при создании декодера — он откладывает вычисление до момента использования.
Railway-Oriented Programming — практика
Объединим всё в ROP-цепочку: сырые данные → парсинг → декодирование → валидация → доменный тип:
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/result
import gleam/string
pub opaque type ValidUser {
ValidUser(name: String, age: Int, email: String)
}
pub fn valid_user_name(u: ValidUser) -> String { u.name }
pub fn valid_user_age(u: ValidUser) -> Int { u.age }
pub fn valid_user_email(u: ValidUser) -> String { u.email }
type RawUser {
RawUser(name: String, age: Int, email: String)
}
fn raw_user_decoder() -> decode.Decoder(RawUser) {
use name <- decode.field("name", decode.string)
use age <- decode.field("age", decode.int)
use email <- decode.field("email", decode.string)
decode.success(RawUser(name:, age:, email:))
}
pub fn parse_valid_user(json_str: String) -> Result(ValidUser, String) {
// 1. Парсинг JSON
use raw <- result.try(
json.parse(json_str, raw_user_decoder())
|> result.map_error(fn(_) { "некорректный JSON" }),
)
// 2. Валидация имени
use _ <- result.try(case string.length(raw.name) >= 2 {
True -> Ok(Nil)
False -> Error("имя слишком короткое")
})
// 3. Валидация email
use _ <- result.try(case string.contains(raw.email, "@") {
True -> Ok(Nil)
False -> Error("некорректный email")
})
// 4. Валидация возраста
use _ <- result.try(case raw.age >= 0 && raw.age <= 150 {
True -> Ok(Nil)
False -> Error("некорректный возраст")
})
// 5. Создание доменного типа
Ok(ValidUser(name: raw.name, age: raw.age, email: raw.email))
}
Каждый шаг может вернуть ошибку, и цепочка result.try прервётся на первом Error. Результат — либо полностью валидный ValidUser, либо описание ошибки.
Проект: PokeAPI клиент
Объединим все концепции в практическом проекте — клиенте для PokeAPI. API возвращает JSON с информацией о покемонах.
Доменная модель
pub type Pokemon {
Pokemon(
id: Int,
name: String,
height: Int,
weight: Int,
types: List(String),
)
}
Доменная модель описывает только те поля PokeAPI-ответа, которые нужны приложению — id, название, размеры и список типов — игнорируя всё остальное.
Декодер
PokeAPI возвращает вложенную структуру. Типы покемона находятся в types[].type.name:
{
"id": 25,
"name": "pikachu",
"height": 4,
"weight": 60,
"types": [
{"slot": 1, "type": {"name": "electric", "url": "..."}}
]
}
Декодер:
import gleam/dynamic/decode
pub fn pokemon_decoder() -> decode.Decoder(Pokemon) {
use id <- decode.field("id", decode.int)
use name <- decode.field("name", decode.string)
use height <- decode.field("height", decode.int)
use weight <- decode.field("weight", decode.int)
use types <- decode.field("types", decode.list(type_name_decoder()))
decode.success(Pokemon(id:, name:, height:, weight:, types:))
}
fn type_name_decoder() -> decode.Decoder(String) {
use name <- decode.subfield(["type", "name"], decode.string)
decode.success(name)
}
pokemon_decoder компонует несколько decode.field через use-синтаксис, а вспомогательный type_name_decoder использует decode.subfield для навигации по вложенной структуре types[].type.name.
HTTP-запрос (для gleam run, не для тестов)
В реальном приложении запрос делается через gleam_httpc:
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/result
pub fn fetch_pokemon(name: String) -> Result(Pokemon, String) {
let url = "https://pokeapi.co/api/v2/pokemon/" <> name
use req <- result.try(
request.to(url)
|> result.map_error(fn(_) { "некорректный URL" }),
)
use resp <- result.try(
httpc.send(req)
|> result.map_error(fn(_) { "ошибка HTTP-запроса" }),
)
use pokemon <- result.try(
json.parse(resp.body, pokemon_decoder())
|> result.map_error(fn(_) { "ошибка декодирования JSON" }),
)
Ok(pokemon)
}
Примечание: в тестах мы не делаем реальных HTTP-запросов. Вместо этого используем захардкоженный JSON-ответ. Это стандартная практика — тесты должны быть быстрыми и детерминированными.
Парсинг без сети (для тестов)
pub fn parse_pokemon(json_str: String) -> Result(Pokemon, Nil) {
json.parse(json_str, pokemon_decoder())
|> result.map_error(fn(_) { Nil })
}
parse_pokemon — упрощённая версия без HTTP-запроса: она принимает уже готовую JSON-строку и применяет декодер, что делает функцию удобной для тестирования с захардкоженными данными.
Упражнения
Все упражнения этой главы складываются в мини-проект PokeDex CLI — консольное приложение для работы с данными о покемонах. Каждое упражнение — это строительный блок реального приложения.
Решения пишите в файле exercises/chapter07/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter07
gleam test
Запускайте тесты после каждого упражнения — они проверяют корректность реализации и подскажут, что ещё нужно доделать.
1. PokemonId — opaque type + smart constructor (Лёгкое)
Создайте непрозрачный тип PokemonId для валидного ID покемона (диапазон 1–1025, как в реальной PokéAPI).
pub opaque type PokemonId {
PokemonId(Int)
}
pub fn pokemon_id_new(id: Int) -> Result(PokemonId, String)
pub fn pokemon_id_value(id: PokemonId) -> Int
pub fn pokemon_id_to_path(id: PokemonId) -> String
Примеры:
pokemon_id_new(25) |> result.is_ok == True
pokemon_id_new(0) |> result.is_error == True
pokemon_id_new(1026) |> result.is_error == True
pokemon_id_to_path(pokemon_id_new(25) |> result.unwrap(..))
== "/api/v2/pokemon/25"
Подсказка: pokemon_id_to_path формирует строку "/api/v2/pokemon/" <> int.to_string(id).
2. pokemon_decoder — расширенный декодер (Среднее)
Расширьте тип Pokemon полями abilities и stats, затем реализуйте декодер для реального формата PokeAPI.
pub type PokemonStat {
PokemonStat(name: String, base_value: Int)
}
pub type Pokemon {
Pokemon(
id: Int, name: String, height: Int, weight: Int,
types: List(String),
abilities: List(String),
stats: List(PokemonStat),
)
}
pub fn pokemon_decoder() -> decode.Decoder(Pokemon)
pub fn parse_pokemon(json_str: String) -> Result(Pokemon, Nil)
Структура JSON PokeAPI:
{
"types": [{"slot": 1, "type": {"name": "electric", "url": "..."}}],
"abilities": [{"ability": {"name": "static", "url": ""}, "is_hidden": false, "slot": 1}],
"stats": [{"base_stat": 35, "effort": 0, "stat": {"name": "hp", "url": ""}}]
}
Подсказка: создайте три вспомогательных декодера — type_name_decoder (через decode.subfield(["type", "name"], ...)), ability_name_decoder (через decode.subfield(["ability", "name"], ...)), stat_decoder (поле "base_stat" + subfield ["stat", "name"]).
3. pokemon_to_json — кодирование в JSON (Среднее)
Закодируйте Pokemon в компактный JSON-формат (для кеширования, не в формате PokeAPI).
pub fn stat_to_json(stat: PokemonStat) -> json.Json
pub fn pokemon_to_json(pokemon: Pokemon) -> json.Json
Формат: плоский объект с полями id, name, height, weight, types (массив строк), abilities (массив строк), stats (массив объектов {name, base_value}).
Подсказка: используйте json.object, json.string, json.int, json.array.
4. search_results_decoder — пагинированный список (Среднее)
Декодируйте ответ PokeAPI для списка покемонов с пагинацией.
pub type NamedResource {
NamedResource(name: String, url: String)
}
pub type SearchResults {
SearchResults(
count: Int,
next: Option(String),
previous: Option(String),
results: List(NamedResource),
)
}
pub fn decode_search_results(json_str: String) -> Result(SearchResults, Nil)
Поля next и previous — nullable (null на первой/последней странице).
Подсказка: используйте decode.optional(decode.string) для nullable-полей. Это не decode.optional_field — поле всегда присутствует, но его значение может быть null.
5. DamageMultiplier — фантомные типы (Среднее-Сложное)
Реализуйте типобезопасный множитель урона с фантомными типами Physical / Special. Компилятор не позволит перемножить физический и специальный множители.
pub type Physical
pub type Special
pub opaque type DamageMultiplier(category) {
DamageMultiplier(Float)
}
pub fn physical(value: Float) -> Result(DamageMultiplier(Physical), String)
pub fn special(value: Float) -> Result(DamageMultiplier(Special), String)
pub fn multiplier_value(m: DamageMultiplier(a)) -> Float
pub fn combine(a: DamageMultiplier(cat), b: DamageMultiplier(cat)) -> DamageMultiplier(cat)
pub fn apply_damage(base: Int, m: DamageMultiplier(a)) -> Int
Примеры:
physical(1.5) |> result.unwrap(..) |> multiplier_value == 1.5
physical(-0.5) |> result.is_error == True
// combine умножает два множителя:
combine(physical(1.5), physical(2.0)) |> multiplier_value == 3.0
// apply_damage: base * multiplier, округлено вниз
apply_damage(100, physical(1.5)) == 150
// combine(physical(1.5), special(2.0)) — ошибка компиляции!
Фантомный тип не позволяет перемножить физический и специальный множители, а combine соединяет два множителя одной категории, перемножая их значения.
6. format_pokemon_card — CLI-вывод (Среднее)
Отформатируйте покемона для красивого вывода в CLI.
pub fn format_pokemon_card(pokemon: Pokemon) -> String
pub fn format_stat_bar(stat: PokemonStat) -> String
format_pokemon_card возвращает:
#025 Pikachu
Тип: electric
Способности: static, lightning-rod
- ID дополняется нулями до 3 цифр (
#006,#025), но 4-значные не обрезаются (#1025) - Имя покемона с заглавной буквы (
pikachu→Pikachu)
format_stat_bar возвращает:
hp [##.............] 35
- Имя стата — 16 символов (дополнено пробелами справа)
- Бар — 15 символов:
#— заполненные,.— пустые - Масштаб: 0–255 → 0–15 символов (используйте
float.round) - Значение в конце
Подсказка: для zero-padding используйте string.repeat("0", pad) <> int.to_string(id).
7. build_pokedex_entry — ROP-цепочка (Сложное)
Постройте полный конвейер: JSON-строка → покемон → валидация → отформатированная карточка.
pub type PokedexError {
InvalidJson
InvalidPokemonId
MissingTypes
MissingAbilities
}
pub fn build_pokedex_entry(json_str: String) -> Result(String, PokedexError)
Цепочка (используйте result.try):
- Распарсить JSON в
Pokemon→ ошибкаInvalidJson - Проверить
idв диапазоне 1–1025 → ошибкаInvalidPokemonId - Проверить
typesне пуст → ошибкаMissingTypes - Проверить
abilitiesне пуст → ошибкаMissingAbilities - Отформатировать через
format_pokemon_card
Примеры:
build_pokedex_entry(pikachu_json) |> result.is_ok == True
build_pokedex_entry("not json") == Error(InvalidJson)
build_pokedex_entry(pokemon_with_id_99999) == Error(InvalidPokemonId)
Подсказка: каждый шаг — use _ <- result.try(...), как в примере с parse_valid_user.
Заключение
В этой главе мы изучили фундаментальные принципы типобезопасного программирования в Gleam:
- Parse, Don't Validate — превращение проверок в гарантии типов
- Opaque types и smart constructors — сокрытие реализации и поддержание инвариантов
- Phantom types — безопасность на уровне типов без накладных расходов
- gleam_json и gleam/dynamic/decode — типобезопасная работа с JSON
- Railway-Oriented Programming — композиция парсинга, декодирования и валидации
Эти паттерны — основа надёжных систем. Компилятор становится союзником: он не даст передать невалидный email, сложить доллары с евро или опубликовать непроверенный документ.
В следующих главах мы изучим, как взаимодействовать с Erlang и JavaScript платформами через FFI, расширяя возможности Gleam за счёт богатых экосистем обеих платформ.
Erlang FFI и системное программирование
«Talk is cheap. Show me the code.» — Linus Torvalds
- Цели главы
- External functions для Erlang
- External types
- gleam_erlang — привязки к Erlang
- Продвинутые техники FFI
- Упражнения
- 1. system_time_seconds — FFI к erlang:system_time (Лёгкое)
- 2. get_api_base_url — переменные окружения (Лёгкое)
- 3. file_exists — проверка существования файла (Среднее)
- 4. read_lines — чтение файла построчно (Среднее)
- 5. LogLevel — безопасная работа с атомами (Среднее)
- 6. pid_to_string — работа с процессами (Среднее-Сложное)
- 7. measure_time — измерение времени выполнения (Сложное)
- 8. ensure_dir — создание директории (Сложное)
- 9. simple_ets_cache — работа с ETS (Сложное)
- 10. spawn_and_receive — процессы и сообщения (Сложное)
- Заключение
Цели главы
В этой главе мы:
- Научимся вызывать Erlang функции через
@external - Изучим внешние типы (external types)
- Познакомимся с привязками из
gleam_erlang - Поймём как работать с атомами, переменными окружения и charlist
- Создадим утилиты для системного программирования
- Напишем обёртки над Erlang-функциями для работы с файлами и процессами
External functions для Erlang
Gleam компилируется в Erlang и работает на BEAM VM. Атрибут @external позволяет вызывать любую функцию из Erlang напрямую:
// Вызов erlang:system_time/1
@external(erlang, "erlang", "system_time")
fn erl_system_time(unit: atom) -> Int
// Можно также объявить как pub
@external(erlang, "os", "system_time")
pub fn os_system_time(unit: atom) -> Int
Атрибут @external связывает Gleam-функцию с Erlang-функцией: первый параметр — таргет (erlang), второй — имя модуля Erlang ("erlang", "os"), третий — имя функции. Типы аргументов и возвращаемого значения объявляются в Gleam — компилятор доверяет вам, что сигнатура корректна.
Двойная проверка типов
Важно понимать: компилятор Gleam не проверяет, что Erlang-функция действительно имеет указанную сигнатуру. Это означает:
// ❌ Компилятор не обнаружит ошибку!
@external(erlang, "erlang", "system_time")
fn wrong_signature(x: String) -> String // Неправильные типы
Такой код скомпилируется, но упадёт в рантайме. Всегда проверяйте документацию Erlang перед написанием FFI-обёрток.
Функции с Gleam-реализацией + FFI fallback
Можно объявить функцию с телом на Gleam и FFI-альтернативой. Если FFI доступен для текущего таргета, он используется; иначе — Gleam-реализация:
@external(erlang, "my_ffi", "fast_sort")
pub fn sort(xs: List(Int)) -> List(Int) {
// Gleam-реализация как fallback
list.sort(xs, int.compare)
}
Такой подход позволяет использовать оптимизированную нативную реализацию там, где она доступна, и автоматически откатываться к Gleam-коду на других платформах.
External types
Внешние типы — типы, определённые вне Gleam. Их нельзя создать или разобрать напрямую в Gleam:
// Тип Atom из Erlang — существует только на BEAM
pub type Atom
// Regex — внутренняя структура зависит от таргета
pub type Regex
Внешние типы объявляются без конструкторов — они непрозрачны для Gleam-кода. Atom существует только на BEAM, Regex может иметь разную реализацию в зависимости от таргета. Для работы с внешними типами используются FFI-функции: конструкторы, аксессоры, преобразователи.
Пример: работа с Erlang reference
// Reference — уникальный идентификатор в Erlang
pub type Reference
@external(erlang, "erlang", "make_ref")
pub fn make_reference() -> Reference
@external(erlang, "erlang", "ref_to_list")
fn reference_to_charlist(ref: Reference) -> Charlist
External type Reference скрывает внутреннее представление — мы можем только создавать и преобразовывать значения через FFI.
gleam_erlang — привязки к Erlang
Библиотека gleam_erlang предоставляет типизированные обёртки над Erlang API.
gleam/erlang/atom
Атомы — уникальные идентификаторы в Erlang. Они похожи на enum-значения, но создаются динамически:
import gleam/erlang/atom
// Создание атома из строки
let assert Ok(a) = atom.from_string("hello")
// Обратно в строку
atom.to_string(a) // "hello"
// Атомы интернированы — одинаковые строки дают один атом
let assert Ok(a1) = atom.from_string("ok")
let assert Ok(a2) = atom.from_string("ok")
// a1 == a2
Атомы в Erlang — это интернированные константы, похожие на символы в Ruby или enumы в других языках. atom.from_string преобразует строку в атом (может вернуть Error, если атом слишком длинный). Важное свойство: одинаковые строки всегда дают один и тот же атом в памяти, поэтому сравнение атомов — это просто сравнение указателей.
Внимание: не создавайте атомы из пользовательского ввода! Таблица атомов в BEAM имеет ограниченный размер и не очищается сборщиком мусора.
Безопасное использование атомов
Лучшая практика — создавать атомы только из известных заранее строк:
// ✓ Хорошо: фиксированный набор атомов
pub type LogLevel {
Debug
Info
Warning
Error
}
pub fn log_level_to_atom(level: LogLevel) -> atom.Atom {
let assert Ok(a) = case level {
Debug -> atom.from_string("debug")
Info -> atom.from_string("info")
Warning -> atom.from_string("warning")
Error -> atom.from_string("error")
}
a
}
// ❌ Плохо: атомы из пользовательского ввода
pub fn dangerous(user_input: String) -> atom.Atom {
let assert Ok(a) = atom.from_string(user_input) // Утечка памяти!
a
}
Переменные окружения через FFI
В ранних версиях gleam_erlang был модуль gleam/erlang/os, но в текущей версии он удалён. Для работы с переменными окружения используем прямой FFI к Erlang. Создаём файл src/my_ffi.erl:
-module(my_ffi).
-export([get_env/1]).
get_env(Name) ->
case os:getenv(binary_to_list(Name)) of
false -> {error, nil};
Value -> {ok, list_to_binary(Value)}
end.
И используем из Gleam:
@external(erlang, "my_ffi", "get_env")
fn get_env(name: String) -> Result(String, Nil)
pub fn env_var_or_default(name: String, default: String) -> String {
case get_env(name) {
Ok(value) -> value
Error(_) -> default
}
}
Это отличный пример применения FFI — нужная функция из Erlang, но нет готовой обёртки. Мы пишем небольшой Erlang-модуль и подключаем через @external.
gleam/erlang — общие утилиты
Модуль gleam/erlang предоставляет утилиты для взаимодействия с BEAM-рантаймом:
import gleam/erlang
// rescue — перехват Erlang-исключений
erlang.rescue(fn() { panic as "oops" })
// Error(Errored(...))
// get_line — чтение строки из stdin
erlang.get_line("Введите имя: ")
// Ok("Алиса\n")
// priv_directory — путь к priv/ директории OTP-приложения
erlang.priv_directory("my_app")
// Ok("/path/to/my_app/priv")
Модуль gleam/erlang предоставляет утилиты для взаимодействия с BEAM-рантаймом: перехват исключений через rescue, чтение пользовательского ввода через get_line и доступ к ресурсам OTP-приложения через priv_directory.
gleam/erlang/charlist
Charlist — строки Erlang (список целых чисел). Нужны для совместимости с Erlang API:
import gleam/erlang/charlist
let cl = charlist.from_string("hello")
charlist.to_string(cl) // "hello"
Charlist используется при вызове Erlang-функций, которые ожидают строки в виде списков символов — charlist.from_string и charlist.to_string обеспечивают конвертацию в обе стороны.
Пример: чтение файла через Erlang file API
import gleam/erlang/charlist
@external(erlang, "file", "read_file")
fn erl_read_file(path: Charlist) -> Result(BitArray, Atom)
pub fn read_file(path: String) -> Result(BitArray, String) {
let charlist_path = charlist.from_string(path)
case erl_read_file(charlist_path) {
Ok(contents) -> Ok(contents)
Error(reason) -> Error("failed to read: " <> atom.to_string(reason))
}
}
Этот пример показывает типичный паттерн FFI-обёртки: Erlang-функция file:read_file/1 принимает путь как Charlist и возвращает Result(BitArray, Atom). Мы оборачиваем её в удобную Gleam-функцию: конвертируем String в Charlist на входе, и Atom в String при ошибке на выходе.
Продвинутые техники FFI
Представление Gleam-типов в Erlang
Gleam компилируется в Erlang, и пользовательские типы (custom types) представляются как кортежи. Это важно понимать при работе с FFI:
// В Gleam:
type Result(a, e) {
Ok(a)
Error(e)
}
// В Erlang становится:
// Ok(value) → {ok, value}
// Error(reason) → {error, reason}
Это означает, что Result в Gleam совместим с Erlang-конвенцией {ok, Value} | {error, Reason}. Erlang-функции, возвращающие такие кортежи, можно типизировать как Result:
// Erlang: file:consult/1 возвращает {ok, Terms} | {error, Reason}
@external(erlang, "file", "consult")
fn erl_consult(path: Charlist) -> Result(List(Dynamic), Atom)
pub fn read_erlang_terms(path: String) -> Result(List(Dynamic), String) {
charlist.from_string(path)
|> erl_consult
|> result.map_error(atom.to_string)
}
Общее правило: конструкторы Gleam преобразуются в кортежи вида {конструктор_строчными, поле1, поле2, ...}:
// В Gleam:
type Status {
Idle
Running(pid: Pid)
Completed(result: Int, time: Int)
}
// В Erlang:
// Idle → {idle}
// Running(pid) → {running, Pid}
// Completed(42, 100) → {completed, 42, 100}
Это позволяет Gleam-коду естественно взаимодействовать с существующими Erlang-библиотеками.
Работа с процессами через FFI
Одно из главных преимуществ BEAM — легковесные процессы. Библиотека gleam/erlang/process предоставляет типобезопасные обёртки, но давайте посмотрим, как они устроены:
import gleam/erlang/process.{type Pid}
// Получение PID текущего процесса
@external(erlang, "erlang", "self")
pub fn self() -> Pid
// Создание процесса с линком
@external(erlang, "proc_lib", "spawn_link")
fn spawn_linked(f: fn() -> anything) -> Pid
// Отправка сообщения процессу
@external(erlang, "erlang", "send")
fn send_message(to: Pid, message: anything) -> anything
// Использование
pub fn example() {
let current_pid = self()
let worker_pid = spawn_linked(fn() {
// Код процесса
io.println("Worker started!")
})
send_message(worker_pid, "hello")
}
Эти примеры показывают ключевые примитивы BEAM: erlang:self/0 возвращает PID текущего процесса, proc_lib:spawn_link/1 создаёт новый процесс и связывает его с родительским (если один упадёт, другой получит сигнал), erlang:send/2 отправляет сообщение в mailbox процесса.
Работа с ETS (Erlang Term Storage)
ETS — встроенная in-memory база данных BEAM. Она позволяет хранить огромные объёмы данных с О(1) доступом:
% src/ets_ffi.erl
-module(ets_ffi).
-export([new_table/1, insert/3, lookup/2, delete_table/1]).
new_table(Name) ->
ets:new(binary_to_atom(Name), [set, public, named_table]).
insert(Table, Key, Value) ->
ets:insert(Table, {Key, Value}),
ok.
lookup(Table, Key) ->
case ets:lookup(Table, Key) of
[{_, Value}] -> {ok, Value};
[] -> {error, nil}
end.
delete_table(Table) ->
ets:delete(Table),
ok.
import gleam/dynamic.{type Dynamic}
pub type EtsTable
@external(erlang, "ets_ffi", "new_table")
pub fn new_table(name: String) -> EtsTable
@external(erlang, "ets_ffi", "insert")
pub fn insert(table: EtsTable, key: String, value: Dynamic) -> Nil
@external(erlang, "ets_ffi", "lookup")
pub fn lookup(table: EtsTable, key: String) -> Result(Dynamic, Nil)
@external(erlang, "ets_ffi", "delete_table")
pub fn delete_table(table: EtsTable) -> Nil
// Использование
pub fn cache_example() {
let cache = new_table("my_cache")
insert(cache, "user:1", dynamic.from("Alice"))
case lookup(cache, "user:1") {
Ok(value) -> io.debug(value)
Error(_) -> io.println("Not found")
}
delete_table(cache)
}
ETS — мощный инструмент для кэширования и совместного состояния между процессами. Таблица создаётся через ets:new/2 с опциями (здесь set означает уникальные ключи, public — доступ из любого процесса). ets:insert/2 добавляет пары ключ-значение, ets:lookup/2 возвращает значение или пустой список.
Calling NIFs (Native Implemented Functions)
NIFs позволяют вызывать код на C, Rust или Zig из Erlang. Пример с Rust NIF через библиотеку rustler:
% src/math_nif.erl
-module(math_nif).
-export([fast_fibonacci/1]).
-on_load(init/0).
init() ->
ok = erlang:load_nif("./priv/math_nif", 0).
fast_fibonacci(_N) ->
erlang:nif_error("NIF library not loaded").
#![allow(unused)] fn main() { // native/math_nif/src/lib.rs (Rust) #[rustler::nif] fn fast_fibonacci(n: i64) -> i64 { if n <= 1 { n } else { let mut a = 0; let mut b = 1; for _ in 2..=n { let temp = a + b; a = b; b = temp; } b } } rustler::init!("math_nif", [fast_fibonacci]); }
@external(erlang, "math_nif", "fast_fibonacci")
pub fn fast_fibonacci(n: Int) -> Int
// Использование
fast_fibonacci(100) // Выполняется в нативном коде
NIFs выполняются напрямую в нативном коде (не на BEAM VM), что даёт огромный прирост производительности для тяжёлых вычислений. erlang:load_nif/2 загружает скомпилированную .so библиотеку. Важно: NIF блокирует scheduler при выполнении — используйте их осторожно для задач, которые выполняются быстро (<1ms), иначе применяйте Dirty NIFs.
Упражнения
Решения пишите в файле exercises/chapter08/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter08
gleam test
1. system_time_seconds — FFI к erlang:system_time (Лёгкое)
Реализуйте функцию, возвращающую текущее время в секундах:
pub fn system_time_seconds() -> Int
Подсказка: используйте @external(erlang, "erlang", "system_time") с атомом second. Создайте атом через:
@external(erlang, "erlang", "binary_to_atom")
fn binary_to_atom(s: String) -> atom.Atom
2. get_api_base_url — переменные окружения (Лёгкое)
Реализуйте функцию, читающую переменную окружения POKEAPI_BASE_URL:
pub fn get_api_base_url() -> String
Если переменная не установлена, возвращает "https://pokeapi.co".
Подсказка: создайте chapter08_ffi.erl с функцией get_env/1 (как в примере выше).
3. file_exists — проверка существования файла (Среднее)
Реализуйте функцию проверки существования файла:
pub fn file_exists(path: String) -> Bool
Подсказка: используйте @external(erlang, "filelib", "is_file"). Не забудьте преобразовать String в Charlist.
4. read_lines — чтение файла построчно (Среднее)
Реализуйте функцию, читающую файл и разбивающую его на строки:
pub fn read_lines(path: String) -> Result(List(String), String)
Подсказка: используйте FFI к file:read_file/1, затем преобразуйте BitArray в String через bit_array.to_string, и разбейте на строки через string.split(..., "\n").
5. LogLevel — безопасная работа с атомами (Среднее)
Реализуйте безопасный ADT для уровней логирования:
pub type LogLevel {
Debug
Info
Warning
Error
}
pub fn log_level_to_atom(level: LogLevel) -> atom.Atom
pub fn log_level_from_atom(a: atom.Atom) -> Result(LogLevel, Nil)
Подсказка: log_level_from_atom должен проверять atom.to_string(a) и возвращать соответствующий LogLevel или Error(Nil).
6. pid_to_string — работа с процессами (Среднее-Сложное)
Реализуйте функцию преобразования Pid (идентификатор процесса BEAM) в строку:
pub fn pid_to_string(pid: process.Pid) -> String
Подсказка: используйте @external(erlang, "erlang", "pid_to_list"), который возвращает Charlist, затем преобразуйте в String.
7. measure_time — измерение времени выполнения (Сложное)
Реализуйте функцию, измеряющую время выполнения переданной функции:
pub fn measure_time(f: fn() -> a) -> #(a, Int)
Возвращает кортеж #(результат, время_в_микросекундах).
Подсказка:
- Получите время до вызова через
erlang:monotonic_time(microsecond) - Вызовите
f() - Получите время после
- Вычтите и верните разницу
8. ensure_dir — создание директории (Сложное)
Реализуйте функцию, создающую директорию (включая родительские):
pub fn ensure_dir(path: String) -> Result(Nil, String)
Подсказка: используйте @external(erlang, "filelib", "ensure_dir"). Эта функция требует путь к файлу (не директории!), поэтому добавьте "/" в конец пути.
9. simple_ets_cache — работа с ETS (Сложное)
Реализуйте простой кэш на ETS:
pub type Cache
pub fn new_cache(name: String) -> Cache
pub fn cache_put(cache: Cache, key: String, value: String) -> Nil
pub fn cache_get(cache: Cache, key: String) -> Result(String, Nil)
pub fn cache_delete(cache: Cache) -> Nil
Подсказка: создайте ets_ffi.erl аналогично примеру из главы. Используйте ets:new/2, ets:insert/2, ets:lookup/2, ets:delete/1.
10. spawn_and_receive — процессы и сообщения (Сложное)
Реализуйте функцию, запускающую процесс и получающую от него сообщение:
pub fn spawn_echo() -> String
Функция должна:
- Создать процесс, который отправляет сообщение
"echo"родителю - Получить это сообщение
- Вернуть его как строку
Подсказка:
- Используйте
process.self()для получения родительского PID - Используйте
process.start(fn() { ... }, True)для создания процесса - Используйте
process.receive(selector, timeout)для получения сообщения
Заключение
В этой главе мы изучили взаимодействие Gleam с Erlang:
- External functions — прямой вызов Erlang-функций через
@external - External types — работа с типами из Erlang
- gleam_erlang — типизированные обёртки над Erlang API
- Атомы — уникальные идентификаторы с безопасным использованием
- Charlist — совместимость со строками Erlang
- Системное программирование — файлы, процессы, переменные окружения
- Продвинутые техники — работа с процессами, ETS и NIFs
FFI к Erlang открывает доступ к мощной экосистеме BEAM — от работы с файлами и сетью до распределённых систем и OTP. При этом Gleam сохраняет типобезопасность и выразительность.
Важно: Elixir-библиотеки (Ecto, Plug, Phoenix, Broadway и т.д.) активно используют макросы, которые недоступны из Gleam. Поэтому на практике из Gleam удобно вызывать именно Erlang-пакеты, а для Elixir-экосистемы потребуется FFI-обёртка или Gleam-аналог.
Что дальше: В главе 10 мы изучим высокоуровневые абстракции OTP для работы с процессами — акторы, супервизоры и философию «Let it crash». Низкоуровневые FFI-функции из этой главы (spawn, send, ETS) станут фундаментом для понимания того, как работают типобезопасные обёртки
gleam/otp/actorиgleam/otp/static_supervisor.
В следующей главе мы переключимся на JavaScript-таргет и изучим FFI для веб-разработки и фронтенд-интеграции.
JavaScript FFI и фронтенд интеграция
«Any application that can be written in JavaScript, will eventually be written in JavaScript.» — Jeff Atwood
- Цели главы
- External functions для JavaScript
- Двойной FFI (Erlang + JavaScript)
- Функции с переменным числом аргументов (rest parameters)
- Работа с JavaScript классами и инстансами
- gleam_javascript — привязки к JavaScript
- Модель конкурентности: BEAM vs JavaScript
- Типобезопасный DOM API
- Интеграция с JavaScript-библиотеками
- Упражнения
- 1. current_timestamp — Date.now() (Лёгкое)
- 2. local_storage — get/set (Среднее)
- 3. console_log_levels — разные уровни логов (Среднее)
- 4. timeout — setTimeout wrapper (Среднее)
- 5. fetch_json — HTTP запрос с парсингом (Среднее-Сложное)
- 6. query_selector — типобезопасный поиск элементов (Сложное)
- 7. json_parse_safe — безопасный JSON.parse (Сложное)
- 8. event_target_value — получение значения из event.target (Сложное)
- 9. varargs_logger — console.log с разными типами (Среднее-Сложное)
- 10. math_operations — varargs для математики (Среднее)
- 11. js_date_wrapper — работа с Date классом (Сложное)
- 12. canvas_wrapper — обёртка для Canvas API (Сложное)
- Сравнение FFI-подходов: Gleam vs другие языки
- Заключение
Цели главы
В этой главе мы:
- Научимся вызывать JavaScript функции через
@external - Изучим двойной FFI (Erlang + JavaScript)
- Познакомимся с
gleam_javascriptи работой с промисами - Поймём различия между JS concurrency и BEAM processes
- Создадим обёртки для DOM API и браузерных функций
- Построим типобезопасный интерфейс для работы с JavaScript-библиотеками
External functions для JavaScript
Для JS-таргета FFI-функции определяются в отдельном .mjs файле:
// В src/my_module.gleam
@external(javascript, "./my_ffi.mjs", "getCurrentTime")
pub fn current_time() -> Int
// В src/my_ffi.mjs
export function getCurrentTime() {
return Date.now();
}
Для JavaScript-таргета FFI-функция объявляется в Gleam с атрибутом @external(javascript, ...), а её реализация помещается в отдельный .mjs-файл, который экспортирует соответствующую функцию.
Соглашения о путях
Путь к .mjs файлу указывается относительно .gleam файла:
// В src/api/client.gleam
@external(javascript, "./client_ffi.mjs", "fetch")
// Ищет src/api/client_ffi.mjs
@external(javascript, "../utils_ffi.mjs", "log")
// Ищет src/utils_ffi.mjs
Путь к FFI-файлу всегда относительный: ./ означает ту же директорию, что и .gleam-файл, ../ — на уровень выше. Компилятор автоматически находит соответствующий .mjs-файл рядом с вашим модулем.
Конвертация типов между Gleam и JavaScript
| Gleam Type | JavaScript Type |
|---|---|
Int | number |
Float | number |
String | string |
Bool | boolean |
List(a) | Array (immutable) |
Result(a, b) | {type: "Ok", 0: value} или {type: "Error", 0: error} |
Option(a) | {type: "Some", 0: value} или {type: "None"} |
#(a, b) | [a, b] (array) |
| Custom type | Object с полем type |
Пример: работа с localStorage
// src/storage_ffi.mjs
export function getItem(key) {
const value = localStorage.getItem(key);
if (value === null) {
return { type: "Error", 0: undefined };
}
return { type: "Ok", 0: value };
}
export function setItem(key, value) {
try {
localStorage.setItem(key, value);
return { type: "Ok", 0: undefined };
} catch (e) {
return { type: "Error", 0: e.message };
}
}
@external(javascript, "./storage_ffi.mjs", "getItem")
pub fn get_item(key: String) -> Result(String, Nil)
@external(javascript, "./storage_ffi.mjs", "setItem")
pub fn set_item(key: String, value: String) -> Result(Nil, String)
В JavaScript Result представлен как объект с полем type: {type: "Ok", 0: value} для успеха и {type: "Error", 0: error} для ошибки. FFI-функция вручную создаёт эти объекты, а Gleam воспринимает их как типобезопасный Result. Обратите внимание: null из localStorage.getItem конвертируется в Error(Nil), а ошибка localStorage.setItem — в Error(String).
Двойной FFI (Erlang + JavaScript)
Одна функция может иметь реализации для обоих таргетов:
@external(erlang, "erlang", "system_time")
@external(javascript, "./time_ffi.mjs", "systemTime")
pub fn system_time() -> Int
Компилятор выберет нужную реализацию в зависимости от таргета (gleam build --target erlang или --target javascript).
Пример: универсальное логирование
// src/logger.gleam
@external(erlang, "io", "format")
@external(javascript, "./logger_ffi.mjs", "log")
pub fn log(message: String) -> Nil
// src/logger_ffi.mjs
export function log(message) {
console.log(message);
}
Такой код работает и на BEAM, и в браузере — компилятор автоматически выбирает правильную реализацию.
Функции с Gleam-реализацией + FFI
Можно объявить функцию с телом на Gleam и FFI-альтернативой. Если FFI доступен для текущего таргета, он используется; иначе — Gleam-реализация:
@external(javascript, "./fast_ffi.mjs", "reverse")
pub fn reverse(xs: List(a)) -> List(a) {
// Gleam-реализация как fallback
list.reverse(xs)
}
Такой подход позволяет использовать оптимизированную нативную реализацию там, где она доступна, и автоматически откатываться к Gleam-коду на других платформах.
Функции с переменным числом аргументов (rest parameters)
В отличие от Erlang, JavaScript поддерживает функции с переменным числом аргументов через rest parameters (...args). Многие встроенные функции используют этот паттерн:
// JavaScript: console.log принимает любое число аргументов
console.log("Hello", "world", 123, true);
// Math.max находит максимум из N чисел
Math.max(1, 5, 3, 9, 2); // 9
Обёртка для функций с переменным числом аргументов
Чтобы вызвать такую функцию из Gleam, оборачиваем её в функцию которая будет принимать List:
// src/console_ffi.mjs
export function logMultiple(messages) {
console.log(...messages);
}
export function mathMax(numbers) {
if (numbers.length === 0) {
return { type: "Error", 0: undefined };
}
return { type: "Ok", 0: Math.max(...numbers) };
}
import gleam/dynamic.{type Dynamic}
@external(javascript, "./console_ffi.mjs", "logMultiple")
pub fn log_multiple(messages: List(Dynamic)) -> Nil
@external(javascript, "./console_ffi.mjs", "mathMax")
pub fn math_max(numbers: List(Float)) -> Result(Float, Nil)
// Использование
pub fn example() {
log_multiple([
dynamic.from("User:"),
dynamic.from("Alice"),
dynamic.from(30),
])
// Console: User: Alice 30
math_max([1.5, 9.2, 3.7, 5.1])
// Ok(9.2)
math_max([])
// Error(Nil)
}
Паттерн прост: JavaScript-функция принимает массив и разворачивает его через spread operator (...messages). Со стороны Gleam это обычная функция, принимающая List. Для разнотипных аргументов используем List(Dynamic), для однотипных — List(Int), List(String) и т.д.
Работа с JavaScript классами и инстансами
JavaScript активно использует классы и объектно-ориентированный подход. При обёртке таких API в Gleam мы используем внешние типы для представления инстансов и FFI-функции для конструкторов и методов.
Пример: встроенный класс Date
// src/date_ffi.mjs
export function newDate() {
return new Date();
}
export function newDateFromTimestamp(timestamp) {
return new Date(timestamp);
}
export function getTime(date) {
return date.getTime();
}
export function toISOString(date) {
return date.toISOString();
}
export function setFullYear(date, year) {
date.setFullYear(year);
return date; // Возвращаем для chain-ability
}
// Внешний тип для инстансов Date
pub type JSDate
// Конструкторы (функции, вызывающие new)
@external(javascript, "./date_ffi.mjs", "newDate")
pub fn new_date() -> JSDate
@external(javascript, "./date_ffi.mjs", "newDateFromTimestamp")
pub fn new_date_from_timestamp(timestamp: Int) -> JSDate
// Методы инстанса
@external(javascript, "./date_ffi.mjs", "getTime")
pub fn get_time(date: JSDate) -> Int
@external(javascript, "./date_ffi.mjs", "toISOString")
pub fn to_iso_string(date: JSDate) -> String
@external(javascript, "./date_ffi.mjs", "setFullYear")
pub fn set_full_year(date: JSDate, year: Int) -> JSDate
// Использование
pub fn example() {
let now = new_date()
let timestamp = get_time(now)
let iso = to_iso_string(now)
let new_year = new_date()
|> set_full_year(2030)
|> to_iso_string()
}
Паттерн для классов:
- Внешний тип (
JSDate) представляет инстанс класса — Gleam не знает его внутреннюю структуру - Конструкторы (
new_date) вызываютnew ClassName()и возвращают инстанс - Методы (
get_time,set_full_year) принимают инстанс первым параметром — это эквивалентthisв JavaScript
Пример: Map с мутациями
// src/js_map_ffi.mjs
export function newMap() {
return new Map();
}
export function mapSet(map, key, value) {
map.set(key, value);
return map; // Для chain-ability
}
export function mapGet(map, key) {
if (map.has(key)) {
return { type: "Ok", 0: map.get(key) };
}
return { type: "Error", 0: undefined };
}
export function mapSize(map) {
return map.size;
}
export function mapClear(map) {
map.clear();
}
pub type JSMap(k, v)
@external(javascript, "./js_map_ffi.mjs", "newMap")
pub fn new_map() -> JSMap(k, v)
@external(javascript, "./js_map_ffi.mjs", "mapSet")
pub fn map_set(map: JSMap(k, v), key: k, value: v) -> JSMap(k, v)
@external(javascript, "./js_map_ffi.mjs", "mapGet")
pub fn map_get(map: JSMap(k, v), key: k) -> Result(v, Nil)
@external(javascript, "./js_map_ffi.mjs", "mapSize")
pub fn map_size(map: JSMap(k, v)) -> Int
@external(javascript, "./js_map_ffi.mjs", "mapClear")
pub fn map_clear(map: JSMap(k, v)) -> Nil
// Использование
pub fn cache_example() {
let cache = new_map()
|> map_set("user:1", "Alice")
|> map_set("user:2", "Bob")
case map_get(cache, "user:1") {
Ok(name) -> io.println(name)
Error(_) -> io.println("Not found")
}
let size = map_size(cache) // 2
map_clear(cache)
}
Важно: JavaScript методы часто мутируют объект. Мы возвращаем инстанс из FFI-функций, чтобы поддерживать pipe operator (|>), но помните, что изменения происходят in-place.
Пример: пользовательский класс Canvas
// src/canvas_ffi.mjs
export class CanvasRenderer {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
}
drawRect(x, y, width, height, color) {
this.ctx.fillStyle = color;
this.ctx.fillRect(x, y, width, height);
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
setLineWidth(width) {
this.ctx.lineWidth = width;
return this;
}
}
// Обёртки для Gleam
export function newCanvasRenderer(canvasId) {
return new CanvasRenderer(canvasId);
}
export function drawRect(renderer, x, y, width, height, color) {
renderer.drawRect(x, y, width, height, color);
}
export function clearCanvas(renderer) {
renderer.clear();
}
export function setLineWidth(renderer, width) {
return renderer.setLineWidth(width);
}
pub type CanvasRenderer
@external(javascript, "./canvas_ffi.mjs", "newCanvasRenderer")
pub fn new_canvas_renderer(canvas_id: String) -> CanvasRenderer
@external(javascript, "./canvas_ffi.mjs", "drawRect")
pub fn draw_rect(
renderer: CanvasRenderer,
x: Float,
y: Float,
width: Float,
height: Float,
color: String,
) -> Nil
@external(javascript, "./canvas_ffi.mjs", "clearCanvas")
pub fn clear_canvas(renderer: CanvasRenderer) -> Nil
@external(javascript, "./canvas_ffi.mjs", "setLineWidth")
pub fn set_line_width(renderer: CanvasRenderer, width: Float) -> CanvasRenderer
// Использование
pub fn draw() {
let canvas = new_canvas_renderer("my-canvas")
clear_canvas(canvas)
canvas
|> set_line_width(5.0)
|> draw_rect(10.0, 10.0, 100.0, 50.0, "red")
|> draw_rect(150.0, 10.0, 100.0, 50.0, "blue")
}
Этот пример показывает обёртку для пользовательского класса: JavaScript-класс CanvasRenderer инкапсулирует состояние (canvas context). Мы создаём обёртки-функции, которые принимают инстанс и вызывают методы. Gleam-код полностью типобезопасен и использует привычный functional стиль, скрывая императивный JS-код.
gleam_javascript — привязки к JavaScript
Библиотека gleam_javascript предоставляет типизированные обёртки над JavaScript API.
gleam/javascript/promise
Промисы — основа асинхронности в JavaScript:
import gleam/javascript/promise
// Создание промиса
promise.new(fn(resolve, _reject) {
resolve(42)
})
// Promise(Int)
// Трансформация результата
promise.new(fn(resolve, _reject) { resolve(42) })
|> promise.map(fn(x) { x * 2 })
// Promise(Int) — 84
// Цепочка промисов
promise.new(fn(resolve, _reject) { resolve("https://api.example.com") })
|> promise.then(fn(url) {
// Возвращаем новый промис
fetch(url)
})
// Promise(Response)
// Обработка ошибок
promise.new(fn(_resolve, reject) { reject("oops") })
|> promise.rescue(fn(error) {
io.println("Error: " <> error)
promise.resolve(0) // Fallback значение
})
Gleam предоставляет типобезопасный API для работы с промисами: promise.new создаёт промис с resolve/reject callback'ами, promise.map трансформирует результат (аналог .then() в JS), promise.then позволяет вернуть новый промис (для цепочек), promise.rescue обрабатывает ошибки. Все функции сохраняют типы — компилятор знает, что Promise(Int) в итоге вернёт Int.
Пример: fetch API
// src/http_ffi.mjs
export function fetch(url) {
return globalThis.fetch(url)
.then(response => response.text())
.then(text => ({ type: "Ok", 0: text }))
.catch(error => ({ type: "Error", 0: error.message }));
}
import gleam/javascript/promise
@external(javascript, "./http_ffi.mjs", "fetch")
pub fn fetch(url: String) -> promise.Promise(Result(String, String))
// Использование
fetch("https://pokeapi.co/api/v2/pokemon/pikachu")
|> promise.map(fn(result) {
case result {
Ok(body) -> io.println("Got: " <> body)
Error(err) -> io.println("Error: " <> err)
}
})
FFI-функция fetch оборачивает нативный fetch() и конвертирует JavaScript Promise в Gleam promise.Promise. Внутри промиса мы получаем текст через .text(), затем оборачиваем результат в Result — ошибки сети перехватываются .catch() и становятся Error(String). Gleam-код использует promise.map для работы с асинхронным результатом.
gleam/javascript/array
JavaScript массивы — mutable, в отличие от immutable Gleam List:
import gleam/javascript/array
// Создание
let arr = array.from_list([1, 2, 3])
// Доступ
array.get(arr, 0) // Ok(1)
array.get(arr, 10) // Error(Nil)
// Длина
array.length(arr) // 3
// Конвертация обратно в List
array.to_list(arr) // [1, 2, 3]
Важно: array.from_list создаёт копию, а не ссылку — изменения в массиве не влияют на исходный список.
gleam/javascript/map
JavaScript объекты как словари:
import gleam/javascript/map
// Создание
let m = map.new()
|> map.set("name", "Alice")
|> map.set("age", "30")
// Чтение
map.get(m, "name") // Ok("Alice")
map.get(m, "city") // Error(Nil)
// Размер
map.size(m) // 2
JavaScript Map (не путать с gleam/dict) — mutable структура для хранения пар ключ-значение. В отличие от Gleam словарей, изменения в javascript/map мутируют исходный объект. map.new() создаёт новый Map, map.set добавляет пару, map.get возвращает Result — Error(Nil) если ключа нет.
Модель конкурентности: BEAM vs JavaScript
BEAM: процессы и акторы
- Легковесные процессы — миллионы одновременных процессов
- Изолированная память — каждый процесс имеет свою память
- Передача сообщений — копирование данных между процессами
- Преемптивная многозадачность — планировщик честно распределяет CPU
// BEAM: параллельные процессы
import gleam/erlang/process
let pid1 = process.start(fn() { heavy_computation_1() }, True)
let pid2 = process.start(fn() { heavy_computation_2() }, True)
// Оба вычисления идут параллельно на разных ядрах
На BEAM каждый process.start создаёт настоящий легковесный процесс с собственным планировщиком. Два процесса выполняются одновременно на разных CPU-ядрах — это истинный параллелизм. Процессы изолированы: каждый имеет свою память, взаимодействие только через передачу сообщений.
JavaScript: event loop и промисы
- Однопоточность — один поток выполнения
- Event loop — очередь задач
- Async/await — синтаксический сахар над промисами
- Не блокирующий I/O — операции вывода не блокируют поток
// JavaScript: concurrency через промисы
const result1 = fetch("https://api.example.com/1");
const result2 = fetch("https://api.example.com/2");
Promise.all([result1, result2]).then(([r1, r2]) => {
// Оба запроса выполнялись "параллельно" (но не на разных ядрах)
});
В JavaScript есть только один поток выполнения. Promise.all запускает оба fetch конкурентно: event loop переключается между ними, пока ждёт I/O операций. Но тяжёлые вычисления заблокируют весь поток — нельзя использовать несколько CPU-ядер без Web Workers. Это конкурентность (concurrency), а не параллелизм (parallelism).
Ключевые различия
| BEAM | JavaScript |
|---|---|
| Истинный параллелизм (multicore) | Конкурентность (event loop) |
| Процессы изолированы | Общее состояние (shared memory) |
| Передача сообщений | Callbacks/Promises |
| Fault tolerance (let it crash) | Error handling (try/catch) |
Вывод: на BEAM можно использовать все ядра CPU для параллельных вычислений. В JavaScript "параллелизм" — это иллюзия: event loop переключается между задачами, но в каждый момент выполняется только одна.
Типобезопасный DOM API
Пример: работа с элементами
// src/dom_ffi.mjs
export function getElementById(id) {
const element = document.getElementById(id);
if (element === null) {
return { type: "Error", 0: undefined };
}
return { type: "Ok", 0: element };
}
export function setInnerText(element, text) {
element.innerText = text;
}
export function addEventListener(element, event, handler) {
element.addEventListener(event, handler);
}
pub type Element
@external(javascript, "./dom_ffi.mjs", "getElementById")
pub fn get_element_by_id(id: String) -> Result(Element, Nil)
@external(javascript, "./dom_ffi.mjs", "setInnerText")
pub fn set_inner_text(element: Element, text: String) -> Nil
@external(javascript, "./dom_ffi.mjs", "addEventListener")
pub fn add_event_listener(
element: Element,
event: String,
handler: fn() -> Nil,
) -> Nil
Внешний тип Element скрывает реализацию DOM-элемента — в Gleam это просто непрозрачный тип. get_element_by_id возвращает Result: если элемент не найден (null в JS), получаем Error(Nil). Функции set_inner_text и add_event_listener принимают Element и безопасно вызывают соответствующие DOM API.
Пример использования
pub fn main() {
case get_element_by_id("app") {
Ok(element) -> {
set_inner_text(element, "Hello from Gleam!")
add_event_listener(element, "click", fn() {
set_inner_text(element, "Clicked!")
})
}
Error(_) -> io.println("Element not found")
}
}
Типичный паттерн работы с DOM: получаем элемент через get_element_by_id, обрабатываем случай отсутствия элемента через pattern matching на Result, затем безопасно работаем с гарантированно существующим элементом. Обработчик события — обычная Gleam-функция, которая компилируется в JavaScript callback.
Интеграция с JavaScript-библиотеками
Пример: wrapper для date-fns
// src/datefns_ffi.mjs
import { format, addDays } from 'date-fns';
export function formatDate(date, pattern) {
return format(date, pattern);
}
export function addDaysToDate(date, days) {
return addDays(date, days);
}
export function now() {
return new Date();
}
pub type JSDate
@external(javascript, "./datefns_ffi.mjs", "now")
pub fn now() -> JSDate
@external(javascript, "./datefns_ffi.mjs", "formatDate")
pub fn format_date(date: JSDate, pattern: String) -> String
@external(javascript, "./datefns_ffi.mjs", "addDaysToDate")
pub fn add_days(date: JSDate, days: Int) -> JSDate
// Использование
pub fn example() {
let today = now()
let tomorrow = add_days(today, 1)
format_date(tomorrow, "yyyy-MM-dd")
// "2026-02-21"
}
Этот пример показывает интеграцию с npm-пакетами: FFI-файл импортирует date-fns, экспортирует обёртки над его функциями. Со стороны Gleam мы работаем с типобезопасным API: JSDate — внешний тип (JavaScript Date объект), функции принимают и возвращают Gleam-типы. Компилятор гарантирует, что мы не передадим строку вместо даты.
Упражнения
Решения пишите в файле exercises/chapter09/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter09
gleam test --target javascript
1. current_timestamp — Date.now() (Лёгкое)
Реализуйте функцию, возвращающую текущее время в миллисекундах:
pub fn current_timestamp() -> Int
Подсказка: создайте my_ffi.mjs с функцией, вызывающей Date.now().
2. local_storage — get/set (Среднее)
Реализуйте типобезопасный интерфейс для localStorage:
pub fn storage_get(key: String) -> Result(String, Nil)
pub fn storage_set(key: String, value: String) -> Result(Nil, String)
pub fn storage_remove(key: String) -> Nil
Подсказка: обработайте случай localStorage.getItem(key) === null как Error(Nil).
3. console_log_levels — разные уровни логов (Среднее)
Реализуйте функции для разных уровней логирования:
pub fn console_log(message: String) -> Nil
pub fn console_warn(message: String) -> Nil
pub fn console_error(message: String) -> Nil
Подсказка: console.log(), console.warn(), console.error().
4. timeout — setTimeout wrapper (Среднее)
Реализуйте типобезопасную обёртку для setTimeout:
pub type TimeoutId
pub fn set_timeout(callback: fn() -> Nil, delay: Int) -> TimeoutId
pub fn clear_timeout(id: TimeoutId) -> Nil
Подсказка: в JavaScript setTimeout возвращает число (id таймера).
5. fetch_json — HTTP запрос с парсингом (Среднее-Сложное)
Реализуйте функцию для HTTP-запросов, возвращающую промис:
pub fn fetch_json(url: String) -> promise.Promise(Result(String, String))
Подсказка: используйте fetch(), затем .text(), оберните в Promise.resolve({type: "Ok", 0: text}).
6. query_selector — типобезопасный поиск элементов (Сложное)
Реализуйте функцию поиска элемента по CSS-селектору:
pub type Element
pub fn query_selector(selector: String) -> Result(Element, Nil)
pub fn query_selector_all(selector: String) -> List(Element)
Подсказка: document.querySelector возвращает null если не найдено. Для querySelectorAll преобразуйте NodeList в массив через Array.from(), затем в Gleam List.
7. json_parse_safe — безопасный JSON.parse (Сложное)
Реализуйте безопасную обёртку для JSON.parse:
pub fn json_parse(json_str: String) -> Result(dynamic.Dynamic, String)
Подсказка: оберните JSON.parse в try/catch, верните {type: "Ok", 0: parsed} или {type: "Error", 0: error.message}.
8. event_target_value — получение значения из event.target (Сложное)
Реализуйте функцию извлечения значения из события input:
pub type Event
pub fn event_target_value(event: Event) -> Result(String, Nil)
Подсказка: проверьте event.target?.value, верните Error если undefined.
9. varargs_logger — console.log с разными типами (Среднее-Сложное)
Реализуйте функцию для логирования множества значений разных типов:
pub fn log_values(values: List(Dynamic)) -> Nil
Подсказка: создайте JavaScript функцию, принимающую массив и использующую spread operator: console.log(...values).
10. math_operations — varargs для математики (Среднее)
Реализуйте функции с переменным числом аргументов:
pub fn sum_all(numbers: List(Float)) -> Float
pub fn multiply_all(numbers: List(Float)) -> Float
Подсказка: в JavaScript используйте reduce():
export function sumAll(numbers) {
return numbers.reduce((a, b) => a + b, 0);
}
11. js_date_wrapper — работа с Date классом (Сложное)
Реализуйте типобезопасную обёртку для JavaScript Date:
pub type JSDate
pub fn new_date() -> JSDate
pub fn date_from_string(iso: String) -> Result(JSDate, Nil)
pub fn add_days(date: JSDate, days: Int) -> JSDate
pub fn format_date(date: JSDate, format: String) -> String
Подсказка:
new Date()для созданияnew Date(isoString)для парсинга (может вернутьInvalid Date)- Для
add_days:date.setDate(date.getDate() + days) - Для
format_dateиспользуйтеtoLocaleDateString()или библиотеку date-fns
12. canvas_wrapper — обёртка для Canvas API (Сложное)
Реализуйте минимальную обёртку для HTML Canvas:
pub type Canvas
pub fn get_canvas(element_id: String) -> Result(Canvas, Nil)
pub fn fill_rect(canvas: Canvas, x: Float, y: Float, w: Float, h: Float, color: String) -> Canvas
pub fn clear(canvas: Canvas) -> Canvas
Подсказка:
document.getElementById(id)?.getContext('2d')для получения контекста- Храните context как инстанс
- Методы
.fillRect(),.clearRect()для рисования
Сравнение FFI-подходов: Gleam vs другие языки
Разные языки, компилирующиеся в JavaScript, используют различные подходы к FFI. Вот сравнение основных конкурентов.
TypeScript — Декларации типов + экосистема
Подход: TypeScript — это надстройка над JavaScript. Типы объявляются через .d.ts файлы (type declarations), но благодаря проекту DefinitelyTyped большинство популярных библиотек уже имеют готовые типы в @types/*.
// TypeScript — прямой доступ к JS API (типы встроены)
const now = Date.now(); // number
localStorage.setItem("key", "value");
console.log("Hello", 123, true); // varargs работают нативно
// Типы для сторонних библиотек — устанавливаются из npm
import { format } from 'date-fns';
// npm install @types/date-fns ← типы из DefinitelyTyped
const formatted: string = format(new Date(), 'yyyy-MM-dd');
// Для библиотеки без готовых типов нужно писать .d.ts вручную
declare module 'my-obscure-lib' {
export function doSomething(x: number): string;
}
Преимущества:
- ✅ Нулевой runtime оверхед — типы удаляются при компиляции
- ✅ Огромная экосистема — @types/* покрывает ~90% популярных библиотек
- ✅ Нет FFI-слоя — пишете JS с проверкой типов
- ✅ Постепенная миграция — можно добавлять типы инкрементально
Недостатки:
- ❌ Слабая типизация —
any,unknown, type assertions (as) обходят проверки - ❌ Runtime ошибки возможны —
null/undefined, неправильные типы в .d.ts - ❌ Доверие типам —
.d.tsмогут быть неточными или устаревшими - ❌ Нет паттерн-матчинга и ADT (algebraic data types)
Бойлерплейт:
- ⭐ Для популярных библиотек: минимальный (
npm install @types/library) - ⚠️ Для редких библиотек: нужно писать
.d.tsвручную (аналогично FFI в Gleam) - ❌ Для legacy JS-кода: может потребоваться много деклараций
Elm — Порты (изоляция)
Подход: Elm полностью изолирован от JavaScript. Взаимодействие только через порты (ports) — асинхронные каналы сообщений.
-- Elm: декларация порта (отправка в JS)
port sendToJS : String -> Cmd msg
-- Elm: подписка на порт (получение из JS)
port receiveFromJS : (String -> msg) -> Sub msg
-- Использование
sendToJS "hello from Elm"
// JavaScript: подключение портов
const app = Elm.Main.init();
app.ports.sendToJS.subscribe(function(data) {
console.log("Got from Elm:", data);
// Вызов JS API
localStorage.setItem("key", data);
// Отправка обратно
app.ports.receiveFromJS.send("saved!");
});
Преимущества:
- ✅ Абсолютная безопасность — Elm-код никогда не упадёт из-за JS
- ✅ Чистота — все побочные эффекты явно описаны
- ✅ No runtime exceptions в Elm-коде
Недостатки:
- ❌ Огромный бойлерплейт — каждый вызов JS требует порт + подписку + JSON-сериализацию
- ❌ Асинхронность — невозможен синхронный вызов JS-функции
- ❌ JSON-граница — можно передавать только сериализуемые данные
- ❌ Нельзя использовать JS-библиотеки напрямую
Бойлерплейт: Очень высокий. Простой Date.now() требует минимум 10 строк кода.
ReScript — Прямой FFI (как Gleam)
Подход: ReScript (бывший BuckleScript) — функциональный язык с JS-подобным синтаксисом и прямым FFI через @module и @val.
// ReScript: FFI к JavaScript
@val external dateNow: unit => float = "Date.now"
@scope("console") @val external log: string => unit = "log"
// Использование
let now = dateNow()
log("Hello")
// Внешние модули
@module("date-fns") external format: (Js.Date.t, string) => string = "format"
let formatted = format(Js.Date.make(), "yyyy-MM-dd")
Преимущества:
- ✅ Простой синтаксис — близок к JavaScript, легко освоить
- ✅ Прямой доступ к JS API (как Gleam)
- ✅ Минимальный бойлерплейт для FFI
- ✅ Отличный вывод типов
- ✅ Генерирует очень читаемый JS-код (почти как рукописный)
Недостатки:
- ⚠️ Нужно вручную объявлять типы для каждой JS-функции
- ⚠️ Доверие компилятору — ошибки в типах FFI не отловятся
- ❌ Маленькая экосистема готовых биндингов (в отличие от @types/*)
Бойлерплейт: Средний — каждая JS-функция требует external декларацию, но это одна строка.
PureScript — Внешние декларации (как Haskell)
Подход: PureScript — Haskell для JavaScript. FFI через foreign import.
-- PureScript: декларация FFI
foreign import dateNow :: Effect Number
foreign import consoleLog :: String -> Effect Unit
-- Использование
main = do
now <- dateNow
consoleLog "Hello"
// ffi.js: реализация
export const dateNow = () => Date.now();
export const consoleLog = (msg) => console.log(msg);
Преимущества:
- ✅ Сильная типизация (как Haskell)
- ✅ Effect-система — все побочные эффекты явны
- ✅ Мощная система типов (type classes, higher-kinded types)
Недостатки:
- ❌ Высокий порог входа — монады, do-нотация, Effect
- ❌ Сгенерированный JS код сложный и большой
- ❌ Бойлерплейт для каждой JS-функции (
.purs+.js)
Бойлерплейт: Высокий — нужны отдельные файлы .purs и .js, плюс понимание Effect-монад.
Gleam — Баланс простоты и безопасности
Подход: Gleam использует @external с отдельными .mjs файлами. Подход похож на ReScript, но проще.
// Gleam: декларация FFI
@external(javascript, "./ffi.mjs", "dateNow")
pub fn date_now() -> Int
@external(javascript, "./ffi.mjs", "consoleLog")
pub fn console_log(msg: String) -> Nil
// ffi.mjs: реализация
export function dateNow() {
return Date.now();
}
export function consoleLog(msg) {
console.log(msg);
}
Преимущества:
- ✅ Простота — проще, чем PureScript и Elm
- ✅ Прямой доступ — как TypeScript/ReScript, но с типами
- ✅ Минимальный бойлерплейт — одна строка
@external+ одна JS-функция - ✅ Читаемый синтаксис — легче Haskell/OCaml
- ✅ Двойной FFI — один код для Erlang и JavaScript
Недостатки:
- ⚠️ Доверие типам — компилятор не проверяет соответствие FFI-сигнатур
- ⚠️ Нет effect-системы —
Nilне отличает чистую функцию от побочного эффекта - ❌ Небольшая экосистема (по сравнению с TypeScript)
Бойлерплейт: Низкий — одна строка Gleam + одна функция JS.
Сравнительная таблица
| Язык | Подход | Бойлерплейт FFI | Безопасность типов | Порог входа | Готовые типы |
|---|---|---|---|---|---|
| TypeScript | Декларации (.d.ts) | ⭐ Минимум* | ⚠️ Слабая | ⭐ Самый низкий | ⭐⭐⭐ @types/* |
| ReScript | @module/@val | ⭐⭐ Средний | ⭐⭐ Хорошая | ⭐ Низкий | ⚠️ Мало |
| Gleam | @external + .mjs | ⭐⭐ Низкий | ⭐⭐ Хорошая | ⭐ Низкий | ⚠️ Растущая |
| Elm | Порты | ❌ Очень высокий | ⭐⭐⭐ Абсолютная | ⚠️ Средний | ❌ Изолирован |
| PureScript | Foreign import | ❌ Высокий | ⭐⭐⭐ Сильная | ❌ Высокий | ⚠️ Небольшая |
* TypeScript: Для популярных библиотек — минимум (npm i @types/lib), для редких — нужно писать .d.ts вручную.
Порог входа: TypeScript ≈ ReScript ≈ Gleam < Elm << PureScript
Вывод
Выбирайте язык в зависимости от приоритетов:
-
Нужна максимальная экосистема и быстрый старт? → TypeScript
- ✅ Готовые типы для 90% библиотек (@types/*)
- ✅ Низкий порог входа, знакомый синтаксис
- ❌ Но слабые гарантии (any, null/undefined, type assertions)
-
Хотите производительность и простоту? → ReScript
- ✅ Простой JS-подобный синтаксис, легко освоить
- ✅ Прямой FFI + отличный вывод типов
- ✅ Генерирует очень чистый JS-код
- ❌ Но маленькая экосистема готовых биндингов
-
Нужна простота + типобезопасность + full-stack (Erlang + JS)? → Gleam
- ✅ Простой синтаксис, сильная типизация, двойной таргет
- ✅ Один язык для бэкенда и фронтенда
- ❌ Но придётся писать FFI-обёртки для большинства библиотек
-
Нужна абсолютная безопасность ценой удобства? → Elm
- ✅ No runtime exceptions в принципе
- ✅ Time-travel debugging, отличные сообщения об ошибках
- ❌ Но очень много бойлерплейта для любого JS-взаимодействия
- ❌ Изоляция от экосистемы JS
-
Готовы к Haskell-уровню сложности? → PureScript
- ✅ Самая мощная система типов (type classes, higher-kinded types)
- ✅ Эффект-система для управления побочными эффектами
- ❌ Но крутая кривая обучения (монады, do-notation, Effect)
- ❌ Сложный сгенерированный код
Спектр сложности:
Простые Сложные
TypeScript ≈ ReScript ≈ Gleam < Elm << PureScript
Компромиссы:
- TypeScript/ReScript: Минимум усилий для FFI, но слабее гарантии
- Gleam: Золотая середина — простой синтаксис + сильные типы + full-stack
- Elm/PureScript: Максимальные гарантии, но море бойлерплейта
Gleam особенно привлекателен, если вы хотите один язык для бэкенда (BEAM) и фронтенда (JavaScript) с сильной типизацией и без академической сложности.
Заключение
В этой главе мы изучили взаимодействие Gleam с JavaScript:
- External functions — вызов JavaScript из Gleam через
.mjsфайлы - Двойной FFI — универсальный код для Erlang и JavaScript
- gleam_javascript — промисы, массивы, объекты
- Varargs (rest parameters) — функции с переменным числом аргументов
- Классы и инстансы — обёртки для JavaScript-классов (Date, Map, Canvas)
- Модель конкурентности — различия между BEAM processes и JS event loop
- DOM API — типобезопасная работа с браузером
- Интеграция с JS-библиотеками — обёртки для сторонних пакетов
JavaScript-таргет позволяет использовать Gleam для фронтенд-разработки — от простых скриптов до полноценных SPA. При этом сохраняются все преимущества типобезопасности и паттерн-матчинга.
Что дальше: В главе 13 мы изучим Lustre — фреймворк для построения UI на Gleam. Lustre абстрагирует низкоуровневую работу с DOM (которую мы изучили в этой главе) и предоставляет декларативный API в стиле The Elm Architecture. Знания из этой главы пригодятся для интеграции сторонних JavaScript-библиотек (date-fns, chart.js) в Lustre-приложения.
В следующей главе мы переключимся обратно на Erlang-таргет и построим веб-приложение с базой данных используя Wisp и PostgreSQL.
Процессы и OTP
«Сделайте так, чтобы всё было процессом» — Джо Армстронг, создатель Erlang
- Цели главы
- Модель акторов
- BEAM: виртуальная машина для конкурентности
- Процессы в Gleam
- Selector — мультиплексирование сообщений
- Паттерн запрос-ответ
- Акторы — процессы с состоянием
- Мониторинг и связывание
- Таймеры
- Супервизоры
- Проект: конкурентный счётчик с супервизором
- Сравнение с другими языками
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Поймём модель акторов — фундамент конкурентности на BEAM
- Научимся создавать процессы и обмениваться сообщениями
- Освоим
SubjectиSelectorдля типобезопасной коммуникации - Изучим акторы (
gleam/otp/actor) — основную абстракцию OTP - Разберём паттерн запрос-ответ (
actor.call) - Познакомимся с мониторингом и связыванием процессов
- Узнаем про супервизоры и стратегии перезапуска
- Прочувствуем философию «Let it crash»
Модель акторов
Связь с главой 8: В главе 8 мы изучили низкоуровневые FFI-функции для работы с процессами BEAM (
erlang:spawn,erlang:send,erlang:self), а также ETS для совместного состояния. В этой главе мы поднимемся на уровень выше и изучим типобезопасные абстракции OTP — акторы, супервизоры и паттерны построения отказоустойчивых систем.
Модель акторов — концептуальная модель конкурентных вычислений, предложенная Карлом Хьюиттом в 1973 году. Это не абстрактная теория — именно она лежит в основе Erlang, Elixir и Gleam.
Что такое актор?
Актор — примитивная единица вычислений. Каждый актор:
- Имеет собственное приватное состояние — никто другой не может его прочитать или изменить напрямую
- Имеет почтовый ящик (mailbox) — очередь входящих сообщений
- Обрабатывает сообщения по одному, последовательно
Когда актор получает сообщение, он может выполнить три действия:
- Создать новых акторов — породить дочерние процессы
- Отправить сообщения другим акторам
- Изменить своё состояние — определить, как обрабатывать следующее сообщение
Ключевые свойства
Изоляция. Акторы не имеют общей памяти. Единственный способ взаимодействия — отправка сообщений. Это устраняет целые классы ошибок: гонки данных (data races), мёртвые блокировки (deadlocks), нужду в мьютексах и семафорах.
Асинхронность. Отправка сообщения — неблокирующая операция. Отправитель не ждёт, пока получатель обработает сообщение. Сообщение попадает в mailbox и ждёт своей очереди.
Последовательная обработка. Хотя миллионы акторов работают параллельно, каждый конкретный актор обрабатывает сообщения строго по порядку. Внутри актора нет конкурентности — это упрощает рассуждения о корректности.
Отказоустойчивость. Если актор упал — ничего страшного. Его состояние изолировано, другие акторы не пострадали. Специальный актор-супервизор заметит падение и перезапустит упавшего.
Аналогия: почтовая служба
Представьте, что каждый актор — это сотрудник в офисе с персональным почтовым ящиком. Сотрудники не ходят друг к другу за стол — они кладут записки в почтовый ящик получателя. Каждый сотрудник проверяет свой ящик и обрабатывает записки по одной. Если сотрудник заболел (упал), менеджер (супервизор) нанимает нового и ставит на то же место.
BEAM: виртуальная машина для конкурентности
BEAM (Bogdan/Björn's Erlang Abstract Machine) — виртуальная машина, на которой работает Gleam (при компиляции в Erlang-таргет). BEAM была создана Ericsson в 1998 году и с тех пор используется в телекоммуникациях, мессенджерах (WhatsApp, Discord) и системах реального времени.
Процессы BEAM
Процессы BEAM — это не потоки операционной системы. Это чрезвычайно легковесные сущности:
| Характеристика | Процессы BEAM | Потоки ОС |
|---|---|---|
| Начальный размер | ~300 байт | ~1 МБ стека |
| Количество | миллионы | тысячи |
| Создание | микросекунды | миллисекунды |
| Планировщик | BEAM (вытесняющий) | ОС |
| Изоляция памяти | полная (свой heap) | общая память |
| GC | per-process (не stop-the-world) | общий |
Вытесняющая многозадачность
BEAM использует вытесняющую (preemptive) многозадачность. Каждому процессу выделяется квота «редукций» (~4000 операций). Когда квота израсходована, планировщик переключается на другой процесс. Это гарантирует:
- Ни один процесс не может заблокировать систему — даже бесконечный цикл не остановит другие процессы
- Soft real-time — время отклика предсказуемо
- Справедливость — все процессы получают процессорное время
Распределённость
Процессы BEAM могут общаться не только внутри одной машины, но и через сеть. BEAM поддерживает кластеры узлов (nodes), где процесс на узле A может отправить сообщение процессу на узле B так же просто, как локальному. Для этого используется модуль gleam/erlang/node.
Процессы в Gleam
Gleam предоставляет типобезопасные обёртки над процессами BEAM через модуль gleam/erlang/process.
Subject — типизированный канал
Subject(message) — ключевой тип для коммуникации между процессами. Subject — это типизированный канал, через который можно отправлять и получать сообщения определённого типа.
import gleam/erlang/process
pub fn main() {
// Создаём Subject для строковых сообщений
let subject: process.Subject(String) = process.new_subject()
// Отправляем сообщение
process.send(subject, "привет!")
// Получаем сообщение (таймаут 1000 мс)
let assert Ok(message) = process.receive(subject, 1000)
// message == "привет!"
}
Важные свойства Subject:
- Типизированный —
Subject(String)принимает только строки,Subject(Int)— только числа - Принадлежит процессу — каждый Subject «принадлежит» процессу, который его создал. Только этот процесс может получать из него сообщения
- Передаваемый — Subject можно передать другому процессу, чтобы тот мог отправлять в него сообщения
Создание процессов
Для создания нового процесса используется process.start:
import gleam/erlang/process
import gleam/io
pub fn main() {
// Создаём Subject для получения результата
let subject = process.new_subject()
// Запускаем новый процесс
// Второй аргумент: True = связанный (linked) процесс
process.start(fn() {
// Этот код выполняется в ДРУГОМ процессе
let result = expensive_computation()
process.send(subject, result)
}, True)
// Ждём результат в текущем процессе
let assert Ok(result) = process.receive(subject, 5000)
io.println("Результат: " <> result)
}
fn expensive_computation() -> String {
process.sleep(100) // Имитация долгой работы
"42"
}
Здесь process.start(fn, linked) создаёт новый процесс, который выполняет переданную функцию. Второй аргумент определяет, будет ли новый процесс связан с родительским (подробнее о связывании — ниже).
Пример: параллельные вычисления
Запустим несколько процессов одновременно и соберём результаты:
import gleam/erlang/process
import gleam/int
import gleam/list
pub fn parallel_sum(numbers: List(Int)) -> Int {
let subject = process.new_subject()
// Запускаем процесс для каждого числа
list.each(numbers, fn(n) {
process.start(fn() {
// Каждый процесс вычисляет квадрат
process.send(subject, n * n)
}, True)
})
// Собираем результаты
let length = list.length(numbers)
collect_results(subject, length, 0)
}
fn collect_results(
subject: process.Subject(Int),
remaining: Int,
acc: Int,
) -> Int {
case remaining {
0 -> acc
_ -> {
let assert Ok(value) = process.receive(subject, 5000)
collect_results(subject, remaining - 1, acc + value)
}
}
}
Важно: порядок получения результатов не гарантирован. Процессы работают параллельно, и кто первый вычислит — тот первый отправит сообщение. Для суммы это не важно, но для упорядоченных результатов нужна дополнительная логика.
receive с таймаутом
process.receive(subject, timeout_ms) возвращает Result:
case process.receive(subject, 100) {
Ok(message) -> io.println("Получено: " <> message)
Error(Nil) -> io.println("Таймаут: сообщение не пришло за 100 мс")
}
Таймаут в миллисекундах. Если сообщение не пришло за указанное время — возвращается Error(Nil). Для бесконечного ожидания используйте process.receive_forever(subject).
Selector — мультиплексирование сообщений
Что если процесс ждёт сообщения из нескольких источников? Например, и от пользователя, и от таймера? Для этого есть Selector.
Selector(payload) позволяет ждать сообщения от нескольких Subject одновременно, возвращая первое пришедшее:
import gleam/erlang/process
import gleam/int
pub fn main() {
let string_subject = process.new_subject()
let int_subject = process.new_subject()
// Отправляем сообщения разных типов
process.send(int_subject, 42)
process.send(string_subject, "hello")
// Создаём Selector, который принимает оба типа → String
let selector =
process.new_selector()
|> process.select(string_subject)
|> process.select_map(int_subject, int.to_string)
// Получаем первое доступное сообщение
case process.selector_receive(selector, 100) {
Ok(value) -> value // Строка — либо "hello", либо "42"
Error(Nil) -> "таймаут"
}
}
Ключевые функции:
process.new_selector()— создаёт пустой селекторprocess.select(selector, subject)— добавляет Subject (тип должен совпадать с payload)process.select_map(selector, subject, fn)— добавляет Subject с преобразованием типаprocess.selector_receive(selector, timeout)— ждёт сообщение из любого добавленного Subjectprocess.merge_selector(a, b)— объединяет два селектора
Selector полезен, когда актор должен реагировать на разные типы событий: пользовательские сообщения, таймеры, сигналы от других процессов.
Паттерн запрос-ответ
Часто нужен синхронный вызов: отправить запрос и дождаться ответа. Функция process.call реализует этот паттерн:
import gleam/erlang/process
pub fn main() {
let server = process.new_subject()
// Запускаем «сервер» в отдельном процессе
process.start(fn() { server_loop(server, 0) }, True)
// Синхронный вызов: отправляем запрос, ждём ответ
let count = process.call(server, 1000, fn(reply_to) {
GetCount(reply_to:)
})
// count == 0
}
pub type ServerMsg {
Increment
GetCount(reply_to: process.Subject(Int))
}
fn server_loop(
subject: process.Subject(ServerMsg),
state: Int,
) -> Nil {
case process.receive_forever(subject) {
Increment -> server_loop(subject, state + 1)
GetCount(reply_to:) -> {
process.send(reply_to, state)
server_loop(subject, state)
}
}
}
process.call(subject, timeout, make_request) делает три вещи:
- Создаёт временный Subject для ответа
- Вызывает
make_request(reply_subject)и отправляет результат серверу - Ждёт ответ на временном Subject
Если ответ не пришёл за timeout миллисекунд — процесс падает (crash). Для более мягкой обработки таймаута используйте ручной receive.
Акторы — процессы с состоянием
Паттерн «процесс + состояние + цикл обработки сообщений» настолько частый, что для него есть готовая абстракция — актор из gleam/otp/actor.
Зачем нужны акторы?
Сравните ручной цикл обработки:
// Ручная реализация — много шаблонного кода
fn server_loop(subject, state) {
case process.receive_forever(subject) {
msg1 -> server_loop(subject, handle_msg1(state, msg1))
msg2 -> server_loop(subject, handle_msg2(state, msg2))
}
}
И актор:
// Актор — только логика обработки
fn handle_message(state, message) {
case message {
msg1 -> actor.continue(handle_msg1(state, msg1))
msg2 -> actor.continue(handle_msg2(state, msg2))
}
}
Актор берёт на себя:
- Создание процесса и Subject
- Цикл получения сообщений
- Обработку инициализации
- Интеграцию с OTP (супервизоры, hot reload)
Создание актора
Актор создаётся через builder-паттерн:
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/result
pub type CounterMsg {
Increment
Decrement
GetCount(reply_to: Subject(Int))
}
fn handle_counter(state: Int, message: CounterMsg) -> actor.Next(Int, CounterMsg) {
case message {
Increment -> actor.continue(state + 1)
Decrement -> actor.continue(state - 1)
GetCount(reply_to) -> {
process.send(reply_to, state)
actor.continue(state)
}
}
}
pub fn start_counter() -> Result(Subject(CounterMsg), actor.StartError) {
actor.new(0) // начальное состояние
|> actor.on_message(handle_counter) // обработчик сообщений
|> actor.start // запуск процесса
|> result.map(fn(started) { started.data }) // извлекаем Subject
}
Разберём по шагам:
actor.new(0)— создаёт builder с начальным состоянием0actor.on_message(handle_counter)— устанавливает функцию обработки. Сигнатура:fn(state, message) -> actor.Next(state, message)actor.start— запускает процесс, возвращаетResult(Started(data), StartError), гдеdata— Subject для отправки сообщений акторуresult.map(fn(started) { started.data })— извлекаем Subject изStarted
Обработка сообщений
Функция-обработчик возвращает actor.Next(state, message), указывая, что делать дальше:
// Продолжить работу с новым состоянием
actor.continue(new_state)
// Остановить актор (нормальное завершение)
actor.stop()
// Остановить актор с ошибкой
actor.stop_abnormal("причина ошибки")
В большинстве обработчиков используется actor.continue с обновлённым состоянием. actor.stop() — для планового завершения (задача выполнена, актор больше не нужен). actor.stop_abnormal — когда актор столкнулся с ситуацией, которую не может обработать: супервизор получит сигнал об аварийном завершении и перезапустит процесс.
actor.send — fire-and-forget
Для отправки сообщения без ожидания ответа:
pub fn main() {
let assert Ok(counter) = start_counter()
// Отправляем сообщения — не ждём ответа
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Decrement)
}
actor.send — это просто псевдоним для process.send. Сообщение попадает в mailbox актора и будет обработано, когда до него дойдёт очередь.
actor.call — запрос с ответом
Для синхронных запросов используйте actor.call:
pub fn main() {
let assert Ok(counter) = start_counter()
actor.send(counter, Increment)
actor.send(counter, Increment)
// Синхронный запрос — ждём ответ (таймаут 1000 мс)
let count = actor.call(counter, waiting: 1000, sending: GetCount)
// count == 2
}
actor.call(subject, waiting: timeout, sending: make_message) работает так:
- Создаёт временный Subject для ответа
- Вызывает
make_message(reply_subject)— создаёт сообщение с каналом ответа - Отправляет это сообщение актору
- Ждёт ответ
Если конструктор сообщения принимает ровно один аргумент (Subject ответа), можно передать его напрямую:
// Эквивалентные записи:
actor.call(counter, waiting: 1000, sending: GetCount)
actor.call(counter, waiting: 1000, sending: fn(reply) { GetCount(reply) })
Первый вариант работает, когда конструктор принимает ровно один аргумент типа Subject — тогда его можно передать напрямую. Второй вариант нужен, если конструктор принимает дополнительные поля: fn(reply) { GetCount(reply_to: reply, extra: value) }.
Полный пример: актор-стек
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/result
pub type StackMsg(a) {
Push(value: a)
Pop(reply_to: Subject(Result(a, Nil)))
Size(reply_to: Subject(Int))
}
fn handle_stack(
stack: List(a),
message: StackMsg(a),
) -> actor.Next(List(a), StackMsg(a)) {
case message {
Push(value) -> actor.continue([value, ..stack])
Pop(reply_to) -> {
case stack {
[] -> {
process.send(reply_to, Error(Nil))
actor.continue([])
}
[top, ..rest] -> {
process.send(reply_to, Ok(top))
actor.continue(rest)
}
}
}
Size(reply_to) -> {
process.send(reply_to, list.length(stack))
actor.continue(stack)
}
}
}
pub fn start_stack() -> Result(Subject(StackMsg(a)), actor.StartError) {
actor.new([])
|> actor.on_message(handle_stack)
|> actor.start
|> result.map(fn(started) { started.data })
}
// Удобные обёртки
pub fn push(stack: Subject(StackMsg(a)), value: a) -> Nil {
actor.send(stack, Push(value))
}
pub fn pop(stack: Subject(StackMsg(a))) -> Result(a, Nil) {
actor.call(stack, waiting: 1000, sending: Pop)
}
pub fn size(stack: Subject(StackMsg(a))) -> Int {
actor.call(stack, waiting: 1000, sending: Size)
}
Обратите внимание на паттерн: приватный тип сообщений + публичные обёртки. Пользователь актора вызывает push(stack, 42), а не actor.send(stack, Push(42)). Это скрывает детали протокола.
Именованные акторы
По умолчанию для взаимодействия с актором нужен его Subject. Но иногда удобно обращаться к актору по имени — например, если это глобальный сервис (кеш, конфигурация, счётчик метрик).
import gleam/erlang/process
import gleam/otp/actor
pub fn start_named_counter(
name: process.Name(CounterMsg),
) -> Result(Subject(CounterMsg), actor.StartError) {
actor.new(0)
|> actor.named(name)
|> actor.on_message(handle_counter)
|> actor.start
|> result.map(fn(started) { started.data })
}
pub fn main() {
// Создаём уникальное имя
let counter_name = process.new_name("global_counter")
// Запускаем именованный актор
let assert Ok(_) = start_named_counter(counter_name)
// Получаем Subject по имени — из любого места программы
let subject = process.named_subject(counter_name)
actor.send(subject, Increment)
}
Именованные акторы полезны, когда Subject нельзя передать напрямую — например, в распределённых системах или при интеграции с Erlang-кодом.
Мониторинг и связывание
Что происходит, когда процесс падает? BEAM предоставляет два механизма обнаружения сбоев.
link — связывание процессов
Связывание (link) создаёт двунаправленную связь между процессами. Если один из связанных процессов падает — второй тоже падает:
import gleam/erlang/process
pub fn main() {
// process.start с True создаёт связанный процесс
let _pid = process.start(fn() {
process.sleep(100)
panic as "Я упал!"
}, True) // True = linked
// Через 100 мс дочерний процесс упадёт,
// и текущий процесс тоже упадёт (они связаны)
process.sleep(200)
}
Связывание обеспечивает домен отказа (failure domain): группа связанных процессов либо все работают, либо все падают. Это полезно, когда процессы зависят друг от друга.
monitor — наблюдение за процессами
Мониторинг — однонаправленное наблюдение. Наблюдатель получает сообщение о падении, но сам не падает:
import gleam/erlang/process
import gleam/io
pub fn main() {
// Запускаем несвязанный процесс
let pid = process.start(fn() {
process.sleep(100)
panic as "Авария!"
}, False) // False = unlinked
// Начинаем мониторинг
let monitor = process.monitor(pid)
// Создаём Selector для получения Down-сообщений
let selector =
process.new_selector()
|> process.select_specific_monitor(monitor, fn(down) { down })
// Ждём сообщение о падении
case process.selector_receive(selector, 500) {
Ok(_down) -> io.println("Процесс упал, но мы живы!")
Error(Nil) -> io.println("Таймаут")
}
}
Мониторинг полезен, когда нужно реагировать на падение, не разделяя судьбу упавшего процесса.
trap_exits — перехват сигналов завершения
trap_exits позволяет связанному процессу перехватить сигнал завершения вместо того, чтобы самому упасть:
import gleam/erlang/process
pub fn main() {
// Включаем перехват сигналов завершения
process.trap_exits(True)
// Запускаем связанный процесс, который упадёт
let _pid = process.start(fn() {
process.sleep(50)
panic as "Ошибка"
}, True)
// Вместо падения получаем ExitMessage
let selector =
process.new_selector()
|> process.select_trapped_exits(fn(exit_msg) { exit_msg })
case process.selector_receive(selector, 200) {
Ok(process.ExitMessage(pid: _, reason:)) -> {
case reason {
process.Normal -> "нормальное завершение"
process.Killed -> "процесс убит"
process.Abnormal(_) -> "аварийное завершение"
}
}
Error(Nil) -> "таймаут"
}
}
Примечание:
trap_exitsнужен редко. В большинстве случаев используйте супервизоры вместо ручного перехвата.
Таймеры
BEAM предоставляет встроенные таймеры для отложенной отправки сообщений.
send_after
process.send_after(subject, delay, message) отправляет сообщение через указанное количество миллисекунд:
import gleam/erlang/process
pub fn main() {
let subject = process.new_subject()
// Отправить сообщение через 500 мс
let timer = process.send_after(subject, 500, "Время вышло!")
// Ждём сообщение
let assert Ok(msg) = process.receive(subject, 1000)
// msg == "Время вышло!"
}
process.send_after возвращает TimerReference — он понадобится, если нужно отменить таймер. process.receive блокирует текущий процесс до получения сообщения или истечения таймаута (второй аргумент в мс).
cancel_timer
Таймер можно отменить до срабатывания:
let timer = process.send_after(subject, 5000, "tick")
// Отменяем таймер
case process.cancel_timer(timer) {
process.Cancelled(time_remaining:) ->
io.println("Отменён, оставалось: " <> int.to_string(time_remaining) <> " мс")
process.TimerNotFound ->
io.println("Таймер уже сработал или не найден")
}
process.TimerNotFound означает, что таймер уже сработал к моменту отмены — типичная гонка состояний, которую нужно предусмотреть. time_remaining содержит оставшееся время в миллисекундах на момент отмены.
sleep
process.sleep(ms) приостанавливает текущий процесс на указанное время. Это не блокирует другие процессы — только текущий:
process.sleep(1000) // Пауза 1 секунда
На BEAM sleep не блокирует поток ОС — планировщик переключится на другие лёгкие процессы, а текущий будет разбужен по истечении указанного времени.
Супервизоры
Супервизоры — сердце философии «Let it crash». Вместо того чтобы писать защитный код для каждой возможной ошибки, мы позволяем процессам падать и полагаемся на супервизоров для восстановления.
Философия «Let it crash»
В традиционном программировании ошибки обрабатываются «оборонительно»:
// Типичный подход — оборонительное программирование
try {
resource = acquire()
try {
result = process(resource)
} catch (ProcessingError e) {
log(e)
rollback(resource)
return default
} finally {
release(resource)
}
} catch (AcquireError e) {
log(e)
return null
}
На BEAM подход другой:
// BEAM подход — Let it crash
result = process(acquire())
// Если что-то пошло не так — процесс упадёт
// Супервизор перезапустит его в чистом состоянии
Почему это работает?
- Изоляция — падение одного процесса не затрагивает другие
- Чистый перезапуск — новый процесс начинает с известного хорошего состояния
- Отделение обработки ошибок — логика восстановления в супервизоре, бизнес-логика — в акторе
- Работает для непредвиденных ошибок — нет нужды предвидеть все возможные сбои
Важно: «Let it crash» не означает «игнорируй ошибки». Ожидаемые ошибки (невалидный ввод, файл не найден) обрабатываются через
Result. «Let it crash» — для неожиданных ситуаций (OOM, битые данные, баги).
static_supervisor
gleam/otp/static_supervisor создаёт дерево надзора:
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision
pub fn start_application() -> actor.StartResult(supervisor.Supervisor) {
supervisor.new(supervisor.OneForOne)
|> supervisor.add(supervision.worker(start_counter))
|> supervisor.add(supervision.worker(start_cache))
|> supervisor.start
}
supervision.worker(start_fn) оборачивает функцию запуска актора. Супервизор вызовет эту функцию при старте и при каждом перезапуске.
Стратегии перезапуска
Супервизор должен знать, что делать при падении дочернего процесса. Три стратегии:
OneForOne — перезапускается только упавший процесс:
До падения: [A] [B] [C]
B падает: [A] [B'] [C] ← только B перезапущен
Используйте, когда дочерние процессы независимы друг от друга.
OneForAll — при падении одного перезапускаются все:
До падения: [A] [B] [C]
B падает: [A'] [B'] [C'] ← все перезапущены
Используйте, когда дочерние процессы тесно связаны и не могут работать без друг друга.
RestForOne — перезапускаются упавший и все запущенные после него:
До падения: [A] [B] [C]
B падает: [A] [B'] [C'] ← B и C перезапущены, A не тронут
Используйте, когда есть упорядоченная зависимость: C зависит от B, B зависит от A.
// OneForOne — независимые воркеры
supervisor.new(supervisor.OneForOne)
// OneForAll — тесно связанные сервисы
supervisor.new(supervisor.OneForAll)
// RestForOne — цепочка зависимостей: db → cache → web
supervisor.new(supervisor.RestForOne)
|> supervisor.add(supervision.worker(start_database))
|> supervisor.add(supervision.worker(start_cache))
|> supervisor.add(supervision.worker(start_web))
Неправильный выбор стратегии ведёт к проблемам: OneForAll для независимых процессов порождает лишние перезапуски; OneForOne для сильно связанных — оставляет систему в несогласованном состоянии. Если сомневаетесь — начинайте с OneForOne и меняйте, когда поведение оказывается некорректным.
Настройка допуска перезапуска
Чтобы избежать бесконечных циклов перезапуска, можно ограничить частоту:
supervisor.new(supervisor.OneForOne)
|> supervisor.restart_tolerance(intensity: 5, period: 60)
// Максимум 5 перезапусков за 60 секунд
// Если больше — супервизор сам завершается
Если лимит превышен, супервизор считает ситуацию неисправимой и завершается сам — его ошибка поднимается к родительскому супервизору по дереву. Это предотвращает бесконечные циклы перезапуска: если процесс падает сразу после старта снова и снова, значит проблема системная, а не временная.
Стратегии перезапуска дочерних процессов
Каждый дочерний процесс имеет собственную стратегию:
// Permanent — всегда перезапускать (по умолчанию)
supervision.worker(start_critical_service)
|> supervision.restart(supervision.Permanent)
// Transient — перезапускать только при аварийном завершении
supervision.worker(start_worker)
|> supervision.restart(supervision.Transient)
// Temporary — никогда не перезапускать
supervision.worker(start_one_off_task)
|> supervision.restart(supervision.Temporary)
Стратегия по умолчанию — Permanent: актор всегда перезапускается, независимо от причины завершения. Transient подходит для воркеров, которые могут завершиться нормально (обработали задачу), но должны перезапускаться при сбоях. Temporary — для одноразовых задач, которые не нужно повторять.
Деревья супервизоров
В реальных приложениях супервизоры образуют дерево. Корневой супервизор наблюдает за дочерними супервизорами, которые в свою очередь наблюдают за рабочими процессами:
[Корневой супервизор]
/ \
[Супервизор БД] [Супервизор воркеров]
/ \ / | \
[Пул [Кеш] [Воркер1] [Воркер2] [Воркер3]
подключений]
Если Воркер2 упадёт — Супервизор воркеров перезапустит его. Если Супервизор воркеров сам упадёт — Корневой супервизор перезапустит его вместе со всеми воркерами. Это обеспечивает поэтапное восстановление.
Проект: конкурентный счётчик с супервизором
Объединим все концепции в рабочем проекте. Создадим счётчик с API для инкремента, декремента и получения значения, обёрнутый в супервизор для отказоустойчивости.
Определение актора
import gleam/erlang/process.{type Subject}
import gleam/io
import gleam/int
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision
import gleam/result
// Тип сообщений
pub type CounterMsg {
Increment
Decrement
GetCount(reply_to: Subject(Int))
Reset
}
// Обработчик сообщений
fn handle_counter(
state: Int,
message: CounterMsg,
) -> actor.Next(Int, CounterMsg) {
case message {
Increment -> actor.continue(state + 1)
Decrement -> actor.continue(state - 1)
GetCount(reply_to) -> {
process.send(reply_to, state)
actor.continue(state)
}
Reset -> actor.continue(0)
}
}
// Функция запуска актора (для супервизора)
pub fn start_counter() -> actor.StartResult(Subject(CounterMsg)) {
actor.new(0)
|> actor.on_message(handle_counter)
|> actor.start
}
// Удобный API
pub fn increment(counter: Subject(CounterMsg)) -> Nil {
actor.send(counter, Increment)
}
pub fn decrement(counter: Subject(CounterMsg)) -> Nil {
actor.send(counter, Decrement)
}
pub fn get_count(counter: Subject(CounterMsg)) -> Int {
actor.call(counter, waiting: 1000, sending: GetCount)
}
pub fn reset(counter: Subject(CounterMsg)) -> Nil {
actor.send(counter, Reset)
}
Публичный API скрывает детали реализации: вызывающий код работает с Subject(CounterMsg), не зная ничего о внутреннем сообщении GetCount или том, как работает actor.call.
Супервизор
pub fn start_supervised() -> actor.StartResult(supervisor.Supervisor) {
supervisor.new(supervisor.OneForOne)
|> supervisor.restart_tolerance(intensity: 3, period: 10)
|> supervisor.add(supervision.worker(start_counter))
|> supervisor.start
}
supervision.worker(start_counter) говорит супервизору: «этот дочерний процесс — рабочий (не супервизор)» и передаёт функцию запуска. restart_tolerance(intensity: 3, period: 10) — не более 3 перезапусков за 10 секунд.
Использование
pub fn main() {
let assert Ok(sup) = start_supervised()
// Получаем Subject счётчика через start_counter напрямую
let assert Ok(counter) = start_counter()
|> result.map(fn(started) { started.data })
increment(counter)
increment(counter)
increment(counter)
decrement(counter)
let count = get_count(counter)
io.println("Счётчик: " <> int.to_string(count))
// Счётчик: 2
}
Если процесс счётчика упадёт (из-за бага, OOM или непредвиденной ошибки), супервизор автоматически перезапустит его с начальным состоянием 0.
Сравнение с другими языками
| Возможность | Gleam/BEAM | Go | Rust | OCaml |
|---|---|---|---|---|
| Конкурентность | Процессы (акторы) | Goroutines + каналы | tokio tasks + каналы | Eio fibers |
| Изоляция | Полная (свой heap) | Общая память | Ownership + Send/Sync | Общая память |
| Планировщик | Вытесняющий | Кооперативный | Кооперативный | Кооперативный |
| Отказоустойчивость | Супервизоры (OTP) | Нет встроенной | Нет встроенной | Нет встроенной |
| Типобезопасность сообщений | Subject(msg) | chan T | mpsc::Sender | Нет |
| Распределённость | Встроенная (nodes) | Нет | Нет | Нет |
Ключевое преимущество BEAM — вытесняющий планировщик и встроенная отказоустойчивость через деревья супервизоров. Другие языки требуют внешних фреймворков для аналогичного поведения.
Упражнения
Все упражнения этой главы работают с процессами и акторами. Решения пишите в файле exercises/chapter08/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter08
gleam test
Запускайте тесты после каждого упражнения — они проверяют корректность реализации актора и его поведение под нагрузкой.
1. echo_actor — эхо-актор (Лёгкое)
Создайте актор, который возвращает полученное сообщение обратно отправителю.
pub type EchoMsg {
Echo(value: String, reply_to: process.Subject(String))
}
pub fn start_echo() -> Result(process.Subject(EchoMsg), actor.StartError)
pub fn send_echo(actor: process.Subject(EchoMsg), message: String) -> String
Примеры:
send_echo(actor, "hello") == "hello"
send_echo(actor, "мир") == "мир"
Подсказка: состояние актора не используется — передайте Nil. Для send_echo используйте actor.call.
2. accumulator — актор-аккумулятор (Лёгкое)
Создайте актор, который накапливает сумму целых чисел.
pub type AccMsg {
Add(value: Int)
GetTotal(reply_to: process.Subject(Int))
}
pub fn start_accumulator() -> Result(process.Subject(AccMsg), actor.StartError)
pub fn accumulate(actor: process.Subject(AccMsg), value: Int) -> Nil
pub fn get_total(actor: process.Subject(AccMsg)) -> Int
Примеры:
accumulate(actor, 10)
accumulate(actor, 20)
accumulate(actor, 30)
get_total(actor) == 60
Подсказка: начальное состояние — 0. accumulate использует actor.send (fire-and-forget), get_total — actor.call.
3. spawn_compute — вычисление в процессе (Лёгкое)
Реализуйте функцию, которая запускает произвольное вычисление в отдельном процессе и возвращает результат в вызывающий процесс.
pub fn spawn_compute(f: fn() -> a) -> a
Примеры:
spawn_compute(fn() { 42 }) == 42
spawn_compute(fn() { "hello" }) == "hello"
Подсказка: создайте Subject, запустите процесс через process.start, в процессе вызовите f() и отправьте результат через process.send. Получите результат через process.receive.
4. key_value_store — хранилище ключ-значение (Среднее)
Создайте актор, реализующий in-memory хранилище ключ-значение на основе Dict.
pub type KvMsg {
Put(key: String, value: String)
Get(key: String, reply_to: process.Subject(Result(String, Nil)))
Delete(key: String)
AllKeys(reply_to: process.Subject(List(String)))
}
pub fn start_kv() -> Result(process.Subject(KvMsg), actor.StartError)
pub fn kv_put(store: process.Subject(KvMsg), key: String, value: String) -> Nil
pub fn kv_get(store: process.Subject(KvMsg), key: String) -> Result(String, Nil)
pub fn kv_delete(store: process.Subject(KvMsg), key: String) -> Nil
pub fn kv_all_keys(store: process.Subject(KvMsg)) -> List(String)
Примеры:
kv_put(store, "name", "Alice")
kv_get(store, "name") == Ok("Alice")
kv_get(store, "age") == Error(Nil)
kv_delete(store, "name")
kv_get(store, "name") == Error(Nil)
kv_all_keys(store) == []
Подсказка: начальное состояние — dict.new(). Используйте dict.insert, dict.get, dict.delete, dict.keys.
5. stack_actor — актор-стек (Среднее)
Создайте актор, реализующий стек (LIFO). Поддержите операции: push, pop, peek (посмотреть верхний без удаления), size.
pub type StackMsg(a) {
StackPush(value: a)
StackPop(reply_to: process.Subject(Result(a, Nil)))
StackPeek(reply_to: process.Subject(Result(a, Nil)))
StackSize(reply_to: process.Subject(Int))
}
pub fn start_stack() -> Result(process.Subject(StackMsg(a)), actor.StartError)
pub fn stack_push(stack: process.Subject(StackMsg(a)), value: a) -> Nil
pub fn stack_pop(stack: process.Subject(StackMsg(a))) -> Result(a, Nil)
pub fn stack_peek(stack: process.Subject(StackMsg(a))) -> Result(a, Nil)
pub fn stack_size(stack: process.Subject(StackMsg(a))) -> Int
Примеры:
stack_push(s, 1)
stack_push(s, 2)
stack_push(s, 3)
stack_peek(s) == Ok(3) // верхний элемент, не удаляя
stack_pop(s) == Ok(3) // извлечь верхний
stack_pop(s) == Ok(2)
stack_size(s) == 1 // остался только 1
stack_pop(s) == Ok(1)
stack_pop(s) == Error(Nil) // пустой стек
Подсказка: состояние — List(a). Push — [value, ..stack]. Pop — pattern matching на [top, ..rest].
6. parallel_map — параллельный map (Сложное)
Реализуйте функцию, которая применяет функцию к каждому элементу списка параллельно, запуская отдельный процесс для каждого элемента.
pub fn parallel_map(items: List(a), f: fn(a) -> b) -> List(b)
Результаты должны быть в том же порядке, что и входной список.
Примеры:
parallel_map([1, 2, 3], fn(x) { x * 2 }) == [2, 4, 6]
parallel_map(["a", "b"], string.uppercase) == ["A", "B"]
parallel_map([], fn(x) { x }) == []
Подсказка: для сохранения порядка создайте отдельный Subject для каждого элемента. Запустите процесс для каждого элемента, который отправит результат в свой Subject. Затем соберите результаты в правильном порядке через list.map(subjects, fn(s) { receive(s, ...) }).
Заключение
В этой главе мы изучили:
- Модель акторов — изоляция, mailbox, последовательная обработка
- Процессы BEAM — легковесные, с вытесняющей многозадачностью
- Subject — типизированный канал для обмена сообщениями
- Selector — мультиплексирование нескольких источников
- Акторы (
gleam/otp/actor) — процессы с состоянием и обработчиком - actor.send и actor.call — асинхронные и синхронные сообщения
- Мониторинг и связывание — обнаружение сбоев
- Супервизоры — автоматический перезапуск упавших процессов
- «Let it crash» — философия отказоустойчивости
В следующей главе мы изучим тестирование в Gleam — от unit-тестов с gleeunit до property-based testing с qcheck и snapshot-тестов с birdie.
Тестирование
«Testing can be fun, actually» — Джакомо Кавальери, автор birdie
- Цели главы
- Зачем тестировать?
- gleeunit — стандартный фреймворк
- Тестирование акторов
- Property-based testing с qcheck
- Snapshot-тестирование с birdie
- Проект: тестирование библиотеки коллекций
- Тестирование JSON roundtrip
- CI: тестирование в GitHub Actions
- Упражнения
- Заключение
Цели главы
В этой главе мы:
- Освоим gleeunit — стандартный тестовый фреймворк Gleam
- Научимся писать выразительные утверждения с
should - Изучим property-based testing (PBT) с qcheck
- Познакомимся с генераторами и shrinking
- Попробуем snapshot-тестирование с birdie
- Разберём паттерны организации тестов
- Научимся тестировать акторы и асинхронный код
- Настроим CI с GitHub Actions
Зачем тестировать?
Gleam — строго типизированный язык, и компилятор ловит многие ошибки. Но типы не могут проверить всё:
- Правильность бизнес-логики (
sortвозвращает отсортированный список, а не простоList(Int)) - Граничные случаи (пустой список, отрицательные числа, unicode)
- Взаимодействие компонентов (JSON encode → decode = оригинал?)
- Регрессии (исправили баг — не сломали другое)
Тесты дополняют типы: типы гарантируют структурную корректность, тесты — семантическую.
gleeunit — стандартный фреймворк
gleeunit — стандартный тестовый раннер для Gleam. Он минималистичен: запускает все публичные функции с суффиксом _test в модулях из директории test/.
Структура теста
// test/my_module_test.gleam
import gleeunit
import gleeunit/should
import my_module
pub fn main() -> Nil {
gleeunit.main()
}
pub fn add_test() {
my_module.add(1, 2)
|> should.equal(3)
}
pub fn add_zero_test() {
my_module.add(0, 0)
|> should.equal(0)
}
Правила:
- Файл в директории
test/ - Функция
mainвызываетgleeunit.main() - Каждый тест — публичная функция с суффиксом
_test - Тесты не принимают аргументов и возвращают
Nil
Запуск тестов
$ gleam test
Compiling chapter09
Compiled in 0.15s
Running chapter09_test.main
.....
5 tests passed
gleeunit выводит точку за каждый прошедший тест и итоговое число. При провале — подробный вывод с ожидаемым и полученным значением.
Утверждения (assertions)
Модуль gleeunit/should предоставляет набор утверждений:
import gleeunit/should
// Равенство
1 + 1 |> should.equal(2)
"hello" |> should.not_equal("world")
// Result
Ok(42) |> should.be_ok
Error("oops") |> should.be_error
// Bool
True |> should.be_true
False |> should.be_false
// Безусловный провал
should.fail()
Все функции should.* при неуспехе паникуют — тест считается проваленным, и gleeunit сообщает, какое значение ожидалось и какое получено.
Пример: тестирование чистых функций
import gleam/string
import gleeunit/should
pub fn capitalize_test() {
string.capitalise("hello")
|> should.equal("Hello")
}
pub fn capitalize_empty_test() {
string.capitalise("")
|> should.equal("")
}
pub fn capitalize_already_test() {
string.capitalise("Hello")
|> should.equal("Hello")
}
Три теста покрывают обычный случай, граничный (пустая строка) и идемпотентный (уже с заглавной буквы). Каждый тест — маленькая история: «при таком входе — ожидаю такой выход».
Пример: тестирование Result
import gleam/int
import gleeunit/should
pub fn parse_valid_test() {
int.parse("42")
|> should.be_ok
|> should.equal(42)
}
pub fn parse_invalid_test() {
int.parse("not a number")
|> should.be_error
}
Обратите внимание на цепочку: should.be_ok возвращает значение внутри Ok, поэтому можно продолжить |> should.equal(42).
Организация тестов
Хорошие практики организации:
// Группируйте тесты по функции с комментариями-разделителями
// ============================================================
// Тесты для sort
// ============================================================
pub fn sort_empty_test() { ... }
pub fn sort_single_test() { ... }
pub fn sort_already_sorted_test() { ... }
pub fn sort_reverse_test() { ... }
// ============================================================
// Тесты для filter
// ============================================================
pub fn filter_empty_test() { ... }
pub fn filter_none_match_test() { ... }
pub fn filter_all_match_test() { ... }
Имена тестов должны описывать что проверяется:
sort_empty_test— сортировка пустого спискаparse_negative_number_test— парсинг отрицательного числаkv_delete_nonexistent_test— удаление несуществующего ключа
Тестирование акторов
Акторы из главы 8 тоже нужно тестировать. Подход прямолинейный: создаём актор, отправляем сообщения, проверяем ответы.
import gleam/otp/actor
import gleeunit/should
pub fn counter_increment_test() {
let assert Ok(counter) = start_counter()
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.call(counter, waiting: 1000, sending: GetCount)
|> should.equal(3)
}
Тест запускает актора, отправляет три Increment через actor.send (пожар-и-забыл), затем через actor.call синхронно получает состояние. call гарантирует, что все предыдущие сообщения обработаны к моменту ответа.
Таймауты в тестах
По умолчанию gleeunit даёт каждому тесту 5 секунд. Для тестов с акторами этого обычно достаточно, но если тест включает process.sleep или ожидание сообщений, может не хватить.
Совет: в тестах используйте небольшие таймауты (
waiting: 100) вместоwaiting: 1000. Если актор не отвечает за 100 мс — скорее всего, есть баг, а не медленность.
Изоляция тестов
Каждый тест должен создавать собственные акторы. Не используйте общие акторы между тестами — порядок выполнения не гарантирован:
// ✓ Хорошо: каждый тест создаёт своего актора
pub fn test_a() {
let assert Ok(actor) = start_counter()
// ...
}
pub fn test_b() {
let assert Ok(actor) = start_counter()
// ...
}
Создавать акторов в каждом тесте — правильный подход: тесты независимы и могут запускаться в любом порядке. Общий актор между тестами приводит к недетерминированным провалам.
Property-based testing с qcheck
Unit-тесты проверяют конкретные примеры: sort([3, 1, 2]) == [1, 2, 3]. Но что если пропущен граничный случай?
Property-based testing (PBT) — подход, при котором вы описываете свойства (законы), которым должна удовлетворять функция, а фреймворк генерирует сотни случайных входных данных и проверяет, что свойства выполняются.
Концепция
Вместо sort([3, 1, 2]) == [1, 2, 3] мы пишем:
«Для любого списка
xs, послеsort(xs)каждый элемент ≤ следующего»
Фреймворк генерирует списки: [], [1], [5, -3, 0, 99, -42], [1, 1, 1], ... — и проверяет свойство на каждом.
qcheck — PBT для Gleam
import gleam/list
import qcheck
pub fn sort_is_sorted_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
let sorted = list.sort(xs, int.compare)
is_sorted(sorted)
|> should.be_true
}
fn is_sorted(xs: List(Int)) -> Bool {
case xs {
[] | [_] -> True
[a, b, ..rest] ->
case a <= b {
True -> is_sorted([b, ..rest])
False -> False
}
}
}
qcheck.given(generator) запускает property-test:
- Генерирует случайные значения с помощью
generator - Передаёт каждое значение в функцию-свойство
- Если свойство нарушено — сжимает (shrinks) контрпример до минимального
Генераторы
Генераторы — источники случайных данных:
// Примитивные генераторы
qcheck.int() // случайный Int
qcheck.float() // случайный Float
qcheck.string() // случайная String
qcheck.bool() // True или False
// Коллекции
qcheck.list(qcheck.int()) // List(Int)
qcheck.list(qcheck.string()) // List(String)
// Ограниченные диапазоны
qcheck.int_uniform_inclusive(1, 100) // Int от 1 до 100
qcheck.small_positive_or_zero_int() // маленькие неотрицательные
// Константы и выбор
qcheck.return(42) // всегда 42
qcheck.from_list([1, 2, 3]) // случайный из списка
Генераторы компонуются: qcheck.list(qcheck.int()) создаёт список из случайных целых. Каждый генератор умеет не только генерировать, но и сжимать (shrink) значения при нахождении контрпримера.
Shrinking — сжатие контрпримеров
Когда свойство нарушено на входе [99, -42, 73, 0, -15], qcheck не просто сообщает об ошибке — он сжимает контрпример, убирая лишние элементы и уменьшая числа, пока свойство всё ещё нарушено:
Failing input: [99, -42, 73, 0, -15]
After shrinking: [1, 0]
Это экономит время на отладку — вместо сложного случая вы видите минимальный.
Пользовательские генераторы
Можно создавать генераторы для своих типов:
import qcheck
pub type Color {
Red
Green
Blue
}
fn color_generator() -> qcheck.Generator(Color) {
qcheck.from_list([Red, Green, Blue])
}
pub type Point {
Point(x: Int, y: Int)
}
fn point_generator() -> qcheck.Generator(Point) {
use x <- qcheck.parameter(qcheck.int())
use y <- qcheck.parameter(qcheck.int())
qcheck.return(Point(x:, y:))
}
qcheck.parameter позволяет комбинировать примитивные генераторы в генератор составного типа. Синтаксис use x <- qcheck.parameter(gen) последовательно «разворачивает» значения из генераторов, аналогично use для Result.
Какие свойства тестировать?
Вот классические свойства, применимые к разным функциям:
Инволюция — применение дважды возвращает оригинал:
pub fn reverse_involution_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
list.reverse(list.reverse(xs)) == xs
|> should.be_true
}
Идемпотентность — повторное применение не меняет результат:
pub fn sort_idempotent_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
let sorted = list.sort(xs, int.compare)
list.sort(sorted, int.compare) == sorted
|> should.be_true
}
Сохранение инварианта — свойство выполняется для любого входа:
pub fn sort_preserves_length_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
list.length(list.sort(xs, int.compare)) == list.length(xs)
|> should.be_true
}
Roundtrip — encode → decode = оригинал:
pub fn json_roundtrip_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
xs
|> encode_ints
|> decode_ints
|> should.equal(Ok(xs))
}
Постусловие — результат удовлетворяет определённому свойству:
pub fn abs_non_negative_test() {
use n <- qcheck.given(qcheck.int())
int.absolute_value(n) >= 0
|> should.be_true
}
Каждый из этих паттернов проверяет фундаментальные математические свойства, а не конкретные примеры. Если функция нарушает инволюцию или идемпотентность — это указывает на серьёзный баг в логике, а не просто неверный частный случай.
Snapshot-тестирование с birdie
Snapshot-тесты сохраняют «снимок» вывода функции и сравнивают с ним при последующих запусках. Это удобно для:
- Форматированного вывода (таблицы, отчёты)
- Сериализации (JSON, HTML)
- Диагностических сообщений
Как работает birdie
import birdie
pub fn format_table_test() {
format_table(["Name", "Age"], [["Alice", "30"], ["Bob", "25"]])
|> birdie.snap("format simple table")
}
При первом запуске gleam test:
- birdie создаёт файл
birdie_snapshots/format_simple_table.acceptedс выводом функции - Тест проходит
При последующих запусках:
- birdie сравнивает текущий вывод с сохранённым
- Если совпадает — тест проходит
- Если отличается — тест падает, показывая diff
Управление снимками
# Запуск тестов (birdie создаёт .new файлы для новых/изменённых снимков)
$ gleam test
# Интерактивный ревью: принять, отклонить или пропустить каждый снимок
$ gleam run -m birdie
birdie показывает diff для каждого изменённого снимка и предлагает:
- Accept — принять новый снимок
- Reject — оставить старый
- Skip — решить позже
Когда использовать snapshot-тесты
- Форматированный вывод: таблицы, отчёты, pretty-print
- Сериализация: JSON, TOML, XML
- Сложные структуры: где
should.equalтребует громоздкий ожидаемый результат - Регрессии формата: заметить, если вывод изменился неожиданно
Snapshot-тесты не заменяют unit-тесты и PBT — они дополняют их. Используйте unit-тесты для логики, PBT для свойств, snapshots для форматирования.
Проект: тестирование библиотеки коллекций
Объединим все подходы для тестирования функций из предыдущих глав.
Unit-тесты
import gleam/list
import gleam/int
import gleeunit/should
pub fn sort_empty_test() {
list.sort([], int.compare)
|> should.equal([])
}
pub fn sort_single_test() {
list.sort([42], int.compare)
|> should.equal([42])
}
pub fn sort_already_sorted_test() {
list.sort([1, 2, 3, 4, 5], int.compare)
|> should.equal([1, 2, 3, 4, 5])
}
pub fn sort_reverse_test() {
list.sort([5, 4, 3, 2, 1], int.compare)
|> should.equal([1, 2, 3, 4, 5])
}
pub fn sort_duplicates_test() {
list.sort([3, 1, 3, 1, 2], int.compare)
|> should.equal([1, 1, 2, 3, 3])
}
Пять тестов покрывают ключевые граничные случаи: пустой список, один элемент, уже отсортированный, обратный порядок, дубликаты. Для конкретных значений unit-тесты читаются как документация.
Property-based тесты
import qcheck
pub fn sort_output_is_sorted_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
let sorted = list.sort(xs, int.compare)
is_sorted(sorted)
|> should.be_true
}
pub fn sort_preserves_elements_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
let sorted = list.sort(xs, int.compare)
list.sort(xs, int.compare) == list.sort(sorted, int.compare)
|> should.be_true
}
PBT-тесты дополняют unit-тесты: там где unit проверяет «правильный ли ответ для [3,1,2]», PBT проверяет «остаётся ли отсортированный результат стабильным при повторной сортировке» для любого возможного входа.
Snapshot-тесты
import birdie
pub fn format_table_snapshot_test() {
format_table(
["ID", "Name", "Score"],
[["1", "Alice", "95"], ["2", "Bob", "87"], ["3", "Charlie", "92"]],
)
|> birdie.snap("score table")
}
Снимок фиксирует форматирование таблицы — любое изменение в пробелах, разделителях или выравнивании будет замечено. birdie.snap принимает уникальное имя снимка, которое становится именем файла.
Тестирование JSON roundtrip
Roundtrip-тесты — один из самых мощных паттернов для PBT. Идея: если мы кодируем значение в JSON и тут же декодируем обратно, должны получить оригинал.
import gleam/dynamic/decode
import gleam/json
// Кодирование списка Int в JSON
pub fn encode_ints(xs: List(Int)) -> String {
xs
|> json.array(json.int)
|> json.to_string
}
// Декодирование JSON в список Int
pub fn decode_ints(s: String) -> Result(List(Int), Nil) {
json.parse(s, decode.list(decode.int))
|> result.map_error(fn(_) { Nil })
}
// Property: roundtrip
pub fn json_roundtrip_test() {
use xs <- qcheck.given(qcheck.list(qcheck.int()))
xs
|> encode_ints
|> decode_ints
|> should.equal(Ok(xs))
}
Этот тест генерирует сотни случайных списков и проверяет, что encode → decode = оригинал. Если есть баг в кодировщике или декодировщике — qcheck его найдёт.
CI: тестирование в GitHub Actions
Настройка CI для Gleam-проекта:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: "27.0"
gleam-version: "1.6.0"
- run: gleam test
- run: gleam format --check src/ test/
Ключевые шаги:
- setup-beam — устанавливает Erlang/OTP и Gleam
- gleam test — запускает все тесты
- gleam format --check — проверяет форматирование (без изменения файлов)
Упражнения
В этой главе упражнения необычные: вы будете реализовывать функции и видеть, как они тестируются разными подходами (unit, PBT, snapshot).
Решения пишите в файле exercises/chapter09/test/my_solutions.gleam. Запускайте тесты:
cd exercises/chapter09
gleam test
Запускайте тесты после каждого упражнения — они проверяют как юнит-тесты, так и property-based тесты для ваших реализаций.
1. is_sorted — проверка сортировки (Лёгкое)
Реализуйте функцию, проверяющую, отсортирован ли список по возрастанию.
pub fn is_sorted(xs: List(Int)) -> Bool
Примеры:
is_sorted([]) == True
is_sorted([1]) == True
is_sorted([1, 2, 3, 4, 5]) == True
is_sorted([1, 3, 2]) == False
is_sorted([5, 4, 3]) == False
Подсказка: рекурсия с pattern matching на [a, b, ..rest]. Базовые случаи: [] и [_] → True.
2. encode_ints / decode_ints — JSON roundtrip (Среднее)
Реализуйте кодирование и декодирование списка целых чисел в/из JSON.
pub fn encode_ints(xs: List(Int)) -> String
pub fn decode_ints(s: String) -> Result(List(Int), Nil)
Примеры:
encode_ints([1, 2, 3]) == "[1,2,3]"
decode_ints("[1,2,3]") == Ok([1, 2, 3])
decode_ints("not json") == Error(Nil)
Тест проверит roundtrip: encode_ints(xs) |> decode_ints == Ok(xs).
Подсказка: json.array(xs, json.int) |> json.to_string для кодирования. json.parse(s, decode.list(decode.int)) для декодирования.
3. my_sort — сортировка с PBT (Среднее)
Реализуйте сортировку списка целых чисел (любым алгоритмом).
pub fn my_sort(xs: List(Int)) -> List(Int)
Тесты проверят несколько свойств вашей сортировки через qcheck:
- Результат отсортирован (каждый элемент ≤ следующего)
- Длина сохраняется
- Идемпотентность (повторная сортировка не меняет результат)
- Сохранение элементов (те же элементы, что и на входе)
Подсказка: можно использовать list.sort(xs, int.compare) или написать свою реализацию (insertion sort, merge sort).
4. int_in_range — генератор чисел в диапазоне (Среднее)
Реализуйте функцию-генератор, которая создаёт целые числа в заданном диапазоне [lo, hi].
pub fn int_in_range(lo: Int, hi: Int) -> qcheck.Generator(Int)
Тесты проверят свойства генератора:
- Все сгенерированные числа ≥ lo
- Все сгенерированные числа ≤ hi
Подсказка: используйте qcheck.int_uniform_inclusive(lo, hi).
5. clamp — ограничение значения с PBT (Сложное)
Реализуйте функцию, ограничивающую значение диапазоном [lo, hi].
pub fn clamp(value: Int, lo: Int, hi: Int) -> Int
Примеры:
clamp(5, 1, 10) == 5 // в диапазоне — не меняется
clamp(-3, 0, 100) == 0 // меньше lo — возвращает lo
clamp(999, 0, 100) == 100 // больше hi — возвращает hi
Тесты проверят через qcheck:
- Результат всегда ≥ lo
- Результат всегда ≤ hi
- Если value в диапазоне — возвращается без изменений
- Идемпотентность:
clamp(clamp(x, lo, hi), lo, hi) == clamp(x, lo, hi)
Подсказка: int.min(hi, int.max(lo, value)) или case-выражение с guards.
Заключение
В этой главе мы изучили:
- gleeunit — стандартный тестовый раннер с утверждениями
should.* - Организация тестов — именование, группировка, изоляция
- Тестирование акторов — создание, отправка сообщений, таймауты
- Property-based testing с qcheck — генераторы, свойства, shrinking
- Snapshot-тестирование с birdie — снимки вывода, интерактивный ревью
- JSON roundtrip — мощный паттерн для PBT
- CI — GitHub Actions для автоматического тестирования
В следующей главе мы создадим полноценное веб-приложение с Wisp — HTTP-фреймворком для Gleam.
Веб-разработка с Wisp
REST API, типобезопасные маршруты и PostgreSQL — всё в одном проекте.
- Цели главы
- Стек: Wisp + Mist + pog
- Первый обработчик
- Маршрутизация через pattern matching
- Middleware через use-выражения
- Разбор тела запроса
- Формирование ответов
- Контекст приложения
- ETS — встроенная in-memory база данных BEAM
- pog — PostgreSQL-клиент
- Squirrel — type-safe SQL
- Проект: TODO API
- Переменные окружения
- Упражнения
- Итоги
- Ресурсы
Цели главы
В этой главе мы:
- Познакомимся с Wisp — практичным веб-фреймворком для Gleam
- Научимся строить маршруты через pattern matching (без DSL)
- Разберём middleware-цепочки через
use-выражения - Изучим работу с JSON в HTTP-запросах и ответах
- Поймём, как подключить PostgreSQL через
pog - Познакомимся со Squirrel — type-safe codegen для SQL
- Построим полноценный TODO API с CRUD-операциями
Стек: Wisp + Mist + pog
Веб-стек Gleam строится из трёх независимых слоёв:
| Слой | Библиотека | Роль |
|---|---|---|
| HTTP-сервер | mist | TCP/HTTP-транспорт |
| Веб-фреймворк | wisp | Маршруты, middleware, запросы/ответы |
| База данных | pog | PostgreSQL-клиент |
| SQL codegen | squirrel | Type-safe SQL из .sql файлов |
Такое разделение обязанностей позволяет использовать каждый компонент независимо. Wisp работает с любым HTTP-сервером, совместимым с интерфейсом gleam_http.
Добавьте зависимости в gleam.toml:
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
gleam_erlang = ">= 0.34.0 and < 2.0.0"
gleam_http = ">= 3.0.0 and < 5.0.0"
gleam_json = ">= 2.0.0 and < 4.0.0"
wisp = ">= 1.0.0 and < 4.0.0"
mist = ">= 4.0.0 and < 6.0.0"
pog = ">= 1.0.0 and < 2.0.0"
wisp — фреймворк, mist — HTTP-сервер, pog — PostgreSQL-драйвер, gleam_http и gleam_json — работа с HTTP-типами и JSON.
Первый обработчик
Wisp строится вокруг функции-обработчика с сигнатурой fn(Request) -> Response:
import wisp
pub fn hello_handler(req: wisp.Request) -> wisp.Response {
wisp.ok()
|> wisp.string_body("Hello, Gleam!")
}
Запустим сервер через Mist:
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
pub fn main() {
wisp.configure_logger()
let assert Ok(_) =
wisp_mist.handler(hello_handler, "secret_key_base_here")
|> mist.new
|> mist.port(8080)
|> mist.start_http
process.sleep_forever()
}
wisp.configure_logger() настраивает структурированный логгер — рекомендуется вызывать в начале программы.
Маршрутизация через pattern matching
В Wisp нет DSL для маршрутов — используется обычный case по результату wisp.path_segments:
pub fn router(req: wisp.Request) -> wisp.Response {
case wisp.path_segments(req) {
// GET /
[] -> home_page(req)
// /api/todos (GET или POST)
["api", "todos"] -> todos_resource(req)
// /api/todos/:id (GET, PUT, DELETE)
["api", "todos", id] -> todo_resource(req, id)
// /health
["health"] -> health_check(req)
// всё остальное — 404
_ -> wisp.not_found()
}
}
Для разделения методов используется req.method:
import gleam/http
pub fn todos_resource(req: wisp.Request) -> wisp.Response {
case req.method {
http.Get -> list_todos(req)
http.Post -> create_todo(req)
_ -> wisp.method_not_allowed([http.Get, http.Post])
}
}
Это стандартный Gleam: никакой магии, никакого отражения — только pattern matching.
Middleware через use-выражения
Wisp middleware — это функции вида fn(handler) -> Response. Они отлично сочетаются с use:
pub fn handle_request(req: wisp.Request) -> wisp.Response {
// Логирует запрос
use <- wisp.log_request(req)
// Оборачивает крэш в 500-ответ
use <- wisp.rescue_crashes
// Обрабатывает HEAD как GET (стандартное поведение HTTP)
use req <- wisp.handle_head(req)
router(req)
}
Каждый use <- добавляет слой обработки. Выполнение идёт сверху вниз при входящем запросе и снизу вверх при формировании ответа — ровно как в традиционных middleware-стеках.
Статические файлы
use <- wisp.serve_static(req, under: "/static", from: priv_directory)
Wisp проверяет, соответствует ли путь запроса under — и если да, возвращает файл из from-директории, не доходя до роутера. Если нет — выполнение продолжается дальше.
Ограничение размера тела
use <- wisp.require_content_type(req, "application/json")
Если заголовок Content-Type не совпадает с ожидаемым, Wisp автоматически вернёт 415 Unsupported Media Type. Это защищает обработчики от неожиданных форматов до того, как они начнут читать тело запроса.
Разбор тела запроса
JSON
import gleam/dynamic/decode
import gleam/json
pub type CreateTodoInput {
CreateTodoInput(title: String, completed: Bool)
}
fn create_todo_input_decoder() {
use title <- decode.field("title", decode.string)
use completed <- decode.field("completed", decode.bool)
decode.success(CreateTodoInput(title:, completed:))
}
pub fn create_todo(req: wisp.Request) -> wisp.Response {
use json_body <- wisp.require_json(req)
case json.parse_bits(json_body, create_todo_input_decoder()) {
Error(_) -> wisp.unprocessable_entity()
Ok(input) -> {
// ... сохранить в БД и вернуть ответ
let response_json = json.object([
#("title", json.string(input.title)),
#("completed", json.bool(input.completed)),
])
wisp.created()
|> wisp.json_response(json.to_string(response_json), 201)
}
}
}
wisp.require_json читает тело и проверяет Content-Type. Если тело не JSON — автоматически возвращает 415 Unsupported Media Type.
Query-параметры
import gleam/uri
pub fn list_todos(req: wisp.Request) -> wisp.Response {
let params = wisp.get_query(req)
// params: List(#(String, String))
let page =
params
|> list.key_find("page")
|> result.try(int.parse)
|> result.unwrap(1)
// ... получить данные с пагинацией
todo
}
wisp.get_query возвращает список пар #(key, value). Цепочка list.key_find → result.try(int.parse) → result.unwrap(1) безопасно извлекает числовой параметр с дефолтным значением, не роняя запрос при невалидных данных.
Формы
pub fn handle_form(req: wisp.Request) -> wisp.Response {
use form <- wisp.require_form(req)
// form.values: List(#(String, String))
// form.files: List(#(String, wisp.UploadedFile))
case list.key_find(form.values, "title") {
Ok(title) -> save_todo(title)
Error(_) -> wisp.bad_request()
}
}
wisp.require_form декодирует как application/x-www-form-urlencoded, так и multipart/form-data. Загруженные файлы доступны через form.files с типом wisp.UploadedFile.
Формирование ответов
Wisp предоставляет функции для всех стандартных HTTP-статусов:
// 2xx
wisp.ok() // 200
wisp.created() // 201
wisp.no_content() // 204
// 3xx
wisp.redirect(to: "/login") // 302
// 4xx
wisp.bad_request() // 400
wisp.not_found() // 404
wisp.method_not_allowed([http.Get, http.Post]) // 405
wisp.unprocessable_entity() // 422
wisp.too_many_requests() // 429
// 5xx
wisp.internal_server_error() // 500
Тело ответа задаётся через pipe:
wisp.ok()
|> wisp.string_body("Hello!")
wisp.ok()
|> wisp.html_body("<h1>Hello!</h1>")
// JSON с явным статусом
wisp.response(200)
|> wisp.set_header("content-type", "application/json")
|> wisp.string_body("{\"ok\":true}")
// Удобный помощник для JSON
wisp.json_response(json_string, 200)
wisp.json_response — сокращение, которое одновременно устанавливает статус, заголовок content-type: application/json и тело. Все остальные методы можно комбинировать через pipe: сначала выбрать статус (wisp.ok(), wisp.response(200)), затем добавить тело.
Контекст приложения
В реальных приложениях обработчикам нужны общие ресурсы: подключение к БД, настройки, кэши. Стандартный паттерн в Wisp — передавать контекст явно:
pub type Context {
Context(db: pog.Connection)
}
pub fn router(req: wisp.Request, ctx: Context) -> wisp.Response {
case wisp.path_segments(req) {
["api", "todos"] -> todos_resource(req, ctx)
_ -> wisp.not_found()
}
}
А при запуске сервера используем замыкание:
pub fn main() {
let db = connect_db()
let ctx = Context(db:)
let assert Ok(_) =
fn(req) { handle_request(req, ctx) }
|> wisp_mist.handler("secret_key_base")
|> mist.new
|> mist.port(8080)
|> mist.start_http
process.sleep_forever()
}
Замыкание fn(req) { handle_request(req, ctx) } — ключевой паттерн: Mist требует функцию fn(Request) -> Response, а Wisp-обработчик принимает дополнительный аргумент ctx. Замыкание захватывает ctx и превращает двуаргументную функцию в одноаргументную. process.sleep_forever() не даёт процессу завершиться — сервер работает, пока запущены порождённые им акторы.
ETS — встроенная in-memory база данных BEAM
До того как подключать PostgreSQL, стоит познакомиться с ETS (Erlang Term Storage) — таблицами, встроенными прямо в BEAM. Это классическая точка входа в экосистему Erlang.
| Свойство | ETS | PostgreSQL (pog) |
|---|---|---|
| Хранение | Оперативная память | Диск |
| Скорость чтения по ключу | O(1) | ~1–5 мс |
| Выживает после рестарта | Нет | Да |
| SQL, транзакции | Нет | Да |
| Сложность запуска | Нулевая | Нужен сервер БД |
ETS идеален для: кэша, хранилища сессий, счётчиков, rate limiting, временных данных.
ETS через @external FFI
Gleam вызывает Erlang-функции через @external. ETS хранит кортежи — в Gleam они записываются как #(a, b, c) и компилируются в Erlang tuples {a, b, c}. Первый элемент — ключ.
import gleam/dynamic.{type Dynamic}
import gleam/erlang/atom.{type Atom}
// ets:new(Name, Options) — создаёт таблицу, возвращает имя-атом
@external(erlang, "ets", "new")
fn ets_new(name: Atom, options: List(Dynamic)) -> Atom
// ets:insert(Table, {Key, Field1, Field2}) — вставляет/обновляет запись
@external(erlang, "ets", "insert")
fn ets_insert(table: Atom, record: #(String, String, Bool)) -> Bool
// ets:lookup(Table, Key) — O(1) поиск по ключу
@external(erlang, "ets", "lookup")
fn ets_lookup(table: Atom, key: String) -> List(#(String, String, Bool))
// ets:tab2list(Table) — все записи
@external(erlang, "ets", "tab2list")
fn ets_tab2list(table: Atom) -> List(#(String, String, Bool))
// ets:delete(Table, Key) — удалить по ключу
@external(erlang, "ets", "delete")
fn ets_delete_key(table: Atom, key: String) -> Bool
Атомы как идентификаторы
Опции ets:new — атомы Erlang. В Gleam атомы создаются через atom.create/1:
pub fn create_store(name: String) -> Atom {
let table_name = atom.create(name)
// set — один ключ = одна запись
// public — чтение/запись из любого процесса
// named_table — доступна по имени без ссылки
let options = [
atom.to_dynamic(atom.create("set")),
atom.to_dynamic(atom.create("public")),
atom.to_dynamic(atom.create("named_table")),
]
ets_new(table_name, options)
}
Атомы в BEAM никогда не уничтожаются GC. Лимит ~1 миллион. Создавайте только константы, никогда — из пользовательского ввода.
CRUD поверх ETS
pub type Todo {
Todo(id: String, title: String, completed: Bool)
}
pub fn insert_todo(table: Atom, title: String) -> Todo {
let id = wisp.random_string(8)
// Кортеж #(id, title, False) сохраняется как Erlang tuple {id, title, false}
ets_insert(table, #(id, title, False))
Todo(id:, title:, completed: False)
}
pub fn find_todo(table: Atom, id: String) -> option.Option(Todo) {
case ets_lookup(table, id) {
[#(i, title, completed)] -> option.Some(Todo(id: i, title:, completed:))
_ -> option.None
}
}
pub fn list_all(table: Atom) -> List(Todo) {
ets_tab2list(table)
|> list.map(fn(row) { Todo(id: row.0, title: row.1, completed: row.2) })
}
ETS-таблица создаётся один раз при старте и передаётся в контексте:
pub type Context {
Context(table: Atom)
}
pub fn main() {
let table = create_store("todos")
let ctx = Context(table:)
let assert Ok(_) =
fn(req) { middleware(req, router(_, ctx)) }
|> wisp_mist.handler(wisp.random_string(64))
|> mist.new
|> mist.port(8080)
|> mist.start
process.sleep_forever()
}
Полная рабочая реализация — в exercises/chapter10/src/chapter10.gleam.
pog — PostgreSQL-клиент
pog — идиоматический PostgreSQL-клиент для Gleam:
import pog
pub fn connect() -> pog.Connection {
pog.default_config()
|> pog.host("localhost")
|> pog.port(5432)
|> pog.database("gleam_todos")
|> pog.user("postgres")
|> pog.password(option.Some("password"))
|> pog.connect
}
pog использует builder-паттерн: pog.default_config() создаёт конфиг с разумными значениями по умолчанию (localhost:5432), а pipe-цепочка переопределяет нужные параметры. pog.connect запускает пул соединений — возвращаемый pog.Connection потокобезопасен и предназначен для переиспользования.
Запросы вручную
pub fn get_todo(db: pog.Connection, id: Int) -> Result(Todo, pog.QueryError) {
let sql = "SELECT id, title, completed FROM todos WHERE id = $1"
use response <- result.try(
pog.query(sql)
|> pog.parameter(pog.int(id))
|> pog.returning({
use id <- decode.field(0, decode.int)
use title <- decode.field(1, decode.string)
use completed <- decode.field(2, decode.bool)
decode.success(Todo(id:, title:, completed:))
})
|> pog.execute(db),
)
case response.rows {
[todo] -> Ok(todo)
[] -> Error(pog.UnexpectedResultCount(expected: 1, got: 0))
_ -> Error(pog.UnexpectedResultCount(expected: 1, got: list.length(response.rows)))
}
}
Запрос строится через pipe: pog.query(sql) задаёт SQL, pog.parameter добавляет типизированные параметры (защита от SQL-инъекций), pog.returning задаёт декодер для строк результата, pog.execute(db) выполняет запрос. Результат response.rows — список декодированных строк; мы ожидаем ровно одну.
Вставка
pub fn create_todo(
db: pog.Connection,
title: String,
) -> Result(Todo, pog.QueryError) {
let sql =
"INSERT INTO todos (title, completed) VALUES ($1, false) RETURNING id, title, completed"
use response <- result.try(
pog.query(sql)
|> pog.parameter(pog.text(title))
|> pog.returning(todo_decoder())
|> pog.execute(db),
)
case response.rows {
[todo] -> Ok(todo)
_ -> Error(pog.UnexpectedResultCount(expected: 1, got: 0))
}
}
RETURNING в SQL позволяет сразу получить вставленную строку — не нужен отдельный SELECT. Тот же паттерн используется для UPDATE ... RETURNING: вместо двух запросов один возвращает изменённые данные.
Squirrel — type-safe SQL
Писать SQL в строках — источник ошибок: опечатки не проверяются компилятором, типы колонок нужно указывать вручную. Squirrel решает это.
Как работает Squirrel
- Создаёте
.sqlфайлы вsrc/<module>/sql/ - Запускаете
gleam run -m squirrel - Squirrel подключается к БД, проверяет запросы и генерирует типизированные функции
src/
└── app/
├── sql/
│ ├── list_todos.sql
│ ├── get_todo.sql
│ ├── create_todo.sql
│ └── delete_todo.sql
└── sql.gleam ← сгенерированный файл
SQL-файлы — единственное место, где пишется SQL вручную. Squirrel читает схему БД, проверяет типы параметров и колонок, после чего генерирует sql.gleam с типизированными функциями.
Пример SQL-файла
-- src/app/sql/list_todos.sql
SELECT id, title, completed
FROM todos
ORDER BY id ASC
После генерации получаем:
// src/app/sql.gleam (сгенерировано автоматически)
import gleam/dynamic/decode
import pog
pub type ListTodosRow {
ListTodosRow(id: Int, title: String, completed: Bool)
}
pub fn list_todos(db: pog.Connection) -> Result(List(ListTodosRow), pog.QueryError) {
let sql = "SELECT id, title, completed FROM todos ORDER BY id ASC"
// ... генерированный код
}
Squirrel генерирует строго типизированный тип строки (ListTodosRow) и функцию с декодером — ошибки маппинга столбцов выявляются при регенерации, а не в рантайме.
Параметризованные запросы
-- src/app/sql/get_todo.sql
SELECT id, title, completed
FROM todos
WHERE id = $1
Squirrel видит тип $1 из схемы БД и генерирует:
pub fn get_todo(
db: pog.Connection,
id: Int,
) -> Result(List(GetTodoRow), pog.QueryError)
Теперь передать строку вместо Int — ошибка компиляции.
Проект: TODO API
Соберём всё вместе в полноценный REST API.
Структура проекта
src/
├── app.gleam ← точка входа
├── router.gleam ← маршрутизация
├── context.gleam ← тип Context
├── handlers/
│ └── todos.gleam ← обработчики /api/todos
└── sql/
├── list_todos.sql
├── get_todo.sql
├── create_todo.sql
├── update_todo.sql
└── delete_todo.sql
Разделение по слоям: context.gleam управляет зависимостями, router.gleam — маршрутизацией, handlers/ — бизнес-логикой, sql/ — запросами к БД. Каждый слой знает только о нижележащем.
context.gleam
import pog
pub type Context {
Context(db: pog.Connection)
}
pub fn new() -> Context {
let db =
pog.default_config()
|> pog.host("localhost")
|> pog.database("gleam_todos")
|> pog.connect
Context(db:)
}
context.new() инициализирует пул соединений с БД при старте приложения. Context как тип позволяет передавать зависимости явно в каждый обработчик — без глобального состояния.
router.gleam
import gleam/http
import wisp
import app/context.{type Context}
import app/handlers/todos
pub fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response {
use <- wisp.log_request(req)
use <- wisp.rescue_crashes
use req <- wisp.handle_head(req)
case wisp.path_segments(req) {
["health"] -> wisp.json_response("{\"status\":\"ok\"}", 200)
["api", "todos"] ->
case req.method {
http.Get -> todos.list(req, ctx)
http.Post -> todos.create(req, ctx)
_ -> wisp.method_not_allowed([http.Get, http.Post])
}
["api", "todos", id] ->
case req.method {
http.Get -> todos.get(req, ctx, id)
http.Put -> todos.update(req, ctx, id)
http.Delete -> todos.delete(req, ctx, id)
_ -> wisp.method_not_allowed([http.Get, http.Put, http.Delete])
}
_ -> wisp.not_found()
}
}
wisp.log_request и wisp.rescue_crashes — middleware, которые добавляются через use <-. wisp.handle_head автоматически обрабатывает HEAD-запросы как GET без тела ответа. Маршрутизация строится через wisp.path_segments и pattern matching.
handlers/todos.gleam
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/result
import wisp
import app/context.{type Context}
pub type Todo {
Todo(id: Int, title: String, completed: Bool)
}
fn todo_to_json(todo: Todo) -> json.Json {
json.object([
#("id", json.int(todo.id)),
#("title", json.string(todo.title)),
#("completed", json.bool(todo.completed)),
])
}
pub fn list(_req: wisp.Request, ctx: Context) -> wisp.Response {
// Squirrel-генерированная функция
case sql.list_todos(ctx.db) {
Error(_) -> wisp.internal_server_error()
Ok(rows) -> {
let todos_json =
rows
|> list.map(fn(row) {
json.object([
#("id", json.int(row.id)),
#("title", json.string(row.title)),
#("completed", json.bool(row.completed)),
])
})
|> json.array
|> json.to_string
wisp.json_response(todos_json, 200)
}
}
}
pub fn create(req: wisp.Request, ctx: Context) -> wisp.Response {
use body <- wisp.require_json(req)
let decoder = {
use title <- decode.field("title", decode.string)
decode.success(title)
}
case json.parse_bits(body, decoder) {
Error(_) -> wisp.unprocessable_entity()
Ok(title) ->
case sql.create_todo(ctx.db, title) {
Error(_) -> wisp.internal_server_error()
Ok([row]) -> {
let response =
json.object([
#("id", json.int(row.id)),
#("title", json.string(row.title)),
#("completed", json.bool(row.completed)),
])
|> json.to_string
wisp.json_response(response, 201)
}
Ok(_) -> wisp.internal_server_error()
}
}
}
list возвращает массив JSON с кодом 200. create требует JSON-тело, декодирует поле title и возвращает созданный объект с кодом 201. При любой ошибке парсинга Wisp автоматически отвечает 422 Unprocessable Entity.
app.gleam — точка входа
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
import app/context
import app/router
pub fn main() {
wisp.configure_logger()
let ctx = context.new()
let secret_key_base = "your_64_char_secret_key_here"
let assert Ok(_) =
fn(req) { router.handle_request(req, ctx) }
|> wisp_mist.handler(secret_key_base)
|> mist.new
|> mist.port(8080)
|> mist.start_http
process.sleep_forever()
}
context.new() инициализирует зависимости, затем сервер монтируется через замыкание. process.sleep_forever() удерживает главный процесс — без него BEAM завершится сразу после старта сервера.
Создание схемы БД
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
);
SERIAL PRIMARY KEY автоматически генерирует уникальный id для каждой строки. NOT NULL гарантирует, что приложение не сохранит задачу без названия. DEFAULT FALSE позволяет не указывать completed при вставке.
Переменные окружения
В продакшене настройки берут из окружения:
import gleam/erlang/os
fn db_config() -> pog.Config {
let host = os.get_env("DB_HOST") |> result.unwrap("localhost")
let port =
os.get_env("DB_PORT")
|> result.try(int.parse)
|> result.unwrap(5432)
let name = os.get_env("DB_NAME") |> result.unwrap("todos")
pog.default_config()
|> pog.host(host)
|> pog.port(port)
|> pog.database(name)
}
os.get_env возвращает Result(String, Nil) — если переменная не задана, result.unwrap подставляет дефолт. В продакшене следует использовать result.try или let assert Ok(...) для обязательных переменных.
Упражнения
Код упражнений находится в exercises/chapter10/.
Структура
Файл test/my_solutions.gleam содержит шаблоны функций с todo. Запустите тесты:
cd exercises/chapter10
gleam test
Тесты завершатся с ошибкой — заполните функции в test/my_solutions.gleam.
Упражнение 10.1 (Лёгкое): Health-check body
Функция health_check_body() -> String должна вернуть JSON-строку {"status":"ok"}.
pub fn health_check_body() -> String {
todo
}
Подсказка: используйте json.object и json.to_string из gleam_json.
Упражнение 10.2 (Лёгкое): Парсинг JSON в тип
pub type Todo {
Todo(title: String, completed: Bool)
}
pub fn parse_todo(s: String) -> Result(Todo, Nil) {
todo
}
Подсказка: json.parse + decode.field.
Упражнение 10.3 (Лёгкое): Сериализация в JSON
pub fn todo_to_json_string(t: Todo) -> String {
todo
}
Подсказка: json.object с полями title и completed.
Упражнение 10.4 (Среднее, для самостоятельного изучения): Middleware для замера времени
Реализуйте middleware, который добавляет заголовок X-Response-Time с временем обработки в миллисекундах. Интеграция с Wisp требует системного времени через FFI.
Упражнение 10.5 (Сложное, для самостоятельного изучения): Auth middleware
Реализуйте middleware require_auth, который проверяет наличие Bearer-токена в заголовке Authorization. Без корректного токена — 401 Unauthorized.
Итоги
Мы построили REST API с:
- Wisp для маршрутизации и middleware
- pog для PostgreSQL
- Squirrel для type-safe SQL (codegen из
.sqlфайлов) - gleam_json + gleam/dynamic/decode для сериализации
Ключевые паттерны Gleam в веб-разработке:
- Маршруты — это
caseпоwisp.path_segments(req), никакого DSL - Middleware — это
use <- middleware(req), использованиеuse-выражений - Контекст — явный параметр
ctx: Context, никакого глобального состояния - Ошибки —
Result, никаких исключений
Ресурсы
- Wisp — официальная документация
- HexDocs — wisp
- HexDocs — pog
- Squirrel — GitHub
- HexDocs — gleam_json
- Gleam web app tutorial
Фронтенд с Lustre
The Elm Architecture на Gleam — типобезопасный UI для браузера и сервера.
- Цели главы
- JavaScript-таргет Gleam
- The Elm Architecture (TEA)
- Простое приложение: счётчик
- Виртуальный DOM
- Атрибуты
- События
- Работа со списками
- Эффекты
- Компоненты
- Server Components
- Full-Stack приложения: Lustre + Wisp
- Server-Side Rendering (SSR)
- Проект: TODO-приложение
- Упражнения
- Итоги
- TEA в других языках и фреймворках
- Ресурсы
Цели главы
В этой главе мы:
- Изучим Elm Architecture (TEA): Model → Update → View
- Познакомимся с виртуальным DOM Lustre
- Научимся работать с событиями и состоянием
- Разберём эффекты (HTTP-запросы, таймеры)
- Поймём, как Lustre работает на JavaScript-таргете
- Рассмотрим server components — компоненты на сервере с WebSocket
- Построим интерактивное TODO-приложение
JavaScript-таргет Gleam
Связь с главой 9: В главе 9 мы изучили JavaScript FFI — вызов JavaScript-функций через
@external, работу с DOM API, промисами и классами. Lustre абстрагирует всю эту низкоуровневую работу: вместо прямых вызововdocument.getElementByIdиaddEventListenerвы работаете с виртуальным DOM и декларативными событиями. Однако знание FFI из главы 9 пригодится для интеграции сторонних JavaScript-библиотек с Lustre.
Lustre работает в браузере — это JavaScript-среда. Gleam компилируется как для Erlang, так и для JavaScript:
# Сборка для браузера
gleam build --target javascript
# Запуск тестов на Node.js
gleam test --target javascript
В gleam.toml можно задать таргет по умолчанию:
name = "my_app"
version = "1.0.0"
target = "javascript"
Важно: большинство модулей из
gleam_erlangиgleam_otpне работают на JS-таргете. Lustre-проекты используютgleam_stdlibи JS-совместимые библиотеки.
The Elm Architecture (TEA)
История и философия
The Elm Architecture (TEA) — архитектурный паттерн, разработанный Evan Czaplicki для языка Elm в 2012 году. Паттерн возник как решение проблемы управления состоянием в функциональном языке без мутаций и побочных эффектов.
Ключевая идея TEA: однонаправленный поток данных (unidirectional data flow). В отличие от двунаправленного биндинга (MVC, MVVM), где изменения могут распространяться в обе стороны, TEA строго контролирует порядок обновлений:
Пользователь → Событие → Сообщение → Update → Новая Модель → View → UI
↑ │
└────────────────────────────────────────────────────────┘
Это делает состояние предсказуемым: одна и та же последовательность сообщений всегда приводит к одному и тому же состоянию. Нет скрытых мутаций, нет «action at a distance».
Влияние TEA на индустрию
TEA вдохновил множество фреймворков и архитектур:
- Redux (JavaScript) — почти прямая адаптация TEA для React, созданная Dan Abramov
- Elmish (F#) — TEA для .NET экосистемы
- SwiftUI (Swift) — Apple использовала идеи TEA для декларативного UI
- Iced (Rust) — GUI-фреймворк для Rust на основе TEA
- Lustre (Gleam) — то, что мы изучаем в этой главе
Компоненты TEA
Lustre реализует классическую Elm Architecture с тремя компонентами:
┌─────────────────────────────────┐
│ │
Msg ▼ │
┌──────────┐ new state ┌──────────┐ │
│ update │ ─────────────► │ model │ │ Msg
└──────────┘ └──────────┘ │
│ │
▼ │
┌──────────┐ │
│ view │ │
└──────────┘ │
│ │
▼ │
виртуальный │
DOM ────┘
(события)
1. Model — состояние приложения (иммутабельные данные)
type Model {
Model(count: Int, todos: List(String))
}
2. Msg — алгебраический тип всех возможных действий
type Msg {
Increment
AddTodo(String)
DeleteTodo(Int)
}
3. Update — чистая функция fn(Model, Msg) -> Model
fn update(model: Model, msg: Msg) -> Model {
case msg {
Increment -> Model(..model, count: model.count + 1)
// ...
}
}
4. View — чистая функция fn(Model) -> Element(Msg)
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [element.text("Count: " <> int.to_string(model.count))]),
html.button([event.on_click(Increment)], [element.text("+")])
])
}
Преимущества TEA
Типобезопасность. Система типов гарантирует, что каждое сообщение обработано — забытый case приведёт к ошибке компиляции.
Предсказуемость. Чистые функции update и view всегда дают один результат для одного входа. Нет скрытого состояния.
Тестируемость. Логика приложения — чистые функции без I/O. Тесты просты: assert update(model, Increment) == Model(count: 1).
Time-travel debugging. Последовательность сообщений полностью описывает историю приложения. Можно «прокручивать» состояние назад и вперёд.
Масштабируемость. Локальные изменения не ломают удалённые части приложения — каждое сообщение явно объявлено в типе Msg.
Нет мутаций, нет глобального состояния — только чистые функции.
Простое приложение: счётчик
import gleam/int
import lustre
import lustre/element.{type Element}
import lustre/element/html
import lustre/event
// 1. Модель — состояние приложения
type Model =
Int
// 2. Сообщения — возможные действия
type Msg {
Increment
Decrement
Reset
}
// 3. Инициализация начального состояния
fn init(_flags) -> Model {
0
}
// 4. Обновление состояния
fn update(model: Model, msg: Msg) -> Model {
case msg {
Increment -> model + 1
Decrement -> model - 1
Reset -> 0
}
}
// 5. Отображение состояния
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [element.text("Счётчик: " <> int.to_string(model))]),
html.button([event.on_click(Increment)], [element.text("+")]),
html.button([event.on_click(Decrement)], [element.text("-")]),
html.button([event.on_click(Reset)], [element.text("Сброс")]),
])
}
// 6. Запуск приложения
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
lustre.simple — для приложений без побочных эффектов. lustre.start монтирует приложение в DOM-элемент с селектором #app.
Виртуальный DOM
Lustre строит виртуальное дерево элементов, которое затем эффективно синхронизируется с реальным DOM браузера.
lustre/element
import lustre/element.{type Element}
// Текстовый узел
element.text("Привет!")
// Произвольный элемент
element.element("my-component", [], [element.text("контент")])
// Фрагмент (несколько узлов без обёртки)
element.fragment([
html.p([], [element.text("Первый")]),
html.p([], [element.text("Второй")]),
])
// Пустой элемент (ничего не рендерится)
element.none()
// Преобразование типа сообщения
element.map(child_element, fn(child_msg) { ParentMsg(child_msg) })
element.none() полезен для условного рендеринга — возвращайте его там, где элемент не нужен, вместо обёртки в Option. element.map преобразует тип сообщения дочернего элемента, позволяя встраивать суб-компоненты с отличным типом Msg.
lustre/element/html
Модуль lustre/element/html содержит функции для всех стандартных HTML-элементов. Каждая функция принимает List(Attribute(msg)) и List(Element(msg)):
import lustre/element/html
html.div([attribute.class("container")], [
html.h1([], [element.text("Заголовок")]),
html.p([], [element.text("Параграф")]),
html.ul([], [
html.li([], [element.text("Пункт 1")]),
html.li([], [element.text("Пункт 2")]),
]),
])
Самозакрывающиеся (void) элементы не принимают дочерних узлов:
html.input([attribute.type_("text"), attribute.placeholder("Введите...")])
html.br([])
html.hr([])
html.img([attribute.src("/logo.png"), attribute.alt("Логотип")])
Void-элементы не принимают дочерних узлов — в HTML они самозакрывающиеся. Попытка передать список дочерних элементов таким функциям приведёт к ошибке компиляции.
Атрибуты
Модуль lustre/attribute содержит функции для HTML-атрибутов:
import lustre/attribute
// Стандартные атрибуты
attribute.id("my-id")
attribute.class("btn btn-primary")
attribute.classes([#("active", is_active), #("disabled", is_disabled)])
attribute.style([#("color", "red"), #("font-size", "16px")])
// Форма
attribute.type_("text") // type — зарезервировано, поэтому type_
attribute.value("текст")
attribute.placeholder("Введите...")
attribute.checked(True)
attribute.disabled(False)
attribute.name("email")
// Медиа и ссылки
attribute.src("/image.png")
attribute.href("/about")
attribute.alt("Описание")
// Произвольный атрибут
attribute.attribute("data-id", "42")
attribute.classes принимает список пар #(class, bool) и применяет только те классы, у которых условие — True. attribute.style принимает список пар #(property, value) вместо строки — удобнее для динамических стилей.
Условные атрибуты
fn button_view(is_loading: Bool) -> Element(Msg) {
html.button(
[
attribute.disabled(is_loading),
attribute.class(case is_loading {
True -> "btn btn--loading"
False -> "btn"
}),
],
[element.text("Сохранить")],
)
}
attribute.disabled(is_loading) и условный класс вычисляются при каждом рендере — если is_loading изменится, Lustre автоматически обновит только изменившиеся атрибуты в DOM.
События
Модуль lustre/event позволяет подписываться на DOM-события:
import lustre/event
// Клик
html.button([event.on_click(ButtonClicked)], [element.text("Нажми")])
// Ввод текста — получаем значение поля
html.input([event.on_input(TextChanged)])
// Отправка формы
html.form([event.on_submit(FormSubmitted)], [...])
// Чекбокс
html.input([
attribute.type_("checkbox"),
event.on_check(CheckboxToggled),
])
event.on_input автоматически извлекает event.target.value из DOM-события и передаёт строку в сообщение. event.on_check передаёт булево значение состояния чекбокса.
Произвольные события с декодером
import gleam/dynamic/decode
// Считываем event.target.value из DOM-события
fn on_input_change(to_msg: fn(String) -> Msg) -> attribute.Attribute(Msg) {
event.on("input", {
use value <- decode.subfield(["target", "value"], decode.string)
decode.success(to_msg(value))
})
}
event.on принимает имя DOM-события и декодер, который разбирает нативный JavaScript-объект события. Это позволяет извлекать любые поля — не только target.value, но и координаты мыши, код клавиши и другие данные события.
Debounce и throttle
// Debounce: не отправлять сообщение чаще, чем раз в 300мс
event.on_input(TextChanged) |> event.debounce(300)
// Throttle: пропускать события не чаще раза в 100мс
event.on_mouse_move(MouseMoved) |> event.throttle(100)
debounce задерживает отправку сообщения: если пользователь печатает быстро, сообщение отправится только через 300мс после последнего нажатия — удобно для поиска в реальном времени. throttle ограничивает частоту — полезно для обработки движения мыши или скролла.
Работа со списками
import gleam/list
type Model {
Model(todos: List(String), input: String)
}
type Msg {
InputChanged(String)
AddTodo
RemoveTodo(Int)
}
fn view(model: Model) -> Element(Msg) {
html.div([], [
// Поле ввода
html.input([
attribute.value(model.input),
attribute.placeholder("Новая задача..."),
event.on_input(InputChanged),
]),
html.button([event.on_click(AddTodo)], [element.text("Добавить")]),
// Список задач
html.ul(
[],
list.index_map(model.todos, fn(todo, i) {
html.li([], [
element.text(todo),
html.button(
[event.on_click(RemoveTodo(i))],
[element.text("✕")],
),
])
}),
),
])
}
list.index_map передаёт в колбэк и элемент, и его индекс — это нужно, чтобы привязать к кнопке «✕» нужный номер задачи для RemoveTodo(i). Атрибут value у html.input синхронизирует поле с моделью, делая ввод управляемым.
Эффекты
Реальные приложения делают HTTP-запросы, работают с localStorage, таймерами. Для этого используются эффекты.
lustre.application — приложение с эффектами
import lustre
import lustre/effect.{type Effect}
fn init(flags) -> #(Model, Effect(Msg)) {
#(
Model(todos: [], loading: True),
// Эффект запускается при инициализации
fetch_todos(),
)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
LoadTodos -> #(model, fetch_todos())
TodosLoaded(todos) -> #(Model(..model, todos:, loading: False), effect.none())
AddTodo(text) -> #(
Model(..model, todos: [text, ..model.todos]),
effect.none(),
)
}
}
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
Отличие от lustre.simple: функции init и update возвращают #(Model, Effect(Msg)) вместо просто Model.
lustre_http — HTTP-запросы
Из главы 9:
lustre_httpпод капотом использует JavaScriptPromise(промисы), которые мы изучили в главе 9. Функцияfetch()возвращает промис, который разрешается вResponse, затем преобразуется в JSON. Lustre оборачивает это в типобезопасный эффект.
import lustre/effect.{type Effect}
import lustre_http
type Msg {
GotTodos(Result(List(Todo), lustre_http.HttpError))
}
fn fetch_todos() -> Effect(Msg) {
lustre_http.get(
"https://api.example.com/todos",
lustre_http.expect_json(todos_decoder(), GotTodos),
)
}
lustre_http.expect_json принимает декодер и конструктор сообщения. При успехе декодирует JSON и передаёт результат в GotTodos(Ok(...)), при сетевой ошибке или неверном JSON — в GotTodos(Error(...)).
Кастомные эффекты
fn save_to_local_storage(key: String, value: String) -> Effect(Msg) {
effect.from(fn(dispatch) {
// Выполняется как побочный эффект
do_save_to_storage(key, value)
dispatch(SaveComplete)
})
}
effect.from создаёт эффект из функции, которая получает dispatch — колбэк для отправки сообщений обратно в Lustre. Код внутри выполняется вне цикла обновления, что позволяет делать любые побочные действия: запись в localStorage, подписки на события, таймеры.
Группировка эффектов
fn init(flags) -> #(Model, Effect(Msg)) {
#(
initial_model,
effect.batch([
fetch_todos(),
load_user_preferences(),
]),
)
}
effect.batch объединяет несколько эффектов в один — они запустятся параллельно при инициализации или обновлении. Это удобно, когда нужно сразу загрузить данные из нескольких источников.
Компоненты
Lustre поддерживает переиспользуемые компоненты в виде Custom Elements Web Components.
Регистрация компонента
import lustre
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import gleam/int
type Model = Int
type Msg {
Increment
Decrement
}
fn init(_) -> #(Model, Effect(Msg)) {
#(0, effect.none())
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Increment -> #(model + 1, effect.none())
Decrement -> #(model - 1, effect.none())
}
}
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.button([event.on_click(Decrement)], [element.text("-")]),
element.text(int.to_string(model)),
html.button([event.on_click(Increment)], [element.text("+")]),
])
}
pub fn register() {
lustre.component("my-counter", init, update, view, component.empty())
}
Использование в HTML:
<my-counter></my-counter>
После вызова register() тег <my-counter> становится полноценным Custom Element — браузер автоматически монтирует в него Lustre-приложение при добавлении в DOM.
Server Components
Lustre поддерживает серверные компоненты — революционный подход, где UI-логика работает на BEAM-сервере, а браузеру нужен лишь ~10kb клиентский рантайм для применения патчей.
Как работают Server Components
Браузер Сервер (BEAM)
┌─────────────┐ WebSocket/SSE ┌──────────────────┐
│ ~10kb JS │ ◄─── патчи ──── │ Lustre-процесс │
│ рантайм │ ──── события ► │ (OTP-актор) │
└─────────────┘ └──────────────────┘
▲ │
│ JSON-патчи ▼
DOM updates {type: "set_attribute", ...}
Жизненный цикл:
- Подключение: Браузер открывает WebSocket к серверу и запрашивает начальный HTML
- Инициализация: Сервер создаёт OTP-актор (процесс) для этого клиента, вызывает
init, возвращает начальный рендер - Взаимодействие: Пользователь кликает кнопку → браузер отправляет событие по WebSocket → сервер вызывает
update(model, msg)→ вычисляет diff → отправляет патч браузеру - Патчинг: Браузер применяет патч к реальному DOM (минимальные изменения)
- Отключение: WebSocket закрывается → OTP-актор останавливается (супервизор может перезапустить при сбое)
Пример: Real-Time Dashboard
import gleam/int
import gleam/erlang/process
import lustre
import lustre/element.{type Element}
import lustre/element/html
import lustre/attribute
import lustre/effect.{type Effect}
type Model {
Model(connected_users: Int, requests_per_second: Int, uptime_seconds: Int)
}
type Msg {
Tick
UserConnected
UserDisconnected
}
fn init(_flags) -> #(Model, Effect(Msg)) {
#(
Model(connected_users: 0, requests_per_second: 0, uptime_seconds: 0),
// Запускаем таймер — каждую секунду отправляем Tick
effect.from(fn(dispatch) {
process.send_after(process.self(), 1000, Tick)
dispatch(Tick)
}),
)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Tick -> #(
Model(
..model,
uptime_seconds: model.uptime_seconds + 1,
requests_per_second: random_int(50, 200),
),
// Следующий тик через 1 секунду
effect.from(fn(dispatch) {
process.send_after(process.self(), 1000, Tick)
}),
)
UserConnected -> #(
Model(..model, connected_users: model.connected_users + 1),
effect.none(),
)
UserDisconnected -> #(
Model(..model, connected_users: model.connected_users - 1),
effect.none(),
)
}
}
fn view(model: Model) -> Element(Msg) {
html.div([attribute.class("dashboard")], [
html.h1([], [element.text("Server Dashboard")]),
html.div([attribute.class("metrics")], [
metric("Connected Users", int.to_string(model.connected_users)),
metric("Requests/sec", int.to_string(model.requests_per_second)),
metric("Uptime", format_uptime(model.uptime_seconds)),
]),
])
}
fn metric(label: String, value: String) -> Element(msg) {
html.div([attribute.class("metric")], [
html.div([attribute.class("label")], [element.text(label)]),
html.div([attribute.class("value")], [element.text(value)]),
])
}
// Запуск server component
pub fn main() {
lustre.start_server_component(lustre.application(init, update, view))
}
Каждый подключённый пользователь получает собственный процесс-актор на сервере. Когда таймер срабатывает, update вычисляет новое состояние, Lustre рендерит виртуальный DOM, вычисляет diff и отправляет только изменения в браузер.
Сравнение с аналогами
| Lustre Server Components | Phoenix LiveView | Blazor Server | |
|---|---|---|---|
| Язык | Gleam | Elixir | C# |
| Платформа | BEAM VM | BEAM VM | .NET CLR |
| Транспорт | WebSocket/SSE | WebSocket | SignalR (WebSocket) |
| Процесс на клиента | ✅ OTP-актор | ✅ GenServer | ❌ Общий поток |
| Типобезопасность | ✅ Статическая | ⚠️ Динамическая | ✅ Статическая |
| Размер клиента | ~10kb | ~30kb | ~500kb |
| Отказоустойчивость | ✅ Let it crash | ✅ Let it crash | ⚠️ Требует настройки |
Phoenix LiveView (Elixir) — прямой вдохновитель Lustre Server Components. Обе технологии используют BEAM VM и один процесс на подключение. Основное отличие: Gleam даёт статическую типизацию, а Elixir — динамическую.
Blazor Server (C#/.NET) — похожий подход, но без изоляции процессов. Все клиенты обслуживаются в одном .NET процессе, что ограничивает отказоустойчивость.
Trade-offs: когда использовать Server Components?
✅ Используйте Server Components когда:
- Real-time данные — дашборды, мониторинг, чаты, совместное редактирование
- Минимальный JavaScript — важен SEO, быстрая загрузка на медленных сетях
- Доступ к серверу — нужен прямой доступ к БД, файловой системе, внутренним API
- Безопасность — бизнес-логика остаётся на сервере, не утекает в клиентский бандл
- Есть BEAM-инфраструктура — уже используете Erlang/Elixir/Gleam на бэкенде
❌ Избегайте Server Components когда:
- Высокая латентность — пользователи из разных континентов (каждый клик = round-trip)
- Офлайн-режим — приложение должно работать без сети (PWA, мобильные приложения)
- Интенсивная интерактивность — анимации, drag-and-drop, игры (лаг будет заметен)
- Масштабирование горизонтально — миллионы одновременных пользователей (требуется sticky sessions или Redis Pub/Sub)
- Статический хостинг — нельзя использовать серверную логику (GitHub Pages, Netlify без функций)
Гибридный подход: используйте Server Components для административных панелей и дашбордов, а классические SPA (CSR) — для публичного интерфейса с высокой интерактивностью.
Full-Stack приложения: Lustre + Wisp
Lustre позволяет создавать full-stack приложения на Gleam: Wisp (Erlang-таргет) на сервере, Lustre (JavaScript-таргет) в браузере. Одна кодовая база, два таргета, общие типы.
Структура монорепозитория
Рекомендуемая структура для full-stack проекта:
my_app/
├── client/ # Lustre SPA (target = javascript)
│ ├── gleam.toml
│ ├── src/
│ │ ├── client.gleam
│ │ └── client_ffi.mjs
│ └── index.html
├── server/ # Wisp API (target = erlang)
│ ├── gleam.toml
│ ├── src/
│ │ ├── server.gleam
│ │ └── routes.gleam
│ └── priv/
│ └── static/ # Собранный клиент
└── shared/ # Общие типы и функции
├── gleam.toml
└── src/
├── models.gleam # Общие типы данных
└── codecs.gleam # JSON-кодеки
Три независимых Gleam-проекта:
- client — JavaScript-таргет, зависит от
lustreиshared - server — Erlang-таргет, зависит от
wisp,mist,pogиshared - shared — без таргета (или оба), экспортирует типы и функции
Общие типы (shared/src/models.gleam)
// Общие типы для клиента и сервера
pub type Todo {
Todo(id: Int, text: String, completed: Bool)
}
pub type User {
User(id: Int, name: String, email: String)
}
pub type ApiResponse(data) {
Success(data: data)
Error(message: String)
}
Эти типы компилируются и для JavaScript (клиент), и для Erlang (сервер) — гарантируется согласованность.
JSON-кодеки (shared/src/codecs.gleam)
import gleam/json
import gleam/dynamic/decode
import shared/models.{type Todo}
// Энкодинг: Gleam → JSON (для отправки с сервера)
pub fn encode_todo(todo: Todo) -> json.Json {
json.object([
#("id", json.int(todo.id)),
#("text", json.string(todo.text)),
#("completed", json.bool(todo.completed)),
])
}
pub fn encode_todos(todos: List(Todo)) -> json.Json {
json.array(todos, encode_todo)
}
// Декодинг: JSON → Gleam (для чтения на клиенте)
pub fn decode_todo(value: dynamic.Dynamic) -> Result(Todo, decode.DecodeErrors) {
decode.into({
use id <- decode.field("id", decode.int)
use text <- decode.field("text", decode.string)
use completed <- decode.field("completed", decode.bool)
models.Todo(id:, text:, completed:)
})
|> decode.from(value)
}
pub fn decode_todos(value: dynamic.Dynamic) -> Result(List(Todo), decode.DecodeErrors) {
decode.list(decode_todo) |> decode.from(value)
}
Кодеки живут в shared и используются на обеих сторонах — сервер энкодит, клиент декодит.
Сервер: API-роуты (server/src/routes.gleam)
import gleam/http
import gleam/json
import wisp
import shared/codecs
import shared/models.{type Todo, Todo}
// Получить все TODO
pub fn get_todos(req: wisp.Request) -> wisp.Response {
// В реальном приложении — запрос к БД
let todos = [
Todo(id: 1, text: "Изучить Lustre", completed: True),
Todo(id: 2, text: "Построить full-stack приложение", completed: False),
]
let json_body = codecs.encode_todos(todos) |> json.to_string()
wisp.response(200)
|> wisp.set_header("content-type", "application/json")
|> wisp.set_body(wisp.Text(json_body))
}
// Создать новое TODO
pub fn create_todo(req: wisp.Request) -> wisp.Response {
use body <- wisp.require_string_body(req)
case json.parse(body, codecs.decode_todo) {
Ok(todo) -> {
// Сохранить в БД...
let response = codecs.encode_todo(todo) |> json.to_string()
wisp.json_response(response, 201)
}
Error(_) -> wisp.bad_request()
}
}
Сервер использует shared/codecs для сериализации данных в JSON.
Клиент: Lustre SPA (client/src/client.gleam)
import gleam/http
import gleam/json
import gleam/result
import lustre
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre_http
import shared/models.{type Todo}
import shared/codecs
type Model {
Model(todos: List(Todo), loading: Bool)
}
type Msg {
FetchTodos
TodosFetched(Result(List(Todo), lustre_http.HttpError))
}
fn init(_) -> #(Model, Effect(Msg)) {
#(
Model(todos: [], loading: True),
fetch_todos(),
)
}
fn fetch_todos() -> Effect(Msg) {
lustre_http.get(
"/api/todos",
lustre_http.expect_json(codecs.decode_todos, TodosFetched),
)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
FetchTodos -> #(Model(..model, loading: True), fetch_todos())
TodosFetched(Ok(todos)) -> #(
Model(..model, todos:, loading: False),
effect.none(),
)
TodosFetched(Error(_)) -> #(
Model(..model, loading: False),
effect.none(),
)
}
}
fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [element.text("TODO List")]),
case model.loading {
True -> html.p([], [element.text("Загрузка...")])
False -> render_todos(model.todos)
},
])
}
fn render_todos(todos: List(Todo)) -> Element(msg) {
html.ul([], list.map(todos, render_todo))
}
fn render_todo(todo: Todo) -> Element(msg) {
html.li([], [element.text(todo.text)])
}
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
Клиент использует те же shared/models и shared/codecs — полная типобезопасность от сервера до браузера.
Сборка и развёртывание
1. Соберите клиента:
cd client
gleam build --target javascript
# Результат в build/dev/javascript/client/client.mjs
2. Скопируйте клиентский бандл в server/priv/static/:
cp build/dev/javascript/client/client.mjs ../server/priv/static/
3. Соберите сервер:
cd ../server
gleam build
gleam run
4. Настройте роуты для статики:
// server/src/server.gleam
import wisp
pub fn handle_request(req: wisp.Request) -> wisp.Response {
use <- wisp.serve_static(req, under: "/static", from: "/priv/static")
case wisp.path_segments(req) {
[] -> index_page() // Отдаёт HTML с <script src="/static/client.mjs">
["api", "todos"] -> routes.get_todos(req)
_ -> wisp.not_found()
}
}
Автоматизация сборки
Добавьте скрипт в package.json или justfile:
{
"scripts": {
"build:client": "cd client && gleam build",
"build:server": "cd server && gleam build",
"build": "npm run build:client && cp client/build/dev/javascript/client/client.mjs server/priv/static/ && npm run build:server",
"dev": "npm run build && cd server && gleam run"
}
}
Теперь npm run dev собирает оба проекта и запускает сервер.
Преимущества full-stack Gleam
✅ Одна кодовая база — один язык, одни инструменты, одна система типов ✅ Общие типы — изменения в модели сразу видны и на клиенте, и на сервере ✅ Типобезопасные API — декодеры гарантируют, что JSON соответствует типам ✅ Рефакторинг без страха — переименовали поле? Компилятор найдёт все проблемы ✅ Кодогенерация не нужна — никаких OpenAPI, Swagger, GraphQL Codegen
Server-Side Rendering (SSR)
Lustre также поддерживает статический серверный рендеринг — генерацию HTML на сервере без интерактивности. Это полезно для:
- SEO-оптимизации (поисковые боты видят готовый HTML)
- Быстрой первой отрисовки (Time to First Paint)
- Статических страниц (блоги, документация, лендинги)
- Прогрессивного улучшения (Progressive Enhancement)
Рендеринг элементов в HTML
Lustre предоставляет две функции для конвертации виртуального DOM в строку:
import lustre/element
// Рендерит элемент в HTML-фрагмент
element.to_string(my_element)
// "<div><h1>Hello</h1></div>"
// Рендерит элемент в полный HTML-документ с <!DOCTYPE>
element.to_document_string(my_element)
// "<!DOCTYPE html><html><head>...</head><body>...</body></html>"
Пример: SSR-роут в Wisp
import gleam/int
import lustre/element
import lustre/element/html
import lustre/attribute
import wisp
type Model {
Model(count: Int)
}
fn view(model: Model) -> element.Element(msg) {
html.html([], [
html.head([], [
html.title([], [element.text("Counter App")]),
html.meta([attribute.attribute("charset", "utf-8")]),
]),
html.body([], [
html.h1([], [element.text("Server-Rendered Counter")]),
html.p([], [element.text("Count: " <> int.to_string(model.count))]),
html.a([attribute.href("/increment")], [element.text("Increment")]),
]),
])
}
pub fn handle_request(req: wisp.Request) -> wisp.Response {
case wisp.path_segments(req) {
[] -> {
let model = Model(count: 0)
let html = view(model) |> element.to_document_string()
wisp.html_response(html, 200)
}
["increment"] -> {
let model = Model(count: 1)
let html = view(model) |> element.to_document_string()
wisp.html_response(html, 200)
}
_ -> wisp.not_found()
}
}
Здесь сервер рендерит HTML на каждый запрос — нет JavaScript, нет интерактивности. Страница полностью статична.
Гидратация (Hydration)
Чтобы добавить интерактивность к серверно-отрендеренному HTML, используется гидратация — процесс «оживления» статического HTML клиентским JavaScript.
Идея: сервер рендерит начальное состояние, клиент подхватывает его и продолжает работу как SPA.
Шаг 1: Сериализуем модель в JSON и встраиваем в HTML
import gleam/json
fn view_with_state(model: Model) -> element.Element(msg) {
html.html([], [
html.head([], [...]),
html.body([], [
html.div([attribute.id("app")], [
// Серверный рендер начального состояния
render_counter(model),
]),
// Встраиваем модель как JSON
html.script([], [
element.text(
"window.__INITIAL_STATE__ = "
<> json.to_string(model_to_json(model))
<> ";"
),
]),
// Подключаем клиентский бандл
html.script([attribute.src("/static/app.js")], []),
]),
])
}
fn model_to_json(model: Model) -> json.Json {
json.object([
#("count", json.int(model.count)),
])
}
Шаг 2: На клиенте читаем __INITIAL_STATE__ и инициализируем приложение
// client.gleam (компилируется в JavaScript)
import lustre
import gleam/dynamic/decode
@external(javascript, "./client_ffi.mjs", "getInitialState")
fn get_initial_state() -> dynamic.Dynamic
fn init(_flags) -> Model {
case decode_model(get_initial_state()) {
Ok(model) -> model
Error(_) -> Model(count: 0) // Fallback
}
}
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
// client_ffi.mjs
export function getInitialState() {
return window.__INITIAL_STATE__ || {};
}
Теперь сервер рендерит начальный HTML (быстрая первая отрисовка), а клиент подхватывает состояние и продолжает работу (интерактивность).
SSR vs CSR vs Server Components
| Подход | Где рендер | Где логика | Интерактивность | SEO |
|---|---|---|---|---|
| CSR (SPA) | Браузер | Браузер | Мгновенная | ❌ Требует JS |
| SSR + гидратация | Сервер → Браузер | Браузер | После загрузки | ✅ Готовый HTML |
| Server Components | Сервер | Сервер | Через WebSocket | ✅ Готовый HTML |
| Статический SSR | Сервер | — | Нет | ✅ Готовый HTML |
Выбор зависит от приоритетов:
- CSR — максимальная интерактивность, не нужен сервер
- SSR + гидратация — баланс SEO и интерактивности
- Server Components — real-time, минимальный JS, но требует постоянного соединения
- Статический SSR — максимальная производительность, но без интерактивности
Проект: TODO-приложение
Соберём полный TODO-список с фильтрацией.
Модель
pub type Filter {
All
Active
Completed
}
pub type Todo {
Todo(id: Int, text: String, completed: Bool)
}
pub type Model {
Model(
todos: List(Todo),
input: String,
filter: Filter,
next_id: Int,
)
}
Filter — алгебраический тип для переключения видимых задач. Todo хранит уникальный id, текст и признак выполнения. next_id в Model монотонно растёт и гарантирует, что каждая новая задача получит уникальный идентификатор.
Сообщения
pub type Msg {
InputChanged(String)
AddTodo
ToggleTodo(Int)
DeleteTodo(Int)
SetFilter(Filter)
ClearCompleted
}
Каждое возможное действие пользователя — отдельный конструктор: ввод текста, добавление, переключение чекбокса, удаление задачи, смена фильтра и очистка выполненных. Система типов не даст забыть ни один случай в update.
Update
fn update(model: Model, msg: Msg) -> Model {
case msg {
InputChanged(text) -> Model(..model, input: text)
AddTodo ->
case string.trim(model.input) {
"" -> model
text -> {
let todo = Todo(id: model.next_id, text:, completed: False)
Model(
..model,
todos: list.append(model.todos, [todo]),
input: "",
next_id: model.next_id + 1,
)
}
}
ToggleTodo(id) -> {
let todos =
list.map(model.todos, fn(todo) {
case todo.id == id {
True -> Todo(..todo, completed: !todo.completed)
False -> todo
}
})
Model(..model, todos:)
}
DeleteTodo(id) -> {
let todos = list.filter(model.todos, fn(todo) { todo.id != id })
Model(..model, todos:)
}
SetFilter(filter) -> Model(..model, filter:)
ClearCompleted -> {
let todos = list.filter(model.todos, fn(todo) { !todo.completed })
Model(..model, todos:)
}
}
}
AddTodo сначала проверяет, что поле не пустое (string.trim), и только потом создаёт задачу — защита от пустых строк. ToggleTodo проходит по всему списку с list.map и инвертирует только нужный элемент по id, не трогая остальные.
View
fn filtered_todos(model: Model) -> List(Todo) {
case model.filter {
All -> model.todos
Active -> list.filter(model.todos, fn(t) { !t.completed })
Completed -> list.filter(model.todos, fn(t) { t.completed })
}
}
fn view(model: Model) -> Element(Msg) {
let remaining =
model.todos
|> list.filter(fn(t) { !t.completed })
|> list.length
html.div([attribute.class("app")], [
html.h1([], [element.text("TODO")]),
html.div([attribute.class("input-row")], [
html.input([
attribute.value(model.input),
attribute.placeholder("Что нужно сделать?"),
event.on_input(InputChanged),
]),
html.button([event.on_click(AddTodo)], [element.text("Добавить")]),
]),
html.ul(
[attribute.class("todo-list")],
model |> filtered_todos |> list.map(todo_view),
),
html.div([attribute.class("footer")], [
element.text(int.to_string(remaining) <> " осталось"),
filter_buttons(model.filter),
]),
])
}
fn todo_view(todo: Todo) -> Element(Msg) {
html.li([], [
html.input([
attribute.type_("checkbox"),
attribute.checked(todo.completed),
event.on_check(fn(_) { ToggleTodo(todo.id) }),
]),
html.span([], [element.text(todo.text)]),
html.button(
[event.on_click(DeleteTodo(todo.id))],
[element.text("✕")],
),
])
}
filtered_todos вычисляется заново при каждом вызове view — это нормально в функциональном UI, где вся функция рендера чистая. todo_view вынесена отдельно, чтобы view оставалась читаемой и компактной.
Упражнения
Код упражнений находится в exercises/chapter11/.
Тесты запускаются на JavaScript-таргете:
cd exercises/chapter11
gleam test
Упражнение 11.1 (Лёгкое): Рендеринг списка
pub fn render_list(items: List(String)) -> Element(msg) {
todo
}
Функция должна вернуть элемент <ul> с <li> для каждой строки из items.
Подсказка: html.ul, html.li, list.map.
Упражнение 11.2 (Лёгкое): Инициализация счётчика
pub fn counter_init() -> Int {
todo
}
Возвращает начальное значение счётчика: 0.
Упражнение 11.3 (Лёгкое): Обновление счётчика
pub type CounterMsg {
CounterIncrement
CounterDecrement
CounterReset
}
pub fn counter_update(model: Int, msg: CounterMsg) -> Int {
todo
}
CounterIncrement увеличивает на 1, CounterDecrement уменьшает на 1, CounterReset сбрасывает в 0.
Упражнение 11.4 (Среднее, самостоятельное): Форма с валидацией
Реализуйте компонент формы:
- Поле для ввода email
- При отправке: если email содержит
@— показать "OK", иначе — "Неверный email" - Используйте
lustre.simple
Упражнение 11.5 (Сложное, самостоятельное): TODO с фильтрами
Реализуйте полноценный TODO-список с:
- Добавлением/удалением задач
- Отметкой выполнения (чекбокс)
- Тремя фильтрами: Все / Активные / Завершённые
Упражнение 11.6 (Сложное, интеграция FFI): Lustre + date-fns
Применение главы 9: Это упражнение требует знаний из главы 9 (JavaScript FFI, классы и библиотеки).
Интегрируйте JavaScript-библиотеку date-fns в Lustre-приложение:
- Установите
date-fns:npm install date-fns - Создайте FFI-модуль
src/date_ffi.mjs:
import { format, addDays, differenceInDays } from 'date-fns';
export function formatDate(date, pattern) {
return format(date, pattern);
}
export function addDays(date, days) {
return addDays(date, days);
}
export function now() {
return new Date();
}
export function differenceInDays(dateLeft, dateRight) {
return differenceInDays(dateLeft, dateRight);
}
- Создайте Gleam-обёртку
src/date_utils.gleam:
pub type JSDate
@external(javascript, "./date_ffi.mjs", "now")
pub fn now() -> JSDate
@external(javascript, "./date_ffi.mjs", "formatDate")
pub fn format_date(date: JSDate, pattern: String) -> String
@external(javascript, "./date_ffi.mjs", "addDays")
pub fn add_days(date: JSDate, days: Int) -> JSDate
@external(javascript, "./date_ffi.mjs", "differenceInDays")
pub fn difference_in_days(date_left: JSDate, date_right: JSDate) -> Int
- Реализуйте приложение, которое:
- Показывает текущую дату в формате
"yyyy-MM-dd" - Позволяет добавить/вычесть дни (кнопки "+1 день", "-1 день")
- Показывает разницу в днях от сегодняшней даты
- Показывает текущую дату в формате
Ожидаемый результат:
Текущая дата: 2026-02-21
Выбранная дата: 2026-02-25 (+4 дня от сегодня)
[−1 день] [Сегодня] [+1 день]
Подсказка: модель — Model(selected_date: JSDate, today: JSDate).
Итоги
Lustre предлагает строго типизированный UI с:
- TEA — однонаправленный поток данных, нет мутаций
- Виртуальный DOM — эффективные обновления браузера
- Эффекты — чистое управление побочными эффектами
- Server Components — компоненты на BEAM с WebSocket
Gleam позволяет использовать один язык и для бэкенда (Wisp, OTP), и для фронтенда (Lustre) — уникальная возможность в мире типизированных языков.
TEA в других языках и фреймворках
Если вам понравилась The Elm Architecture, вот другие реализации и вдохновлённые ей фреймворки:
Elm (оригинал)
- Язык: Elm (чисто функциональный, компилируется в JavaScript)
- Сайт: https://elm-lang.org/
- Оригинальная реализация TEA от Evan Czaplicki
- Самая строгая типизация, нет runtime exceptions
- Идеален для изучения TEA в чистом виде
Redux (JavaScript/TypeScript)
- Экосистема: React
- Сайт: https://redux.js.org/
- TEA для JavaScript: actions = Msg, reducers = update
- Redux Toolkit упрощает шаблонный код
- Самая популярная адаптация TEA (миллионы приложений)
Elmish (F#)
- Платформа: .NET
- Сайт: https://elmish.github.io/elmish/
- TEA для F# с поддержкой .NET экосистемы
- Интеграция с Xamarin, WPF, Avalonia
- Использует F# discriminated unions для Msg
Iced (Rust)
- Применение: Desktop GUI
- Сайт: https://iced.rs/
- Кроссплатформенный GUI-фреймворк на основе TEA
- Использует Rust enums для сообщений
- Поддержка WebAssembly для веб-приложений
SwiftUI (Swift)
- Платформа: Apple (iOS, macOS, watchOS)
- Декларативный UI с однонаправленным потоком данных
@Stateи@Bindingвдохновлены TEA- Интегрирован в официальный SDK Apple
Ресурсы
Lustre:
- HexDocs — lustre
- Lustre Quickstart
- Lustre — GitHub
- Building your first Gleam web app with Wisp and Lustre
The Elm Architecture:
- The Elm Architecture (оригинальный гайд)
- Redux — A Predictable State Container for JS Apps
- Elmish: Elm-like abstractions for F#
Заключение и следующие шаги
Поздравляем! Вы завершили курс "Gleam by Example" и прошли путь от основ функционального программирования до создания production-ready приложений на платформе BEAM и в браузере.
- Путь, который мы прошли
- Что дальше изучать
- Ресурсы сообщества
- Как внести вклад
- Практические проекты для закрепления
- Заключительные мысли
Путь, который мы прошли
За 13 глав вы освоили:
Основы Gleam (главы 1-6)
- Функциональное программирование: иммутабельность, pattern matching, функции высшего порядка
- Система типов: статическая типизация, вывод типов, Result и Option
- Коллекции: списки, кортежи, словари, множества
- Рекурсия и свёртки: рекурсивные алгоритмы, list.fold
- Обработка ошибок: паттерн Result, монадический style, Railway-Oriented Programming
- Битовые массивы: работа с бинарными данными, парсинг протоколов
Продвинутые концепции (главы 7-9)
- Type Safety: Parse Don't Validate, opaque types, phantom types
- Валидация данных: gleam/dynamic/decode, типобезопасный парсинг JSON
- Erlang FFI: интеграция с BEAM экосистемой, работа с файлами, процессами
- JavaScript FFI: фронтенд-разработка, DOM API, промисы, localStorage
Production-ready разработка (главы 10-13)
- Процессы и OTP: акторы, супервизоры, fault tolerance
- Тестирование: модульные тесты, Property-Based Testing с qcheck, snapshot testing
- Веб-разработка: HTTP-серверы на Wisp, REST API, PostgreSQL, middleware
- Фронтенд: Lustre framework, MVU-архитектура, SSR, компоненты
Практические навыки
- Построили PokeAPI-клиент с типобезопасным парсингом
- Создали TODO API с PostgreSQL-хранилищем
- Разработали Lustre-приложение с server-side rendering
- Написали Telegram-бота с FSM и персистентностью
Что дальше изучать
Углублённые темы Gleam
1. Реализация OTP behaviours
Gleam поддерживает стандартные OTP-паттерны:
import gleam/otp/supervisor
import gleam/otp/task
// Supervisor с несколькими workers
pub fn start() {
supervisor.start(fn(children) {
children
|> supervisor.add(supervisor.worker(start_db_pool))
|> supervisor.add(supervisor.worker(start_api_server))
|> supervisor.add(supervisor.worker(start_background_jobs))
})
}
Ресурсы:
- Документация gleam_otp: https://hexdocs.pm/gleam_otp/
- Примеры OTP-приложений: https://github.com/gleam-lang/otp
2. Генераторы кода и макросы
Хотя Gleam не поддерживает макросы напрямую, можно использовать codegen для повторяющихся паттернов:
// Генерация SQL-запросов через Squirrel
// https://github.com/giacomocavalieri/squirrel
3. Производительность и профилирование
BEAM предоставляет мощные инструменты профилирования:
# Профилирование через :observer
gleam run -m erlang -- -s observer start
# Flamegraphs для production
# https://github.com/gleam-lang/gleam_erlang_profiler
Экосистема BEAM
Распределённые системы
BEAM создан для распределённых приложений:
// Подключение к удалённым нодам
import gleam/erlang/node
pub fn connect_cluster() {
node.connect("app@server1.example.com")
node.connect("app@server2.example.com")
}
Изучите:
- Distributed Erlang: http://www.erlang.org/doc/reference_manual/distributed.html
- libcluster для автоматического обнаружения нод
- Horde для распределённых супервизоров
Горячая перезагрузка кода
BEAM позволяет обновлять код без остановки приложения:
# Compile и загрузить новый код
gleam build
# Релиз-менеджер загрузит модули без downtime
Ресурсы:
- https://www.erlang.org/doc/design_principles/release_handling
Observability
Мониторинг production-систем:
import telemetry
pub fn track_request(duration: Int) {
telemetry.emit("http.request", [#("duration", duration)])
}
Инструменты:
- Telemetry для метрик: https://hexdocs.pm/telemetry/
- Phoenix LiveDashboard (адаптируется для Gleam-приложений)
- Grafana + Prometheus для визуализации
Специализация
Real-time приложения
WebSockets, Server-Sent Events, Phoenix Channels:
import mist/websocket
pub fn handle_websocket(req) {
websocket.upgrade(req, fn(conn) {
// Обработка WebSocket-сообщений
websocket.send(conn, "Hello from Gleam!")
})
}
Проекты:
- Чат-приложение с WebSocket
- Live dashboard с SSE
- Multiplayer-игра
Embedded системы (Nerves)
Gleam работает на Raspberry Pi и других embedded-устройствах через Nerves:
# Установка Nerves
mix archive.install hex nerves_bootstrap
# Создание Nerves-проекта с Gleam
mix nerves.new my_app --target rpi4
Ресурсы:
- https://nerves-project.org/
- Gleam + Nerves примеры: https://github.com/nerves-project
CLI утилиты
Gleam отлично подходит для command-line инструментов:
import glint
pub fn main() {
glint.new()
|> glint.add_command("build", build_command)
|> glint.add_command("test", test_command)
|> glint.run(argv)
}
Библиотеки:
- glint: https://hexdocs.pm/glint/ (CLI framework)
- shellout: выполнение shell-команд
- gleam_json: парсинг конфигурационных файлов
Ресурсы сообщества
Официальные каналы
- Discord: https://discord.gg/gleam (самое активное сообщество, ~10k участников)
- GitHub Discussions: https://github.com/gleam-lang/gleam/discussions
- Форум: https://gleam.run/community/
Обучающие материалы
- Awesome Gleam: https://github.com/gleam-lang/awesome-gleam (каталог библиотек)
- Gleam Weekly newsletter: https://gleam.run/news/
- Exercism Gleam track: https://exercism.org/tracks/gleam (интерактивные упражнения)
- YouTube: Gleam Programming Language канал (конференции, туториалы)
Блоги и статьи
- Официальный блог: https://gleam.run/news/
- Hayleigh Thompson: https://hayleigh.dev/ (maintainer Lustre)
- Louis Pilfold: https://lpil.uk/ (создатель Gleam)
Книги и курсы
- "Learn You Some Erlang": классика для понимания BEAM (применимо к Gleam)
- "Designing for Scalability with Erlang/OTP": паттерны для production-систем
Как внести вклад
Gleam — молодой и активно развивающийся язык. Сообщество приветствует вклад на любом уровне.
Вклад в Gleam
- Открыть issue/PR в Gleam: https://github.com/gleam-lang/gleam
- Баг-репорты
- Feature requests
- Улучшения документации
- Исправления в компиляторе (написан на Rust)
Написать библиотеку
Экосистема Gleam активно растёт. Популярные направления:
- Парсеры: JSON, TOML, YAML, XML
- HTTP-клиенты: обёртки над hackney/httpc
- База данных: драйверы для MySQL, SQLite, Redis
- Утилиты: работа с датами, валидация, крипто
Как начать:
gleam new my_awesome_lib
cd my_awesome_lib
gleam add gleam_stdlib
# Опубликовать на Hex.pm
gleam publish
Перевести документацию
Русскоязычное сообщество Gleam растёт. Можно:
- Перевести официальную документацию
- Написать туториалы на русском
- Создать обучающие видео
Поделиться опытом
- Написать статью о миграции проекта на Gleam
- Выступить на митапе/конференции
- Создать open-source проект и рассказать о нём
Практические проекты для закрепления
Теперь, когда вы знаете Gleam, закрепите знания на реальных проектах:
Уровень 1: Библиотеки и утилиты
- URL-парсер (практика: parse don't validate, opaque types)
- Markdown → HTML конвертер (практика: рекурсия, pattern matching)
- CSV-парсер (практика: bit arrays, Result-based error handling)
Уровень 2: Веб-приложения
- URL shortener (практика: Wisp, PostgreSQL, валидация)
- Pastebin-клон (практика: файлы, кэш, syntax highlighting)
- RSS-агрегатор (практика: HTTP-клиенты, cron-задачи, атомы)
Уровень 3: Real-time системы
- WebSocket чат (практика: mist/websocket, процессы, state)
- Live quiz платформа (практика: SSE, Lustre, временные ограничения)
- Multiplayer game (практика: game loop, синхронизация, latency)
Уровень 4: Распределённые системы
- Distributed task queue (практика: OTP, распределённые акторы, supervisor trees)
- Multi-node чат (практика: distributed Erlang, node.connect)
- Metrics aggregator (практика: telemetry, time series, persistence)
Заключительные мысли
Gleam сочетает элегантность функциональных языков с мощью платформы BEAM. Вы получили:
- Type safety без runtime-overhead
- Конкурентность через легковесные процессы
- Fault tolerance благодаря OTP
- Универсальность: backend, frontend, CLI, embedded
Ключевые принципы, которые стоит помнить:
- "Parse, Don't Validate" — делайте невалидные состояния непредставимыми
- "Let It Crash" — не бойтесь падений, используйте супервизоры
- "Immutability by Default" — иммутабельность упрощает рассуждения о коде
- "Railway-Oriented Programming" — композируйте операции через Result
Gleam только начинает свой путь. Присоединяйтесь к сообществу, стройте классные проекты, делитесь знаниями. Удачи в ваших Gleam-приключениях! ✨
Дополнительные ресурсы:
- Gleam Language Tour: https://tour.gleam.run/
- Gleam Standard Library: https://hexdocs.pm/gleam_stdlib/
- Awesome Gleam: https://github.com/gleam-lang/awesome-gleam
- Discord: https://discord.gg/gleam
Спасибо, что прочли эту книгу. Надеемся, она была полезна! Если у вас есть предложения, откройте issue на GitHub.
Telegram-бот с Telega
Полноценный Telegram-бот: команды, роутер, middleware, inline-клавиатуры и тестирование.
- Цели главы
- Как работают Telegram-боты
- Первый бот
- Архитектура: дерево супервизоров
- Router — маршрутизация обновлений
- Reply — отправка ответов
- Session — встроенные сессии
- Conversation API — линейные диалоги
- Конечные автоматы (FSM) в ботах
- Flows API — персистентные FSM
- Middleware
- Обработка ошибок
- Тестирование бота
- Деплой
- Упражнения
- Итоги
- Ресурсы
Цели главы
В этой главе мы:
- Познакомимся с Telega — библиотекой для Telegram-ботов на Gleam
- Научимся строить роутер для обработки команд и сообщений
- Освоим Session — встроенное хранилище данных пользователя
- Изучим Conversation API — линейные многошаговые диалоги через
wait_*функции - Разберём Flow API — персистентные конечные автоматы с навигацией
- Рассмотрим inline-клавиатуры, callback queries и валидацию ввода
- Поймём архитектуру дерева супервизоров бота
- Научимся тестировать бота через
telega/testing
Как работают Telegram-боты
Telegram предоставляет два способа получения обновлений от пользователей: long polling и webhooks. Выбор между ними — один из первых архитектурных решений при создании бота.
Long Polling — активное получение
Бот постоянно спрашивает сервера Telegram: "есть новые сообщения?". Если сообщений нет, то соединение остаётся открытым до 30 секунд (по умолчанию), затем запрос повторяется. Это pull-модель: бот сам забирает обновления.
Плюсы:
- Простая настройка — не нужен публичный URL или SSL-сертификат
- Работает на локальной машине (для разработки)
- Последовательная обработка сообщений — нет конкуренции за данные
- Предсказуемое поведение, проще отлаживать
Минусы:
- Выше нагрузка на сеть — постоянные запросы даже когда сообщений нет
- Бот должен работать 24/7, нельзя "спать" между сообщениями
- Не подходит для serverless-платформ (AWS Lambda, Cloudflare Workers)
Webhook — реактивное получение
Telegram отправляет POST-запрос на ваш сервер при каждом новом обновлении. Это push-модель: сервер сообщает боту о событиях, бот не опрашивает.
Плюсы:
- Меньше нагрузки — запросы только при реальных событиях
- Подходит для serverless — функция "просыпается" только при сообщении
- Масштабируется автоматически на облачных платформах
- Экономия ресурсов (и денег) при низкой активности
Минусы:
- Требуется публичный URL с валидным SSL-сертификатом
- Сложнее отлаживать локально
- Конкурентная обработка — несколько обновлений могут прийти одновременно
- Критично: Telegram ждёт ответа в течение ~10 секунд. Если обработчик не успевает, обновление отправляется повторно, что приводит к дублированию сообщений
Важно для webhook: Долгие операции (запросы к внешним API, тяжёлые вычисления) нужно выносить в фоновую очередь. Отвечайте Telegram'у быстро, обрабатывайте асинхронно.
Что выбрать?
| Сценарий | Рекомендация |
|---|---|
| Локальная разработка | Long polling |
| Простой бот на VPS/dedicated сервере | Long polling (проще) |
| Serverless (Lambda, Workers) | Webhook (единственный вариант) |
| Высокая нагрузка, авто-масштабирование | Webhook |
| Бот с сессиями и состоянием | Long polling (меньше race conditions) |
Рекомендация для начинающих: Начните с long polling — он проще и надёжнее. Переходите на webhook только если есть конкретные причины (serverless, экономия).
Telega фокусируется на polling-режиме. HTTP-клиент вынесен в отдельный пакет telega_httpc, а запуск бота создаёт полное дерево супервизоров автоматически.
Первый бот
Установите зависимости:
[dependencies]
telega = ">= 1.0.0 and < 2.0.0"
telega_httpc = ">= 1.0.0 and < 2.0.0"
telega — основная библиотека бота, telega_httpc — HTTP-адаптер для взаимодействия с Telegram API. Они разделены, чтобы при необходимости можно было подставить свой HTTP-клиент.
Простейший эхо-бот — ключевые моменты:
import gleam/erlang/process
import telega
import telega/reply
import telega/router
import telega_httpc
pub fn main() {
// Роутер с одним обработчиком — любой текст отзывается эхом
let bot_router =
router.new("echo_bot")
|> router.on_any_text(fn(ctx, text) {
let assert Ok(_) = reply.with_text(ctx:, text:)
Ok(ctx)
})
// Создаём HTTP-клиент и бота в режиме polling
let client = telega_httpc.new(token: "YOUR_BOT_TOKEN")
let assert Ok(_bot) =
telega.new_for_polling(api_client: client)
|> telega.with_router(bot_router)
|> telega.init_for_polling_nil_session()
// Бот запущен — дерево супервизоров управляет polling
process.sleep_forever()
}
Разберём по шагам:
telega_httpc.new(token:)создаёт HTTP-клиент с токеном ботаtelega.new_for_polling(api_client:)создаёт конфигурацию бота для polling-режимаtelega.with_router(bot_router)подключает роутер с обработчикамиtelega.init_for_polling_nil_session()запускает полное дерево супервизоров (без пользовательских сессий)
После init_for_polling_nil_session() бот уже работает: polling-процесс опрашивает Telegram, а process.sleep_forever() не даёт main-процессу завершиться.
Полный код echo_bot.gleam
Бот отвечает тем же текстом на любое текстовое сообщение — добавим обработку команд через роутер.
Архитектура: дерево супервизоров
Вызов telega.init_for_polling_nil_session() (или telega.init_for_polling() при использовании сессий) запускает не просто один процесс, а целое дерево супервизоров на базе OTP (см. главу 8). Понимание этой архитектуры поможет при отладке и масштабировании бота.
Структура дерева
TelegaRootSupervisor (OneForOne)
├── ChatInstanceFactory
│ └── ChatInstance (для каждого chat_id)
│ └── FSM: ROUTING <-> WAITING
├── Bot (управляет конфигурацией и роутером)
└── Polling (long polling процесс)
TelegaRootSupervisor — корневой супервизор стратегии OneForOne. Если один дочерний процесс падает, перезапускается только он, не затрагивая остальные.
ChatInstanceFactory — фабрика, создающая отдельный процесс (ChatInstance) для каждого чата. Когда от пользователя приходит первое сообщение, фабрика порождает новый процесс. Последующие сообщения от того же пользователя направляются в его существующий процесс.
ChatInstance — процесс, привязанный к конкретному чату. Внутри работает конечный автомат с двумя состояниями:
- ROUTING — обычный режим. Входящее обновление проходит через роутер и обрабатывается подходящим хендлером.
- WAITING — режим ожидания. Бот вызвал
wait_text,wait_numberили другую wait-функцию. Следующее сообщение от пользователя не проходит через роутер, а передаётся напрямую в ожидающий хендлер.
Bot — процесс, хранящий конфигурацию бота (роутер, настройки сессий, middleware).
Polling — процесс, выполняющий long polling запросы к Telegram API. Получает обновления и направляет их в соответствующие ChatInstance через фабрику.
Изоляция чатов
Ключевое свойство архитектуры — изоляция. Каждый чат обрабатывается в собственном процессе. Это означает:
- Крэш обработчика одного пользователя не влияет на остальных
- Состояние сессии и wait-функций принадлежит конкретному чату
- Сообщения от разных пользователей обрабатываются параллельно
- Супервизор автоматически перезапустит упавший ChatInstance
Это поведение наследуется от BEAM VM и модели акторов Erlang/OTP. Если вы прочитали главу 8 про процессы и супервизоры, архитектура Telega покажется знакомой.
Жизненный цикл обновления
- Polling-процесс получает пакет обновлений от Telegram API
- Каждое обновление направляется в ChatInstanceFactory по
chat_id - Фабрика находит существующий ChatInstance или создаёт новый
- ChatInstance проверяет своё состояние:
- ROUTING: обновление проходит через middleware и роутер
- WAITING: обновление передаётся в ожидающую wait-функцию
- Обработчик выполняется, отправляет ответы через
reply.* - Если обработчик вызывает
wait_*, ChatInstance переходит в состояние WAITING
Понимание этого цикла особенно важно при работе с Conversation API: когда обработчик вызывает telega.wait_text(...), ChatInstance переключается в WAITING и ждёт следующего сообщения, а не нового вызова роутера.
Router — маршрутизация обновлений
telega/router — основной модуль для создания роутера. Роутер регистрирует обработчики для разных типов обновлений, а telega.with_router подключает его к боту:
import telega/reply
import telega/router
let bot_router =
router.new("my_bot")
|> router.on_command("start", fn(ctx, _command) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Привет! /help для помощи.")
Ok(ctx)
})
router.new("name") создаёт именованный роутер. Каждый |> router.on_command(...) добавляет обработчик и возвращает обновлённый роутер — чистая цепочка без мутаций.
Полные примеры router_example.gleam
Команды
import telega/reply
import telega/router
fn build_router() {
router.new("my_bot")
// /start
|> router.on_command("start", fn(ctx, _command) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Добро пожаловать!")
Ok(ctx)
})
// /help
|> router.on_command("help", fn(ctx, _command) {
let assert Ok(_) =
reply.with_text(
ctx:,
text: "Доступные команды:\n/start — начало\n/help — помощь\n/echo — повторить текст",
)
Ok(ctx)
})
}
Каждый обработчик — обычная функция fn(ctx, command) -> Result(ctx, error). _command — параметр с данными команды (command.command, command.payload), здесь он не нужен. Все хендлеры регистрируются цепочкой через |>.
Обработка текстовых сообщений
// Любой текст — text передаётся как аргумент обработчика
|> router.on_any_text(fn(ctx, text) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Вы написали: " <> text)
Ok(ctx)
})
// Только если текст точно совпадает с "ping"
|> router.on_text(router.Exact("ping"), fn(ctx, _text) {
let assert Ok(_) = reply.with_text(ctx:, text: "pong!")
Ok(ctx)
})
Текст сообщения передаётся вторым аргументом обработчика — не нужно обращаться к ctx чтобы его получить. router.on_text принимает паттерн: Exact, Prefix, Contains или Suffix.
Композиция роутеров
Telega предоставляет три способа комбинировать роутеры: merge, compose и scope.
merge — объединение команд
router.merge(first, second) складывает команды, callbacks и routes двух роутеров в один. Если есть конфликт (одинаковая команда), побеждает первый роутер:
let admin_router =
router.new("admin")
|> router.on_command("ban", fn(ctx, _cmd) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Пользователь заблокирован")
Ok(ctx)
})
|> router.on_command("stats", fn(ctx, _cmd) {
let assert Ok(_) = reply.with_text(ctx:, text: "Статистика бота")
Ok(ctx)
})
let user_router =
router.new("user")
|> router.on_command("start", fn(ctx, _cmd) {
let assert Ok(_) = reply.with_text(ctx:, text: "Привет!")
Ok(ctx)
})
// Один роутер обрабатывает /start, /ban, /stats
router.merge(user_router, admin_router)
merge удобен когда у вас логически разделённые группы команд (пользовательские, административные, модераторские), которые нужно объединить.
compose — последовательная цепочка
router.compose(first, second) создаёт цепочку: обновление пробуется на первом роутере, если не совпало — на втором. Каждый роутер сохраняет свои middleware:
let commands_router =
router.new("commands")
|> router.on_command("start", fn(ctx, _cmd) {
let assert Ok(_) = reply.with_text(ctx:, text: "Привет!")
Ok(ctx)
})
let fallback_router =
router.new("fallback")
|> router.on_any_text(fn(ctx, text) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Не понимаю: " <> text)
Ok(ctx)
})
// Сначала пробуем commands_router, потом fallback_router
router.compose(commands_router, fallback_router)
В отличие от merge, compose сохраняет независимость middleware каждого роутера. Это важно, когда admin-роутер должен проверять права доступа, а user-роутер — нет.
scope — условная маршрутизация
router.scope(router, predicate) оборачивает роутер предикатом. Обновления попадают в роутер только если предикат возвращает True:
let admin_router =
router.new("admin")
|> router.on_command("ban", handle_ban)
// admin_router обрабатывает обновления только от администраторов
let scoped = router.scope(admin_router, fn(ctx) {
list.contains(admin_ids, ctx.chat_id)
})
Полные примеры композиции
Reply — отправка ответов
Модуль telega/reply предоставляет функции для отправки различных типов сообщений:
import telega/reply
// Текстовое сообщение
let assert Ok(_) = reply.with_text(ctx:, text: "Привет!")
// Текст с HTML-форматированием
let assert Ok(_) = reply.with_html(ctx:, text: "<b>Жирный</b> текст")
// Текст с Markdown
let assert Ok(_) = reply.with_markdown(ctx:, text: "*Жирный* текст")
Обратите внимание на именованные аргументы: reply.with_text(ctx:, text: "..."). Все функции reply используют labeled args для ясности. Функции возвращают Result(Message, TelegaError) — нужно обработать результат или использовать assert Ok.
Все функции reply.* отправляют ответ в тот же чат, из которого пришло обновление: ctx содержит идентификатор чата и клиент бота.
Клавиатуры: Inline vs Reply
Telegram предоставляет два типа клавиатур, которые работают принципиально по-разному:
Inline-клавиатуры (кнопки под сообщением)
Inline-клавиатуры отображаются под конкретным сообщением внутри чата. При нажатии кнопки отправляется callback query — невидимое для пользователя событие. В чате не появляется новое сообщение, только действие.
Когда использовать:
- Навигация по меню (настройки, пагинация)
- Действия без текстового ответа (лайк/дизлайк, выбор опции)
- Игровые элементы управления
- Подтверждения (Да/Нет для удаления)
Пример:
import telega/keyboard
import telega/reply
fn send_settings_menu(ctx) {
let lang_cb = keyboard.string_callback_data("lang")
let notification_cb = keyboard.string_callback_data("notification")
let close_cb = keyboard.string_callback_data("close")
let assert Ok(kb) =
keyboard.inline_builder()
|> keyboard.inline_text(
"🌍 Изменить язык",
keyboard.pack_callback(callback_data: lang_cb, data: "open"),
)
let assert Ok(kb) =
kb
|> keyboard.inline_text(
"🔔 Уведомления",
keyboard.pack_callback(callback_data: notification_cb, data: "open"),
)
let kb = kb |> keyboard.inline_next_row() // Новая строка кнопок
let assert Ok(kb) =
kb
|> keyboard.inline_text(
"❌ Закрыть",
keyboard.pack_callback(callback_data: close_cb, data: "close"),
)
let kb = keyboard.inline_build(kb)
let assert Ok(_) =
reply.with_markup(
ctx:,
text: "⚙️ Настройки:",
markup: keyboard.inline_to_markup(kb),
)
Ok(ctx)
}
Построение inline-клавиатуры:
keyboard.inline_builder()— создаёт билдерkeyboard.inline_text(builder, text, callback)— добавляет кнопку, возвращаетResult(InlineKeyboardBuilder, String)(валидирует длину callback data)keyboard.string_callback_data(id)— создаёт идентификатор callback-данныхkeyboard.pack_callback(callback_data:, data:)— упаковывает данные вKeyboardCallbackkeyboard.inline_next_row()— начинает новую строку кнопокkeyboard.inline_build(builder)— завершает построениеkeyboard.inline_to_markup(kb)— конвертирует в формат дляreply.with_markup
Кнопки расположатся так:
[🌍 Изменить язык] [🔔 Уведомления]
[❌ Закрыть]
Reply-клавиатуры (замена системной клавиатуры)
Reply-клавиатуры заменяют стандартную клавиатуру пользователя. При нажатии кнопки отправляется обычное текстовое сообщение, видимое в чате. Текст кнопки = текст сообщения.
Когда использовать:
- Множественный выбор с видимыми ответами
- Анкеты и опросы (ответы должны быть в истории чата)
- Быстрый ввод типичных команд (/start, /help)
- Когда важна прозрачность — пользователь видит что отправил
Пример:
import telega/keyboard
import telega/reply
fn ask_confirmation(ctx) {
let kb =
keyboard.builder()
|> keyboard.text("✅ Да")
|> keyboard.text("❌ Нет")
|> keyboard.next_row()
|> keyboard.text("❓ Не уверен")
|> keyboard.build()
let assert Ok(_) =
reply.with_markup(
ctx:,
text: "Подтвердите действие:",
markup: keyboard.to_markup(kb),
)
Ok(ctx)
}
Функции Reply-клавиатуры:
keyboard.builder()— создаёт билдерkeyboard.text(text)— добавляет текстовую кнопкуkeyboard.next_row()— начинает новую строкуkeyboard.build()— завершает построениеkeyboard.to_markup()— конвертирует дляreply.with_markup
Ключевые отличия
| Аспект | Inline-клавиатура | Reply-клавиатура |
|---|---|---|
| Расположение | Под сообщением | Вместо системной клавиатуры |
| Что отправляется | Callback query (невидимо) | Текстовое сообщение (видимо) |
| Текст кнопки vs данные | Можно разделить: текст "Да", данные "confirm_yes" | Одно и то же: кнопка "Да" отправит сообщение "Да" |
| Видимость в чате | Ничего не добавляется | Появляется новое сообщение |
| Обработка | router.on_callback или wait_callback_query | router.on_text или wait_text |
Важно: Эти клавиатуры взаимоисключающие — нельзя использовать обе в одном сообщении. При редактировании сообщения нельзя изменить тип клавиатуры.
Обработка inline-клавиатур
Callback queries обрабатываются через роутер или через Conversation API:
router.new("settings_bot")
|> router.on_callback(router.Exact("lang"), fn(ctx, _data, query_id) {
// Обязательно отвечаем на callback query
let assert Ok(_) =
reply.answer_callback_query(
ctx:,
parameters: AnswerCallbackQueryParameters(
callback_query_id: query_id,
text: Some("Открываю..."),
show_alert: None,
url: None,
cache_time: None,
),
)
show_language_menu(ctx)
})
Обработчик callback принимает три аргумента — ctx, data и query_id. Для ответа на callback query используйте reply.answer_callback_query(ctx:, parameters:) с типом AnswerCallbackQueryParameters.
Критично: Всегда вызывайте reply.answer_callback_query — иначе Telegram показывает бесконечную загрузку на кнопке.
Обработка reply-клавиатур
Reply-клавиатуры обрабатываются как обычный текст:
router.new("quiz_bot")
|> router.on_text(router.Exact("✅ Да"), fn(ctx, _) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Отлично! Продолжаем.")
Ok(ctx)
})
Полные примеры клавиатур
Session — встроенные сессии
Представьте: пользователь выбрал язык интерфейса — русский. Отправил команду /start, получил приветствие. Через час пишет /help — и бот снова отвечает по-русски. Как бот "помнит" выбор пользователя?
Каждое сообщение в Telegram — изолированное событие. Без дополнительного механизма бот забывает всё между обновлениями. Сессии решают эту проблему: это персональная память под каждого пользователя, где бот хранит данные между сообщениями.
Зачем нужны сессии?
Типичные примеры:
- Настройки пользователя: язык, часовой пояс, формат даты
- Контекст: последняя команда, текущий раздел меню
- Временные данные: выбранные фильтры, параметры поиска
- Счётчики: сколько раз пользователь выполнил действие
Важно: Сессии в Telega живут в памяти BEAM-процесса (внутри ChatInstance) и не переживают перезапуск бота по умолчанию. Для персистентных данных используйте SessionSettings с сохранением или Flow API с Storage.
Создаём тип сессии
Сессия — это обычный Gleam-тип. Определите что ваш бот должен помнить о каждом пользователе:
pub type MusicBotSession {
MusicBotSession(
language: String, // Язык интерфейса
favorite_genre: Option(String), // Любимый жанр (может быть не задан)
plays_count: Int, // Сколько треков прослушано
)
}
// Значения по умолчанию для новых пользователей
pub fn default_session() -> MusicBotSession {
MusicBotSession(
language: "ru",
favorite_genre: None,
plays_count: 0,
)
}
Каждый пользователь получает свою копию этих данных. Изменения в сессии пользователя A не влияют на пользователя B — это гарантируется изоляцией процессов ChatInstance.
Подключаем сессии к боту
Сессии подключаются через telega.with_session_settings и telega.init_for_polling:
import telega
import telega/bot
import telega/router
import telega_httpc
pub fn build_bot(token: String) {
let client = telega_httpc.new(token:)
let bot_router =
router.new("music_bot")
|> router.on_command("start", handle_start)
|> router.on_command("lang", handle_change_language)
let assert Ok(bot) =
telega.new_for_polling(api_client: client)
|> telega.with_router(bot_router)
|> telega.with_session_settings(bot.SessionSettings(
persist_session: fn(_key, session) { Ok(session) },
get_session: fn(_key) { Ok(None) },
default_session: default_session,
))
|> telega.init_for_polling()
bot
}
SessionSettings содержит три функции:
default_session— фабрика для новых пользователей. Вызывается при первом сообщении отchat_idpersist_session— вызывается после каждого обновления сессии для сохранения (в памяти, в БД и т.д.)get_session— вызывается при создании ChatInstance для восстановления сессии
В примере выше persist_session и get_session — заглушки (in-memory). Для продакшена замените их на функции, работающие с базой данных.
Обратите внимание: вместо init_for_polling_nil_session() используется init_for_polling() — когда сессии настроены, нужен обычный init.
Читаем данные из сессии
Сессия доступна через ctx.session в любом обработчике:
import telega/bot.{type Context}
import telega/reply
fn handle_stats(ctx: Context(MusicBotSession, Nil), _command) {
let plays = ctx.session.plays_count
let genre = ctx.session.favorite_genre |> option.unwrap("не выбран")
let message =
"Ваша статистика:\n"
<> "Прослушано треков: " <> int.to_string(plays) <> "\n"
<> "Любимый жанр: " <> genre
let assert Ok(_) = reply.with_text(ctx:, text: message)
Ok(ctx)
}
Обратите внимание на сигнатуру: Context(MusicBotSession, Nil). Первый параметр типа — тип сессии. Компилятор проверит, что вы обращаетесь только к существующим полям.
Обновляем сессию
Сессии иммутабельны — создаём обновлённую версию и сохраняем через bot.next_session:
fn handle_play_track(ctx: Context(MusicBotSession, Nil), _command) {
// Увеличиваем счётчик через spread-синтаксис
let updated =
MusicBotSession(..ctx.session, plays_count: ctx.session.plays_count + 1)
let assert Ok(_) = reply.with_text(ctx:, text: "Трек начал играть!")
bot.next_session(ctx:, session: updated)
}
bot.next_session(ctx:, session:) использует именованные аргументы. Spread-синтаксис ..ctx.session копирует все поля, затем перезаписываем только нужные.
Полные примеры работы с сессиями
Когда использовать сессии (и когда нет)
Session — это инструмент для быстрого доступа к пользовательским данным в памяти. Выбирайте правильный инструмент для задачи:
| Задача | Решение | Почему |
|---|---|---|
| Язык интерфейса | Session | Читается в каждом обработчике, редко меняется |
| Счётчик действий (статистика) | Session | Быстрые обновления, можно потерять при перезапуске |
| Текущий раздел меню | Session | Временный контекст навигации |
| Форма регистрации (имя, email, возраст) | Conversation API | Многошаговый диалог с валидацией |
| Товары в корзине | База данных | Критичные данные, нельзя потерять |
| Незавершённое бронирование | Flow + Storage | Нужна персистентность + навигация "назад" |
| История заказов | База данных | Долгосрочное хранение |
Эмпирическое правило:
- Session — для эфемерных данных, которые можно пересоздать или потерять без последствий
- База данных — для критичных данных, которые нельзя потерять
- Conversation/Flow — для процессов с несколькими шагами
Conversation API — линейные диалоги
Conversation API позволяет писать многошаговые диалоги как последовательность операций. Обработчик "приостанавливается" на каждой функции wait_* и автоматически продолжается при получении нужного сообщения.
Базовая концепция
Традиционный обработчик обрабатывает одно обновление за раз. Conversation API позволяет собирать несколько сообщений подряд:
import telega
import telega/bot.{type Context}
import telega/reply
// Традиционный подход — одно сообщение
fn handle_echo(ctx, text) {
reply.with_text(ctx:, text: "Вы написали: " <> text)
}
// Conversation API — последовательность сообщений
fn handle_name_conversation(ctx: Context(Nil, Nil), _command) {
let assert Ok(_) = reply.with_text(ctx:, text: "Как вас зовут?")
use ctx, name <- telega.wait_text(ctx:, or: None, timeout: None)
let assert Ok(_) = reply.with_text(ctx:, text: "Сколько вам лет?")
use ctx, age_str <- telega.wait_text(ctx:, or: None, timeout: None)
let assert Ok(_) =
reply.with_text(
ctx:,
text: "Привет, " <> name <> "! Вам " <> age_str <> " лет.",
)
Ok(ctx)
}
Каждый use ctx, value <- telega.wait_* приостанавливает выполнение. BEAM сохраняет состояние процесса (ChatInstance переходит в состояние WAITING), и обработчик продолжается, когда пользователь отправляет следующее сообщение.
Функции ожидания
Базовые wait-функции:
import telega
// Любое текстовое сообщение
use ctx, text <- telega.wait_text(ctx:, or: None, timeout: None)
// Число с проверкой диапазона
use ctx, age <- telega.wait_number(
ctx:,
min: Some(13),
max: Some(120),
or: None,
timeout: None,
)
// Email с regex-валидацией
use ctx, email <- telega.wait_email(ctx:, or: None, timeout: None)
Все wait_* функции принимают:
ctx:— текущий контекстor:— обработчик для неожиданных сообщений (Some(handler)илиNone)timeout:— тайм-аут в миллисекундах (Some(60_000)илиNone)
Forms API — валидация ввода
Conversation API включает функции с автоматической валидацией:
import telega
import telega/bot
// Число с проверкой диапазона
use ctx, age <- telega.wait_number(
ctx:,
min: Some(13),
max: Some(120),
or: Some(bot.HandleText(fn(ctx, _) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Введите число от 13 до 120")
Ok(ctx)
})),
timeout: None,
)
// Email с regex-валидацией
use ctx, email <- telega.wait_email(
ctx:,
or: Some(bot.HandleText(fn(ctx, _) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Некорректный email. Попробуйте снова.")
Ok(ctx)
})),
timeout: None,
)
// Типобезопасный выбор из вариантов
pub type Plan {
Free
Premium
}
use ctx, plan <- telega.wait_choice(
ctx:,
options: [#("Бесплатный", Free), #("Премиум", Premium)],
or: None,
timeout: None,
)
wait_number парсит текст в Int и проверяет диапазон. wait_email проверяет формат через regex. wait_choice автоматически создаёт inline-клавиатуру и возвращает типобезопасное значение.
Обработка ошибок и тайм-аутов
Параметр or: обрабатывает неожиданные сообщения:
use ctx, age <- telega.wait_number(
ctx:,
min: Some(18),
max: Some(100),
or: Some(bot.HandleAny(fn(ctx, update) {
case update {
bot.TextMessage(text) -> {
let assert Ok(_) =
reply.with_text(ctx:, text: "Это не число. Попробуйте снова.")
// Ждём ввод повторно
telega.wait_number(ctx:, min: Some(18), max: Some(100), or: None, timeout: None)
}
bot.CommandMessage("cancel", _) -> {
let assert Ok(_) = reply.with_text(ctx:, text: "Отменено.")
Ok(ctx)
}
_ -> {
let assert Ok(_) =
reply.with_text(ctx:, text: "Отправьте число или /cancel")
telega.wait_number(ctx:, min: Some(18), max: Some(100), or: None, timeout: None)
}
}
})),
timeout: Some(60_000), // 60 секунд
)
Если пользователь не ответит за timeout миллисекунд, диалог автоматически отменяется.
Пример: форма регистрации
Ключевые моменты:
fn handle_register(ctx: Context(Nil, Nil), _command) {
let assert Ok(_) =
reply.with_text(ctx:, text: "Давайте зарегистрируемся! Как вас зовут?")
use ctx, name <- telega.wait_text(ctx:, or: None, timeout: Some(120_000))
let assert Ok(_) = reply.with_text(ctx:, text: "Сколько вам лет?")
use ctx, age <- telega.wait_number(
ctx:, min: Some(13), max: Some(120), or: ..., timeout: Some(60_000),
)
let assert Ok(_) = reply.with_text(ctx:, text: "Ваш email?")
use ctx, email <- telega.wait_email(ctx:, or: ..., timeout: Some(60_000))
let assert Ok(_) = reply.with_text(ctx:, text: "Выберите тарифный план:")
use ctx, plan <- telega.wait_choice(
ctx:,
options: [#("Бесплатный", Free), #("Премиум", Premium)],
or: None,
timeout: Some(60_000),
)
// Сохраняем в БД...
let assert Ok(_) =
reply.with_text(ctx:, text: "Регистрация завершена!")
Ok(ctx)
}
Весь диалог — одна функция без явного FSM. Валидация встроена, ошибки обрабатываются через or:, тайм-ауты предотвращают зависание.
Полный пример формы регистрации
Когда использовать Conversation API
Используйте Conversation API когда:
- Линейный диалог (2-5 шагов)
- Валидация с повтором при ошибке
- Не нужна навигация "назад"
- Не требуется персистентность (пользователь может начать заново)
- Простая форма сбора данных
НЕ используйте Conversation API когда:
- Сложное ветвление логики (много условных переходов)
- Нужна кнопка "Назад" или произвольные переходы между шагами
- Состояние должно сохраниться при перезапуске бота
- Диалог переиспользуется в нескольких местах (используйте Flow + Subflows)
В этих случаях используйте Flow API.
Конечные автоматы (FSM) в ботах
Conversation API удобен для линейных диалогов, но у него есть ограничения:
- Нет навигации "назад" или произвольных переходов между шагами
- Состояние теряется при перезапуске бота (нет персистентности)
- Логика переходов неявная — сложно увидеть всю картину диалога
- Сложно переиспользовать части диалога в разных местах
Конечные автоматы (Finite State Machines, FSM) решают эти проблемы. FSM — это модель, где есть конечное число состояний и переходы между ними по событиям:
Y(t) = f(X(t), Y(t-1))
Состояние в момент t зависит от входа и предыдущего состояния. Важное свойство: у FSM нет "ленты" для хранения промежуточных вычислений — только управляющие состояния. Это делает автоматы простыми и предсказуемыми.
Зачем явно выделять состояния?
Классическая ситуация в коде без FSM:
type UserSession {
UserSession(
is_waiting_name: Bool,
is_waiting_phone: Bool,
is_waiting_email: Bool,
has_confirmed: Bool,
is_cancelled: Bool,
// ... и так далее
)
}
Мозг не справляется отслеживать все возможные комбинации. А теперь представьте: один тип, в котором видны все возможные состояния:
type RegistrationState {
Idle
AwaitingName
AwaitingPhone
AwaitingEmail
Confirming
Completed
Cancelled
}
Сразу понятно, в каких состояниях может находиться регистрация. Компилятор проверит, что все варианты обработаны.
Что даёт явный FSM:
- Ясность — вся логика переходов в одном месте
- Меньше багов — нельзя случайно попасть в невозможное состояние
- Проще поддерживать — новый разработчик сразу видит картину
- Персистентность — состояние можно сохранить в БД и восстановить
State explosion и statecharts
Главная боль классических FSM — взрыв состояний. Добавляете один новый аспект, а количество состояний растёт экспоненциально.
Пример — форма валидации:
- Начинаем с двух состояний:
Valid,Invalid - Добавили
Enabled,Disabled— уже 4 состояния - Добавили
Dirty,Pristine— 8 состояний
В 1987 году Дэвид Харел придумал statecharts — это FSM на стероидах:
- Параллельные регионы — независимые аспекты моделируются отдельно
- Иерархия — состояния могут быть вложенными
- Guards — условия на переходах
Telega Flow API реализует эти идеи для Telegram-ботов.
Flows API — персистентные FSM
telega/flow — это набор модулей для построения сложных диалогов как FSM. В отличие от Conversation API, Flow даёт:
- Персистентность — состояние сохраняется в storage
- Навигацию — можно вернуться назад или перейти к любому шагу
- Типизированные шаги — шаги задаются через ADT, а конвертеры
step_to_string/string_to_stepтребуют обработки всех вариантов. Однако компилятор не проверяет, что для каждого варианта вызванadd_step— пропущенный шаг приведёт к ошибке в runtime - Композицию — можно встраивать одни flows в другие (subflows)
Создание Flow
Flow API состоит из нескольких модулей: flow/builder, flow/action, flow/registry, flow/storage, flow/handler, flow/instance, flow/types. Flow строится через builder:
import telega/flow/builder
import telega/flow/action
import telega/flow/storage
type RegistrationStep {
Welcome
CollectName
CollectPhone
CollectEmail
Confirm
}
fn step_to_string(step: RegistrationStep) -> String {
case step {
Welcome -> "welcome"
CollectName -> "collect_name"
CollectPhone -> "collect_phone"
CollectEmail -> "collect_email"
Confirm -> "confirm"
}
}
fn string_to_step(s: String) -> Result(RegistrationStep, Nil) {
case s {
"welcome" -> Ok(Welcome)
"collect_name" -> Ok(CollectName)
"collect_phone" -> Ok(CollectPhone)
"collect_email" -> Ok(CollectEmail)
"confirm" -> Ok(Confirm)
_ -> Error(Nil)
}
}
pub fn create_registration_flow(storage) {
builder.new("registration", storage, step_to_string, string_to_step)
|> builder.add_step(Welcome, welcome_handler)
|> builder.add_step(CollectName, collect_name_handler)
|> builder.add_step(CollectPhone, collect_phone_handler)
|> builder.add_step(CollectEmail, collect_email_handler)
|> builder.add_step(Confirm, confirm_handler)
|> builder.build(initial: Welcome)
}
builder.new требует функции-конвертеры step_to_string и string_to_step — они нужны для сериализации состояния в storage.
Каждый обработчик шага возвращает действие (action):
fn collect_name_handler(ctx, instance) {
case flow_instance.get_data(instance, "name") {
Some(name) -> {
// Валидируем и переходим дальше
action.goto(CollectPhone)
}
None -> {
let assert Ok(_) =
reply.with_text(ctx:, text: "Как вас зовут?")
action.wait("name")
}
}
}
Действия (actions)
Действия определены в модуле flow/action:
import telega/flow/action
// Перейти к следующему шагу
action.next
// Перейти к конкретному шагу
action.goto(CollectPhone)
// Ожидать ввода пользователя
action.wait("input_key")
// Завершить flow
action.complete
// Отменить flow
action.cancel
Storage — персистентность
Conversation API хранит состояние диалога в памяти процесса ChatInstance. Если бот перезапустится — диалог потеряется. Flow решает эту проблему через storage — абстракцию над хранилищем состояния.
При создании Flow через builder.new передаётся storage — объект, который умеет сохранять и загружать FlowInstance. Flow автоматически вызывает storage после каждого перехода между шагами.
import telega/flow/storage
// Для разработки — in-memory (ETS, не переживает перезапуск VM)
let mem_storage = storage.memory()
Для реального решения нужно реализовать storage с сохранением в базу данных (PostgreSQL, SQLite). Интерфейс storage определяет четыре операции: save, load, delete и list_by_user — этого достаточно, чтобы Flow мог восстановить диалог после перезапуска бота.
Регистрация Flow в роутере
Flow регистрируются через реестр:
import telega/flow/registry
let registration_flow = create_registration_flow(storage)
let flow_reg =
registry.new()
|> registry.register(registry.OnCommand("register"), registration_flow)
let bot_router =
router.new("my_bot")
|> router.on_command("help", handle_help)
|> registry.apply_to_router(flow_reg)
Когда пользователь отправляет /register, запускается registration_flow. Если у пользователя уже есть активный flow, он продолжается с того места, где остановился.
Когда использовать Flow
| Сценарий | Подход |
|---|---|
| Простая команда или эхо | Router |
| Хранение настроек пользователя | Session |
| Линейный диалог (2-5 шагов) | Conversation API |
| Диалог с валидацией | Conversation API (wait_number, wait_email) |
| Диалог с ветвлениями и возвратом назад | Flow |
| Персистентность между перезапусками | Flow + Storage |
| Переиспользуемые части диалога | Flow + Subflows |
Flow — это более высокий уровень абстракции. Он строится поверх тех же примитивов (Handler, Context, wait_*), но добавляет FSM-модель с персистентностью и навигацией.
Middleware
Telega предоставляет систему middleware для обработчиков обновлений. Middleware оборачивают обработчики бота — функции типа fn(Context, Data) -> Result(Context, Error).
Middleware применяются через router.use_middleware:
import telega/router
let bot_router =
router.new("my_bot")
|> router.use_middleware(fn(handler) {
fn(ctx, data) {
// Логируем перед обработкой
io.println("Обработка обновления...")
// Вызываем оригинальный обработчик
let result = handler(ctx, data)
// Логируем после обработки
io.println("Обработка завершена")
result
}
})
|> router.on_command("start", handle_start)
|> router.on_any_text(handle_text)
Middleware — это функция, которая принимает обработчик и возвращает обёрнутый обработчик. Это стандартный паттерн "декоратор".
Пример: фильтрация по пользователям
fn admin_only_middleware(handler) {
fn(ctx, data) {
let admin_ids = [123_456_789, 987_654_321]
case list.contains(admin_ids, ctx.chat_id) {
True -> handler(ctx, data)
False -> {
let assert Ok(_) =
reply.with_text(ctx:, text: "Доступ запрещён")
Ok(ctx)
}
}
}
}
let admin_router =
router.new("admin")
|> router.use_middleware(admin_only_middleware)
|> router.on_command("ban", handle_ban)
|> router.on_command("stats", handle_stats)
Пример: обработка ошибок
fn error_recovery_middleware(handler) {
fn(ctx, data) {
case handler(ctx, data) {
Ok(ctx) -> Ok(ctx)
Error(err) -> {
io.println("Handler error: " <> string.inspect(err))
let assert Ok(_) =
reply.with_text(ctx:, text: "Произошла ошибка. Попробуйте позже.")
Ok(ctx)
}
}
}
}
Композиция middleware
Middleware применяются в порядке вызова:
let bot_router =
router.new("my_bot")
|> router.use_middleware(logging_middleware) // 1. Логирование
|> router.use_middleware(admin_only_middleware) // 2. Фильтр
|> router.use_middleware(error_recovery_middleware) // 3. Обработка ошибок
|> router.on_command("ban", handle_ban)
Обновление проходит через цепочку middleware сверху вниз: логирование -> фильтр -> обработка ошибок -> обработчик.
Обработка ошибок
В Gleam нет исключений — все ошибки передаются через Result. Это касается и Telega:
Уровни ошибок
1. Ошибки reply: Каждая функция reply.* возвращает Result(Message, TelegaError). Если Telegram API недоступен или токен невалиден, вы получите Error:
fn handle_start(ctx, _cmd) {
case reply.with_text(ctx:, text: "Привет!") {
Ok(_message) -> Ok(ctx)
Error(err) -> {
io.println("Не удалось отправить сообщение: " <> string.inspect(err))
Error(err)
}
}
}
В примерах мы часто используем let assert Ok(_) = reply.with_text(...) для краткости. В продакшене обрабатывайте ошибки явно или используйте middleware для recovery.
2. Ошибки обработчиков: Если обработчик возвращает Error, ChatInstance логирует ошибку. Процесс не падает — следующее сообщение будет обработано нормально.
3. Крэши процессов: Если обработчик паникует (например, let assert Ok(_) на Error), ChatInstance перезапускается супервизором. Сессия и состояние wait-функций теряются, но бот продолжает работать.
Стратегии обработки
// Стратегия 1: assert (для некритичных ботов)
fn handle_start(ctx, _cmd) {
let assert Ok(_) = reply.with_text(ctx:, text: "Привет!")
Ok(ctx)
}
// Стратегия 2: use + result.try (для production)
fn handle_start(ctx, _cmd) {
use _msg <- result.try(reply.with_text(ctx:, text: "Привет!"))
Ok(ctx)
}
// Стратегия 3: middleware (глобально)
let bot_router =
router.new("my_bot")
|> router.use_middleware(error_recovery_middleware)
|> router.on_command("start", handle_start)
Рекомендация: используйте assert при разработке и прототипировании, result.try или middleware в production-коде.
Тестирование бота
Telega включает полноценный testing toolkit в модулях telega/testing/*. Он позволяет тестировать бота без реального Telegram-соединения.
Conversation DSL
Основной инструмент тестирования — telega/testing/conversation. Он предоставляет декларативный DSL для описания разговора с ботом:
import telega/testing/conversation
pub fn start_command_test() {
conversation.conversation_test()
|> conversation.send("/start")
|> conversation.expect_reply("Привет!")
|> conversation.run(my_router, fn() { Nil })
}
Разберём по шагам:
conversation.conversation_test()создаёт пустую тест-цепочкуconversation.send("текст")симулирует отправку сообщения от пользователяconversation.expect_reply("текст")ожидает точный ответ от ботаconversation.run(router, session_factory)запускает тест с указанным роутером
session_factory — функция, создающая начальную сессию. Для ботов без сессий передайте fn() { Nil }.
Методы проверки ответов
// Точное совпадение ответа
|> conversation.expect_reply("Привет!")
// Ответ содержит подстроку
|> conversation.expect_reply_containing("Привет")
// Ответ содержит inline-клавиатуру с указанными кнопками
|> conversation.expect_keyboard(buttons: ["Список", "Добавить"])
expect_reply_containing особенно полезен, когда текст ответа может меняться (например, содержит дату или имя пользователя), но ключевые слова остаются.
Тестирование многошаговых диалогов
Conversation DSL поддерживает цепочки send/expect для тестирования wait-функций:
pub fn register_flow_test() {
conversation.conversation_test()
|> conversation.send("/register")
|> conversation.expect_reply_containing("зовут") // Бот спрашивает имя
|> conversation.send("Alice") // Пользователь отвечает
|> conversation.expect_reply_containing("Alice") // Бот подтверждает
|> conversation.run(register_router, fn() { Nil })
}
Этот тест проверяет полный цикл: команда -> вопрос -> ответ -> подтверждение. Под капотом conversation.run создаёт mock-окружение, которое симулирует ChatInstance с его FSM (ROUTING -> WAITING -> ROUTING).
Тестирование inline-клавиатур
pub fn menu_keyboard_test() {
conversation.conversation_test()
|> conversation.send("/menu")
|> conversation.expect_keyboard(buttons: ["Список", "Добавить"])
|> conversation.run(menu_router, fn() { Nil })
}
expect_keyboard(buttons:) проверяет, что бот отправил сообщение с inline-клавиатурой, содержащей кнопки с указанными текстами.
Тестирование чистой логики
Помимо conversation DSL, не забывайте тестировать чистые функции отдельно:
import gleeunit/should
pub fn format_task_list_test() {
format_task_list([
Task(text: "Купить молоко", done: False),
Task(text: "Написать тесты", done: True),
])
|> should.equal("Ваши задачи:\n1. ☐ Купить молоко\n2. ✅ Написать тесты")
}
Чистые функции (парсинг команд, форматирование, state machine переходы) тестируются стандартным gleeunit без Telega testing toolkit. Это быстрее и проще.
Паттерны тестирования
Разделяйте логику и побочные эффекты. Выносите бизнес-логику в чистые функции (парсинг, валидация, форматирование), а обработчики бота делайте тонкими — только вызовы reply и wait:
// Чистая функция — легко тестировать
pub fn parse_command(text: String) -> BotCommand { ... }
// Тонкий обработчик — тестируется через conversation DSL
fn handle_command(ctx, cmd) {
case parse_command(cmd.text) {
CmdStart -> reply.with_text(ctx:, text: "Привет!")
CmdHelp -> reply.with_text(ctx:, text: help_text())
_ -> reply.with_text(ctx:, text: "Неизвестная команда")
}
}
Тестируйте каждый роутер отдельно. Если вы используете router.merge, тестируйте admin_router и user_router по отдельности, а затем один интеграционный тест для merged_router.
Используйте expect_reply_containing для хрупких текстов. Если ответ бота может незначительно меняться (форматирование, пунктуация), проверяйте ключевые слова через expect_reply_containing, а не точное совпадение.
Полные примеры тестирования
Деплой
Переменные окружения
export TELEGRAM_TOKEN="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
Токен передаётся через переменную окружения — ни один секрет не хранится в коде. Приложение читает его через os.get_env при старте.
Запуск в production
Polling-бот запускается как обычное Erlang/OTP-приложение:
gleam build
gleam run
Дерево супервизоров, созданное init_for_polling_nil_session(), автоматически управляет всеми процессами. При крэше отдельного ChatInstance супервизор перезапустит его. Polling-процесс при ошибке сети переподключится автоматически.
Для продакшена на VPS/сервере:
# Компилируем release
gleam export erlang-shipment
# Запускаем
./build/erlang-shipment/entrypoint.sh run
Erlang-shipment создаёт автономный пакет, который можно запустить без установки Gleam/Erlang на целевом сервере.
Мониторинг
Так как бот работает на BEAM, доступны стандартные инструменты мониторинга Erlang/OTP:
- observer — визуальный монитор процессов, памяти, сообщений
- logger — встроенное логирование Erlang (настраивается через middleware)
- telemetry — метрики (если подключить зависимость)
Упражнения
Код упражнений находится в exercises/appendix_a/.
cd exercises/appendix_a
gleam test
Упражнение A.1 (Лёгкое): Парсинг команд бота
pub type BotCommand {
CmdStart
CmdHelp
CmdList
CmdAdd(title: String)
CmdDone(index: Int)
CmdUnknown(text: String)
}
pub fn parse_command(text: String) -> BotCommand {
todo
}
Распарсите текст сообщения:
"/start"->CmdStart"/help"->CmdHelp"/list"->CmdList"/add Buy milk"->CmdAdd("Buy milk")"/done 3"->CmdDone(3)"/done abc"->CmdUnknown("/done abc")(нельзя парсить число)- всё остальное ->
CmdUnknown(text)
Подсказка: case string.trim(text) { "/add " <> title -> CmdAdd(title) ... }, int.parse(n) для проверки числа.
Упражнение A.2 (Лёгкое): Форматирование одной задачи
pub type Task {
Task(text: String, done: Bool)
}
pub fn format_task(t: Task) -> String {
todo
}
Task("Buy milk", False)->"☐ Buy milk"Task("Write tests", True)->"✅ Write tests"
Упражнение A.3 (Лёгкое): Форматирование списка задач
pub fn format_task_list(tasks: List(Task)) -> String {
todo
}
[]->"Список задач пуст. Добавьте: /add <задача>"[task...]->"Ваши задачи:\n1. ☐ Buy milk\n2. ✅ Write tests"
Подсказка: list.index_map, string.join.
Упражнение A.4 (Среднее): State machine для многошагового диалога
pub type ConvState {
Idle
AwaitingTitle
}
pub fn conversation_step(state: ConvState, input: String) -> #(ConvState, String) {
todo
}
Реализуйте переходы:
Idle + "/add"->#(AwaitingTitle, "Введите название задачи:")Idle + "/help"->#(Idle, "Доступные команды:\n/list — список задач\n/add — добавить задачу\n/help — помощь")Idle + другое->#(Idle, "Не понимаю. /help — список команд.")AwaitingTitle + title->#(Idle, "✅ Задача «title» добавлена!")
Подсказка: case state, string.trim(input) { Idle, "/add" -> ... }.
Упражнение A.5 (Среднее): Полный dispatch команд
pub fn dispatch(cmd: BotCommand, tasks: List(Task)) -> #(List(Task), String) {
todo
}
Обработайте все команды:
CmdStart->#(tasks, "Привет! Я TODO-бот.\n/help — список команд")CmdHelp->#(tasks, "Команды:\n/list — список задач\n/add <задача> — добавить\n/done <номер> — выполнено")CmdList->#(tasks, форматированный список)CmdAdd(title)->#([...tasks, Task(title, False)], "✅ Задача «title» добавлена!")CmdDone(n)-> задачаn-1помечаетсяdone=True; если нет ->"Нет задачи с номером n"CmdUnknown(t)->#(tasks, "Не понимаю: t. /help — список команд")
Подсказка: используйте format_task_list из упражнения A.3.
Упражнение A.6 (Среднее): Greeting-роутер
pub fn build_greeting_router() -> Router(Nil, Nil) {
todo
}
Создайте роутер с двумя командами:
/start-> ответ содержит "Привет"/help-> ответ содержит "/start"
Тест проверяет через telega/testing/conversation DSL.
Подсказка: router.new("greeting") |> router.on_command("start", ...), reply.with_text(ctx:, text: "...").
Упражнение A.7 (Среднее): Echo-роутер
pub fn build_echo_router() -> Router(Nil, Nil) {
todo
}
Создайте роутер-эхо: повторяет любой текст обратно с префиксом "Эхо: ".
Пример: "hello" -> "Эхо: hello"
Подсказка: router.on_any_text(fn(ctx, text) { ... }).
Упражнение A.8 (Среднее): Register-роутер с многошаговым диалогом
pub fn build_register_router() -> Router(Nil, Nil) {
todo
}
Создайте роутер с многошаговым диалогом:
/register-> бот спрашивает "Как вас зовут?"- пользователь отвечает -> бот отвечает "Добро пожаловать, <имя>!"
Подсказка: telega.wait_text(ctx:, or: None, timeout: None).
Упражнение A.9 (Сложное): Menu-роутер с inline-клавиатурой
pub fn build_menu_router() -> Router(Nil, Nil) {
todo
}
Создайте роутер с inline-клавиатурой:
/menu-> отправляет сообщение "Выберите действие:" с inline-кнопками "Список" и "Добавить"
Тест проверяет через conversation.expect_keyboard(buttons: ["Список", "Добавить"]).
Подсказка: keyboard.inline_builder() |> keyboard.inline_text(...), keyboard.string_callback_data("id"), keyboard.pack_callback(callback_data:, data:).
Упражнение A.10 (Среднее): Merged-роутер
pub fn build_merged_router() -> Router(Nil, Nil) {
todo
}
Создайте два роутера и объедините их через router.merge:
- admin_router:
/ban-> ответ содержит "заблокирован" - user_router:
/start-> ответ содержит "Привет"
Подсказка: router.merge(first, second).
Итоги
Мы построили Telegram-бота, который объединяет концепции из предыдущих глав:
- Типобезопасность — Gleam ловит ошибки на этапе компиляции
- Router — декларативная маршрутизация команд и сообщений
- Композиция роутеров — merge, compose, scope для модульной архитектуры
- Session — встроенное хранение данных пользователя через
SessionSettings - Conversation API — линейные многошаговые диалоги через
wait_*функции - Flow API — персистентные FSM с навигацией и композицией
- Middleware — кроссрежущая логика через
router.use_middleware - Testing toolkit — тестирование через conversation DSL без реального Telegram
- Дерево супервизоров — изоляция чатов, автоматический перезапуск (глава 8)
- Result и use — обработка ошибок без исключений (глава 5)
Telega — зрелая библиотека с продуманной архитектурой. Бот на Gleam работает на BEAM и наследует всю его отказоустойчивость: крэш обработчика одного сообщения не убивает весь сервер. Каждый чат обрабатывается в изолированном процессе, Conversation API делает код диалогов линейным и понятным, а Flow API добавляет персистентность и навигацию для сложных сценариев.