Перейти к содержимому

Rust для разработчиков на Go: практическое руководство

Если ты пишешь на Go, в Rust ты найдёшь больше знакомого, чем, возможно, ожидаешь. Оба языка компилируются в один быстрый исполняемый файл. В обоих есть структуры с методами вместо классов, нет наследования, ошибки возвращаются как значения, а не бросаются исключениями, есть стандартный форматтер, которым пользуются все, и один консольный инструмент, который собирает, тестирует и скачивает пакеты. struct на Go с несколькими методами переводится на Rust почти строка в строку.

Новое в основном касается памяти и явности. В Rust нет сборщика мусора: у каждого значения один владелец, а компилятор проверяет владение (ownership) и заимствование (borrowing) до запуска программы. Нет nil (вместо него Option), а if err != nil превращается в Result и оператор ?. Интерфейсы по-прежнему есть (они называются трейтами), но тип должен прямо сказать, что реализует трейт. Больше всего времени на обучение уйдёт на владение. Остальное будет ощущаться как более строгий и подробный Go.

Зачем разработчики на Go учат Rust? Обычно по одной из этих причин: сервис, в котором паузы сборщика мусора или расход памяти начинают иметь значение; тяжёлая для процессора работа; код, который работает без операционной системы (встраиваемые системы) или в браузере (WebAssembly); или просто желание, чтобы компилятор ловил гонки данных и падения в духе nil ещё до продакшена. Если тебе нужно более общее сравнение Rust с Go, C++ и Python, загляни в урок Rust против C++, Go и Python. Эта страница копает глубже и написана для тебя — разработчика на Go.

Пользуйся этой таблицей как разговорником. В последнем столбце — ссылка на урок этого сайта, который объясняет сторону Rust.

