Подготовка к собеседованию по Rust

Вопросы для собеседования бэкенд-разработчика на Rust

15 отобранных вопросов для собеседования по бэкенду на Rust, сгруппированных по уровням квалификации. Используйте их для повторения основ, понимания практических компромиссов и рассуждений на уровне Senior-разработчика о производственных системах.

Начать ИИ-собеседование по RustКредитная карта не требуется. Доступна 1 бесплатная сессия.
Практика технических собеседований на английскомРежим, в котором не носители языка могут потренироваться проходить интервью.

Вопросы для Junior-разработчиков

1Как правила владения в Rust определяют, когда передавать значение, заимствовать, клонировать или делить состояние?

У значения один владелец. Перемещение значения без `Copy` передаёт владение, а когда владелец завершает жизнь, значение обычно уничтожается. Функция может принять значение во владение, временно заимствовать его по общей или эксклюзивной изменяемой ссылке либо получить собственное значение через `clone`, если тип это поддерживает. Клонирование не обязано копировать вложенные данные: у `Arc` оно добавляет владельца того же выделения. Для совместного изменения состояния обработчиков нужен подходящий способ синхронизации.

use std::sync::Arc;

struct AppState {
    service_name: String,
}

async fn handler(state: Arc<AppState>, request_id: String) -> String {
    // Borrow: no ownership transfer of the String inside state.
    let name: &str = &state.service_name;

    // Clone only if an owned independent value is needed.
    let owned_name = name.to_owned();

    format!("{owned_name}:{request_id}")
}

fn configure(state: Arc<AppState>) {
    // Cloning Arc increments the reference count; it does not clone AppState.
    let state_for_route = Arc::clone(&state);
    let _ = state_for_route;
}
Попробовать ответить на вопрос AI-тренеру

2Чем заимствование через &T отличается от &mut T и как это влияет на сигнатуры обработчиков?

`&T` — общая ссылка: несколько таких ссылок могут использоваться одновременно, и обычное присваивание через них запрещено. Типы с внутренней изменяемостью могут предоставлять контролируемые изменения. `&mut T` — эксклюзивная ссылка, разрешающая изменение без конкурирующего доступа к той же части значения. Для чтения принимайте узкий тип вроде `&str`; для изменения — `&mut T`, владение или подходящую абстракцию. Для общего состояния параллельных обработчиков обычно используют общий дескриптор и синхронизацию.

struct User {
    name: String,
    login_count: u64,
}

fn display_name(user: &User) -> &str {
    &user.name
}

fn record_login(user: &mut User) {
    user.login_count += 1;
}

fn main() {
    let mut user = User { name: "Ada".into(), login_count: 0 };

    let name = display_name(&user); // shared borrow for reading
    println!("{name}");

    record_login(&mut user); // exclusive mutable borrow for mutation
}
Попробовать ответить на вопрос AI-тренеру

3Как работает Box и какие задачи решает размещение данных в куче?

`Box<T>` владеет значением; для обычного ненулевого размера оно размещается в куче. При уничтожении `Box` уничтожается и значение, а память освобождается. Указатель позволяет дать конечный размер рекурсивному типу и владеть значением динамического размера, например `Box<dyn Trait>`. Перемещение `Box` передаёт владение, не перемещая само значение в куче. Дескриптор для динамического размера может содержать дополнительные метаданные.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
Попробовать ответить на вопрос AI-тренеру

4Для чего в Rust нужны Result и Option и как с ними обычно работают?

`Option<T>` выражает наличие `Some(T)` или ожидаемое отсутствие `None`. `Result<T, E>` выражает успех `Ok(T)` или ошибку `Err(E)`. Для разбора вариантов используют `match`, `if let` и `let else`; для преобразования — `map`, `and_then`, `ok_or` и методы подстановки значения. `?` позволяет передать ошибку или отсутствие выше, если тип результата совместим. `unwrap` и `expect` оставляйте для тестов и проверенных инвариантов, поскольку они могут вызвать панику.

fn parse_port(s: Option<&str>) -> u16 {
    s.and_then(|v| v.parse::<u16>().ok())
     .unwrap_or(8080)
}

