Библиотека mo для Go: Монады и функциональные паттерны
Добро пожаловать в мир функционального программирования на Go! Библиотека mo (от monads) — это мощный инструмент, который привносит в императивный язык Go концепции монад и функциональных паттернов, популярных в Haskell, Scala и Rust. Если вы когда-нибудь сталкивались с проблемой обработки ошибок через if err != nil, бесконечных вложенных проверок на nil или громоздких цепочек вызовов, mo предлагает элегантное решение.
Основная цель библиотеки — предоставить типобезопасные контейнеры (Option, Result, Either), которые инкапсулируют возможные состояния (наличие/отсутствие значения, успех/ошибка) и позволяют работать с ними в функциональном стиле. Вместо того чтобы писать десятки условных операторов, вы строите цепочки трансформаций, где каждый шаг обрабатывается автоматически. Это делает код более читаемым, предсказуемым и менее подверженным ошибкам, особенно в сложных бизнес-логиках.
Библиотека mo не пытается превратить Go в Haskell. Она предлагает прагматичный подход: вы используете функциональные паттерны там, где это действительно упрощает код, сохраняя при этом привычный синтаксис Go. mo особенно полезна для обработки ошибок, работы с опциональными значениями, композиции функций и реализации пайплайнов обработки данных. Она хорошо сочетается с горутинами и каналами, позволяя строить надежные конкурентные программы.
Установка
Установка библиотеки mo происходит стандартным для Go способом через менеджер пакетов go get. Выполните следующую команду в терминале в корне вашего проекта (там, где находится go.mod):
go get github.com/samber/mo
После выполнения этой команды библиотека будет добавлена в ваш go.mod файл, и вы сможете импортировать её в свои исходные файлы. Убедитесь, что у вас установлена версия Go 1.18 или выше (требуется для поддержки дженериков).
Быстрый старт
Давайте начнем с классического примера — безопасного деления двух чисел. В традиционном Go мы бы вернули ошибку. С mo мы используем тип Result, который может быть либо Ok (успех), либо Err (ошибка).
package main
import (
"fmt"
"github.com/samber/mo"
)
// safeDivide возвращает Result, который содержит либо результат деления, либо ошибку.
func safeDivide(a, b float64) mo.Result[float64] {
if b == 0 {
return mo.Err[float64](fmt.Errorf("деление на ноль"))
}
return mo.Ok(a / b)
}
func main() {
result := safeDivide(10, 2)
// Используем метод Match для обработки обоих случаев.
result.Match(
func(value float64) {
fmt.Printf("Результат: %.2f\n", value)
},
func(err error) {
fmt.Printf("Ошибка: %s\n", err)
},
)
// Попробуем деление на ноль.
result2 := safeDivide(10, 0)
result2.Match(
func(value float64) {
fmt.Printf("Результат: %.2f\n", value)
},
func(err error) {
fmt.Printf("Ошибка: %s\n", err)
},
)
}
Этот пример демонстрирует базовую идею: вместо того чтобы проверять ошибку вручную, мы передаем ответственность за обработку библиотеке, используя метод Match.
Основные методы и функции
Ниже представлены ключевые компоненты библиотеки mo. Мы рассмотрим 8 основных методов/типов, которые покроют 90% ваших потребностей.
1. mo.Ok[T any] и mo.Err[T any]
Сигнатура:
func Ok[T any](value T) Result[T]func Err[T any](err error) Result[T]
Что делает: Создают экземпляры типа Result[T]. Ok представляет успешное выполнение операции, содержащее значение типа T. Err представляет ошибку, содержащую объект error.
package main
import (
"errors"
"fmt"
"github.com/samber/mo"
)
func main() {
success := mo.Ok(42)
failure := mo.Err[int](errors.New("что-то пошло не так"))
fmt.Println(success.IsOk()) // true
fmt.Println(failure.IsErr()) // true
}
2. Result.Match
Сигнатура:
func (r Result[T]) Match(onSuccess func(T), onFailure func(error))
Что делает: Выполняет один из двух переданных коллбэков в зависимости от состояния Result. Если это Ok, вызывается onSuccess со значением. Если Err — вызывается onFailure с ошибкой. Это основной способ обработки результата.
package main
import (
"errors"
"fmt"
"github.com/samber/mo"
)
func main() {
result := mo.Ok("Hello, World!")
result.Match(
func(v string) { fmt.Println("Успех:", v) },
func(err error) { fmt.Println("Ошибка:", err) },
)
// Вывод: Успех: Hello, World!
result2 := mo.Err[string](errors.New("ошибка"))
result2.Match(
func(v string) { fmt.Println("Успех:", v) },
func(err error) { fmt.Println("Ошибка:", err) },
)
// Вывод: Ошибка: ошибка
}
3. Result.Map
Сигнатура:
func (r Result[T]) Map(f func(T) U) Result[U]
Что делает: Трансформирует значение внутри Result, если он в состоянии Ok. Если Result — это Err, то трансформация не применяется, и ошибка "пробрасывается" дальше. Позволяет строить цепочки преобразований без проверки ошибок на каждом шагу.
package main
import (
"fmt"
"github.com/samber/mo"
"strconv"
)
func main() {
// Начинаем с Ok("123")
result := mo.Ok("123").
Map(func(s string) int {
i, _ := strconv.Atoi(s)
return i
}).
Map(func(i int) string {
return fmt.Sprintf("Число: %d", i)
})
result.Match(
func(v string) { fmt.Println(v) }, // Вывод: Число: 123
func(err error) { fmt.Println(err) },
)
}
4. Result.FlatMap
Сигнатура:
func (r Result[T]) FlatMap(f func(T) Result[U]) Result[U]
Что делает: Похож на Map, но функция f сама возвращает Result[U]. Это необходимо, когда трансформация может завершиться ошибкой. FlatMap "разворачивает" вложенный Result, предотвращая появление Result[Result[U]].
package main
import (
"fmt"
"github.com/samber/mo"
"strconv"
)
func parseInt(s string) mo.Result[int] {
i, err := strconv.Atoi(s)
if err != nil {
return mo.Err[int](err)
}
return mo.Ok(i)
}
func main() {
result := mo.Ok("42").
FlatMap(parseInt).
Map(func(i int) string {
return fmt.Sprintf("Квадрат: %d", i*i)
})
result.Match(
func(v string) { fmt.Println(v) }, // Вывод: Квадрат: 1764
func(err error) { fmt.Println(err) },
)
// Пример с ошибкой
result2 := mo.Ok("abc").
FlatMap(parseInt).
Map(func(i int) string {
return fmt.Sprintf("Квадрат: %d", i*i)
})
result2.Match(
func(v string) { fmt.Println(v) },
func(err error) { fmt.Println("Ошибка:", err) }, // Вывод: Ошибка: strconv.Atoi: parsing "abc": invalid syntax
)
}
5. mo.Some[T any] и mo.None[T any]
Сигнатура:
func Some[T any](value T) Option[T]func None[T any]() Option[T]
Что делает: Создают экземпляры типа Option[T]. Some представляет наличие значения, None — его отсутствие. Это безопасная альтернатива указателям, которые могут быть nil.
package main
import (
"fmt"
"github.com/samber/mo"
)
func findUser(id int) mo.Option[string] {
if id == 1 {
return mo.Some("Alice")
}
return mo.None[string]()
}
func main() {
user := findUser(1)
user.Match(
func(name string) { fmt.Println("Найден:", name) },
func() { fmt.Println("Пользователь не найден") },
) // Вывод: Найден: Alice
user2 := findUser(2)
user2.Match(
func(name string) { fmt.Println("Найден:", name) },
func() { fmt.Println("Пользователь не найден") },
) // Вывод: Пользователь не найден
}
6. Option.Map
Сигнатура:
func (o Option[T]) Map(f func(T) U) Option[U]
Что делает: Трансформирует значение внутри Option, если он в состоянии Some. Если Option — None, трансформация не применяется.
package main
import (
"fmt"
"github.com/samber/mo"
)
func main() {
option := mo.Some(10).
Map(func(i int) int { return i * 2 }).
Map(func(i int) string { return fmt.Sprintf("Результат: %d", i) })
option.Match(
func(v string) { fmt.Println(v) }, // Вывод: Результат: 20
func() { fmt.Println("Значение отсутствует") },
)
none := mo.None[int]().
Map(func(i int) int { return i * 2 })
none.Match(
func(v int) { fmt.Println(v) },
func() { fmt.Println("Значение отсутствует") }, // Вывод: Значение отсутствует
)
}
7. Option.FlatMap
Сигнатура:
func (o Option[T]) FlatMap(f func(T) Option[U]) Option[U]
Что делает: Аналог Result.FlatMap, но для Option. Используется, когда функция трансформации сама может вернуть None.
package main
import (
"fmt"
"github.com/samber/mo"
)
func getDiscount(price float64) mo.Option[float64] {
if price > 100 {
return mo.Some(price * 0.9) // 10% скидка
}
return mo.None[float64]()
}
func main() {
finalPrice := mo.Some(150.0).
FlatMap(getDiscount).
Map(func(p float64) string {
return fmt.Sprintf("Цена со скидкой: %.2f", p)
})
finalPrice.Match(
func(v string) { fmt.Println(v) }, // Вывод: Цена со скидкой: 135.00
func() { fmt.Println("Скидка не применима") },
)
finalPrice2 := mo.Some(50.0).
FlatMap(getDiscount).
Map(func(p float64) string {
return fmt.Sprintf("Цена со скидкой: %.2f", p)
})
finalPrice2.Match(
func(v string) { fmt.Println(v) },
func() { fmt.Println("Скидка не применима") }, // Вывод: Скидка не применима
)
}
8. Either.Left и Either.Right
Сигнатура:
func Left[L any, R any](value L) Either[L, R]func Right[L any, R any](value R) Either[L, R]
Что делает: Тип Either[L, R] представляет значение одного из двух возможных типов. Обычно Left используется для ошибки, а Right — для успеха (следуя соглашению из Haskell). Это более общая альтернатива Result, где "ошибка" может быть любого типа, а не только error.
package main
import (
"fmt"
"github.com/samber/mo"
)
func validateAge(age int) mo.Either[string, int] {
if age >= 18 {
return mo.Right[string, int](age)
}
return mo.Left[string, int]("Возраст меньше 18")
}
func main() {
result := validateAge(25)
result.Match(
func(errMsg string) { fmt.Println("Ошибка:", errMsg) },
func(age int) { fmt.Println("Возраст:", age) },
) // Вывод: Возраст: