Основы Rust: первые программы

Этот пост — вольный перевод на русский вот этой оригинальной статьи (с нашими дополнениями в местах, где это показалось нужным), которую написал Стив Донован.

Пишем Hello world

Первоначальная цель Hello world, с тех пор как была написана первая версия на языке Си, заключалась в тестировании компилятора и запуске реальной мини-программы.// hello.rsfn main()

// hello.rs
fn main() {
println("Hello, World!");
}

$ rustc hello.rs
$ ./hello

Rust — это язык с фигурными скобками, точками с запятой, комментариями в стиле C++ и главной стартовой функцией — пока все знакомо.

Восклицательный знак здесь указывает на то, что это вызов макроса. Для программистов на C++ это может быть неприятно, поскольку они привыкли к серьезным навороченным макросам на Си — но уверяем, что макросы в Rust более понятные и вменяемые.

Однако компилятор необычайно прозорлив, и если вы опустите это восклицание, то получите ошибку:

error[E0425]: unresolved name `println`
 --> hello2.rs:2:5
  |
2 |     println("Hello, World!");
  |     ^^^^^^^ did you mean the macro `println!`?

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

Следующим шагом будет введение переменной:

// let1.rs
fn main() {
    let answer = 42;
    println!("Hello {}", answer);
}

Синтаксические ошибки — это ошибки на этапе компиляции, а не ошибки времени выполнения, как в динамических языках, таких как Python или JavaScript. Это избавит вас от многих проблем в дальнейшем. И если для примера мы написали ‘answr‘ вместо ‘answer‘, компилятор на самом деле вполне толково обнаружит это:

4 |     println!("Hello {}", answr);
  |                         ^^^^^ did you mean `answer`?

Макрос println! принимает строку формата и некоторые значения, он очень похож на форматирование, используемое в Python 3.

Еще один очень полезный макрос — assert_eq! Это рабочая лошадка тестирования в Rust, с помощью его вы утверждаете, что две вещи должны быть равны, и если это не так, то возникает паника.

// let2.rs
fn main() {
    let answer = 42;
    assert_eq!(answer,42);
}

Это не приведет ни к какому результату. Но измените 42 на 40:

thread 'main' panicked at
'assertion failed: `(left == right)` (left: `42`, right: `40`)',
let2.rs:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.

И это наша первая runtime-ошибка в Rust.

Циклы и ветвление

Все, что нужно, можно сделать более одного раза с помощью циклов:

// for1.rs
fn main() {
    for i in 0..5 {
        println!("Hello {}", i);
    }
}

Диапазон не является инклюзивным, поэтому i идет от 0 до 4. Это удобно в языке, который индексирует такие вещи, как массивы, начиная от 0.

А вот пример, как можно работать внутри циклов с условиями:

// for2.rs
fn main() {
    for i in 0..5 {
        if i % 2 == 0 {
            println!("even {}", i);
        } else {
            println!("odd {}", i);
        }
    }
}
even 0
odd 1
even 2
odd 3
even 4

i % 2 равен нулю, если 2 может без остатка делиться на i. Rust использует операторы в стиле языка Си. Скобки вокруг условия отсутствуют, как и в Go, но надо использовать фигурные скобки вокруг блока.

Это позволяет переписать то же самое в более наглядном виде:

// for3.rs
fn main() {
    for i in 0..5 {
        let even_odd = if i % 2 == 0 {"even"} else {"odd"};
        println!("{} {}", even_odd, i);
    }
}

Традиционно в языках программирования есть условия (например, if) и выражения (например, 1+i). В Rust почти все может быть выражением. Поэтому перегружать «троичный оператор» из примера выше подробностями не нужно.

Обратите внимание, что в этих блоках нет ни одной точки с запятой!

Сложение

Компьютеры очень хороши в арифметике (если вы не знали). Вот наша первая попытка сложить все числа от 0 до 4. Сейчас мы применим на практике все, что узнали выше.