assert_eq!(parse_port(Some("3000")), 3000);
assert_eq!(parse_port(None), 8080);
Попробовать ответить на вопрос AI-тренеру

5Как в Rust работает оператор ? и как при этом преобразуется тип ошибки?

У `Result` выражение `expr?` извлекает значение из `Ok`, а при `Err` досрочно завершает функцию с ошибкой. У `Option` оно извлекает содержимое `Some` или возвращает `None`. Возвращаемый тип должен поддерживать такое распространение. Для `Result` ошибку можно преобразовать в тип ошибки функции реализацией `From`, написанной вручную или с помощью `thiserror`.

use std::{fs, io, num::ParseIntError};

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let s = fs::read_to_string(path)?; // io::Error -> AppError
    let n = s.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(n)
}
Попробовать ответить на вопрос AI-тренеру

6Как сопоставление с образцом для enum повышает корректность предметной модели и API?

Варианты перечисления задают допустимые состояния и могут хранить данные. `match` выбирает вариант и извлекает эти данные. Компилятор требует охватить все варианты; ветвь `_` тоже покрывает оставшиеся случаи. Если перечислить варианты явно, появление нового состояния заставит пересмотреть места обработки, а не незаметно направит его в общую ветвь.

enum PaymentStatus {
    Pending,
    Paid { receipt_id: String },
    Failed(String),
}

fn message(status: PaymentStatus) -> String {
    match status {
        PaymentStatus::Pending => "Payment is pending".to_string(),
        PaymentStatus::Paid { receipt_id } => format!("Paid, receipt {receipt_id}"),
        PaymentStatus::Failed(reason) => format!("Payment failed: {reason}"),
    }
}
Попробовать ответить на вопрос AI-тренеру

7Что такое тип-обёртка (newtype) в Rust и как он помогает безопасно работать с идентификаторами, деньгами и секретами?

Создайте отдельный тип поверх существующего представления, например `struct UserId(Uuid);` или `struct AccessToken(String);`. Компилятор не позволит перепутать `UserId` с `OrderId`, хотя внутри у них один тип. То же касается центов и долларов, произвольной строки и проверенного токена. В конструкторе обёртки можно проверять инварианты, а реализациями `Debug`, `Display` и `Serialize` управлять тем, что допустимо выводить или передавать.

use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);

struct SecretToken(String);

impl std::fmt::Debug for SecretToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SecretToken(**redacted**)")
    }
}

fn load_user(id: UserId) {
    // query by user id
}

fn example(user_id: UserId, order_id: OrderId) {
    load_user(user_id);
    // load_user(order_id); // does not compile: expected UserId, found OrderId
}
Попробовать ответить на вопрос AI-тренеру

Вопросы для Middle-разработчиков

8Как работает Drop в Rust, в каком порядке освобождаются ресурсы и почему асинхронное завершение требует отдельного метода?

При обычном уничтожении значения Rust сначала вызывает его `Drop::drop`, если он реализован, затем уничтожает поля в порядке объявления. Локальные переменные обычно уничтожаются в обратном порядке объявления. Ресурс привязывают к владеющему объекту: например, при уничтожении защитного объекта снимается блокировка. Это принцип RAII. Метод `drop` синхронный и не может ожидать асинхронную операцию, поэтому для гарантированного сброса буфера или завершения обмена вызывают явный асинхронный метод.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Guard,
    second: Guard,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn main() {
    let _a = Guard("a");
    let _pair = Pair {
        first: Guard("first"),
        second: Guard("second"),
    };
    let _b = Guard("b");
}
Попробовать ответить на вопрос AI-тренеру

9Что такое время жизни ссылки в Rust и когда в API его приходится указывать явно?

Время жизни описывает, на каком участке ссылка действительна. Аннотации выражают связи между ссылками для компилятора, не продлевают жизнь значения и ничего не добавляют при выполнении. Простые связи выводятся из сигнатуры. Явная аннотация нужна, например, когда есть несколько входных ссылок и нужно указать источник возвращаемой ссылки, либо когда структура хранит ссылку.

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

struct UserView<'a> {
    name: &'a str,
}

fn main() {
    let a = String::from("short");
    let b = String::from("longer");
    let result = longest(&a, &b);
    let view = UserView { name: result };
    println!("{}", view.name);
}
Попробовать ответить на вопрос AI-тренеру

