Привет, Хабр!
Rust — язык программирования с акцентом на безопасность, скорость и параллелизм. Rust решает многие проблемы, с которыми сталкиваются на других ЯП, например, управление памятью без сборщика мусора.
Очевидно, из-за многих преимуществ Rust выбирают все большей компаний. В этой статье рассмотрим пять часто задаваемых вопросов на собеседованиях, которые помогут подготовиться к собеседованию по части Rust.
Как управлять памятью в Rust без сборщика мусора?
В Rust используется уникальная система владения данными.
Система владения в Rust — это база. Каждое значение в Rust имеет переменную, которая является его владельцем. Может быть только один владелец за раз, и когда владелец выходит из области видимости, значение будет очищено. Это предотвращает утечки памяти, так как память автоматом очищается, когда объект теряет своего владельца.
Rust позволяет переменным занимать данные, что делается с помощью ссылок. Ссылки могут быть неизменяемыми и изменяемыми. Неизменяемые ссылки позволяют иметь несколько ссылок на одни и те же данные, но не позволяют их модифицировать. Изменяемые ссылки позволяют модифицировать данные, но Rust гарантирует, что только одна изменяемая ссылка может существовать в данный момент времени.
fn main() {
let mut x = 5;
let y = &x; // неизменяемая ссылка
let z = &mut x; // изменяемая ссылка
println!("y: {}", y); // выводит: y: 5
// println!("z: {}", z); // ошибка: не может быть использовано, так как x уже заимствовано как изменяемое
*z = 7; // изменяем значение x через изменяемую ссылку
println!("z: {}", z); // выводит: z: 7
// println!("y: {}", y); // ошибка: не может быть использовано, так как x уже заимствовано как изменяемое
}
Время жизни — это аннотации, которые Rust использует для обеспечения того, чтобы все займы были действительны. Время жизни указывает компилятору, как долго ссылка на данные должна оставаться действительной. Это позволяет избежать проблем с висячими указателями, когда указатель или ссылка указывают на освобождённую память.
fn main() {
let r;
{
let x = 5;
r = &x;
// println!("r: {}", r); // ошибка: x не живёт достаточно долго
}
// println!("r: {}", r); // ошибка: x уже освобожден, и r становится висячей ссылкой
}
// функция, которая принимает две ссылки и возвращает ссылку с длиннейшей жизнью
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
fn main2() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("The longest string is '{}'", result);
} // string2 выходит из области видимости и освобождается, но string1 все еще валидна
// println!("The longest string is '{}'", result); // ошибка: string2 выходит из области видимости раньше, чем result
}
Rust использует строгую систему типов и понятие trait для управления поведением типов в контексте владения и заимствования. Например, типы, реализующие трейт Copy
, могут быть автоматом скопированы без необходимости явного управления памятью. В отличие от этого, типы, реализующие трейт Drop
, обязаны определить специальное поведение при удалении, так как они не могут быть скопированы автоматически.
Подробнее в этом вопросе вам поможет разобраться наша статья — Как работает управление памятью в Rust без сборщика мусора.
Что такое макросы и как их использовать в Rust?
Декларативные макросы в Rust определяются с помощью macro_rules!
и часто описываются как "макросы по примеру". Они позволяют создавать шаблоны, которые затем могут быть использованы для генерации кода на основе этих шаблонов.
Пример:
macro_rules! create_struct {
($name:ident, $($field_name:ident: $field_type:ty),*) => {
struct $name {
$(pub $field_name: $field_type,)*
}
};
}
create_struct!(Person, name: String, age: u32);
Макрос create_struct
определяет структуру с именем и полями, указанными в аргументах макроса. Так можно быстро генерировать новые типы данных с заданными полями без повторения однотипного кода определения структур.
Фичей декларативных макросов является их способность работать непосредственно с синтаксическими конструкциями языка Rust, что позволяет им влиять на структуру программы на этапе компиляции. Макросы могут принимать различные формы входных данных и генерировать код на основе этих данных.
Процедурные макросы более сложней и позволяют выполнять операции над кодом, подобно функциям. Они обрабатывают входные данные в виде потоков токенов и генерируют новый код, который встраивается непосредственно в место вызова макроса. Процедурные макросы подразделяются на три типа: макросы производного типа, атрибутивные макросы и функционально-подобные макросы.
Например, макрос для автоматической реализации трейта Debug
:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(MyDebug)]
pub fn my_debug_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let gen = quote! {
impl std::fmt::Debug for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, stringify!(#name))
}
}
};
gen.into()
}
MyDebug
— процедурный макрос, который реализует интерфейс Debug
для любой структуры, к которой он применяется. Юзаем библиотеки syn
и quote
для парсинга и генерации кода соответственно.
У нас также есть статья про макросы в Rust.
Как обрабатывать ошибки в Rust и что такое типы Option и Result?
Тип Option<T>
используется, когда значение может отсутствовать. Этот тип представляет собой перечисление enum
, которое имеет два варианта: Some(T)
, когда значение присутствует, и None
, когда значение отсутствует. Юзаем Option
когда нужно явно обрабатывать случаи, когда значение может быть не задано, без использования нулевых указателей.
Простой пример функции, которая ищет юзера по имени и возвращает его возраст, если юзер найден:
fn find_age_by_name(users: &[(String, u32)], name: &str) -> Option<u32> {
for (user_name, age) in users {
if user_name == name {
return Some(*age);
}
}
None
}
fn main() {
let users = vec![("Alice".to_string(), 30), ("Bob".to_string(), 22)];
let age = find_age_by_name(&users, "Alice");
println!("Age: {:?}", age);
}
Если имя пользователя найдено, функция возвращает Some(age)
, иначе — None
.
А вот типResult<T, E>
используется для обработки операций, которые могут завершиться ошибкой. Этот тип также является перечислением и имеет два варианта: Ok(T)
, указывающий на успешное выполнение операции, и Err(E)
, указывающий на наличие ошибки.
Использование Result
часто связано с функциями, которые могут выдавать ошибки, такими как чтение файла или выполнение сетевого запроса. Rust принуждает разрабов активно решать, что делать с возможной ошибкой, используя конструкции match
или оператор ?
для упрощения кода.
Пример функции, которая пытается прочитать файл и возвращает содержимое файла или ошибку:
use std::fs::File;
use std::io::{self, Read};
fn read_file_to_string(filename: &str) -> Result<String, io::Error> {
let mut file = File::open(filename)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file_to_string("example.txt") {
Ok(content) => println!("File content: {}", content),
Err(e) => println!("Error reading file: {}", e),
}
}
Функция read_file_to_string
использует оператор ?
для упрощения обработки ошибок. Если операция открытия файла или чтения из файла завершается ошибкой, функция возвращает Err
.
Иногда может потребоваться обработать ситуацию, где функция может вернуть либо ошибку, либо опциональное значение. Вот пример:
fn find_user(id: u32) -> Result<Option<String>, String> {
if id == 0 {
Err("Invalid ID".to_string())
} else if id == 1 {
Ok(Some("Alice".to_string()))
} else {
Ok(None)
}
}
fn main() {
match find_user(1) {
Ok(Some(user)) => println!("User found: {}", user),
Ok(None) => println!("No user found"),
Err(e) => println!("Error: {}", e),
}
}
find_user
может возвращать Result
, который либо сообщает об ошибке, если ID недействителен, либо возвращает Option<String>
, указывающий на то, найден ли пользователь или нет.
Как настроить и использовать кросс-компиляцию?
Кросс-компиляция позволяет создавать исполняемые файлы для одной платформы, работая на другой. Для упрощения процесса кросс-компиляции используют утилиту cross
, которая облегчает компиляцию, управляя контейнерами Docker с нужными инструментами.
Устанавливается cross
через Cargo:
cargo install cross
cross
работает с контейнерными движками, такими как Docker или Podman.
В файле Cargo.toml
указывает зависимости или настройки, специфичные для платформы. Например, различные библиотеки или функции, которые должны компилироваться только для определённых систем:
[target.'cfg(target_os = "windows")'.dependencies]
winapi = "0.3"
Например здесь зависимость winapi
будет использоваться только при компиляции для Windows.
Затем создается файл .cargo/config
для доп. конфигурации, такой как указание конкретного линкера для целевой системы:
[target.x86_64-pc-windows-gnu]
linker = "gcc"
Это кстати важно при работе с C зависимостями или когда требуется особая конфигурация для линковки.
Для начала компиляции юзаем команду cross build
с указанием нужной целевой платформы:
cross build --target x86_64-pc-windows-gnu
Это приведёт к созданию исполняемого файла для Windows, даже если находитесь в среде Linux.
Чтобы убедиться, что все ок используем cross
:
cross test --target x86_64-pc-windows-gnu
Многопоточность: как управлять состоянием?
Mutex в Rust гарантирует, что только один поток может получить доступ к защищаемым данным в любой момент времени. Когда поток хочет получить доступ к данным, он должен захватить или заблокировать Mutex. Это действие предотвращает доступ других потоков к этим данным, пока первый поток не завершит работу и не разблокирует Mutex
Пример Mutex:
use std::sync::Mutex;
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num += 1;
}
println!("m = {:?}", m.lock().unwrap());
Mutex::new
используется для создания нового Mutex, который защищает данные 5
. Mutex блокируется с помощью lock
, и после изменения данных блокировка автоматически снимается, когда MutexGuard
выходит из области видимости.
Atomic типы предоставляют способ выполнения атомарных операций без необходимости блокировки. Эти операции гарантируются атомарными на уровне процессора.
Пример использования атомарных типов:
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::SeqCst);
println!("Counter: {}", counter.load(Ordering::SeqCst));
AtomicUsize::new
создает атомарную переменную. fetch_add
атомарно увеличивает значение на 1
, а load
используется для получения текущего значения.
Также не нужно забывать про систему владения и заимствования, которая помогает предотвратить многие распространенные ошибки в многопоточных и параллельных программах. Владение гарантирует, что только один поток может владеть данными и, следовательно, изменять их, в то время как заимствование позволяет потокам безопасно делиться данными на чтение.
Не забывайте также о том, что успешное собеседование — это не только демонстрация технических знаний, но и возможность показать свои аналитические способности, умение решать проблемы и готовность к обучению.
Больше кейсов из жизни эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным списком курсов можно ознакомиться в каталоге.
Комментарии (6)
feelamee
26.04.2024 06:46интересно, сколько интервью вы прошли, чтобы подвести такую статистику
я был лишь на одном
единственное что у меня спрашивали - это какие будут паддинги в C++ структуре при заданных полях)
andreymal
26.04.2024 06:46+2Вот вроде статья про самые основы, описанные в растбуке, но почему-то тут плохо почти всё
4 самых частых вопросов
Даже заголовок не вычитали. И дальше по тексту навалом опечаток, упоминать которые не буду
Это предотвращает утечки памяти
Тем не менее, важно понимать, что Rust не предотвращает все утечки памяти
только одна изменяемая ссылка может существовать в данный момент времени.
Упущен крайне важный нюанс: в этот же момент времени не может существовать неизменяемых ссылок
// println!("r: {}", r); // ошибка: x не живёт достаточно долго
Чушь: если раскомментировать первый println (но не второй), то код успешно компилируется и запускается
// функция, которая ... возвращает ссылку с длиннейшей жизнью
Просто чушь, даже не знаю как прокомментировать
типы, реализующие трейт
Drop
, обязаны определить специальное поведение при удалении, так как они не могут быть скопированы автоматически.Опять набор слов какой-то. Возможно, автор хотел сказать, что нельзя реализовать трейты Copy и Drop одновременно, но он почему-то не смог
Юзаем
Option
... без использования нулевых указателейЕсли речь про собеседование, то, вероятно, где-то тут стоит упомянуть null pointer optimization
Mutex в Rust гарантирует, что только один поток может получить доступ к защищаемым данным в любой момент времени.
А как расшарить-то мьютекс другим потокам, если в растовой системе владения владелец может быть только один, а ссылка не проживёт достаточно долго? Без упоминания хотя бы Arc пример является бессмысленным
Dominux
26.04.2024 06:46Вопросы максимально простейшие, без их знаний писать код на расте невозможно принципе
alex88django_novice
26.04.2024 06:46Drop semantic вроде нужна, дабы избежать double-fee / use-after-free, которые ведут к UB, нет?
eoanermine
4 из 5 вопросов — азы, которые подробно рассказываются в каждом учебнике. Интересные у вас собеседования.
leonidv
Как и много других вопрос с собеседований. Я считаю нормальной идей кандидатов проверять в основ, особенно если это junior позиции.