Go Rust Подробнее
Сборщик мусора Владение и заимствование, проверяемые при компиляции; память освобождается, когда владелец выходит из области видимости Владение, Заимствование
struct + методы struct для данных + блок impl для методов Структуры
Получатель по значению func (a Account) &self (читает, без копирования) или self (забирает владение) Структуры
Получатель-указатель func (a *Account) &mut self (может изменить значение) Структуры
Функция-конструктор NewAccount(...) Ассоциированная функция, по соглашению Account::new(...) Структуры
Заглавная буква = экспортировано Ключевое слово pub Модули
Нулевые значения (var c Config) Неявных нулевых значений нет; каждое поле нужно задать. #[derive(Default)] + Config::default() Структуры, Популярные трейты
Встраивание структур Встраивания нет. Положи структуру в поле и напиши небольшие методы-переходники или дели поведение через трейты Трейты, Паттерны проектирования
interface (реализуется неявно) trait (реализуется явно через impl Trait for Type) Трейты
[]Shape из разных типов Vec<Box<dyn Shape>> (трейт-объект) Трейт-объекты
any / interface{} + type switch enum с вариантом на каждый случай, затем match Перечисления, match
Обобщения func Max[T cmp.Ordered](a, b T) T Обобщения с ограничениями трейтов: fn max<T: PartialOrd>(a: T, b: T) -> T Обобщения, Продвинутые обобщения
nil-указатель, nil-map, nil-интерфейс Option<T>: либо Some(value), либо None Option
v, ok := m[k] m.get(&k) возвращает Option HashMap
(T, error) + if err != nil { return err } Result<T, E> + оператор ? Result
errors.New, sentinel-ошибки, errors.Is / errors.As Перечисление ошибок, по которому делаешь match (помогает крейт thiserror) Свои типы ошибок, anyhow и thiserror
fmt.Errorf("...: %w", err) anyhow с .context("...") или map_err anyhow и thiserror
panic / recover panic! для настоящих ошибок в коде; восстановление нужно редко (std::panic::catch_unwind) Паника
defer Drop, который срабатывает, когда значение выходит из области видимости (RAII) Область видимости, Умные указатели
go f() std::thread::spawn(...) или tokio::spawn(...) для асинхронных задач Многопоточность, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (без ограничения) или sync_channel(n); tokio::sync::mpsc в асинхронном коде Каналы
select tokio::select! в асинхронном коде или select! из crossbeam-channel Async подробно
sync.Mutex рядом с данными Mutex<T> оборачивает данные; он сам разблокируется, когда guard удаляется Общее состояние
sync.WaitGroup JoinHandle::join() или std::thread::scope Многопоточность
sync.Once std::sync::OnceLock / LazyLock Идиомы стандартной библиотеки
context.Context Единого аналога нет: удали future, чтобы отменить его, tokio::time::timeout или CancellationToken из tokio-util Async подробно
Значения func и замыкания Замыкания, описываемые трейтами Fn, FnMut, FnOnce Замыкания, Fn, FnMut и FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() Цикл for, Итераторы
Единственный цикл for loop, while и for Циклы
Итераторы range-over-func (iter.Seq) Трейт Iterator с адаптерами вроде filter, map, sum, collect Итераторы, Свои итераторы
switch match (компилятор проверяет, что ты учёл все случаи) match, Сопоставление с образцом
const + iota enum (при желании с явными числами) Перечисления
:= let (по умолчанию неизменяемая; let mut, чтобы разрешить изменения) Переменные
string (байты, обычно UTF-8) String (владеющая) и &str (заимствованная), всегда корректный UTF-8 Строки
rune, for _, r := range s char, for c in s.chars() Строки
[]byte Vec<u8> или &[u8] Срезы
fmt.Println, %v, %+v println!, {} (Display), {:?} и {:#?} (Debug, с #[derive(Debug)]) Вывод на экран
String() string (интерфейс Stringer) Реализуй Display (и .to_string() заработает бесплатно) Популярные трейты
Теги структур `json:"user_name"` Атрибуты: #[serde(rename = "user_name")] JSON и Serde
Преобразования int(x), float64(n) as для чисел, From/Into и TryFrom, если преобразование может не удаться Приведение типов, Преобразования
Целые числа тихо переполняются Паника в отладочных сборках, переход через край в release; checked_add, wrapping_add, saturating_add говорят, чего ты хочешь Переполнение целых чисел
*T и &x Ссылки &T и &mut T (никогда не null, не могут пережить значение); Box<T> для владеющего значения в куче Заимствование, Умные указатели
Пакеты Модули (mod) внутри крейта; use вместо import Модули
go generate Макросы и скрипты сборки build.rs Макросы, Процедурные макросы
Пакет unsafe Блоки unsafe и сырые указатели Unsafe
cgo extern "C" и FFI (bindgen генерирует привязки из заголовков C) FFI: вызов C
Теги сборки //go:build linux #[cfg(target_os = "linux")] и фичи Cargo Фичи Cargo и cfg

В Go срезы и map встроены в язык. В Rust они есть в стандартной библиотеке, плюс ещё несколько.

Go Rust Подробнее
[]T, которым ты владеешь и в который добавляешь Vec<T> Векторы
[]T, переданный в функцию, которая только читает &[T] (срез, заимствованный у Vec или массива) Срезы
[N]T [T; N] Массивы
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Векторы
s[a:b] &v[a..b] Срезы
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Векторы
map[K]V HashMap<K, V> HashMap
map[K]struct{} или map[K]bool в роли множества HashSet<T> HashSet
Собрать и отсортировать ключи map перед выводом BTreeMap<K, V> (ключи остаются отсортированными) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque и BinaryHeap
container/heap (реализовать 5 методов) BinaryHeap<T>: просто push и pop; сначала наибольший, оберни в Reverse, чтобы сначала был наименьший VecDeque и BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Сортировка и поиск
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Векторы
strings.Builder String с push_str или write! Строки
sync.Map Mutex<HashMap<K, V>>, RwLock или крейт dashmap Общее состояние

Две вещи, которые стоит знать. Во-первых, как и у map в Go, у HashMap в Rust нет фиксированного порядка, так что сортируй перед выводом или используй BTreeMap. Во-вторых, знаменитый сюрприз со срезами Go (два среза делят один базовый массив, и append в одном меняет другой) в Rust невозможен: пока одна часть кода держит &mut-заимствование Vec, никто другой не может даже посмотреть на него.

Хорошая новость: ты привык к одному инструменту, который делает всё, и в Rust такой тоже есть. Cargo покрывает go build, go run, go test, go mod и go install.

Go Rust Примечания
Команда go cargo Собирает, запускает, тестирует, добавляет пакеты, публикует. См. Cargo и крейты
go mod init cargo new my_app (cargo new --lib для библиотеки) Создаёт папку, Cargo.toml, src/main.rs и git-репозиторий
go.mod Cargo.toml
go.sum Cargo.lock Создаётся и обновляется автоматически
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Обновляет в пределах диапазонов версий из Cargo.toml
pkg.go.dev crates.io для пакетов, docs.rs для документации по их API
go build / go run / go test cargo build / cargo run / cargo test Добавь --release для оптимизированной сборки; отладочные сборки работают намного медленнее
go vet, staticcheck, golangci-lint cargo clippy Официальный, с сотнями полезных проверок
gofmt / goimports cargo fmt (rustfmt) Один стандартный стиль, как у gofmt
go doc cargo doc --open Примеры кода в doc-комментариях запускаются как тесты. См. Документация
go test -bench criterion (запуск через cargo bench) См. Производительность
go test -cover cargo llvm-cov или cargo tarpaulin
go test -race Для безопасного кода не нужен: гонки данных — ошибки компиляции. Код unsafe проверяет Miri
go test -fuzz cargo fuzz Ставится через cargo install cargo-fuzz
testify Встроенные assert! и assert_eq!; pretty_assertions для более удобных диффов См. Тестирование и Тестирование: дополнительно
gomock / mockery mockall или написанные вручную фейки, реализующие твой трейт
go install pkg@latest cargo install name
go work (рабочие пространства) Рабочее пространство Cargo (workspace) Один Cargo.toml наверху перечисляет крейты-участники
Строка toolchain в go.mod rustup и файл rust-toolchain.toml См. Инструменты и установка
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... Часто нужен линкер для целевой платформы; cross или cargo-zigbuild упрощают дело
govulncheck cargo audit (уведомления RustSec), cargo deny
gopls rust-analyzer Работает в VS Code, Neovim, Helix, Zed и других редакторах
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb или CodeLLDB в VS Code
pprof cargo flamegraph, perf или любой нативный профилировщик
air (живая перезагрузка) bacon или cargo watch

Небольшой Cargo.toml рядом со знакомым тебе go.mod:

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Одно отличие ты заметишь сразу: в стандартную библиотеку Go входят HTTP-сервер, JSON, шаблоны и криптография. Стандартная библиотека Rust намеренно меньше, поэтому JSON, HTTP, async и случайные числа берутся из крейтов. Добавить крейт — одна команда cargo add, а горсткой крейтов (serde, tokio, clap, regex, rand) пользуются почти все.

Библиотеки и фреймворки: «Что использовать вместо…?»

Заголовок раздела «Библиотеки и фреймворки: «Что использовать вместо…?»»

Это первый практический вопрос: «Мы используем Gin и GORM. А что есть в Rust?» Вот ближайшие аналоги. Как и в Go, в Rust обычно есть два-три хороших варианта от сообщества, а не один официальный ответ.

Go Rust Примечания
Gin, Echo, Chi, Fiber axum (мы делаем такой в проекте веб-API), actix-web, Rocket axum и actix-web широко используются в продакшене
Сервер net/http hyper (низкий уровень), обычно используется через axum HTTP-сервера в стандартной библиотеке нет
Middleware http.Handler Слои tower и tower-http (CORS, сжатие, трассировка, таймауты) axum использует tower напрямую
Клиент net/http reqwest По умолчанию асинхронный, есть необязательный блокирующий клиент. См. Работа с сетью
grpc-go tonic (с prost для protobuf)
gorilla/websocket tokio-tungstenite или фича ws в axum
html/template, text/template askama (проверяется при компиляции), tera, minijinja
net (TCP/UDP) std::net или tokio::net для асинхронного кода См. Работа с сетью
errgroup tokio::task::JoinSet
Go Rust Примечания
encoding/json + теги структур serde + serde_json Также умеет TOML (крейт toml) и другие форматы. См. JSON и Serde
database/sql + pgx sqlx (асинхронный, обычный SQL, который проверяется на твоей базе данных при компиляции), tokio-postgres Несмотря на имя, никак не связан с jmoiron/sqlx из Go
sqlc Макросы query! из sqlx Оба начинают с SQL, который пишешь ты, и дают типизированные результаты
GORM SeaORM (асинхронный, в стиле сущностей), Diesel (построитель запросов с проверкой типов) См. Базы данных
go-sqlite3 rusqlite
go-redis redis

CLI, конфигурация, логирование и повседневные инструменты

Заголовок раздела «CLI, конфигурация, логирование и повседневные инструменты»
Go Rust Примечания
cobra, urfave/cli, flag clap (аргументы выводятся из структуры через derive) См. Аргументы командной строки и Проект: CLI-утилита на clap
viper config или figment Слои из файлов и переменных окружения, затем десериализация через serde
log/slog, zap, zerolog tracing + tracing-subscriber или log + env_logger Структурированное логирование. См. Логирование
regexp regex Та же идея, что у RE2 в Go: сопоставление за линейное время, без обратных ссылок. См. Регулярные выражения
time std::time для длительностей; chrono или jiff для дат и часовых поясов См. Дата и время
embed include_str! / include_bytes! или rust-embed для целых папок
bubbletea ratatui Терминальные интерфейсы
Prometheus client_golang prometheus или metrics
sync/atomic std::sync::atomic
Go Rust Примечания
Wails Tauri (веб-интерфейс + бэкенд на Rust) Очень похожая идея. См. GUI-приложения
Fyne egui, Iced, Slint Официального GUI-тулкита нет
Ebitengine macroquad, Bevy Bevy ещё до 1.0 и меняет API между релизами. См. Разработка игр
TinyGo embedded-hal, Embassy Rust работает без ОС и без кучи. См. Встраиваемый Rust
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Маленькие файлы, не нужно тащить с собой GC. См. Rust и WebAssembly

Бок о бок: структуры и методы (почти одно и то же)

Заголовок раздела «Бок о бок: структуры и методы (почти одно и то же)»

Здесь разработчики на Go чувствуют себя как дома. Вот небольшой тип счёта на Go:

type Account struct {
Owner string // экспортировано: заглавная буква
balance int // не экспортировано
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("баланс:", account.Balance())
}

И на Rust:

use std::fmt;
// `pub` делает то же, что заглавная буква в Go.
pub struct Account {
pub owner: String,
balance: i64, // приватное, как поле со строчной буквы в Go
}
impl Account {
// Как NewAccount в Go: обычная функция. `new` — только соглашение.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Как получатель-указатель: этот метод изменяет счёт.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Только читает. Копия не делается, в отличие от получателя по значению в Go.
pub fn balance(&self) -> i64 {
self.balance
}
}
// Метод String() из Go (интерфейс Stringer) становится Display.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("баланс: {}", account.balance());
}