10Что означает время жизни 'static и как оно влияет на фоновые задачи и API Rust?

Ссылка с `'static` может оставаться действительной до конца программы, например ссылка на строковый литерал. Ограничение `T: 'static` исключает заимствования с более коротким временем жизни, но само значение можно уничтожить раньше. Исполнитель может продолжить фоновую задачу после возврата запустившей функции, поэтому обычный `spawn` требует подходящего `Future` с `'static`. Передавайте внутрь `async move` принадлежащие задаче данные или копию дескриптора `Arc`; не навязывайте `'static` коротким заимствующим операциям без необходимости.

use std::sync::Arc;

struct AppState {
    name: String,
}

fn spawn_background(state: Arc<AppState>, user_id: String) {
    tokio::spawn(async move {
        // The task owns an Arc handle and a String, so it does not borrow this function's stack.
        println!("{}:{user_id}", state.name);
    });
}
Попробовать ответить на вопрос AI-тренеру

11Как типажи обеспечивают полиморфизм Rust и чем отличаются обобщения, impl Trait и dyn Trait?

Типаж описывает общее поведение. Параметр `T: Trait` обычно даёт статический вызов через мономорфизацию. `impl Trait` в аргументе задаёт анонимный обобщённый параметр, а в результате скрывает один конкретный тип, выбранный реализацией. `dyn Trait` используют за ссылкой или указателем вроде `Box<dyn Trait>`: метод выбирается по таблице вызовов во время выполнения, что позволяет единообразно хранить разные реализации, если типаж допускает такое использование.

trait Render { fn render(&self) -> String; }

struct Html;
struct Json;

impl Render for Html { fn render(&self) -> String { "html".into() } }
impl Render for Json { fn render(&self) -> String { "json".into() } }

fn render_generic<T: Render>(x: &T) -> String { x.render() } // static dispatch
fn render_impl_arg(x: &impl Render) -> String { x.render() } // anonymous generic
fn make_renderer() -> impl Render { Html } // one hidden concrete type
fn render_dyn(x: &dyn Render) -> String { x.render() } // dynamic dispatch

fn main() {
    let items: Vec<Box<dyn Render>> = vec![Box::new(Html), Box::new(Json)];
    for item in items { println!("{}", render_dyn(item.as_ref())); }
}
Попробовать ответить на вопрос AI-тренеру

12Что такое ассоциированный тип типажа и когда он лучше обобщённого параметра?

Ассоциированный тип объявляют в типаже, а реализация его конкретизирует, как `Iterator::Item`. Он удобен, когда у каждой реализации один естественный тип результата или элемента. Обобщённый параметр подходит, когда выбор должен задавать вызывающий код либо один тип должен поддерживать несколько реализаций типажа с разными аргументами.

trait Repository {
    type Entity;
    fn get(&self, id: u64) -> Option<Self::Entity>;
}

struct User;
struct UserRepo;

impl Repository for UserRepo {
    type Entity = User;
    fn get(&self, _id: u64) -> Option<Self::Entity> { Some(User) }
}

fn load<R: Repository>(repo: &R) -> Option<R::Entity> {
    repo.get(1)
}
Попробовать ответить на вопрос AI-тренеру

Вопросы для Senior-разработчиков

13Как реализовать транзакционную таблицу исходящих событий в Rust?

В одной транзакции сохраните изменение состояния и событие с уникальным идентификатором в таблице `outbox`. Рабочий процесс или захват изменений затем публикует события в брокер. Для конкурирующих обработчиков можно короткой транзакцией резервировать строки через `FOR UPDATE SKIP LOCKED`, а сетевую отправку выполнять после фиксации резерва. После подтверждения брокера отметьте отправку. Сбой между отправкой и отметкой приведёт к повтору, поэтому получатель должен обрабатывать идентификатор идемпотентно, а просроченный резерв — освобождаться.

use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

