Раст через его основополагающие принципы

в 22:20, , рубрики: Rust, перевод, Программирование

У меня есть несколько мыслей об изучении языков программирования.

Во-первых, мы подходим к этому неправильно. Я уверен, что вы испытывали такие же ощущения. Вы пытаетесь изучить новый язык и не совсем понимаете, как в нём всё устроено. Почему в одном месте используется один синтаксис, а в другом другой? Все эти странности раздражают, и в итоге мы возвращаемся к привычному языку.

Я считаю, что наше восприятие языков играет с нами злую шутку. Вспомните, как вы последний раз обсуждали новый язык. Кто-то упомянул о нём, а кто-то другой поинтересовался его скоростью, синтаксисом или имеющимся веб-фреймворком.

Это очень похоже на обсуждение автомобилей. Слышали о новом Ford Bratwurst? Насколько он быстр? Смогу ли я проехать на нём через озеро?

Когда мы похожим образом говорим о языках, то подразумеваем, что они взаимозаменяемы. Как машины. Если я знаю, как управлять Toyota Hamhock, значит смогу вести и Ford Bratwurst без каких-либо проблем. Разница только в скорости и приборной панели, не так ли?

Но представьте, как будет выглядеть PHP-автомобиль. А теперь вообразите, насколько будет отличаться автомобиль Lisp. Пересесть с одного на другой потребует гораздо большего, чем усвоить, какая кнопка управляет отоплением.

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

Синтаксис и скорость языка выражают его ключевые характеристики. Например, Ruby известен тем, что выше всего ценит "комфорт разработчика", и это повлияло на все его особенности. Java придаёт большое значение обратной совместимости, что также отразилось на языке.

Таким образом, моя следующая идея такова: лучше изучать язык через его ключевые особенности. Если мы поймём, почему в языке были приняты те или иные решения, будет проще понять, как именно он работает.


Давайте посмотрим на ключевые ценности Rust:

  • Скорость
  • Безопасность работы с памятью (memory safety)
  • Параллелизм (concurrency)

Давайте отложим параллелизм на время и сфокусируемся на двух остальных целях Rust: скорости и безопасности работы с памятью.

Стоит уточнить: в Rust под "безопасностью работы с памятью" подразумевается, что он не допустит ошибку сегментации (segmentation fault), которая знакома вам не понаслышке, если вы работали с С или С++. Если же вы (как и я) избегали этих языков, тогда это может быть непривычным. Представьте себе следующие ситуации:

  • обращение к пятому элементу массива из двух элементов
  • вызов метода на нулевом значении
  • одновременное изменение одной переменной двумя функциями, оставляющее её значение неопределённым
  • не освобождённая память

В Ruby вы можете получить исключение, но в таких языках, как C, случится нечто похуже. Возможно, ваша программа аварийно завершится. А может быть, она выполнит какой-то произвольный код, и ваша небольшая программа на С приведёт к гигантской уязвимости. Упс.

Под "безопасностью работы с памятью" в Rust подразумевается то, что такой проблемы не возникнет.
Примечание переводчика: Rust разрешает утечки памяти в безопасном коде и не может гарантировать их отсутствие в общем случае. Поскольку гарантировать отсутствие циклических ссылок для Rc/Arc (указателя со счётчиком ссылок) невозможно, функция forget не является "небезопасной" (unsafe). Логика понятна, хотя мне крайне не нравится — предпочёл бы чтобы эта функция всё-таки была unsafe, чтобы подчеркнуть, что с ней надо обращаться осторожно.

Ruby тоже защищает вас от ошибок сегментации, однако использует для этого сборщик мусора. Это здорово, но оказывает негативное влияние на производительность.

Но ведь Rust придаёт скорости большое значение! Следуя этой цели, Rust отказывается от сборщика мусора. Управление памятью — задача программиста. Подождите, а как же все эти ужасающие баги, которые я упоминал?! Так как Rust ценит скорость, он заставляет меня управлять памятью. Вот только если второй ключевой ценностью этого языка является безопасность работы с памятью, то почему он заставляет меня работать с ней вручную?!

