Rust для Java-разработчика
Перевод | Автор оригинала: Elisabeth Schulz
Rust для Java-разработчиков - пошаговое введение
Экосистема Java обширна и может решить практически любую проблему, с которой вы столкнетесь. Тем не менее, его возраст проявляется в нескольких частях, что делает его неуклюжим и непривлекательным для некоторых разработчиков Java - разработчиков, которые могут быть заинтересованы в Rust, одном из перспективных языков, которые конкурируют за внимание разработчиков. В этом сообщении блога мы исследуем, что делает языки похожими и что отличает их. Он предлагает пошаговое руководство по нескольким основным функциям, а также по тому, сколько концепций Java переведено на Rust.
Как и любой язык программирования, предназначенный для использования в реальной жизни, Rust предлагает гораздо больше, чем может научить одна запись в блоге. Цель этого поста - дать первый обзор Rust для разработчиков Java. Те, кто интересуется подробностями и дополнительным чтением, могут найти дополнительную документацию в книге Rust. В этом руководстве мы рассмотрим следующие темы:
- Синтаксис
- Время жизни объекта
- Право собственности
- Трэйты
- Общий код
- Замыкания и функциональные особенности
- Обработка ошибок
- Параллелизм
Простой синтаксис: как заставить машину делать то, что вы имеете в виду
Вы можете сказать, что синтаксис не имеет значения, пока не станет. В конце концов, синтаксис определяет, на что вы смотрите в течение всего дня, и тонко влияет на то, как вы подходите к проблеме. И Rust, и Java - императивные языки с объектно-ориентированными функциями. Таким образом, в своей основе синтаксис Rust должен быть знаком Java-разработчику. Доступны почти все концепции, которые вы регулярно используете в Java. Просто они выглядят немного иначе.
Объекты и структуры
public class Person implements Named {
private String name;
private int age;
public Person(String name) {
this.name = name;
this.age = 18;
}
@Override public String getName() {
return name;
}
}
Этот фрагмент кода должен быть знаком большинству разработчиков Java. Аналогичный фрагмент Rust может выглядеть примерно так:
pub struct Person {
name: String,
age: u32
}
impl Person {
pub fn new(name: String) -> Self {
Person { name, age: 18 }
}
}
impl Named for Person {
fn name(&self) -> String {
return self.name.clone();
}
}
Этот код выглядит как знакомым, так и отличным от кода Java. Код Java «концентрирует» все знания о том, что такое класс. Напротив, код Rust состоит из нескольких блоков. Каждый из этих блоков рассказывает нам об одном из аспектов структуры.
Сама структура
Первый из этих блоков - это фактическое определение структуры. Он определяет, как структура выглядит в памяти. Этот блок сообщает нам, что структура является общедоступной и имеет два (неявно закрытых) поля. Из этого определения компилятор Rust знает достаточно, чтобы сгенерировать экземпляр структуры. Однако этот блок еще ничего не говорит нам о том, что может делать структура.
Собственная реализация
Второй блок определяет «внутреннюю реализацию» класса. Эта фраза звучит довольно громоздко, но означает просто «вещи, которые структура может делать сама по себе». Подумайте о методах, определенных в классе, без соответствующего интерфейса или метода суперкласса. Фактически, любой метод, который вы не можете аннотировать с помощью @Override, является внутренним методом.
В нашем примере мы определяем единственную внутреннюю функцию. Функции объявляются с ключевым словом fn. В Java нет специального ключевого слова для объявления функции / метода. Напротив, Rust требует этого синтаксиса. Объявленная функция называется new и возвращает Self. Self - это особый тип, который иногда может пригодиться, особенно когда мы начинаем писать общий код. Это просто означает «текущий тип». Аналогично, self (обратите внимание на нижний регистр!) Означает текущий объект и является ближайшим родственником this в Java. В Rust методы и функции очень похожи - методы - это просто функции, которые принимают в качестве первого аргумента некий вариант себя.
Реализация трэйта
Наконец, у нас есть реализация Named. Эта трэйта соответствует интерфейсу Java. Итак, нам нужно предоставить ряд методов для выполнения именованного контракта. В отличие от Java, мы не пишем эти методы вместе с собственными. Вместо этого мы создаем новый блок верхнего уровня, содержащий только методы одного трэйта. Для этого есть две причины: структура может фактически реализовывать несколько свойств с определенными конфликтующими методами. В Java это было бы проблемой, поскольку было бы невозможно определить, что следует вызвать. В Rust оба могут сосуществовать. Кроме того, что более важно, вы можете реализовать трейт в двух местах: в определении структуры и в определении трейта. Это означает, что, хотя в Java вы не можете заставить String реализовывать ваш интерфейс, в Rust вполне возможно предоставить реализацию вашего трейта для String.
trait Stringify {
fn stringify(self) -> String;
}
impl Stringify for String {
fn stringify(self) -> String {
self
}
}
Переменные, константы и вычисления
class Foo {
double calculate(long x, double y, int z) {
var delta = x < z ? 2 : -5;
x += delta;
var q = y * x;
return q + z;
}
}
Этот фрагмент может показаться неинтересным большинству разработчиков Java. На самом деле, здесь не так много всего. Просто базовая арифметика.
fn calculate(x: i64, y: f64, z: i32) -> f64 {
let x = x + (if x < z as i64 { 2 } else { -5 });
let q = y * x;
q + z
}
Соответствующая функция Rust выглядит очень похоже, но есть несколько моментов, которые стоит учесть. Во-первых, мы видим немного странное заявление. x объявляется как параметр, а затем повторно объявляется let. Это означает, что оно затеняет предыдущее объявление - начиная со строки 3 и далее видно только вычисленное значение. Обратите внимание, что это не меняет значение x - это константа. Вместо этого он меняет значение символа.
Также стоит отметить, что мы просто используем if для нашей проверки. Операция if с обоими вариантами then и else создает значение, как и тройной оператор в Java.
Фактически, любой блок, который заканчивается значением, неявно «возвращает» это значение. По этой причине мы можем просто закрыть объявление функции с помощью выражения q + z без необходимости писать явный возврат. Фактически, возврат необходим только для раннего возврата из функции. Обратите внимание на отсутствие точки с запятой - добавление единицы «уничтожает» значение, превращая выражение в оператор.
Итерация
fn loops(limit: u32, values: Vec<i32>, condition: bool) {
// while (condition)
while condition {
}
// for (var i: values)
for i in values {
}
// for (var i = 0; i < limit; i++)
for i in 0..limit {
}
// do-while must be simulated
loop {
if !condition {
break
}
}
}
Итерация выполняется так же, как и в Java, при этом циклы практически не меняются. Существует удобная аббревиатура для обозначения бесконечного цикла (называемого просто циклом), а ключевое слово for допускает итерацию «повторяемых вещей». Разработчики Java знают Iterable
Но как насчет классического цикла for в Java? for(int i = 0; i <limit; i ++)
- это вариант синтаксиса, который мы не видим на стороне Rust. Секрет здесь в двух точках в i..limit. Это создает тип с именем Range, который обеспечивает требуемую реализацию IntoIterator. Хотя это не полностью соответствует всем возможностям цикла «init-check-update for», оно очень элегантно охватывает наиболее распространенное использование. Более сложные случаи нужно будет записать с помощью while.
Совпадение
fn match_it(x: Option<i32>, flag: bool) -> i32 {
match x {
None => 0,
Some(3) => 3,
Some(_) if !flag => 450,
Some(x) if x > 900 => 900,
_ => -1
}
}
Примерно аналогично выражению switch в Java, match предлагает эту функциональность и многое другое. Как и переключатель Java, они позволяют выбирать разные значения в одном кратком заявлении. В отличие от Java, ответвления оператора match могут выполнять гораздо более структурное сопоставление - в этом случае мы можем переходить в зависимости от наличия значения параметра, дополнительных ограничений и значения по умолчанию. Обратите внимание, что сопоставление действительно проверяет полноту - необходимо охватить все случаи.
Вы уловили маленькую концепцию, которую мы только что проскользнули мимо вас? Выражения Some и None - это два возможных значения перечисления Option в Rust. Rust позволяет значениям enum фактически быть полными собственными структурами, включая поля данных - то, что не будет работать в Java, поскольку значения enum могут существовать только один раз. Таким образом, у нас есть удобный и безопасный способ смоделировать «что-то, что может существовать, но не обязательно» - если объект присутствует, он будет сконструирован как Some(значение), в противном случае как None, и пользователь может проверить, какое это который через совпадение.
Жизнь и смерть: без вывоза мусора
Разработчики Java, вам нужно быть храбрым. В Rust нет сборщика мусора. У старших из вас могут быть воспоминания о malloc / free, в то время как младшие могут ломать голову над тем, как программа должна когда-либо освобождать память. К счастью, существует простое и элегантное решение проблемы, когда уничтожать данные в Rust. Каждая область действия очищается после себя и уничтожает все данные, которые больше не нужны. Те из вас, у кого есть опыт работы с C++, могут вспомнить этот подход как «RAII».
Что это значит? Фактически, это означает то, что каждый разработчик Java, вероятно, находит интуитивно понятным: ваша программа восстанавливает память, когда она становится недоступной. Ключевое отличие в том, что Rust делает это немедленно, а не откладывает до сборки мусора.
Перемещение объектов
fn destruction() -> String{
let string1 = String::from("Hello World");
let string2 = String::new();
let string3 = string2; // string2 moved to string3, no longer valid
for i in 0..32 {
let another_string = String::from("Yellow Submarine");
do_something(another_string); // another_string "given away" here, no longer our concern
}
return string1; // string1 returned to caller, survives past this method
// string3 destroyed here
}
В отличие от Java, в Rust объект не всегда является ссылкой - когда вы объявляете переменную как String в Java, вы фактически выражаете «ссылку на String». Могут быть и другие ссылки на ту же строку практически в произвольных частях памяти программы. Напротив, если вы скажете String в Rust, это именно то, что вы получите - саму строку, эксклюзивную и не разделяемую ни с чем другим (по крайней мере, изначально). Если вы передадите String в другую функцию, сохраните ее в структуре или иным образом передадите куда-нибудь, вы потеряете к ней доступ сами. Строка2 становится недействительной, как только она присваивается другой переменной.
Единая область видимости владеет любым объектом - либо структурой, либо переменной в стеке. Программа может перемещать объект из области видимости в область видимости. В этом примере another_string перемещается из области уничтожения в область do_something. Эта область становится владельцем и потенциально уничтожает ее. Точно так же строка1 выходит из функции в операторе return и, таким образом, переходит в собственность того, кто ее вызвал. Только string3 становится недоступной после выхода из функции и уничтожается.
В этой схеме есть исключение. Любой тип, реализующий Copy, не перемещается при переназначении значения - вместо этого он копируется (как может подразумевать название). Копия - это независимый объект со своим жизненным циклом. Clone - похожая трэйта, но требует, чтобы вы явно «подтвердили», что хотите получить потенциально дорогостоящую копию, вызвав метод.
Фактически, копирование и клонирование предоставляют функции, аналогичные интерфейсу Cloneable JDK.
Вопросы собственности: ссылки и изменчивость
Схема владения, описанная в предыдущем разделе, может показаться простой и интуитивно понятной, но у нее есть одно важное следствие: как бы вы написали функцию, которая что-то делает с объектом, который вы хотите использовать в будущем, в идеале без перетасовки мегабайт данных в вашей памяти. ? Ответ - «используйте ссылки».
Java и Rust: их взгляд на ссылки
Для Java все является справочником - ну почти все. Есть несколько примитивных типов, например int или boolean. Но любой тип объекта всегда находится за ссылкой и, следовательно, доступен косвенно. Поскольку в любом случае все является ссылкой, вы даже не объявляете ничего для этого. Это означает, как вы, вероятно, знаете, что, разместив объект «где-то», вы можете использовать его произвольными способами. В конце концов, сборщик мусора уничтожит его.
Это подразумевает нечто одновременно легкое для понимания и тонкое: ссылки могут существовать произвольное время - они определяют, как долго живет объект, а не наоборот. Вы можете передавать и хранить ссылки где угодно. Объект живет достаточно долго, чтобы ссылки всегда оставались действительными.
Как объяснялось в предыдущей главе, Rust сохраняет четкое право собственности на объект. Это позволяет языку немедленно очищать объект, когда он становится неиспользуемым. На этом этапе больше не может быть ссылок - в противном случае вы все равно сможете получить доступ к объекту после его смерти.
Ссылка вводится ключевым словом ref, но также может быть объявлена в типе переменной. Как правило, оператор & превращает значение в ссылку. Как часть типа & объявляет тип ссылкой.
fn reference() -> u32 {
let x: u32 = 10;
let ref y = &x; // reference to x
let z: &u32;
{
let short_lived: u32 = 82;
z = &short_lived;
}
*z
}
Этот код недействителен - и компилятор Rust сообщает нам, что short_lived не живет достаточно долго. Справедливо. Мы можем создавать ссылки на другой объект в памяти. Взамен мы должны убедиться, что эти ссылки не болтаются после смерти объекта.
Общая боль - изменчивость и ссылки
class ConcurrentModification {
public static void main(String[] args) {
var list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (var str : list) {
System.out.println("Doubling " + str);
list.add(str + str);
}
}
}
Многие разработчики Java сталкивались с ошибкой, показанной в этом фрагменте кода. Вы изменяете объект, который используется в данный момент. Вы запускаете код. Бац! ConcurrentModificationException. Как ни странно, альтернативы были бы хуже. Неожиданный бесконечный цикл обычно труднее отладить, чем относительно чистое исключение. Фактический одновременный доступ многих потоков был бы еще хуже. Поэтому было бы неплохо, если бы компилятор обеспечил здесь некоторую безопасность.
fn endless_loop() {
let mut vector = vec!["A".to_string(), "B".to_string(), "C".to_string()];
for string in &vector {
let mut new_string = string.clone();
new_string.push_str(&string);
vector.push(new_string)
}
}
Весь этот класс ошибок невозможен в Rust. Этому препятствует очень простое правило: у вас может быть столько ссылок на объект только для чтения, сколько вам нужно, или у вас может быть одна ссылка, допускающая изменение. Таким образом, потенциально бесконечный цикл в предыдущем примере не может возникнуть в Rust. Итератор потребует неизменяемую ссылку на список. Эта ссылка заблокирует создание изменяемой ссылки. Однако нам понадобится изменяемая ссылка для push. Таким образом, компилятор отклоняет образец кода.
Обратите внимание, что этот код снова незаметно вводит новую концепцию: mut. Этот модификатор сообщает, что переменная или ссылка могут изменять значения. Это противоположно подходу в Java. В Java каждая переменная является изменяемой, если она не объявлена окончательной.
В Java все в порядке с внутренними изменениями конечных объектов. Вы можете объявить окончательный список и по-прежнему добавлять в него элементы. В Rust вы не можете создать ссылку mut на переменную, отличную от mut. Если ваш Vec не является изменяемым, это также включает изменение его содержимого (обычно существуют некоторые исключения). Хотя это означает, что вам нужно иногда немного более глубоко задуматься о изменчивости, это, по крайней мере, предотвращает UnsupportedOperationException.
Ссылки в стиле Java в Rust: Rc и Arc
Для решения многих проблем все, что нам нужно, - это нативный подход в Rust: мы выделяем объект, что-то с ним делаем, а затем уничтожаем, когда он выполнил свою задачу. Но иногда нам нужна семантика, подобная Java. Мы хотим, чтобы что-то оставалось живым до тех пор, пока мы где-то это используем. Подумайте о пулах подключений. Мы определенно хотим разделить пул между несколькими объектами.
struct Pool;
struct RequestContext {
connection_pool: Rc<Pool>
}
fn share_the_love() -> Vec<RequestContext>{
let mut result = Vec::new();
let pool = Rc::new(Pool);
for _ in 0..1000 {
let connection_pool = pool.clone();
result.push(RequestContext { connection_pool })
}
return result;
}
Rc в этом примере кода означает подсчет ссылок. Rc «обволакивает» реальный объект. Его дешево клонировать, и он может предоставить ссылку на реальный объект «позади» Rc. Каждый из созданных объектов RequestContext может существовать в течение разного времени жизни. Rc можно даже клонировать и связать с чем-то еще, не затрагивая их - и второй пул не будет создан.
fn bad_rc() {
struct Container {
contained: RefCell<Option<Rc<Container>>>
}
let mut outer = Rc::new(Container { contained: RefCell::new(None) });
*outer.contained.borrow_mut() = Some(outer)
// and the container lives forever
}
Подсчет ссылок - это недорогая стратегия управления сроками жизни. У него много преимуществ, но есть одно важное предостережение - он не может справиться с циклами. В этом примере мы создаем такой цикл. Этот объект будет жить вечно - ссылка внутри себя может поддерживать его жизнь. В Java это не проблема, сборщик мусора может игнорировать такие внутренние ссылки. В Rust внешний Rc уничтожается, но внутренний сохраняет объект в живых. Также обратите внимание на RefCell. Это одно из исключений из упомянутого ранее правила «глубокой изменчивости». Rc может захотеть защитить нас от изменения общего значения (разрешив только неизменяемую ссылку). Тем не менее RefCell готова нарушить это правило и позволить нам выстрелить себе в ногу.
Rc дешевый и делает как можно меньше. Это не требует дорогостоящей логики для работы в параллельных сценариях. Если вы предпочитаете работать с несколькими потоками, обменивающимися данными, вам следует использовать его близкого родственника Arc. Arc работает точно так же, но выполняет дополнительную синхронизацию для безопасной работы через границы потоков.
Наследование земли: особенности и реализации
Мы узнали, что такое трэйты характера, еще в самом начале. Они являются аналогом интерфейсов Java в Rust. За исключением решения о том, чтобы реализация трейта была независимым блоком, они выглядят почти одинаково. И по большей части они могут быть такими. Однако реализация интерфейсов охватывает только одно из двух ключевых слов Java: реализации. А как насчет extends, некогда сияющей звезды объектно-ориентированного программирования, отошедшей на второй план с годами?
Короче говоря, это не часть языка Rust. Никакого конкретного наследования невозможно. Одна из ваших структур может иметь поле другой структуры и делегировать некоторые из ее методов. Вы можете реализовать AsRef или что-то подобное для другой структуры. Что вы не можете сделать, так это переопределить методы других структур или рассматривать одну структуру как другую при присвоении значений.
Возможно, одна трэйта требует, чтобы другая работала. Это похоже на расширение интерфейса в Java - чтобы реализовать дочернюю трэйту, вам также необходимо реализовать родительскую трэйту. Однако есть небольшое различие. Как всегда, каждая трэйта получает свой блок.
Основное использование интерфейсов Java - вызов методов интерфейса независимо от их реализации. То же самое возможно и в Rust. В Rust это называется динамической отправкой и обозначается ключевым словом dyn.
fn stringify(something: &dyn AsRef<str>) -> String {
String::from(something.as_ref())
}
fn call_stringify() {
struct Foo;
impl AsRef<str> for Foo {
fn as_ref(&self) -> &str {
"This is custom"
}
}
stringify("Hello World"); // &str
stringify(&String::new()); //&String
stringify(&Foo); // &Foo
}
В этом фрагменте мы видим эту возможность в действии: мы определяем единственную функцию, которую можно вызывать со ссылками на любое количество типов, реализующих трэйту AsRef
Укладываем вещи в крэйти
Подход «просто передача ссылки» отлично подходит для работы с параметрами. Это интуитивно понятно и очень похоже на то, что вы делали бы на Java. Возможно, это не самый быстрый способ сделать что-то, но обычно он работает хорошо. Однако иногда мы не хотим передавать параметр функции - вместо этого мы хотим вернуть значение из функции.
fn return_dyn() -> dyn AsRef<str> {
return "Hello World"
}
К сожалению, хотя с точки зрения Java-разработчика это выглядит «должно работать», Rust имеет некоторые дополнительные ограничения. А именно, право собственности на объект переходит к вызывающей стороне. Не вдаваясь в технические подробности, получение права собственности на объект означает обязательство хранить и этот объект. И для этого нам нужно знать одну важную деталь: нам нужно знать его размер.
Все объекты Java находятся в большой куче, и их истинный размер на самом деле довольно сложно определить. У Rust другая стратегия: Rust хочет хранить в стеке столько данных, сколько разумно. Когда вы выделяете структуру, вы фактически кладете столько байтов в стек. Просто возвращение dyn Trait не дает достаточно информации для этого. В конце концов, насколько вы знаете, могут быть разные реализации в зависимости от некоторых внутренних условий. Так что для динамического возврата о стеке не может быть и речи.
fn return_dyn() -> Box<dyn AsRef<str>> {
return Box::new("Hello World")
}
Используя тип Box
Не совсем названия вещей
Есть альтернатива боксу ценностей. Хотя упаковка объекта очень похожа на стиль Java, Rust не стремится использовать много кучи. В конце концов, отслеживать кучу сравнительно медленно и сложно. Иногда причина вернуть трэйту - просто скрыть информацию. Часто разработчики не хотят изменять тип в зависимости от некоторых параметров, а вместо этого просто не раскрывают такие детали реализации.
fn return_impl() -> impl AsRef<str> {
return "Hello World"
}
Выглядит очень аккуратно и аккуратно. Он не раскрывает тип реализации, а просто говорит: «Я возвращаю то, что вы можете использовать в качестве трэйта», не вдаваясь в подробности, что это такое. Но за метафорическим капюшоном - компилятор знает. Он знает и может оптимизировать для фактического типа, вплоть до того, что вообще не выполняет динамический вызов.
Вообще говоря: дженерики
Практически все разработчики Java знают хотя бы основы дженериков: именно они делают Collection et. al. работайте разумно. Без дженериков (и до Java 5) все эти типы работали исключительно с объектами. Под капотом они по-прежнему делают это, удаляя все общие типы и заменяя их «верхней границей». В Rust нет общего супертипа, такого как Object, но все же есть общие типы (некоторые из них вы уже видели в этой статье).
Поскольку в Rust нет «общего супертипа», понятно, что его подход должен быть другим. И действительно, это так. Если Java создает один и тот же код для всех возможных параметров типа, Rust вместо этого генерирует специальный код для каждой комбинации фактических параметров типа.
Вы можете определить ограничения для параметров типа в Java - и Rust работает таким же образом. Там, где в Java используется синтаксис T extends S, в Rust есть несколько менее многословная альтернатива: T: S. Помните, что в Rust нет способа «расширить структуру», поэтому только свойства могут ограничивать тип. Можно потребовать несколько трэйтов, просто указав Trait1 + Trait2, как в нотации Java Interface1 и Interface2. Однако, поскольку свойства Rust часто намного уже, чем обычно бывает в интерфейсах Java, вы будете встречать плюс-нотацию гораздо чаще.
Альтернативы динамической отправке
fn wrap<A>(param: A) -> Vec<A> {
let mut v = Vec::new();
v.push(param);
v
}
fn add_three<A: Add>(one: A, two: A, three: A) -> A {
one.add(two).add(three)
}
fn example() {
wrap("Hello World"); // calls wrap<&str>
wrap(999); // calls wrap<i32>
add_three(10, 20, 30); // calls add_three<i32>
add_three(0.5, 0.9, 38.4); // calls add_three<f64>
}
Приведенный выше фрагмент иллюстрирует этот шаблон. У нас есть две функции, которые принимают параметры нескольких типов и работают с ними. Однако второй пример на самом деле интересен: мы действительно используем операцию плюса свойства Add. Тем не менее, код не содержит dyn.
Это связано с упомянутой ранее разницей в стратегии. Когда вызывается наша функция add_three, компилятор фактически создает разные функции для каждого A - и может даже решить встроить некоторые или все эти вызовы. В нашем примере с 32-битными целыми числами нет необходимости даже вызывать какие-либо функции для их добавления. Компилятор может генерировать чрезвычайно высокопроизводительный машинный код.
.add_three:
lea (%rdi,%rsi,1),%eax
add %edx,%eax
retq
Связанные типы и универсальные типы
Обобщения - это хорошо известная концепция Java-разработчикам, и эта концепция хорошо переносится в Rust. Однако есть ключевое различие: Java не поддерживает реализацию одного и того же универсального интерфейса дважды - даже с разными параметрами типа.
class Twice implements Comparable<Twice>, Comparable<String> {
public int compareTo(String o) {
return 0;
}
public int compareTo(Twice o) {
return 0;
}
}
Это может показаться неожиданным даже для опытных разработчиков Java, но на то есть веская причина: стирание типа. Поскольку параметр типа Comparable забыт, фактический метод compareTo должен иметь параметры объекта. Только один метод может иметь такую точную сигнатуру, и на самом деле у него нет возможности выяснить, в какой из двух методов compareTo пересылать аргумент. Напротив, Rust позволяет две реализации одного и того же трейта с разными параметрами типа. Компилятор генерирует оба из них и в каждом случае выбирает «правильный». Нет стирания типа и, следовательно, нет необходимости в «скрытом» методе пересылки.
Иногда эта способность оказывается благом - у разработчика больше возможностей и меньше шансов ошибиться. Однако иногда это неудобно. Типаж IntoIterator - один из таких примеров. Вероятно, это не следует реализовывать несколько раз. Каким будет тип переменной в цикле for? По этой причине есть способ переместить переменную типа «в» свойство: Связанные типы.
trait AssociatedType {
type TheType;
}
impl AssociatedType for i32 {
type TheType = String;
}
fn mogrify<A: AssociatedType, B: AssociatedType<TheType=String>>(a: A, b: B) {
}
Со связанным типом у вас нет переменной типа в предложении impl - и, следовательно, вы не можете реализовать одну и ту же трэйту дважды. Таким образом, вы получаете такое же поведение, как и в Java. Возможна только одна реализация. В Rust это можно сделать намеренно, а не ограничивать историей языка.
В приведенном выше примере есть еще один интересный фрагмент кода. Строка 9 показывает, как ссылаться на трэйт со связанным типом. Если нам не нужно знать сам тип, мы просто пишем привязку трейта, как обычно. Но если нам действительно нужны эти знания, мы можем заглянуть под капот и рассматривать связанный тип как параметр. Синтаксис немного отличается от «обычных» параметров. Связанные типы необходимо указывать как Name = Value, а не только по их положению.
Функциональное мышление: лямбды и замыкания
Лямбды уже давно являются частью Java, впервые появившись в Java 8. По сути, они представляют собой ярлык для превращения функции (метода) в объект. До появления Java 8 для этого требовался специальный (часто анонимный) класс и множество нотаций. Вероятно, неудивительно, что Rust предлагает такие же возможности. Фактически, даже обозначения должны показаться знакомыми большинству разработчиков Java.
fn double_and_summarize(input: &Vec<i32>) -> i32 {
input.iter().map(|x| x * 2).fold(0, |a, b| a + b)
}
За исключением некоторых тонкостей в обозначениях (отсутствие фигурных скобок и т.д.), Код Rust очень похож на то, что мы писали бы на Java. Все становится несколько интереснее, если мы посмотрим на основы кода «функционального стиля». В Java используется понятие интерфейса SAM. Фактически, любой интерфейс, в котором отсутствует реализация по умолчанию для одного метода, может служить целью для лямбда-выражения. Rust более явный и, возможно, более ограниченный, чем Java. Для представления функций существует специальное семейство черт.
Типы функций (и способы их использования)
Трэйты «функции» в Rust особенные. Вы можете реализовать это семейство черт только с синтаксисом замыкания. У трейтов есть несколько особый синтаксис. Все они имеют вид TraitName (argumentTypeList ...) (-> Result)?
«Функциональное семейство» состоит из трех черт. Каждое закрытие, которое вы определяете, автоматически реализует максимально допустимое из возможных.
- FnOnce - самое «слабое» из этих трех семейств. Вы можете вызвать эти функции не более одного раза. Основная причина этого может заключаться в том, что функция получает право собственности на объект и уничтожает его после завершения.
- Семейство FnMut не имеет такого же ограничения, но все же несколько ограничено в своей применимости. У реализации есть возможность изменить своего «получателя». Приемник аналогичен этому в Java. Однако вместо FnOnce можно использовать FnMut.
- Fn - самый общий класс функций. Вы можете вызывать их несколько раз, и они не фиксируют какое-либо (изменяемое) состояние. По сути, у этих функций нет «памяти». Замыкание Fn можно использовать вместо двух других типов.
fn invoke_once<F: FnOnce()-> SomeStruct>(function: F) -> SomeStruct {
function()
}
fn invoke_mut<F: FnMut() -> SomeStruct>(function: &mut F) -> SomeStruct {
function()
}
fn invoke<F: Fn() -> SomeStruct>(function: &F) -> SomeStruct {
function()
}
fn invoke_with_once_closure() {
let s = SomeStruct;
let closure = || s;
invoke_once(closure);
}
fn invoke_with_mut_closure() {
let mut count = 0;
let mut closure = || {
count += 1;
SomeStruct
};
invoke_mut(&mut closure);
invoke_once(closure);
}
fn invoke_with_nonmut_closure() {
let mut closure = || SomeStruct;
invoke(&closure);
invoke_mut(&mut closure);
invoke_once(closure);
}
В этом примере показаны различные типы закрытия, которые могут возникнуть. Первый (определенный в invoke_with_once_closure) активно становится владельцем переменной и, таким образом, вынужден реализовать самую слабую из трех черт, FnOnce. Во втором примере при каждом вызове создается собственное значение. Таким образом, он может производить значение несколько раз. Однако он захватывает часть своего вызывающего окружения. Чтобы иметь возможность увеличивать x, неявно создается &mut. Таким образом, замыкание требует самого изменяемого контекста.
Эта дополнительная сложность служит довольно простой цели: отслеживать, что и как долго живет. Представьте, что вы ссылаетесь на локальную переменную в замыкании и имеете выход из содержащего блока, тем самым уничтожая значение. Это еще раз демонстрирует разницу в философии дизайна. Java решила снизить сложность, исключив более сложные случаи FnMut и FnOnce. В конце концов, все полученные значения должны быть «фактически окончательными».
Возврат закрытия
Хотя это, возможно, не самый распространенный вариант использования, иногда полезно вернуть закрытие.
class MakeRunnable {
Runnable makeRunnable(Object o) {
return() -> {
runWith(o);
};
}
}
В Java это очень элегантно из-за соглашения SAM - вы просто возвращаете интерфейс, который хотите реализовать в закрытии. В теле метода вы можете записать закрытие в операторе возврата. Простой.
fn make_runnable(a: SomeStruct) -> impl Fn() {
move || runWith(&a)
}
Достичь того же в Rust немного сложнее. Нам нужно дать компилятору еще одну подсказку: ключевое слово move. Без этого ключевого слова значение a умерло бы, как только вернется вызов make_runnable. Таким образом, закрытие будет ссылаться на мертвую ценность, и случатся плохие вещи. Ключевое слово move сообщает компилятору Rust вместо этого переместить любую захваченную переменную в собственность замыкания.
Также обратите внимание, что эта функция использует возвращаемый тип impl Trait, о котором говорилось ранее. Без этого синтаксиса нам все-таки понадобился бы именованный тип, и пришлось бы вручную реализовывать закрывающие функции.
Когда что-то пойдет не так: обработка ошибок
Обработка ошибок - это головная боль для большинства разработчиков. Это может легко отвлечь от цели кода. Обработка ошибок также является одним из наиболее вероятных виновников сложной логики. В худшем случае разработчик просто отказывается от обработки ошибок - в результате в случайные моменты возникают загадочные сбои. Любой достойный язык требует удобной для пользователя стратегии обработки ошибок.
Здесь пути Rust и Java довольно существенно расходятся. Java - дитя 90-х. Новаторская на тот момент концепция исключений занимает центральное место в стратегии обработки ошибок. Вообще говоря, метод выдает исключение, чтобы сигнализировать об ошибке. Это прерывает выполнение текущего метода и «переходит назад» в стек к соответствующему обработчику.
Забота о результатах
Это очень удобная модель для разработчика, которой лишь немного мешают накладные расходы на выполнение объявлений throw. Также очень дорого реализовать. Rust гораздо больше, чем Java, заботится о производительности. Таким образом, очевидно, что Rust предпочел бы другой способ обработки ошибок, а не создание исключений: кодирование успеха или неудачи операции в возвращаемое значение. Подобно типу Optional
fn some_function() -> Result<i32, SomeError> {
return Ok(389)
}
По сути, приведенный выше фрагмент кода выражает то же самое, что и эта подпись Java:
class SomeClass {
int someMethod() throws SomeException {
return 529;
}
}
Ключевое отличие здесь в том, что сбой не распространяется автоматически по стеку: нет необходимости в специальной логике для поиска обработчика исключений. Возможно, что наиболее важно, нет трассировки стека - все функции возвращаются нормально, хотя и с результатом, указывающим на ошибку.
На первый взгляд это кажется очень подверженным ошибкам. Ведь очень легко просто забыть проверить результат звонка или вовсе отказаться от него. К счастью, Rust предлагает возможность, которую Java не может компенсировать: компилятор, предназначенный для помощи разработчику в обнаружении таких ошибок. Rust имеет возможность пометить возвращаемое значение как «необходимо использовать», и компиляция завершится ошибкой, если вы отбросите такое возвращаемое значение.
The? Оператор
fn f() -> Result<i32, SomeError>{
match function1() {
Err(e) => Err(e),
Ok(v) => {
let mut sum = 0;
for element in v {
match function2(element) {
Err(e) => return Err(e),
Ok(intermediate) => match function3(intermediate) {
Err(e) => return Err(e),
Ok(next) => sum += next,
}
}
}
Ok(sum)
},
}
}
Этот код вне уродства - он на грани непонятного. К счастью, существует особый вид синтаксиса, облегчающий задачу правильной обработки результатов:?. Этот безобидный оператор фактически служит сокращением для приведенных выше утверждений. Если вы используете этот оператор try, код читается очень похоже на код Java, без использования гораздо более дорогостоящего механизма исключения.
fn f2() -> Result<i32, SomeError>{
let mut sum = 0;
for element in function1()? {
sum += function3(function2(element)?)?
}
Ok(sum)
}
Различные типы ошибок
Не все ошибки одинаковы. В конце концов, тип результата параметризуется по типу ошибки, а также по типу результата. Типы ошибок могут быть простыми, как «что-то пошло не так», до относительно сложных структур с большим количеством полезной информации по обработке ошибок. Следовательно, может возникнуть необходимость преобразовать один вид ошибки в другой. Код ? Оператор уже поддерживает это: если существует переход от фактической ошибки к ожидаемой ошибке, оператор просто использует ее для преобразования. В противном случае может потребоваться некоторый настраиваемый код (например, вызов map_err для объекта Result).
Многие библиотеки («крэйти») определяют тип ошибки, специфичный для этой библиотеки, а некоторые также предлагают удобный ярлык для работы с потенциально неудачными операциями: они определяют псевдоним типа для Result, который исправляет параметр ошибки, поэтому пользователь может сэкономить на вводе параметр ошибки каждый раз.
Когда все потеряно
Во вступлении к этой главе мы упоминали, что Rust не любит производить обратную трассировку или иметь дело с «внезапным выходом» из функций. Это правда, но это еще не вся картина. Есть одна часть головоломки: паника. Эта функция делает именно то, что подразумевает ее название. Он сдается и убегает, как исключение Java. Это не лучший способ справляться с вещами в Rust и в основном используется в случаях, когда ошибка находится на уровне неудачного утверждения. Другими словами, ваша программа должна запаниковать, если она сама замечает ошибку (например, выход за пределы массива). Паника - это инструмент отладки, а не правильный способ обработки ошибок.
Вы действительно можете «поймать» панику, если воспользуетесь некоторыми функциями стандартной библиотеки, но обычно от этого мало пользы. Обратите внимание, что, к счастью, даже паника - это «управляемая паника» - вся очистка все еще выполняется при выходе из каждого прицела.
Несколько способов выполнения нескольких задач: как Rust и Java обрабатывают параллелизм
Ваш телефон, вероятно, имеет несколько ядер, и любая программа, не использующая более одного из них, должна спросить себя: почему бы и нет? И, как следствие, параллельное и параллельное программирование становится все более важным.
В настоящее время существует два основных подхода к этому: параллельное вычисление (на основе потоков) и параллельное выполнение. Достопочтенный Thread API и гораздо более молодой API CompletionStage предоставляют их на Java. У обоих есть близкие родственники в Rust, и у обоих есть одно серьезное ограничение: возможность безопасного обмена данными между потоками. С Java это всегда было открытой проблемой: вы всегда можете свободно делиться ссылками. Вам просто нужно правильно управлять общим доступом. Вам также необходимо знать, что означает «правильно» в каждом конкретном случае.
В Rust очень ясно, что может разделяться между разными параллельными контекстами: все, что реализует Sync. Точно так же все, что реализует Send, можно передавать между разными потоками. Однако помните всю концепцию владения - неизменной ссылкой может быть Sync, но если ее время жизни недостаточно велико, чтобы гарантировать, что все задачи, с которыми вы ее разделяете, выполнены, вы все равно не сможете использовать ее в нескольких контекстах.
Компилятор автоматически реализует правильные свойства Send и Sync. Обычно вы будете взаимодействовать с обоими типами. Причина проста: любой тип, полностью состоящий из типов отправки, будет сам отправлять, а основные типы - это отправить. То же самое и с Sync. Однако есть некоторые исключения - поэтому обязательно ознакомьтесь с полной документацией.
Заправка нити в иглу
Темы здесь очень давно - точнее, с 90-х годов. По сути, они представляют собой легковесные процессы с совместным использованием памяти. Java упрощает создание нового потока.
class Threads {
private static final int SIZE = 500;
int[] foo() throws Exception {
var results = new int[SIZE];
var threads = new Thread[SIZE];
for (var i = 0; i < SIZE; i++) {
final int threadNum = i;
threads[threadNum] = new Thread(() -> {
results[threadNum] = 2 * threadNum;
});
threads[i].start();
}
for (var thread: threads) {
thread.join();
}
return results;
}
}
Пригодно, но не увлекательно. Основная проблема здесь в том, что потоки не могут эффективно передавать свои результаты обратно генерирующей функции, но в остальном это довольно легко понять - в конце концов, никакие данные не передаются между потоками.
fn threads() -> Result<Vec<i32>, Box<dyn Error>> {
let size = 400;
let thread_results: Vec<JoinHandle<i32>> = (0..size).map(|i| {
spawn(move || i * 2)
}).collect();
let mut result = Vec::new();
for join in thread_results {
result.push(join.join()?)
}
Ok(result)
}
Rust выглядит очень похоже, но предлагает небольшую изюминку - у каждого потока есть JoinHandle, который генерируется путем порождения (вместо сохранения изменяемого представления потока вокруг). Этот JoinHandle позволяет выполнять только несколько основных операций - гораздо меньше, чем Thread, но позволяет дождаться завершения потока и получить значение результата.
В будущее
Потоки отлично подходят для простого параллелизма - особенно для серверных приложений, где каждый из потоков будет видеть один запрос от начала до конца. Эта модель, как вы, наверное, знаете, не самая эффективная и отзывчивая. В конце концов, большую часть времени потоки будут блокироваться в ожидании ввода-вывода.
class Completeable {
CompletionStage<SomeType> processing() {
return loadDataFromDatabase()
.thenCompose(data -> {
var written = writeToFileSystem(data);
var additionallyLoaded = loadMoreData(data);
var processWriteAdditionally = additionallyLoaded.thenCombine(written, (data2, ignore) -> data2);
return processWriteAdditionally.thenCompose(data2 -> callRestWebService(data, data2));
});
}
}
Этот код Java достаточно хорошо читается, если вы знакомы с API - он объединяет в цепочку несколько асинхронных вызовов и заставляет их все быть успешными, давая окончательный результат. Конечно, в этом примере опущены все детали вызовов, но само количество скобок вызывает небольшую головную боль.
async fn processing() -> SomeType {
let loaded = load_from_database().await;
let write_op = write_to_fs(&loaded);
let load_additional_op = load_more_data(&loaded);
let (_, additional_loaded) = join!(write_operation, load_additional_op);
call_rest_service(&loaded, &additional_loaded).await
}
Rust решил расширить свой синтаксис, поскольку асинхронный код важен, и в будущем он станет еще более важным. Следовательно, соответствующий код на Rust выглядит намного чище.
Однако специальный синтаксис, по сути, просто сахар - async fn - это, по сути, обычная функция, возвращающая impl Future <Output = T>. Фактически, модификатор async на самом деле не требуется «сам по себе» - это просто синтаксический сахар для объявления такой функции, типа, который служит возвращаемым типом, и реализации трейта Future. Без него код был бы очень похож на пример кода Java.
Выводы
В этом посте вы узнали некоторые основы Rust. Итак, заменит ли Rust полностью Java в ближайшие пять лет? Нет, наверное, нет. Но это новый многообещающий язык низкого уровня. Он невероятно быстрый, хорошо структурированный и в целом веселый и выразительный. Кроме того, язык поддерживает прикладных программистов с помощью некоторых из лучших средств диагностики и языковых функций, которые я видел за два десятилетия разработки. Лучше всего то, что это удивительно безопасно, но при этом на низком уровне. Целые классы распространенных ошибок полностью исключаются правилами языка, что является немалым подвигом.
Итак, когда вы будете выполнять следующий микросервис, почему бы не дать Rust шанс? Возможно, вы захотите ознакомиться с фреймворком Actix для своего веб-сервера. Если вы хотите глубже изучить язык, книга Rust - ваш первый ресурс. Для тех, кто регулярно сталкивается с sun.misc.Unsafe, заглянув на небезопасный подъязык в Rustonomicon, можно получить массу творческих идей.