Вопросы на собеседовании Rust-разработчика: разбор по грейдам
Собеседование по Rust устроено иначе, чем по большинству языков. Про синтаксис почти не спрашивают: если человек дошёл до интервью, он умеет писать функции и структуры. Проверяют другое, понимает ли он модель владения, и что произойдёт, когда компилятор перестанет спорить, а начнётся продакшен.
Ниже вопросы, которые задают чаще всего, с коротким разбором и пометкой уровня. Актуально для версий 1.75 и новее.
Владение и заимствование: входной фильтр
Этот блок идёт первым почти всегда, и на нём отсеивают.
Что происходит при передаче String в функцию? Владение переходит вызываемой функции, а исходная переменная становится недоступной. Ключевая деталь, которую хотят услышать: физически копируются только три слова заголовка, указатель, длина и ёмкость, а буфер в куче остаётся на месте. Никаких счётчиков в рантайме нет, весь учёт статический.
Почему i32 копируется, а String перемещается? Числа реализуют Copy, потому что побитовая копия полноценно заменяет оригинал. String владеет буфером в куче, и две копии заголовка привели бы к двойному освобождению. Отсюда же следствие, которое спрашивают следом: тип с реализацией Drop не может быть Copy.
Сколько ссылок можно держать одновременно? Либо сколько угодно неизменяемых, либо ровно одна изменяемая. Плохой ответ останавливается здесь. Хороший продолжает: правило закрывает изменение коллекции во время перебора, висячие указатели после перевыделения буфера и гонки данных между потоками, причём проверяется всё на этапе компиляции.
Что такое NLL? Нелексические лайфтаймы: заимствование живёт до последнего использования ссылки, а не до конца блока. Из-за этого код, который раньше требовал искусственных блоков, теперь компилируется как есть. Вопрос уровня middle, и по ответу видно, читал человек свежую документацию или учебник пятилетней давности.
Что означает 'static? Тут ждут, что вы разведёте два смысла. У ссылки это время жизни всей программы, например строковый литерал. В ограничении T: 'static это свойство типа не содержать заимствований короче программы, и ему удовлетворяет обычная String, созданная в рантайме. Второй смысл всплывает в thread::spawn, и его путают чаще всего.
Типы и трейты: территория middle
Чем трейт отличается от интерфейса? Полей и состояния у трейта нет, наследования тоже. Зато его можно реализовать для чужого типа из другой библиотеки, а у методов бывает реализация по умолчанию, выраженная через другие методы трейта. Так устроен Iterator: достаточно написать next, и вся цепочка адаптеров приходит бесплатно.
Когда impl Trait, а когда Box<dyn Trait>? Первый вариант мономорфизируется и ничего не стоит в рантайме, но означает один конкретный тип: вернуть разные типы из разных веток не выйдет, будет ошибка про несовместимые типы. Box<dyn Trait> нужен, когда в одной коллекции лежат разные реализации, и стоит аллокации плюс косвенного вызова через таблицу методов.
Почему трейт с обобщённым методом нельзя использовать как dyn? Таблица методов требует конечного списка адресов, а обобщённый метод порождает функцию на каждый тип. Такой трейт называют не dyn-совместимым, и с версии 1.83 компилятор пишет именно «not dyn compatible», раньше говорили object safe. Обход есть: пометить проблемный метод ограничением where Self: Sized, тогда он исключается из таблицы.
Почему #[derive(Clone)] на структуре с Rc<T> требует Clone от T? Классическая грабля. Макрос механически вешает ограничение на каждый параметр типа, хотя Rc клонируется независимо от содержимого. Лечится ручной реализацией без лишнего ограничения.
Многопоточность: любимый блок интервьюеров
В чём разница между Send и Sync? Send означает, что значение можно переместить в другой поток, Sync что на него можно дать ссылку сразу нескольким. Формально T: Sync эквивалентно &T: Send. Сильный ответ добавляет пример: RefCell это Send, но не Sync, поэтому перемещать его между потоками можно, а разделять по ссылке нельзя.
Почему Rc нельзя отправить в поток? Счётчик ссылок неатомарный, и одновременное клонирование из двух потоков может потерять инкремент. Дальше значение уничтожится раньше времени, и оставшиеся ссылки станут висячими. Arc делает то же атомарно и потому годится для многопотока.
Зачем Arc<Mutex<T>> и нельзя ли обойтись чем-то одним? Две обёртки решают два разных вопроса. Arc отвечает на вопрос, кому принадлежит значение, если владельцев несколько. Mutex на вопрос, кто именно сейчас его меняет. По отдельности не работает: Arc<i32> не даст записи, а Mutex без Arc не переживёт передачу в потоки.
Когда снимается блокировка мьютекса? Когда уничтожается guard, который вернул lock. Метода unlock нет специально. Дальше идёт добивающий вопрос про ловушку: guard, созданный прямо в условии if let, живёт до конца всей конструкции, поэтому повторный захват внутри ветки приводит к самоблокировке. Это одна из самых частых причин зависаний в продакшене.
Что такое отравление мьютекса? Если поток паникует, удерживая блокировку, данные могли остаться в нецелостном состоянии. Следующий lock вернёт ошибку, и это сигнал подумать, а не механически написать unwrap.
Async: где ломаются почти все
Что произойдёт, если вызвать async-функцию и не написать .await? Ничего. Вызов строит объект-future, тело начинает выполняться только при опросе. Отличие от промисов в JavaScript, где работа стартует при создании, спрашивают почти всегда.
Во что компилятор превращает async-функцию? В конечный автомат: каждая точка ожидания становится состоянием, а локальные переменные, живущие через неё, переезжают в структуру состояния. Отсюда и требование Pin, потому что у такого автомата бывают ссылки на собственные поля.
Что будет, если в асинхронном обработчике сделать тяжёлое вычисление? Оно займёт воркер целиком: планирование кооперативное, вытеснения нет. Снаружи это выглядит коварно, сервис жив и держит нагрузку, а хвост задержек разъезжается. Лечится выносом в spawn_blocking или в пул rayon.
Чем tokio::spawn отличается от join!? join! опрашивает несколько future конкурентно внутри одной задачи, без требований Send и 'static. spawn отдаёт задачу рантайму, она может уехать на другой воркер и пережить вызывающего. По времени оба варианта выигрывают у последовательных ожиданий, разница в требованиях к типам и времени жизни.
Что такое cancel safety? Вопрос уровня senior. Когда в select! побеждает одна ветка, остальные future уничтожаются прямо в точке ожидания, то есть отменяются. Операция безопасна к отмене, если такое прерывание не теряет данные. Приём сообщения из канала безопасен, а составное «прочитать длину, потом тело» уже нет: часть данных уже вычитана из сокета и пропала.
Можно ли держать обычный MutexGuard через .await? Нет, и по двум причинам сразу. Формально он не Send, и задача не соберётся для многопоточного рантайма. По сути, пока задача ждёт ответа сети, блокировка держится, и остальные стоят в очереди. Асинхронный мьютекс существует ровно для случая, когда блокировку действительно надо удерживать через ожидание.
Unsafe и низкий уровень: senior-часть
Что разрешает unsafe? Пять операций: разыменовать сырой указатель, вызвать unsafe-функцию или функцию из FFI, обратиться к изменяемому static, прочитать поле union, реализовать unsafe-трейт. Ответ «отключает проверки компилятора» неверен и сразу снижает грейд: проверка типов, владение и заимствование внутри блока работают как обычно.
Что такое неопределённое поведение? Не падение программы, а нарушение предположений, на которые опирается оптимизатор: висячая ссылка, две живые изменяемые ссылки на одно место, значение вне допустимого диапазона типа, гонка данных. Опасность в том, что симптомов может не быть годами, а сломается всё от смены версии компилятора. Ловят это интерпретатором Miri на тестах вокруг unsafe.
Зачем repr(C)? Раскладка структуры по умолчанию не определена, компилятор вправе переставлять поля ради уплотнения. Для обмена с C нужен фиксированный порядок и выравнивание по правилам C. Следом обычно спрашивают про repr(packed) и почему на его поля нельзя брать ссылки.
Что произойдёт, если Rust-функция паникует внутри вызова из C? Разворачивание стека через кадры C это неопределённое поведение. Поэтому экспортируемые наружу функции оборачивают в catch_unwind и отдают код ошибки.
Что спросят помимо языка
Собеседование редко ограничивается языком. Обычно добавляют SQL, если сервис ходит в базу, алгоритмическую секцию на общих основаниях и System Design для позиций от middle. Специфика ниши тоже играет: в блокчейн-компаниях спросят про криптографию и консенсус, во встраиваемых системах про работу без операционной системы, в инфраструктурных командах про сети и нагрузку.
Отдельно стоит подготовить рассказ, почему вы пишете на Rust. В нише, где вакансий немного, а конкуренция за них выше, мотивация проверяется всерьёз.
Хотите проверить себя не по списку, а по-настоящему: в тренажёре «Сеньорчик» есть трек Rust, где вопросы идут лесенкой от базы к трейдофам, после ответа сразу разбор, а темы, которые проседают, движок возвращает снова. Открывается прямо в Telegram, начать можно бесплатно.