Вывод:

Ada: 150
баланс: 150
▶ Попробуй сам

Форма та же: данные в структуре, методы привязаны к ней, конструктор — просто функция. Отличия небольшие, но важные. Методы живут вместе в блоке impl. Получатель точно говорит, что делает метод: &self читает, &mut self изменяет, а просто self забирает значение и расходует его. А поскольку deposit изменяет счёт, переменная должна быть объявлена как let mut. В Go любой, у кого есть *Account, может его изменить; в Rust право что-то менять видно в коде.

Бок о бок: неявные интерфейсы становятся явными трейтами

Заголовок раздела «Бок о бок: неявные интерфейсы становятся явными трейтами»

В Go тип удовлетворяет интерфейсу, просто если у него есть нужные методы:

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// Нигде нет "implements": достаточно иметь методы.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "круг" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "прямоугольник" }
func describe[T Shape](s T) {
fmt.Printf("%s: %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("общая площадь: %.2f\n", total)
}

В Rust ты записываешь эту связь явно: impl Shape for Circle:

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// Именно эту строку Go опускает.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"круг"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"прямоугольник"
}
}
// Обобщённая функция, как describe[T Shape](s T) в Go.
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Как []Shape в Go: разные типы за одним трейтом.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("общая площадь: {total:.2}");
}