// add1.rs
fn main() {
    let sum = 0;
    for i in 0..5 {
        sum += i;
    }
    println!("sum is {}", sum);
}

Но код не компилируется, хотя смотрится вроде все логично:

error[E0384]: re-assignment of immutable variable `sum`
 --> add1.rs:5:9
3 |     let sum = 0;
  |         --- first assignment to `sum`
4 |     for i in 0..5 {
5 |         sum += i;
  |         ^^^^^^^^ re-assignment of immutable variable

Имутабельная переменная? Это переменная, которая не может изменяться. Переменные let по умолчанию могут присваивать значение только при объявлении. Но добавление волшебного слова mut («пожалуйста, сделайте эту переменную изменяемой») помогает:

// add2.rs
fn main() {
    let mut sum = 0;
    for i in 0..5 {
        sum += i;
    }
    println!("sum is {}", sum);
}

Это может вызвать недоумение, если вы пришли из других языков, где переменные могут быть перезаписаны по умолчанию. Что делает что-то «переменной»? То, что ей присваивается вычисляемое значение во время выполнения — то есть это не константа. Это слово также используется в математике, например, когда мы говорим «пусть переменная n будет наибольшим числом в множестве S».

Есть причина, по которой переменные по умолчанию объявляются в Rust доступными только для чтения. В больших программах становится трудно отследить, где происходит запись. Поэтому Rust делает такие вещи, как изменяемость («возможность записи»), явными и строгими. В языке много хитростей, но Rust старается быть максимально предсказуемым.

Rust является статически типизированным и сильно типизированным — эти понятия часто путают, но вспомните Си (статически, но слабо типизированный) и Python (динамически, но сильно типизированный). В статических типах тип известен во время компиляции, а динамические типы становятся известны только во время выполнения.

Однако пока что создается впечатление, что Rust скрывает от вас эти типы. Какой именно тип у i? Компилятор может определить его, начиная с 0, с помощью вывода типов и приходит к i32 (четырехбайтовое знаковое целое число).

Давайте сделаем ровно одно изменение — превратим 0 в 0.0. Затем мы получаем ошибки:

error[E0277]: the trait bound `{float}: std::ops::AddAssign<{integer}>` is not satisfied
 --> add3.rs:5:9
  |
5 |         sum += i;
  |         ^^^^^^^^ the trait `std::ops::AddAssign<{integer}>` is not implemented for `{float}`
  |

Итак, медовый месяц в нашем обучении закончился, начинаются сложности. Каждый оператор (например, +=) соответствует trait’у, который представляет собой абстрактный интерфейс, который должен быть реализован для каждого конкретного типа. Мы подробно рассмотрим это позже, но здесь вам нужно знать только то, что AddAssign — это имя фичи, реализующей оператор +=, а ошибка говорит о том, что числа с плавающей запятой не реализуют этот оператор для целых чисел (полный список трейтов операторов находится здесь).

Опять же Rust любит быть явным — он не будет молча преобразовывать целое число в число с плавающей точкой за вас.

Мы должны явно привести это значение к значению с плавающей точкой, вот так:

// add3.rs
fn main() {
    let mut sum = 0.0;
    for i in 0..5 {
        sum += i as f64;
    }
    println!("sum is {}", sum);
}

Функции — это одно из мест, где компилятор не будет вычислять типы за вас. И это было преднамеренным решением, поскольку в языках вроде Haskell настолько мощный вывод типов, что явных имен типов почти нет. Это хороший стиль Haskell — вводить явные подписи типов для функций. Rust требует этого всегда.

Вот простая пользовательская функция:

/ fun1.rs

fn sqr(x: f64) -> f64 {
    return x * x;
}

fn main() {
    let res = sqr(2.0);
    println!("square is {}", res);
}

Rust возвращается к старому стилю объявления аргументов, когда тип следует за именем. Так это делалось в языках, производных от Алгола, таких как Паскаль.

Опять же никаких преобразований целых чисел в дробные — если заменить 2.0 на 2, то мы получим явную ошибку:

8 |     let res = sqr(2);
  |                   ^ expected f64, found integral variable
  |

На самом деле вы редко увидите функции, написанные с использованием оператора возврата. Чаще всего это выглядит следующим образом:

fn sqr(x: f64) -> f64 {
    x * x
}

Это происходит потому, что тело функции (внутри {}) имеет значение последнего выражения, как и в случае блока if-выражения.

Поскольку точка с запятой вставляется полуавтоматически человеческими пальцами, вы можете добавить ее сюда и получить следующую ошибку:

  |
3 | fn sqr(x: f64) -> f64 {
  |                       ^ expected f64, found ()
  |
  = note: expected type `f64`
  = note:    found type `()`
help: consider removing this semicolon:
 --> fun2.rs:4:8
  |
4 |     x * x;
  |       ^

Использование return не является неправильным, но код без него чище. Вы все равно будете использовать return для возврата из функции раньше времени.

Некоторые операции могут быть элегантно выражены рекурсивно:

fn factorial(n: u64) -> u64 {
    if n == 0 {
        1
    } else {
        n * factorial(n-1)
    }
}

Поначалу это может показаться немного странным, и тогда лучше всего воспользоваться карандашом и бумагой и решить несколько примеров. Однако обычно это не самый эффективный способ выполнения такой операции.

Значения также могут передаваться по ссылке. Ссылка создается с помощью & и разыменовывается с помощью *.

fn by_ref(x: &i32) -> i32{
    *x + 1
}

fn main() {
    let i = 10;
    let res1 = by_ref(&i);
    let res2 = by_ref(&41);
    println!("{} {}", res1,res2);
}
// 11 42

Что если вы хотите, чтобы функция изменила один из своих аргументов? Вводим изменяемые ссылки:

// fun4.rs

fn modifies(x: &mut f64) {
    *x = 1.0;
}

fn main() {
    let mut res = 0.0;
    modifies(&mut res);
    println!("res is {}", res);
}

Это больше похоже на то, как это делается в C, чем в C++. Вы должны явно передать ссылку (с помощью &) и явно разыменовать ее с помощью *. А затем установить mut, потому что он не используется по умолчанию.

По сути, Rust вводит здесь потенциальные проблемы и не очень скрыто подталкивает вас к возврату значений из функций напрямую. К счастью, в Rust есть мощные способы выражения таких вещей, как «операция прошла успешно, и вот результат», поэтому &mut нужен не так часто. Передача по ссылке важна, когда у нас есть большой объект и мы не хотим его копировать.

Стиль type-after-variable применяется и к let, когда вы действительно хотите точно определить тип переменной:

let bigint: i64 = 0;

Пришло время начать пользоваться документацией. Она будет установлена на вашей машине вместе с компилятором, и вы можете использовать rustup doc --std, чтобы открыть ее в браузере.

Обратите внимание на поле поиска в верхней части экрана, поскольку оно надолго станет вашим помощником. Оно работает полностью автономно.

Допустим, мы хотим посмотреть, где находятся математические функции, поэтому ищем «cos». Первые два результата показывают, что она определена как для чисел с плавающей одинарной запятой, так и двойной точности. Функция определяется как метод, например, так:

let pi: f64 = 3.1416;
let x = pi/2.0;
let cosine = x.cos();

Почему нам нужен явный тип f64? Потому что без него константа может быть либо f32, либо f64, а они очень разные.

Позвольте процитировать пример, приведенный для cos, но написанный как полная программа (assert! является двоюродным братом assert_eq!):

fn main() {
    let x = 2.0 * std::f64::consts::PI;

    let abs_difference = (x.cos() - 1.0).abs();

    assert!(abs_difference < 1e-10);
}

std::f64::consts::PI — это многозначное слово! :: означает то же самое, что и в C++ (в других языках часто пишется через ‘.‘) — это полное квалифицированное имя. Мы получаем это полное имя из второго запроса на поиск PI.

До сих пор наши маленькие Rust-программы были свободны от всех этих import и include, которые обычно замедляют обсуждение программ типа Hello world. Давайте сделаем эту программу более читабельной с помощью оператора use:

use std::f64::consts;

fn main() {
    let x = 2.0 * consts::PI;

    let abs_difference = (x.cos() - 1.0).abs();

    assert!(abs_difference < 1e-10);
}

Массивы и срезы

Все статически типизированные языки имеют массивы, которые представляют собой значения, упакованные в памяти от старта до хвоста. Массивы индексируются с нуля:

// array1.rs
fn main() {
    let arr = [10, 20, 30, 40];
    let first = arr[0];
    println!("first {}", first);

    for i in 0..4 {
        println!("[{}] = {}", i,arr[i]);
    }
    println!("length {}", arr.len());
}

И на выходе мы получаем:

first 10
[0] = 10
[1] = 20
[2] = 30
[3] = 40
length 4

В этом случае Rust точно знает, какого размера массив, и, если вы попытаетесь обратиться к arr[4], это приведет к ошибке компиляции.

Изучение нового языка часто подразумевает отказ от ментальных привычек из языков, которые вы уже знаете; если вы Python-ист, то эти скобки говорят о List. Массивы могут быть изменяемыми (если мы вежливо попросим), но нельзя добавлять новые элементы.

Массивы не так часто используются в Rust, потому что тип массива включает его размер. Тип массива в примере — [i32; 4]; тип [10, 20] будет [i32; 2] и так далее: у них разные типы. Поэтому их неудобно передавать в качестве аргументов функций.

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

Обратите внимание в примере ниже на два важных момента: как записать тип среза + то, что для передачи его в функцию нужно использовать &.

// array2.rs
// read as: slice of i32
fn sum(values: &[i32]) -> i32 {
    let mut res = 0;
    for i in 0..values.len() {
        res += values[i]
    }
    res
}

fn main() {
    let arr = [10,20,30,40];
    // look at that &
    let res = sum(&arr);
    println!("sum {}", res);
}

Проигнорируем на время код sum и посмотрим на &[i32]. Связь между массивами и срезами в Rust аналогична связи между массивами и указателями в Cи, за исключением двух важных различий — срезы в Rust отслеживают свой размер (и будут паниковать, если вы попытаетесь получить доступ за пределами этого размера), и вы должны явно сказать, что хотите передать массив как срез, используя оператор &.

Программист на Си произносит & как «адрес», программист на Rust произносит его как «заимствовать». Это слово будет ключевым при изучении Rust. Заимствование — это название распространенной схемы в программировании, когда вы передаете что-то по ссылке (что почти всегда происходит в динамических языках) или передаете указатель в Си. Все, что заимствовано, остается в собственности первоначального владельца.

Нарезка на кусочки и кубики

Вы не можете распечатать массив обычным способом с помощью {}, но можете сделать отладочную печать с помощью {:?}.

// array3.rs
fn main() {
    let ints = [1, 2, 3];
    let floats = [1.1, 2.1, 3.1];
    let strings = ["hello", "world"];
    let ints_ints = [[1, 2], [10, 20]];
    println!("ints {:?}", ints);
    println!("floats {:?}", floats);
    println!("strings {:?}", strings);
    println!("ints_ints {:?}", ints_ints);
}

Что дает:

ints [1, 2, 3]
floats [1.1, 2.1, 3.1]
strings ["hello", "world"]
ints_ints [[1, 2], [10, 20]]

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

Если вам интересно, каковы реальные типы этих переменных, вот полезный трюк. Просто объявите переменную с явным типом, который, как вы знаете, будет неправильным:

let var: () = [1.1, 1.2];

Вот информативная ошибка:

3 |     let var: () = [1.1, 1.2];
  |                   ^^^^^^^^^^ expected (), found array of 2 elements
  |
  = note: expected type `()`
  = note:    found type `[{float}; 2]`

({float} означает «некоторый тип с плавающей точкой, который еще не полностью определен»)

Слайсы дают вам различные представления одного и того же массива:

// slice1.rs
fn main() {
    let ints = [1, 2, 3, 4, 5];
    let slice1 = &ints[0..2];
    let slice2 = &ints[1..];  // open range!

    println!("ints {:?}", ints);
    println!("slice1 {:?}", slice1);
    println!("slice2 {:?}", slice2);
}
ints [1, 2, 3, 4, 5]
slice1 [1, 2]
slice2 [2, 3, 4, 5]

Это аккуратная нотация, которая похожа на срезы Python, но с большим отличием: копия данных никогда не создается. Все эти срезы заимствуют данные из своих массивов. У них очень тесная связь с массивом, и Rust тратит много усилий на то, чтобы эта связь не нарушалась.

Необязательные значения

Срезы, как и массивы, могут быть индексированы. Rust узнает размер массива во время компиляции, но размер среза известен только во время выполнения. Поэтому s[i] может привести к ошибке вне границ при выполнении и вызовет панику. И здесь нет исключений.

Осмыслите это как следует, потому что это шокирует. Вы не можете завернуть сомнительный, вызывающий панику код в какой-нибудь try-block и просто «поймать ошибку» — по крайней мере не в том виде, который вы хотели бы использовать каждый день. Так как же Rust может быть безопасным?

Существует метод slice get, который не вызывает паники. Но что он возвращает?

// slice2.rs
fn main() {
    let ints = [1, 2, 3, 4, 5];
    let slice = &ints;
    let first = slice.get(0);
    let last = slice.get(5);

    println!("first {:?}", first);
    println!("last {:?}", last);
}
// first Some(1)
// last None

last не сработал (мы забыли о нулевом индексировании), но вернул что-то под названием Nonefirst сработал нормально, но отображается как значение, обернутое в Some. Добро пожаловать в тип Option! Это может быть либо Some, либо None.

У типа Option есть несколько полезных методов:

    println!("first {} {}", first.is_some(), first.is_none());
    println!("last {} {}", last.is_some(), last.is_none());
    println!("first value {}", first.unwrap());

// first true false
// last false true
// first value 1

Если бы вы развернули last, то получили бы панику. Но по крайней мере вы можете сначала вызвать is_some, чтобы убедиться в этом — например, если у вас есть явное не-значение по умолчанию:

let maybe_last = slice.get(5);
    let last = if maybe_last.is_some() {
        *maybe_last.unwrap()
    } else {
        -1
    };

Обратите внимание на * — точный тип внутри Some — &i32, что является ссылкой. Нам нужно разыменовать его, чтобы вернуться к значению i32.

Это долго, поэтому есть короткий путь — unwrap_or вернет значение, которое ему было дано, если опция была None. Типы должны совпадать — get возвращает ссылку, поэтому вам придется составить &i32 с &-1. Наконец, снова используйте *, чтобы получить значение в виде i32.

let last = *slice.get(5).unwrap_or(&-1);

Легко пропустить &, но компилятор здесь подстраховывает вас. Если это было -1, rustc скажет «ожидалось &{целое}, найдена интегральная переменная», а затем «help: try with &-1».

Вы можете представить Option как поле, которое может содержать значение или ничего (None). В Haskell это называется Maybe. Он может содержать значение любого типа, которое является его параметром типа. В данном случае полным типом является Option<&i32>, используя нотацию в стиле C++ для дженериков. Разворачивание этого ящика может привести к взрыву, но, в отличие от кота Шредингера, мы можем заранее знать, содержит ли он значение.

Очень часто функции/методы Rust возвращают такие maybe-боксы, поэтому научитесь удобно их использовать.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *