Начни программировать на Rust быстро. Циклы, условия, коллекции, методы и pattern matching

30 July, 2021

9 min read

Последнее обновление: 30 July, 2021

Хотите быстро начать писать код на Rust? Я прочитал Rust Book, Full Stack Rust и Rust for Creative Programmers, проходил техническое интервью по Rust (да, серьезно, в Украине уже открываются вакансии, где требуется Rust), записал тонну конспектов и собрал их в единое целое, чтобы тебе, моему читателю, было стартовать намного проще.

Для кого пост будет полезным?

  • вы умеете программировать на другом языке программирования, и хотите быстро привыкнуть к Расту
  • будет легче, если вы прошли какой-то курс вроде CS50 или учили C/C++
  • вы уже установили компилятор Rust к себе на машину. Если нет, быстрее устанавливайте!

Начнем с самого важного.

Примитивные типы Rust

Примитивных типов в языке не так уж и много. Вот они:

  • bool — boolean
  • char — знак
  • integer — помечается, как i<x>, где x это то, сколько битов вам нужно под число.

Rust поддерживает инты вместительностью до 128 битов включительно. Инты бывают с битом по знак, signed: i8, i16, i32, i64, i128 и unsigned, без бита под знак: u8, u16, u32, u64, u128.

  • isize: int размерность которого зависит от машины, на которой компилируется код. Может быть i32 на 32-bit CPU и i64 на 64-bit CPU.
  • usize: точно такой же int, только unsigned
  • f32 and f64: 32-bit и 64-bit числа с плавающей точкой
  • array. Его размер четко зафиксирован, вмещает в себя только элементы одного типа
  • str: string slices
  • functions
  • (T, U, …) — tuple. Отличается от array тем, что может вмещать в себя элементы разных типов.
  • unit()

Как можно скомпилировать Rust код?

Самый просто способ скомпилировать код, это использовать Rust компилятор (ха-ха, не ожидали?).

rustc <filename.rs>

Переменные Rust

Все переменные в Rust по умолчанию неизменяемые. Они объявляются с помощью ключевого слова let.

Вот так сделать не получится.

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

Но, если нужно сделать изменяемую переменную, можно объявить переменную со словом mut. Тогда получится ее поменять.

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

В Rust так же можно определять константы.

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

Так же в константе обязательна аннотация типа.

fn main() {
// & str -- аннотация типа
const EXAMPLE: &str = "hello";
}

Скоупы и управление памятью

Переменные в Rust не просто занимают место в stack, они так же владеют ресурсами (например, могут попросить себе кусок памяти на хипе, как это делает Box).

Rust следует парадигме RAII (Resource Acquisition Is Initialization). Всякий раз как объект выходит из скоупа, вызывается деструктор и ресурсы, принадлежащие объекту, высвобождаются. Если по простому, то убивается переменная и все, что в ней лежит.

Скоуп легко заметить по его обозначению

{ // начало скоупа
<всякий движ>
} // конец скоупа
<все чем владел скоуп, будет уничтожено>

Так как переменные ответственны за менеджмент ресурсов, которые им выделяются ⇒ ресурсы могут иметь только одного владельца.

Когда мы делаем вот такие вот вещи let x = y или foo(x) , мы меняем владельца ресурсов. Был x, а стал кто-то другой... y или foo().

Выражаясь языком Rust, мы совершаем move. После того, как сделан move мы не можем пользоваться ресурсами от лица предыдущего владельца.

// функция поглощает свой аргумент
fn destroy_box(c: Box<i32>) {
    println!("Destroying a box that contains {}", c);

    // после завершения скоупа переменная-аргумент будет дропнута,
    // а ее ресурсы освобождены
}

fn main() {
    // инт который будет сохранен в стаке
    let x = 5u32;

    // копируем значения икса в игрик, все ок,
    // у каждого из них будет свой инт на стаке
    let y = x;

    // можем их использовать, все хорошо
    println!("x is {}, and y is {}", x, y);

    // `a` это владелец бокса,
    // бокс делает инт на хипе
    let a = Box::new(5i32);

    println!("a contains: {}", a);

    // копируем значение а в б,
    // теперь то, чем владел a, перешло по наследству к б
    let b = a;

    // а больше не может получить доступ к этому куску памяти на хипе
    //println!("a contains: {}", a);
    // попробуйте убрать коммент с этой линии и протестировать

    // эта функция сейчас поглотит б
    destroy_box(b);
    // все, б и данным которые в ней были хана! Скоуп функции завершен

    //println!("b contains: {}", b);
    // попробуйте убрать коммент и убедитесь что я говорю правду
}

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

