Основи Rust: пишемо перші тестові програми

Игорь Грегорченко

У нашій серії матеріалів ми розглянемо базові основи новомодної мови Rust. А у другій частині цього циклу на основі вивченого спробуємо написати найпростіші смарт-контракти для таких блокчейн-проектів, як Solana. У цьому туторіалі буде багато прикладів, мало теорії та швидкий темп просування вперед.

Цей пост — вільний переклад на українську ось цієї оригінальної статті (з нашими доповненнями в місцях, де це здалося потрібним), яку написав Стів Донован.

Пишемо Hello world

Початкова мета Hello world, відколи була написана перша версія мовою Cі, полягала в тестуванні компілятора та запуску реальної мініпрограми.

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

Rustце мова з фігурними дужками, крапками з комою, коментарями в стилі C++ та головною стартовою функцією — поки що все знайоме.

Знак оклику тут вказує на те, що це виклик макросу. Для програмістів на 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;
  |       ^

Тип ()– це порожній тип (void). Усе в Rust має значення, але іноді це буває просто ніщо. Компілятор знає, що це поширена помилка і допомагає вам. Будь-хто, хто проводив час із компілятором C++, знає, наскільки це незвичайно.

Ще кілька прикладів цього стилю виразу без return:

// absolute value of a floating-point number
fn abs(x: f64) -> f64 {
    if x > 0.0 {
        x
    } else {
        -x
    }
}

// ensure the number always falls in the given range
fn clamp(x: f64, x1: f64, x2: f64) -> f64 {
    if x < x1 {
        x1
    } else if x > x2 {
        x2
    } else {
        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 аналогічний зв’язку між масивами та вказівниками в Ci, за винятком двох важливих відмінностей — зрізи в Rust відстежують свій розмір (і будуть панікувати, якщо ви спробуєте отримати доступ за межами цього розміру), і ви повинні явно сказати, що хочете передати масив як зріз, використовуючи оператор &.

Програміст на Cі вимовляє &як «адреса», програміст на Rust вимовляє його як «запозичувати». Це слово буде ключовим для вивчення Rust. Запозичення — це назва поширеної схеми в програмуванні, коли ви передаєте щось за посиланням (що майже завжди відбувається в динамічних мовах) або передаєте вказівник у Ci. Усе, що запозичене, залишається у власності первісного власника.

Нарізка на шматочки та кубики

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

// 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не спрацював (ми забули про нульове індексування), але повернули щось під назвою None. firstспрацював нормально, але відображається як значення, загорнуте в 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-боксы, тому навчіться їх зручно використовувати.

Далі буде…

Останні статті

Більше 50% Go i Ruby розробників з досвідом 3+ роки найняли на $5000. PHP — на самому дні

Більше половини Go i Ruby розробників з досвідом 3+ роки найняли на $5000 або більше.…

26.04.2024

Програмісти намагалися втекти з України в Молдову, щоб влаштуватись на роботу

Прикордонники недалеко від с. Кучурган Одеської області затримали двох програмістів, які намагалися втекти з України…

26.04.2024

В Україні запускають безплатне навчання блокчейн-розробці на Solana

Українське Solana-комʼюніті Kumeka Team з 7 травня запускає безплатне навчання блокчейн-розробці — Solana BootCamp. Про…

26.04.2024

Не гаяли часу. Туреччина створила спеціальні візи для «цифрових кочівників» з України

Туреччина створила спеціальні візи для диджитал-номадів або «цифрових кочівників». Скористатися ними зможуть і українці. Про…

26.04.2024

Росіяни, вірогідно, вкрали для гри про ПВК «Вагнер» створені українцями ассети бійців СБУ

Російська студія NoName Company, вірогідно, вкрала для розробки тактичного шутеру Best in Hell про ПВК…

26.04.2024

11 травня відбудеться хакатон студентських інновацій University Software Bootcamp

11 та 12 травня в NAU HUB відбудеться хакатон студенських новацій University Software Bootcamp. Про…

25.04.2024