Вывод:

круг: 3.14
общая площадь: 9.14
▶ Попробуй сам

Явность стоит одну строку на тип и даёт то, чего Go не умеет. У трейта могут быть методы по умолчанию, так что типы получают общее поведение бесплатно. Свой трейт можно реализовать для типов, которые писал не ты, даже для i32 или String (Go позволяет добавлять методы только типам из твоего пакета). А обобщённая describe компилируется отдельно для каждого конкретного типа, так что у вызова нет поиска во время работы. Box<dyn Shape> — это стиль интерфейсов Go: одно значение, которое указывает на «какой-то тип, реализующий Shape», а метод ищется во время работы. См. Трейты и Трейт-объекты.

В Go указатель, которого может не быть, равен nil, и проверить его нужно не забыть:

type User struct {
Name string
Manager *User // nil означает «руководителя нет»
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + ": такого пользователя нет")
continue
}
boss := "нет"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name+": руководитель —", boss)
}
}

В Rust «может отсутствовать» — часть типа. Option<&User> — это либо Some(user), либо None, и компилятор не даст использовать пользователя, пока ты не обработал None:

struct User {
name: String,
manager: Option<String>, // Go: Manager *User, который может быть nil
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("нет");
println!("{name}: руководитель — {boss}");
}
None => println!("{name}: такого пользователя нет"),
}
}
}