Компилятор Rust гарантирует нам, что референс всегда будет указывать на валидный объект, а не на случайную белиберду. Кто учил C++, тот поймет о чем я.

// Эта функция поглотит свой аргумент, а после окончания скоупа...
// вы уже догадались наверное что будет
fn eat_box_i32(boxed_i32: Box<i32>) {
    println!("Destroying box that contains {}", boxed_i32);
}

// эта функция поглотит референс и после завершения скоупа референс будет дропнут
// но референс это просто референс, нам его не жалко
fn borrow_i32(borrowed_i32: &i32) {
    println!("This int is: {}", borrowed_i32);
}

fn main() {
    // бокс -- это число на хипе, а второе -- число в стаке
    let boxed_i32 = Box::new(5_i32);
    let stacked_i32 = 6_i32;

    // Смотрим как ведет себя добрая функция которая кушает только референсы
    borrow_i32(&boxed_i32);
    borrow_i32(&stacked_i32);

    {
        // Возьмем референс от инта который у нас на хипе
        let _ref_to_i32: &i32 = &boxed_i32;

        // хобана, эта строчка не даст скомпилироваться программе
				// после завершения функции, бокс будет уничтожен, а у нас тут сверху
				// свежий референс как бы
        eat_box_i32(boxed_i32);
        // FIXME ^ Comment out this line

        // с референсом все норм работать будет
        borrow_i32(_ref_to_i32);
    }
		// скоуп закончился, _ref_to_i32 уже давно хана

    // никаких референсов на бокс не осталось,
		// можно отдать его на съедение функции
    eat_box_i32(boxed_i32);
}

К переменным, менеджменту памяти в Rust, референсам и еще многим разным вещам придется привыкать. Скорее всего, это займет у вас около 3 месяцев, если вы пришли из какого-нибудь Python, типа меня. Это темная сторона программирования на Rust, но как только привыкните — будет клево и очень спокойно.

Как можно получить право на изменение данных в переменной, но не поглощать переменную?

К нам на помощь приходят mutable references. &mut <var name> позволяет скоупу не только читать данные в переменной, но и получить право на изменение их.

// не надо боятьсч, тут будет много незнакомого, но скоро вы все поймете
#[derive(Clone, Copy)]
struct Book {
    // `&'static str` -- указатель на неизменяемый str,
		// про них я расскажу немного ниже
    author: &'static str,
    title: &'static str,
    year: u32,
}

// эта функция берет обычный референс, он дает право читать данные
fn borrow_book(book: &Book) {
    println!("I immutably borrowed {} - {} edition", book.title, book.year);
}

// эта функция берет mut ref, и имеет право на изменение данных в переменной
fn new_edition(book: &mut Book) {
    book.year = 2014;
    println!("I mutably borrowed {} - {} edition", book.title, book.year);
}

fn main() {
    // мы создали неизменяемую переменную бук
    let immutabook = Book {
        author: "Douglas Hofstadter",
        title: "Gödel, Escher, Bach",
        year: 1979,
    };

    // мы делаем копию неизменяемого бука и делаем ее изменяемой
    let mut mutabook = immutabook;

    // берет обычный реф, все хорошо
    borrow_book(&immutabook);

    // берем обычный реф на изменяемый бук, все хорошо
    borrow_book(&mutabook);

    // берем мут реф на мут бук, все ок
    new_edition(&mut mutabook);

    // ошибка, тут не скомпилируется
		// нельзя взять мут реф на неизменяемую переменную
    new_edition(&mut immutabook);
}

Чтобы лучше разобраться в концепциях управления памяти, глава из Rust Book вам очень поможет.

Как работают conditions (условный оператор) в Rust?

Если вы знакомы с JavaScript синтаксисом, то в Rust conditions пишутся точно так же.

Но, запомните важный момент: обе ветки условия должны возвращать один и тот же тип.

fn main() {
    let rust_is_awesome = true;
    let answer;
    if rust_is_awesome {
        answer = "Indeed!";
    } else {
        answer = "Well, you should try rust!";
    }
    println!("{}", answer);
}