Между этими двумя целями наблюдается явное противоречие. Помня об этом, давайте попробуем разобраться в Rust!

fn main() {
  let x = 1;
  println!("{}", x);
}

[Запустить]

Это одна из наиболее простых программ, которые можно написать на Rust.

Стремясь гарантировать безопасность работы с памятью, как вариант, вы можете запретить изменения данных.

Таким образом, в Rust всё по умолчанию неизменно (immutable).

fn main() {
  let x = 1;
  x = x + 1; // error: re-assignment of immutable variable `x` [E0384]
  println!("{}", x);
}

[Запустить]

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

fn main() {
  let mut x = 1;
  x = x + 1;
  println!("{}", x); // 2
}

[Запустить]

При помощи ключевого слова mut можно явно указать на то, что значение может изменяться. Явность — это четвёртая из главных особенностей Rust. Забавно, но я не видел, чтобы она вообще явно упоминалась среди целей языка, хотя при наличии выбора между явным и неявным, Rust обычно выбирает первое.

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

fn main() {
  let mut x = 1;
  // передаём х в функцию, которая удаляет данные из памяти
  println!("{}", x); // и получаем проблему при попытке использовать х
}

[Запустить]

Оставаясь верным своим основным ценностям, Rust привносит новую идею — владение (ownership). В Rust каждое значение должно иметь одного владельца. И участок памяти, принадлежащий владельцу, освобождается, когда тот выходит из области видимости.

Давайте посмотрим, как это работает:

fn main() {
  let original_owner = String::from("Hello");
  let new_owner = original_owner;
  println!("{}", original_owner); // error: use of moved value: `original_owner` [E0382]
}

[Запустить]

Многословный синтаксис String::from создаёт строку, которой мы действительно владеем. Затем мы передаём владение, и в этот момент original_owner владеет… ничем. У нашей строки может быть только один владелец.

Через систему владения Rust обеспечивает безопасность работы с памятью. Если у данных может быть только один владелец, то возможность изменить их разными потоками управления одновременно исключается. По тем же причинам невозможно обратиться к данным, которые были уничтожены.

Ранее я говорил, что значения уничтожаются при выходе из области видимости. До сих пор мы имели дело только с одной областью видимости — нашей функцией main. В большинстве программ существует больше одной области видимости. В Rust области видимости ограничиваются фигурными скобками.

fn main() {
  let first_scope = String::from("Hello");

  {
    let second_scope = String::from("Goodbye");
  }

  println!("{}", second_scope); // error: unresolved name `second_scope` [E0425]
}

[Запустить]

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

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

Теперь мы понимаем немного больше о том, как устроен Rust:

  • У данных может быть только один владелец.
  • Переменные уничтожаются при выходе из области видимости.

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

Для начала о функциях. Мы объявляем их точно так же, как нашу функцию main:

fn same_length() {
}

fn main() {
  same_length();
}

[Запустить]

Наша функция same_length должна принимать два параметра: исходную строку и строку для сравнения.

fn same_length(s1, s2) { // error: expected one of `:` or `@`, found `,`
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");

  println!("{}", same_length(source, other));
}

[Запустить]

Rust питает особую любовь к явности, поэтому мы не можем объявить функцию, не указав, какие данные будут в неё передаваться. Rust использует сильную статическую типизацию в сигнатурах функций. Таким образом, компилятор может убедиться, что мы применяем наши функции правильно, предотвращая ошибки. Явная типизация также помогает легко видеть, что функция принимает. Наша функция принимает только строки, что мы и укажем:

fn same_length(s1: String, s2: String) {
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");

  println!("{}", same_length(source, other)); // error: the trait `core::fmt::Display` is not implemented for the type `()` [E0277]
}

[Запустить]

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

fn same_length(s1: String, s2: String) { // error: mismatched types: expected `()`, found `bool`
  false
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");

  println!("{}", same_length(source, other));
}

[Запустить]