Вывод:

Bob: руководитель — Ada
Ada: руководитель — нет
Eve: такого пользователя нет
▶ Попробуй сам

Та же идея заменяет поиск в стиле comma-ok из Go. if email, ok := emails[name]; ok { ... } превращается в if let Some(email) = emails.get(name) { ... }. За одним стоит следить: в Go emails["missing"] тихо даёт нулевое значение "". В Rust emails["missing"] паникует, поэтому используй .get() всегда, когда ключа может не быть. 'a в find_user — это время жизни (lifetime): оно говорит компилятору, что возвращаемая &User указывает внутрь users. Скоро ты с ними познакомишься.

Обработка ошибок в Go знаменита своим трёхстрочным шаблоном:

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("неверный порт %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("порт %d вне диапазона", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("ошибка:", err)
continue
}
fmt.Println("слушаем", addr)
}
}

Rust сохраняет идею (ошибки — это значения, которые ты возвращаешь), но сворачивает шаблон в один символ, ?:

fn parse_port(s: &str) -> Result<u16, String> {
// u16 не вмещает 70000, поэтому parse() сам ловит «вне диапазона».
let port: u16 = s.parse().map_err(|e| format!("неверный порт {s:?}: {e}"))?;
if port == 0 {
return Err("порт 0 зарезервирован".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` означает: если это ошибка, сразу верни её.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("слушаем {addr}"),
Err(e) => println!("ошибка: {e}"),
}
}
}

Вывод:

слушаем localhost:8080
ошибка: неверный порт "abc": invalid digit found in string
ошибка: неверный порт "70000": number too large to fit in target type
ошибка: порт 0 зарезервирован
▶ Попробуй сам

Тексты invalid digit found in string («в строке недопустимая цифра») и number too large to fit in target type («число слишком велико для целевого типа») печатает сама стандартная библиотека Rust, поэтому они на английском.

Разница глубже, чем экономия строк. В Go n, err := strconv.Atoi(s) даёт тебе оба значения, и ничто не мешает использовать n, когда err не nil. Result — это либо значение, либо ошибка, так что до значения не добраться, не разобравшись сначала с ошибкой. А если полностью проигнорировать Result, компилятор выдаст предупреждение.

В настоящих проектах ошибки-строки String заменяют чем-то более богатым. Перечисление ошибок (часто написанное с крейтом thiserror) играет роль sentinel-ошибок и errors.Is: вызывающий код делает match по варианту. Крейт anyhow даёт .context("loading config")? — это версия Rust для fmt.Errorf("loading config: %w", err). См. Свои типы ошибок и anyhow и thiserror.

Бок о бок: горутины и каналы становятся потоками, mpsc и задачами

Заголовок раздела «Бок о бок: горутины и каналы становятся потоками, mpsc и задачами»

Вот классический fan-in на Go: запускаем несколько горутин, отправляем результаты в канал и закрываем его, когда они закончат:

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("всего:", total)
}

Версия на Rust с потоками и каналом из стандартной библиотеки немного короче:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // каждый поток получает своего отправителя
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// Удаляем своего отправителя. Когда все отправители исчезнут, канал
// закроется и цикл ниже завершится: WaitGroup не нужен.
drop(tx);
let total: i32 = rx.iter().sum();
println!("всего: {total}");
}

Вывод:

всего: 14
▶ Попробуй сам

Кое-что устроено иначе. mpsc означает «multi-producer, single-consumer» (много отправителей, один получатель): отправителя можно клонировать, а получателя нельзя (для каналов в стиле Go со многими читателями используй crossbeam-channel). mpsc::channel() не ограничен по размеру — в Go такого нет; mpsc::sync_channel(n) — это make(chan T, n) из Go, а sync_channel(0) — небуферизованный канал. close никто не вызывает: канал закрывается, когда удаляется последний отправитель. move отдаёт каждому потоку свою копию n и свой tx — так Rust гарантирует, что потоки никогда не делят данные небезопасно. См. Каналы и Многопоточность.

thread::spawn запускает настоящий поток ОС, а он тяжелее горутины. Для горстки рабочих потоков это нормально. Для тысяч одновременных соединений Rust использует асинхронные задачи в среде выполнения вроде tokio — это намного ближе к горутинам:

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Как `go func() { ... }()`, но ты получаешь дескриптор обратно.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("всего: {total}");
}

Вывод:

всего: 14
▶ Попробуй сам

Честно говоря, здесь Go проще. В Go любая функция может блокироваться, и среда выполнения сама с этим справляется. В Rust ты помечаешь функции async, вызываешь их через .await и выбираешь среду выполнения. Награда в том, что компилятор отказывается собирать код, в котором два потока могли бы одновременно изменить одни и те же данные, так что go test -race нечего было бы искать. См. Async/Await, Async подробно и Общее состояние про Arc<Mutex<T>> — версию Rust для структуры с sync.Mutex рядом с данными.

В Go ты планируешь очистку через defer, и отложенные вызовы выполняются в обратном порядке, когда функция возвращается:

func process() {
fmt.Println("открываем файл")
defer fmt.Println("закрываем файл")
fmt.Println("блокируем мьютекс")
defer fmt.Println("разблокируем мьютекс")
fmt.Println("работаем")
}
func main() {
process()
fmt.Println("готово")
}

В Rust очистка принадлежит самому значению. Когда значение выходит из области видимости, срабатывает его реализация Drop, и значения удаляются в обратном порядке — точно как твои defer:

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("открываем файл");
let _file = Guard("закрываем файл");
println!("блокируем мьютекс");
let _lock = Guard("разблокируем мьютекс");
println!("работаем");
} // здесь удаляется _lock, затем _file
fn main() {
process();
println!("готово");
}

Вывод:

открываем файл
блокируем мьютекс
работаем
разблокируем мьютекс
закрываем файл
готово
▶ Попробуй сам

В настоящем коде Drop редко пишут самому: File закрывается сам, MutexGuard сам снимает блокировку, TcpStream сам отключается. Забыть тут нечего. Два отличия от defer стоит знать. Drop срабатывает в конце блока, а не функции, поэтому ловушки Go, когда defer копятся внутри цикла, не существует: каждая итерация цикла убирает за собой. И осторожнее с let _ = Guard(...): голое _ вообще не сохраняет значение, поэтому оно удаляется сразу. Используй имя вроде _lock.

Все, кто приходит из Go, натыкаются на одни и те же кочки. Знать их заранее полезно.

  • Передача значения может его переместить. В Go при передаче среза в функцию копируется небольшой заголовок, и обе стороны продолжают им пользоваться. В Rust передача Vec перемещает (move) его в функцию, и после этого использовать его нельзя:

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    Компилятор говорит, что prices уже перемещён в total. Исправление — привычка из Go в форме Rust: пусть функция заимствует срез, fn total(prices: &[u32]), а вызов будет total(&prices). Сначала прочитай урок Владение: он объясняет это правило. E0382 подробно разбирает эту ошибку.

  • Анализатор заимствований. Одновременно может быть много читателей (&T) или один писатель (&mut T), но никогда и то и другое. Код на Go, где много структур держат указатели на одну общую структуру и меняют её, требует в Rust другой архитектуры или умных указателей вроде Rc<RefCell<T>> или Arc<Mutex<T>>. Приготовься к нескольким неделям раздражения. Потом станет намного легче.

  • Нет нулевых значений. let x: i32;, а затем println!("{x}") — ошибка компиляции, и при создании структуры нужно задать каждое поле. Если хочешь значения по умолчанию в стиле Go, сделай derive Default и пиши Config { port: 8080, ..Default::default() }.

  • Реализация трейта явная. Наличие метода area не делает твой тип Shape. Нужен impl Shape for Circle. Плюс в том, что любую реализацию можно найти поиском.

  • String и &str. В Go один тип string. В Rust два: String владеет своим текстом, &str заимствует его. Функции обычно принимают &str, структуры обычно хранят String. И строку нельзя индексировать через s[0], как в Go; используй s.chars() или s.as_bytes()[0]. См. Строки.

  • Неиспользуемое — это предупреждения, а не ошибки. Go отказывается компилировать код с неиспользуемым импортом или переменной. Rust только предупреждает, а приставка _ к имени говорит «не используется намеренно».

  • Переполнение целых чисел. Go тихо переходит через край. Rust паникует в отладочных сборках и переходит через край в release, так что выбирай checked_add, wrapping_add или saturating_add, когда переполнение возможно. Целочисленные литералы по умолчанию имеют тип i32, а индексы — usize. См. Переполнение целых чисел.

  • Стандартная библиотека меньше. В std нет HTTP-сервера, JSON и асинхронной среды выполнения. Ты будешь добавлять их через cargo add. Поначалу это кажется странным, но популярные крейты стабильны и хорошо документированы.

  • Async нужна среда выполнения, и он расползается. async fn ничего не делает, пока её не дождутся через await внутри среды выполнения вроде tokio, и .await можно писать только в асинхронном коде. В Go такого разделения нет. Начни с обычных потоков; к async переходи, когда понадобится много одновременных соединений.

  • Время компиляции. Go создавали так, чтобы он компилировался очень быстро. Rust компилируется медленнее, особенно чистые сборки с множеством крейтов. Пока редактируешь, используй cargo check: он пропускает генерацию кода и работает намного быстрее.

  • Кросс-компиляция требует небольшой настройки. GOOS=linux go build просто работает. В Rust ты добавляешь целевую платформу через rustup, и часто нужен ещё линкер для неё. Инструменты вроде cross делают это за тебя.

Оба языка — хороший выбор, и многие команды используют оба. Вот честное практическое правило.

Выбирай Go, когда нужно быстро выпускать сетевые сервисы и внутренние инструменты, когда команда ценит код, который любой сможет читать уже через неделю, когда очень важна быстрая сборка или когда ты работаешь в мире cloud-native (Kubernetes, Docker, Terraform), где Go — общий язык. Стандартная библиотека Go и его простая модель конкурентности делают многие серверные программы лёгкими в написании.

Выбирай Rust, когда нужна предсказуемая задержка без пауз сборщика мусора, низкий расход памяти или каждая капля процессорного времени (парсеры, базы данных, игровые движки, видео, сжатие). Rust также лучше подходит для кода без операционной системы, для WebAssembly и для библиотек, которые хочется вызывать из других языков. И он окупается там, где падение из-за гонки данных или пропущенной проверки на nil обходится дорого. Discord написал известный пост о том, как перевёл один сервис с Go на Rust (на английском) из-за всплесков задержки, вызванных сборкой мусора.

Цена Rust — время на обучение и более медленная сборка. Цена Go — сборщик мусора и ошибки, которые проявляются во время работы, а не при компиляции. Какая цена больнее, зависит от твоего проекта.

Этот сайт не обязательно читать по порядку. Для разработчика на Go этот маршрут пропускает то, что ты уже знаешь, и тратит время там, где Rust отличается:

  1. Первые шаги и Инструменты и установка: установи Rust, настрой rust-analyzer и запусти cargo new.
  2. Переменные и типы данных: пробеги глазами, в основном чтобы узнать let mut и числовые типы.
  3. Владение, затем заимствование, затем срезы: самые важные для тебя страницы. Не торопись.
  4. Строки: String и &str.
  5. Структуры и перечисления, затем match и сопоставление с образцом. Перечисления Rust с данными заменяют многие type switch из Go.
  6. Option и Result, затем свои типы ошибок и anyhow и thiserror.
  7. Трейты, обобщения, популярные трейты и трейт-объекты.
  8. Замыкания и итераторы: Rust опирается на цепочки итераторов гораздо больше, чем Go.
  9. Векторы, HashMap и HashSet.
  10. Модули, Cargo и крейты и тестирование.
  11. Времена жизни и умные указатели.
  12. Многопоточность, каналы, общее состояние, затем async/await.
  13. JSON и Serde и логирование, а потом собери проект веб-API.

В обоих упражнениях дан фрагмент на Go. Перенеси его на Rust.

Эта программа на Go считает слова и выводит их в алфавитном порядке. Перенеси её на Rust. Подсказка: используй HashMap<&str, u32> и *counts.entry(word).or_insert(0) += 1 вместо counts[w]++ из Go. text.split_whitespace() — это strings.Fields.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
Показать решение
use std::collections::HashMap;
fn main() {
let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

Вывод:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Попробуй сам

Здесь нет нулевого значения, на которое можно опереться, поэтому entry(word).or_insert(0) говорит «начни с 0, если слово новое». Индексация counts[key] безопасна, потому что каждый ключ взят из самого словаря. Ещё короче: используй BTreeMap вместо HashMap, и ключи выйдут уже отсортированными, так что вектор keys можно убрать. Слова в примере оставлены английскими, чтобы алфавитный порядок совпадал с исходной программой.

Этот код на Go возвращает sentinel-ошибку и проверяет её через errors.Is. Перенеси его так, чтобы withdraw возвращала Result<(), WithdrawError>, где WithdrawError — это enum с двумя вариантами. Подсказка: реализуй fmt::Display для перечисления, чтобы его можно было вывести, и делай match по варианту там, где Go использует errors.Is.

var ErrInsufficientFunds = errors.New("недостаточно средств")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("неверная сумма %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("отклонено:", err)
} else {
fmt.Println("ошибка:", err)
}
continue
}
fmt.Println("ок, баланс", acc.Balance)
}
}
Показать решение
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "неверная сумма {n}"),
WithdrawError::InsufficientFunds => write!(f, "недостаточно средств"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("ок, баланс {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("отклонено: {e}"),
Err(e) => println!("ошибка: {e}"),
}
}
}

Вывод:

ок, баланс 70
ошибка: неверная сумма 0
отклонено: недостаточно средств
▶ Попробуй сам

Sentinel-ошибка и ошибка из fmt.Errorf становятся двумя вариантами одного перечисления, а errors.Is — веткой match. e @ WithdrawError::InsufficientFunds сопоставляет вариант и заодно даёт всё значение как e, чтобы его вывести. Приятный бонус: если позже добавить третий вариант, компилятор покажет каждый match, который нужно обновить. Реализацию Display может написать за тебя крейт thiserror.

  • Многое из Go переносится: структуры с методами, отсутствие наследования, ошибки как значения, один стандартный форматтер и один инструмент для сборки и тестирования.
  • Главные сдвиги: владение вместо сборщика мусора, Option вместо nil и Result + ? вместо if err != nil.
  • Интерфейсы становятся трейтами, и их реализация явная (impl Trait for Type). defer превращается в автоматический Drop в конце области видимости.
  • Горутины и каналы становятся thread::spawn + mpsc для нескольких рабочих потоков или задачами tokio, когда их много; компилятор исключает гонки данных.
  • Cargo заменяет go build, go test и go mod; clippy и rustfmt заменяют go vet и gofmt. Вместо Gin смотри на axum или actix-web, вместо GORM — на SeaORM или Diesel, вместо pgx и sqlc — на sqlx, а вместо cobra — на clap.

Проверь себя

1. В Go тип удовлетворяет интерфейсу, просто если у него есть нужные методы. Как тип в Rust реализует трейт?

2. Что делает ? после вызова, который возвращает Result?

3. Когда с std::sync::mpsc останавливается for msg in rx (или rx.iter())?

4. Твоя команда на Go делает HTTP API на Gin. Какой крейт Rust ближе всего?

Результат: 0 / 4

  • Язык программирования Rust: официальная книга (перевод на русский). Для разработчиков на Go важнее всего главы 4 (владение), 9 (ошибки), 10 (обобщения и трейты) и 16 (многопоточность).
  • Tokio tutorial (на английском): асинхронная среда выполнения, на которой работает большинство серверов на Rust, — ближе всего к планировщику горутин в Go.
  • Документация axum (на английском): веб-фреймворк, на который стоит посмотреть первым, если ты пришёл с Gin, Echo или Chi.
  • Why Discord is switching from Go to Rust (на английском): история реальной команды, которая перевела чувствительный к задержкам сервис с Go на Rust.
  • Are We Web Yet? (на английском): обзор веб-библиотек и фреймворков Rust от сообщества.