Функции, переменные и return в Rust

Если не ставить ; в конце, то Rust примет "Troy".to_string() как возвращаемое значение функции. Можно писать return "Troy".to_string() , но намного удобнее без return. return рекомендуется использовать только для раннего возврата.

fn get_troy() -> String {
        "Troy".to_string()
    }

fn get_something_or_troy(x: usize) -> String {
	if x == ... {
		return "Troy".to_string();
	}
	<много другой логики>
}

Если не указывать тип возвращаемого значения при объявлении функции, компилятор будет думать, что функция должна вернуть Unit (). Если вы в функции неожиданно сделаете возврат какого-то значения другого типа, компилятор включит режим разгневанного краба.

https://rustacean.net/assets/corro.svg

Циклы в языке Rust

В языке несколько видов циклов и один из них — бесконечный. Больше никаких while True (кто пришел сюда их Python, тот поймет).

fn main() {
    let mut x = 1024;
    loop {
        if x < 0 {
            break;
        }
        println!("{} more runs to go!", x);
        x -= 1;
    }
}

Бесконечный цикл объявляется словом loop и разрывается словом break.

Если у нас есть несколько бесконечных циклов, один внутри другого, мы можем маркировать их подписями (labels).

fn silly_sub(a: i32, b: i32) -> i32 {
    let mut result = 0;
    'increment: loop {
        if result == a {
            let mut dec = b;
            'decrement: loop {
                if dec == 0 {
                    break 'increment;
                } else {
                    result -= 1;
                    dec -= 1;
                }
            }
        } else {
            result += 1;
        }
    }
    result
}

fn main() {
    let a = 10;
    let b = 4;
    let result = silly_sub(a, b);
    println!("{} minus {} is {}", a, b, result);
}

Подпись или имя на цикле объявляется так: '<label>: loop . Когда мы хотим его остановить, мы пишем break '<label>.

В языке есть while цикл, он такой же, как везде. Самый обычный.

while <condition> {
        <do something>
    }

Есть еще for цикл и тут немного синтаксического сахара. Он может быть инклюзивный и обычный.

// это обычный цикл, он не включает в себя 10
for i in 0..10 {
        ...
    }

// этот цикл включает в себя 10
for i in 0..=10 {
  ...
}

Кастомные типы или структуры в Rust

Мы, как разработчики, можем определять несколько видов типов данных в Rust.

— структуры, structs

— enums

— unions

Традиционно их именование происходит согласно CamelCase (если вы знаете го, привет вам).

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

Методы для structs и enums

Structs и enums могут иметь методы. Они объявляются с помощью impl блоков.

Пример для структуры.

struct Player {
    name: String,
    iq: u8,
    friends: u8
}

impl Player {
    fn with_name(name: &str) -> Player {
        Player {
            name: name.to_string(),
            iq: 100,
            friends: 100
        }
    }

    fn get_friends(&self) -> u8 {
        self.friends
    }

    fn set_friends(&mut self, count: u8) {
        self.friends = count;
    }
}

Пример для enum.

enum PaymentMode {
    Debit,
    Credit,
    Paypal
}

fn pay_by_credit(amt: u64) {
    println!("Processing credit payment of {}", amt);
}

fn pay_by_debit(amt: u64) {
    println!("Processing debit payment of {}", amt);
}

fn paypal_redirect(amt: u64) {
    println!("Redirecting to paypal for amount: {}", amt);
}

impl PaymentMode {
    fn pay(&self, amount: u64) {
        match self {
            PaymentMode::Debit => pay_by_debit(amount),
            PaymentMode::Credit => pay_by_credit(amount),
            PaymentMode::Paypal => paypal_redirect(amount)
        }
    }
}

Как вы видите из примера выше, enum используется для того, чтобы смоделировать один из возможных видов оплаты. Особенно полезны enums для того, чтобы моделировать state — состояние. Комбинируя их с match statements программирование на Rust становится особенно приятным занятием. О match statements я расскажу чуть позже.

Методы и для структур, и для enum могут быть:

  • ассоциированные

Мне лично они больше всего похожи на то, что называют класс методами.

// мы работаем непосредственно со структурой
Player::with_name("David");
// или
Player::new("David");
  • истанс

Сначала вам необходимо сделать инстанс структуры или enum, и потом, вызывая эти методы, они поглощают инстанс в виде self , &self или &mut self

