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.
Понятия языка: из Go в Rust
Заголовок раздела «Понятия языка: из Go в Rust»Пользуйся этой таблицей как разговорником. В последнем столбце — ссылка на урок этого сайта, который объясняет сторону 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, никто другой не может даже посмотреть на него.
Инструменты: из go в cargo
Заголовок раздела «Инструменты: из go в cargo»Хорошая новость: ты привык к одному инструменту, который делает всё, и в 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», а метод ищется во время работы. См. Трейты и Трейт-объекты.
Бок о бок: nil становится Option
Заголовок раздела «Бок о бок: nil становится Option»В 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: руководитель — AdaAda: руководитель — нет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. Скоро ты с ними познакомишься.
Бок о бок: if err != nil становится Result и ?
Заголовок раздела «Бок о бок: if err != nil становится Result и ?»Обработка ошибок в 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 рядом с данными.
Бок о бок: defer становится Drop
Заголовок раздела «Бок о бок: defer становится Drop»В 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»Все, кто приходит из 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` trait7 | let sum = total(prices);| ------ value moved here8 | 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, сделай deriveDefaultи пиши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 или Rust? Когда что выбирать
Заголовок раздела «Go или Rust? Когда что выбирать»Оба языка — хороший выбор, и многие команды используют оба. Вот честное практическое правило.
Выбирай Go, когда нужно быстро выпускать сетевые сервисы и внутренние инструменты, когда команда ценит код, который любой сможет читать уже через неделю, когда очень важна быстрая сборка или когда ты работаешь в мире cloud-native (Kubernetes, Docker, Terraform), где Go — общий язык. Стандартная библиотека Go и его простая модель конкурентности делают многие серверные программы лёгкими в написании.
Выбирай Rust, когда нужна предсказуемая задержка без пауз сборщика мусора, низкий расход памяти или каждая капля процессорного времени (парсеры, базы данных, игровые движки, видео, сжатие). Rust также лучше подходит для кода без операционной системы, для WebAssembly и для библиотек, которые хочется вызывать из других языков. И он окупается там, где падение из-за гонки данных или пропущенной проверки на nil обходится дорого. Discord написал известный пост о том, как перевёл один сервис с Go на Rust (на английском) из-за всплесков задержки, вызванных сборкой мусора.
Цена Rust — время на обучение и более медленная сборка. Цена Go — сборщик мусора и ошибки, которые проявляются во время работы, а не при компиляции. Какая цена больнее, зависит от твоего проекта.
Путь обучения для разработчиков на Go
Заголовок раздела «Путь обучения для разработчиков на Go»Этот сайт не обязательно читать по порядку. Для разработчика на Go этот маршрут пропускает то, что ты уже знаешь, и тратит время там, где Rust отличается:
- Первые шаги и Инструменты и установка: установи Rust, настрой rust-analyzer и запусти
cargo new. - Переменные и типы данных: пробеги глазами, в основном чтобы узнать
let mutи числовые типы. - Владение, затем заимствование, затем срезы: самые важные для тебя страницы. Не торопись.
- Строки:
Stringи&str. - Структуры и перечисления, затем match и сопоставление с образцом. Перечисления Rust с данными заменяют многие type switch из Go.
- Option и Result, затем свои типы ошибок и anyhow и thiserror.
- Трейты, обобщения, популярные трейты и трейт-объекты.
- Замыкания и итераторы: Rust опирается на цепочки итераторов гораздо больше, чем Go.
- Векторы, HashMap и HashSet.
- Модули, Cargo и крейты и тестирование.
- Времена жизни и умные указатели.
- Многопоточность, каналы, общее состояние, затем async/await.
- JSON и Serde и логирование, а потом собери проект веб-API.
Практика
Заголовок раздела «Практика»В обоих упражнениях дан фрагмент на Go. Перенеси его на Rust.
1. Перенеси счётчик слов
Заголовок раздела «1. Перенеси счётчик слов»Эта программа на 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: 2bird: 1cat: 1dog: 1the: 3Здесь нет нулевого значения, на которое можно опереться, поэтому entry(word).or_insert(0) говорит «начни с 0, если слово новое». Индексация counts[key] безопасна, потому что каждый ключ взят из самого словаря. Ещё короче: используй BTreeMap вместо HashMap, и ключи выйдут уже отсортированными, так что вектор keys можно убрать. Слова в примере оставлены английскими, чтобы алфавитный порядок совпадал с исходной программой.
2. Перенеси sentinel-ошибку
Заголовок раздела «2. Перенеси sentinel-ошибку»Этот код на 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 от сообщества.