И снова о явности. Функции должны декларировать не только то, что они принимают, но и тип возвращаемого значения. Мы возвращаем bool:

#[allow(unused_variables)]
fn same_length(s1: String, s2: String) -> bool {
  false
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");

  println!("{}", same_length(source, other)); // false
}

[Запустить]

Круто. Это компилируется. Давайте попробуем реализовать сравнение. У строк есть функция len, которая возвращает их длину:

fn same_length(s1: String, s2: String) -> bool {
  s1.len() == s2.len()
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");

  println!("{}", same_length(source, other)); // false
}

[Запустить]

Здорово. Теперь произведём два сравнения!

fn same_length(s1: String, s2: String) -> bool {
  s1.len() == s2.len()
}

fn main() {
  let source = String::from("Hello");
  let other = String::from("Hi");
  let other2 = String::from("Hola!");

  println!("{}", same_length(source, other));
  println!("{}", same_length(source, other2)); // error: use of moved value: `source` [E0382]
}

[Запустить]

Помните правила? Может быть только один владелец, и после закрывающей фигурной скобки значения уничтожаются. Когда мы вызываем same_length, то передаём ей владение нашими строками, и по завершению этой функции они удаляются. Комментарии немного облегчат вам восприятие.

fn same_length(s1: String, s2: String) -> bool {
  s1.len() == s2.len()
}

fn main() {
  let source = String::from("Hello"); // source владеет "Hello"
  let other = String::from("Hi"); // other владеет "Hi"
  let other2 = String::from("Hola!"); // other2 владеет "Hola!

  println!("{}", same_length(source, other)); 
  // Мы передали `same_length` владение source и other,
  // и они были уничтожены после завершения этой функции

  println!("{}", same_length(source, other2));  // error: use of moved value: `source` [E0382]
  // source больше ничем не владеет
}

[Запустить]

Это выглядит как серьёзное ограничение. Хорошо, что Rust ценит безопасность работы с памятью так высоко, но стоит ли оно того?

Rust игнорирует наше недовольство и придерживается своих ценностей, вводя понятие заимствования (borrowing). Значение может иметь только одного владельца, но любое количество заимствующих. Заимствование в Rust обозначается символом &.

#[allow(unused_variables)]
fn main() {
  let original_owner = String::from("Hello");
  let new_borrower = &original_owner;
  println!("{}", original_owner);
}

[Запустить]

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

fn same_length(s1: &String, s2: &String) -> bool {
  s1.len() == s2.len()
}

fn main() {
  let source = String::from("Hello"); // source владеет "Hello"
  let other = String::from("Hi"); // other владеет "Hi"
  let other2 = String::from("Hola!"); // other2 владеет "Hola!"

  println!("{}", same_length(&source, &other)); // false
  // Мы только одалживаем source и other функции same_length, так что они не будут уничтожены

  println!("{}", same_length(&source, &other2)); // true
  // Мы можем одолжить source снова!
}

[Запустить]

Мы явно одолжили наши данные функции, которая явно говорит, что только одалживает их, а не забирает владение. Когда same_length завершается, то заканчивается и одалживание, но данные не уничтожаются.

Погодите, разве это не нарушает безопасность памяти, о которой мы столько говорили? Не приведёт ли это к катастрофе?

fn main() {
  let mut x = String::from("Hi!");
  let y = &x;
  y.truncate(0);
  // О нет! truncate удаляет нашу строку!
}

[Запустить]

Хм… нет. Из безопасности работы с памятью в Rust вытекают следующие правила:

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

Запустите приведённый выше код и увидите результат.

<anon>:4:3: 4:4 error: cannot borrow immutable borrowed content `*y` as mutable

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

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

Если мои теории, упомянутые в начале статьи, верны, это введение в одалживание не получилось таким уж сложным. Надеюсь, это понятнее, чем если бы я сказал: "Rust позволяет одалживать данные, просто не забывайте использовать &, и всё будет хорошо". Надеюсь.

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

Автор: DarkEld3r

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js