// мы совершаем действие над инстансом (экземпляром) структуры
player.get_friends();

Rust типы данных: коллекции

В Rust существуют такие виды коллекций, доступных "из коробки":

  • arrays — имеют фиксированную длину, хранят в себе элементы одного типа
  • tuples — имеют фиксированную длину, хранят в себе элементы разных типов
  • vectors — динамические arrays, умеют расти по требованию
  • maps — хэшмапы, вроде их все знают 🙂 аналог JS объектов или Python dict
  • slices — используются, чтобы получить доступ только к определенному диапазону элементов в коллекции

Итераторы

Итератор — это структура, которая позволяет эффективно работать с элементами, упакованными в коллекции.

Тут вы можете вспомнить Python и iter(list) или C++ vector.begin().

В Rust итераторы ленивые(!), они не читают всю коллекцию в память. Для того, чтобы получить доступ к элементам, когда вам это нужно, итератор предоставляет next() метод, который каждый раз при итерации пытается прочитать следующий элемент из коллекции.

В Rust итератором может стать любой тип, который определит для себя Iterator Trait. Trait — это какая-то общая характеристика, которую могут разделять много типов. О Trait я расскажу подробнее немного позже.

После того, как в типе определен Iterator Trait мы можем использовать его в циклах и итерировать его сколько захотим 🙂

Многие коллекции из стандартной библиотеки языка уже имеют этот Trait определенным: Vector, HashMap, BTreeMap и разные другие.

Почти все коллекции можно конвертировать в итератор, используя iter() или into_iter() методы.

Match Statement в Rust

В Rust имплементирован pattern matching c помощью match statement.

fn req_status() -> u32 {
    200
}

fn main() {
    let status = req_status();
    match status {
        200 => println!("Success"),
        404 => println!("Not found"),
        other => println!("Request failed with the code: {}", other)
    }
}

очень напоминает switch statement в JS.

// код из MDN
const expr = "Papayas";
switch (expr) {
  case "Oranges":
    console.log("Oranges are $0.59 a pound.");
    break;
  case "Mangoes":
  case "Papayas":
    console.log("Mangoes and papayas are $2.79 a pound.");
    // expected output: "Mangoes and papayas are $2.79 a pound."
    break;
  default:
    console.log(`Sorry, we are out of ${expr}.`);
}

Вот еще один пример из Rust, более расширенный.

fn main() {
    let number = 13;

    println!("Tell me about {}", number);
    match number {
        1 => println!("One!"),
        2 | 3 | 5 | 7 | 11 => println!("This is a prime"),
        13..=19 => println!("A teen"),
        _ => println!("Ain't special"),
    }
}

Rust String and &str

fn main() {
    let question = "How are you"; // &str type
    let person: String = "Troy".to_string();
    let namaste = String::from("नमस्ते");
    println!("{}! {}, {}?", namaste, question, person);
}

Чтобы понять строки и строковые литералы, я рекомендую сначала ознакомиться с тем, что такое stack и heap allocation. Я уже затрагивал эту тему выше, и вы, если вы внимательный читатель, уже успели загуглить и посмотреть, что это такое (если не знаете или не помните).

String размещаются на хипе, тогда как &str тип — это указатель на существующий где-тоString. Этот указатель может храниться в стаке или хипе, или еще где-нибудь.

String так же динамический тип, как Vec. Его лучше всего использовать, когда нам нужно владеть данными.

str — неизменяемая коллекция UTF-8 байтов. Сколько места в памяти занимает переменная — неизвестно, вы можете использовать ее только в виде указателя, поэтому str обычно является нам в виде &str. Этот тип еще называют string slice или просто slice.

В следующей серии

  • анонимные функции 😍 то, что называется closures
  • система модулей в Rust
  • пакетный менеджер Cargo — офигенная вещь
  • расширяем Cargo: watcher, инсталлятор зависимостей, упаковщик в .deb
  • тесты

Полезные ссылки. Начни практиковаться как можно быстрее

Руководство по изучению Rust от человека, который шарит лучше, чем я

Первые три месяца на Rust, тред на реддите (на английском)

Напиши свою первую программу на Rust (туториал на английском)

Упражнения для начинающих Rust разработчиков

Подписаться на мою рассылку

Похожие посты

Troy Köhler

TwitterYouTubeInstagramLinkedin