pub async fn create_user_with_outbox(
    mut tx: Transaction<'_, Postgres>,
    username: &str,
    email: &str,
) -> Result<(), sqlx::Error> {
    let user_id = Uuid::new_v4();
    sqlx::query!(
        "INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
        user_id, username, email
    )
    .execute(&mut *tx)
    .await?;

    let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
    sqlx::query!(
        "INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
        Uuid::new_v4(),
        "user_events",
        payload
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

pub async fn run_outbox_relay(pool: PgPool) {
    loop {
        let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
        let rows = sqlx::query!(
            "SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
        )
        .fetch_all(&mut *tx)
        .await;

        if let Ok(events) = rows {
            for event in events {
                // publish_to_broker(&event.topic, &event.payload).await;
                let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
                    .execute(&mut *tx)
                    .await;
            }
            let _ = tx.commit().await;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    }
}
Попробовать ответить на вопрос AI-тренеру

14Как спроектировать операцию, которая изменяет БД и вызывает внешний API?

Сохраните намерение и состояние ожидания в локальной транзакции, зафиксируйте её и лишь затем вызывайте API. Передавайте один и тот же ключ идемпотентности при повторах, если провайдер поддерживает эту гарантию. При успехе отметьте завершение, при окончательном отказе — ошибку и при необходимости выполните компенсацию. Тайм-аут не показывает, был ли эффект: оставьте состояние неопределённым и сверяйте результат по статусу провайдера или повторяйте с тем же ключом в пределах его контракта.

use uuid::Uuid;

pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
    let payment_id = Uuid::new_v4();
    let idempotency_key = format!("pay_{}", payment_id);

    // 1. Commit intent locally before making external network calls
    sqlx::query!(
        "INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
        payment_id, user_id, amount, idempotency_key
    )
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    // 2. Call external API with timeout and idempotency key
    let client = reqwest::Client::new();
    let api_result = client.post("https://api.payment.com/v1/charges")
        .header("Idempotency-Key", &idempotency_key)
        .json(&serde_json::json!({ "amount": amount }))
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await;

    // 3. Update state based on outcome; handle timeouts via reconciliation
    match api_result {
        Ok(resp) if resp.status().is_success() => {
            sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Ok(_) => {
            sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Err(_) => {
            sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}
Попробовать ответить на вопрос AI-тренеру

15Как спроектировать многопользовательский Rust-бэкенд со строгой изоляцией данных и динамической маршрутизацией?

1. Middleware Axum/Actix определяет запрошенного арендатора из проверенных утверждений или маршрута и проверяет право аутентифицированного вызывающего. Клиентский header/subdomain сам не авторитетен. Типизированный TenantContext помещается в request extensions; extractors требуют его для бизнес-логики. 2. В общей БД таблицы имеют tenant_id, PostgreSQL RLS использует, например, параметризованный SELECT set_config('app.tenant_id', $1, true) внутри транзакции, проверенные политики и роли без обхода. Обёртка запросов — другой, прикладной контроль. На каждом полученном соединении задавайте транзакционный контекст, проверяйте повторное использование пула и фоновые задания. При schema-per-tenant используйте безопасно экранированные/разрешённые имена и ограниченное состояние search_path; схемы не заменяют привилегии и авторизацию. При database-per-tenant реестр пулов, например Arc<DashMap<TenantId, PgPool>> или LRU, динамически выбирает БД. 3. Передавайте контекст явно через границы async-задач. Globals и thread_local! не подходят: задача Tokio может мигрировать между рабочими потоками и смешать арендаторов.

use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);

#[derive(Clone)]
pub struct AppState {
    pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}

#[derive(Clone)]
pub struct TenantContext {
    pub tenant_id: TenantId,
    pub pool: PgPool,
}

#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
    type Rejection = (axum::http::StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let tenant_header = parts.headers.get("X-Tenant-ID")
            .and_then(|v| v.to_str().ok())
            .ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
        
        let tenant_id = TenantId(tenant_header.to_string());
        
        let pool = match state.pool_registry.get(&tenant_id) {
            Some(p) => p.value().clone(),
            None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
        };

        Ok(TenantContext { tenant_id, pool })
    }
}
Попробовать ответить на вопрос AI-тренеру

Собеседования на телефоне: Rust

Отвечайте голосом и получайте обратную связь от ИИ в приложении для Android.

Скачать EngineerSpeak в Google Play
Сканируйте для скачивания