Rust для программистов на Python
Перевод | Автор оригинала: Armin Ronacher
Теперь, когда Rust 1.0 вышел и стал достаточно стабильным, я подумал, что было бы интересно написать введение в Rust для программистов на Python. В этом руководстве рассматриваются основы языка и сравниваются различные конструкции и их поведение.
С точки зрения языка Rust - совсем другой зверь по сравнению с Python. Не только потому, что один является компилируемым языком, а другой интерпретируется, но и потому, что принципы, которые в них заложены, совершенно разные. Какими бы разными ни были языки в основе, у них много общего в идеях о том, как должны работать API. Как программист на Python, многие концепции должны быть знакомы.
Синтаксис
Первое отличие, которое вы заметите как программист на Python, - это синтаксис. В отличие от Python, Rust - это язык с множеством фигурных скобок. Однако на то есть веская причина, и это то, что в Rust есть анонимные функции, замыкания и множество цепочек, которые Python не может хорошо поддерживать. Эти функции намного проще понять и написать на языке без отступов. Давайте посмотрим на один и тот же пример на обоих языках.
Сначала пример Python трехкратной печати «Hello World»:
def main():
for count in range(3):
print "{}. Hello World!".format(count)
И вот то же самое в Rust:
fn main() {
for count in 0..3 {
println!("{}. Hello World!", count);
}
}
Как видите, довольно похоже. def становится fn, а двоеточия - фигурными скобками. Другая большая разница в синтаксисе заключается в том, что Rust требует информации о типе для параметров для работы, что не является тем, что вы делаете в Python. В Python 3 доступны аннотации типов, которые имеют тот же синтаксис, что и в Rust.
Одна из новых концепций по сравнению с Python - это функции с восклицательными знаками в конце. Это макросы. Во время компиляции макрос расширяется во что-то еще. Это, например, используется для форматирования и печати строк, потому что таким образом компилятор может обеспечить правильное форматирование строк во время компиляции. Не случайно вы не соответствуете типам или количеству аргументов функции печати.
Трэйты против протоколов
Самая знакомая, но отличающаяся друг от друга особенность - это поведение объекта. В Python класс может выбрать определенное поведение, реализовав специальные методы. Обычно это называется «соответствие протоколу». Например, чтобы сделать объект итерируемым, он реализует метод iter, который возвращает итератор. Эти методы должны быть реализованы в самом классе и не могут быть изменены впоследствии (игнорируя monkeypatching).
В Rust концепция очень похожа, но вместо специальных методов используются трейты. Трэйты немного отличаются тем, что достигают одной и той же цели, но реализация ограничена локально, и вы можете реализовать больше трэйтов для типов из другого модуля. Например, если вы хотите придать целым числам особое поведение, вы можете сделать это, не меняя ничего в целочисленном типе.
Чтобы сравнить эту концепцию, давайте посмотрим, как реализовать тип, который можно добавлять к самому себе. Сначала в Python:
class MyType(object):
def __init__(self, value):
self.value = value
def __add__(self, other):
if not isinstance(other, MyType):
return NotImplemented
return self.__class__(self.value + other.value)
И вот то же самое в Rust:
use std::ops::Add;
struct MyType {
value: i32,
}
impl MyType {
fn new(value: i32) -> MyType {
MyType { value: value }
}
}
impl Add for MyType {
type Output = MyType;
fn add(self, other: MyType) -> MyType {
MyType { value: self.value + other.value }
}
}
Здесь пример Rust выглядит немного длиннее, но он также имеет автоматическую обработку типов, чего не делает пример Python. Первое, что вы заметите, это то, что в Python методы живут в классе, тогда как в Rust данные и операции существуют независимо. Структура определяет макет данных, а impl MyType определяет методы, которые имеет сам тип, тогда как impl Add для MyType реализует трейт Add для этого типа. Для реализации Add нам также необходимо определить тип результата наших операций добавления, но мы избегаем дополнительной сложности, связанной с необходимостью проверки типа во время выполнения, как мы должны делать в Python.
Другое отличие состоит в том, что в Rust конструктор явный, тогда как в Python он довольно волшебный. Когда вы создаете экземпляр объекта в Python, он в конечном итоге вызывает init для инициализации объекта, тогда как в Rust вы просто определяете статический метод (по соглашению, называемый new), который выделяет и создает объект.
Обработка ошибок
Обработка ошибок в Python и Rust совершенно разная. В то время как в Python ошибки выдаются как исключения, ошибки в Rust передаются обратно в возвращаемом значении. Сначала это может показаться странным, но на самом деле это очень хорошая концепция. Глядя на функцию, довольно ясно, какую ошибку она возвращает.
Это работает, потому что функция в Rust может возвращать результат. Результат - это параметризованный тип, который имеет две стороны: успех и неудачу. Например, Result<i32, MyError> означает, что функция либо возвращает 32-битное целое число в случае успеха, либо MyError в случае ошибки. Что произойдет, если вам нужно вернуть более одной ошибки? Здесь все отличается с философской точки зрения.
В Python функция может выйти из строя с любой ошибкой, и вы ничего не можете с этим поделать. Если вы когда-либо использовали библиотеку «запросов» Python и перехватывали все исключения запросов, а затем вас раздражало, что ошибки SSL не обнаруживаются этим, вы поймете проблему. Вы мало что можете сделать, если библиотека не документирует то, что возвращает.
В Rust ситуация совсем иная. Сигнатура функции включает ошибку. Если вам нужно вернуть две ошибки, способ сделать это - создать собственный тип ошибки и преобразовать внутренние ошибки в более совершенные. Например, если у вас есть библиотека HTTP, и внутри она может выйти из строя с ошибками Unicode, ошибками ввода-вывода, ошибками SSL, что у вас есть, вам необходимо преобразовать эти ошибки в один тип ошибки, специфичный для вашей библиотеки, и пользователям, тогда нужно только иметь дело с этим . В Rust предусмотрена цепочка ошибок, при которой такая ошибка может указывать на исходную ошибку, из-за которой она возникла, если вам нужно.
Вы также можете в любой момент использовать тип Box
Если в Python ошибки распространяются незримо, в Rust они распространяются заметно. Это означает, что вы можете видеть всякий раз, когда функция возвращает ошибку, даже если вы решили не обрабатывать ее там. Это включено попыткой! макрос. Этот пример демонстрирует это:
use std::fs::File;
fn read_file(path: &Path) -> Result<String, io::Error> {
let mut f = try!(File::open(path));
let mut rv = String::new();
try!(f.read_to_string(&mut rv));
Ok(rv)
}
И File::open, и read_to_string могут завершиться ошибкой ввода-вывода. Попробуй! макрос распространит ошибку вверх и вызовет ранний возврат из функции и распакует сторону успеха. При возврате результата он должен быть заключен в Ok, чтобы указать успех, или в Err, чтобы указать на сбой.
Попробуй! Макрос вызывает типаж From, чтобы разрешить преобразование ошибок. Например, вы можете изменить возвращаемое значение с io::Error на MyError и реализовать преобразование из io::Error в MyError, реализовав трейт From, и он будет там автоматически вызываться.
В качестве альтернативы вы можете изменить возвращаемое значение с io::Error на Box
Если вы не хотите обрабатывать ошибку и вместо этого прерывать выполнение, вы можете использовать unwrap() результат. Таким образом вы получите значение успеха, и если результатом была ошибка, программа прервется.
Изменчивость и собственность
Часть, в которой Rust и Python становятся совершенно разными языками, - это концепция изменчивости и владения. Python - это язык со сборкой мусора, и в результате с объектами во время выполнения может происходить практически все. Вы можете свободно передавать их, и это будет «просто работать». Очевидно, что вы все еще можете создавать утечки памяти, но большинство проблем будут решены автоматически во время выполнения.
Однако в Rust нет сборщика мусора, но управление памятью по-прежнему работает автоматически. Это обеспечивается концепцией, известной как отслеживание владения. Все, что вы можете создать, принадлежит другому. Если вы хотите сравнить это с Python, вы можете представить, что все объекты в Python принадлежат интерпретатору. В Rust владение гораздо более местным. Вызов функций может иметь список объектов, и в этом случае объекты принадлежат списку, а список принадлежит области действия функции.
Более сложные сценарии владения могут быть выражены аннотациями времени жизни и сигнатурами функций. Например, в случае реализации Add в предыдущем примере получатель назывался self, как в Python. Однако в отличие от Python значение «перемещается» в функцию, тогда как в Python метод вызывается с изменяемой ссылкой. Это означает, что в Python вы можете сделать что-то вроде этого:
leaks = []
class MyType(object):
def __add__(self, other):
leaks.append(self)
return self
a = MyType() + MyType()
Каждый раз, когда вы добавляете экземпляр MyType к другому объекту, вы также переносите себя в глобальный список. Это означает, что если вы запустите приведенный выше пример, у вас будет две ссылки на первый экземпляр MyType: одна находится в утечках, а другая - в. В Rust это невозможно. Владелец может быть только один. Если вы добавите self в leaks, компилятор «переместит» значение туда, и вы не сможете вернуть его из функции, потому что оно уже было перемещено в другое место. Вам придется сначала переместить его обратно, чтобы вернуть (например, снова удалив его из списка).
Итак, что делать, если вам нужно иметь две ссылки на объект? Вы можете одолжить стоимость. У вас может быть неограниченное количество неизменяемых заимствований, но у вас может быть только одно изменяемое заимствование (и только если не было выдано неизменных заимствований).
Функции, которые работают с неизменяемыми заимствованиями, помечены как &self, а функции, которым требуется изменяемое заимствование, помечены как &mut self. Вы можете давать рекомендации только в том случае, если вы являетесь владельцем. Если вы хотите переместить значение из функции (например, вернув его), у вас не может быть никаких непогашенных ссуд, и вы не можете ссудить значения после того, как передали право собственности от себя.
Это большое изменение в том, как вы думаете о программах, но вы к этому привыкнете.
Заимствования во время выполнения и владельцы изменяемых компонентов
До сих пор почти все это отслеживание владения проверялось во время компиляции. Но что, если вы не можете подтвердить право собственности во время компиляции? В вашем распоряжении несколько вариантов. Одним из примеров является использование мьютекса. Мьютекс позволяет вам гарантировать во время выполнения, что только один человек имеет изменяемое заимствование для объекта, но сам мьютекс владеет объектом. Таким образом, вы можете написать код, который обращается к одному и тому же объекту, но только когда поток может получить к нему доступ одновременно.
В результате это также означает, что вы не можете случайно забыть использовать мьютекс и вызвать гонку данных. Он не компилируется.
Но что, если вы хотите программировать как на Python и не можете найти владельца памяти? В этом случае вы можете поместить объект в подсчитанную оболочку, на которую указывает ссылка, и таким образом предоставить его во время выполнения. Таким образом, вы очень приближаетесь к поведению Python только потому, что можете вызывать циклы. Python разбивает циклы в своем сборщике мусора, в Rust нет эквивалента.
Чтобы лучше показать это, давайте рассмотрим сложный пример Python и его эквивалент на Rust:
из threading import Lock, Thread
def fib(num):
if num < 2:
return 1
return fib(num - 2) + fib(num - 1)
def thread_prog(mutex, results, i):
rv = fib(i)
with mutex:
results[i] = rv
def main():
mutex = Lock()
results = {}
threads = []
for i in xrange(35):
thread = Thread(target=thread_prog, args=(mutex, results, i))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
for i, rv in sorted(results.items()):
print "fib({}) = {}".format(i, rv)
Итак, мы создаем 35 потоков и заставляем их вычислять ужасным образом, увеличивая числа Фибоначчи. Затем мы присоединяемся к потокам и печатаем отсортированные результаты. Здесь вы сразу заметите, что нет внутренней связи между мьютексом (блокировкой) и массивом результатов.
Вот пример Rust:
use std::sync::{Arc, Mutex};
use std::collections::BTreeMap;
use std::thread;
fn fib(num: u64) -> u64 {
if num < 2 { 1 } else { fib(num - 2) + fib(num - 1) }
}
fn main() {
let locked_results = Arc::new(Mutex::new(BTreeMap::new()));
let threads : Vec<_> = (0..35).map(|i| {
let locked_results = locked_results.clone();
thread::spawn(move || {
let rv = fib(i);
locked_results.lock().unwrap().insert(i, rv);
})
}).collect();
for thread in threads { thread.join().unwrap(); }
for (i, rv) in locked_results.lock().unwrap().iter() {
println!("fib({}) = {}", i, rv);
}
}
Большие отличия от версии Python здесь в том, что мы используем карту B-дерева вместо хеш-таблицы и помещаем ее в мьютекс Arc'ed. Что это? Прежде всего, мы используем B-дерево, потому что оно автоматически сортирует, что нам и нужно. Затем мы помещаем его в мьютекс, чтобы мы могли заблокировать его во время выполнения. Отношения установлены. Наконец, мы поместили его в дугу. Ссылка на дугу учитывает то, что в нее входит. В этом случае файл mutex. Это означает, что мы можем быть уверены, что мьютекс будет удален только после завершения работы последнего потока. Аккуратный.
Итак, вот как работает код: мы считаем до 20, как в Python, и для каждого из этих чисел запускаем локальную функцию. В отличие от Python, здесь мы можем использовать закрытие. Затем мы делаем копию дуги в локальном потоке. Это означает, что каждый поток видит свою собственную версию Arc (внутренне это увеличивает счетчик ссылок и автоматически уменьшает его, когда поток умирает). Затем мы создаем поток с локальной функцией. Движение говорит нам переместить замыкание в поток. Затем мы запускаем функцию Фибоначчи в каждом потоке. Когда мы блокируем нашу дугу, мы получаем результат, который можно развернуть и вставить. Игнорируйте на мгновение развертку, именно так вы превращаете явные результаты в панику. Однако дело в том, что вы можете получить карту результатов только тогда, когда разблокируете мьютекс. Заблокировать случайно нельзя!
Затем собираем все потоки в вектор. Наконец, мы перебираем все потоки, присоединяемся к ним и затем печатаем результаты.
Здесь следует отметить два момента: видимых типов очень мало. Конечно, есть дуга, и функция Фибоначчи принимает беззнаковые 64-битные целые числа, но кроме этого, никаких типов не видно. Мы также можем использовать здесь карту B-дерева вместо хеш-таблицы, потому что Rust предоставляет нам такой тип.
Итерация работает точно так же, как в Python. Единственная разница в том, что в Rust в этом случае нам нужно получить мьютекс, потому что компилятор не может знать, что потоки завершились, и мьютекс не нужен. Однако есть API, который этого не требует, просто он еще не стабилен в Rust 1.0.
Производительность выглядит примерно так, как вы ожидаете. (Этот пример намеренно ужасен, чтобы показать, как работает многопоточность.)
Юникод
Моя любимая тема: Unicode :) Здесь Rust и Python немного отличаются. Python (как 2, так и 3) имеют очень похожую модель Unicode, которая предназначена для сопоставления данных Unicode с массивами символов. Однако в Rust строки Unicode всегда хранятся как UTF-8. В прошлом я уже рассказывал, почему это решение намного лучше, чем то, что делают Python или C# (см. Также UCS vs UTF-8 как Internal String Encoding). Однако что очень интересно в Rust, так это то, как он справляется с уродливой реальностью нашего мира кодирования.
Во-первых, Rust прекрасно понимает, что API-интерфейсы операционных систем (как в Windows Unicode, так и в Linux без Unicode) довольно ужасны. Однако, в отличие от Python, он не пытается принудительно использовать Unicode в этих областях, вместо этого он имеет разные типы строк, которые могут (в пределах разумного) преобразовывать друг друга относительно дешево. Это очень хорошо работает на практике и делает операции со строками очень быстрыми.
Для подавляющего большинства программ кодирование / декодирование не требуется, потому что они принимают UTF-8, просто нужно запустить дешевую проверку валидации, обработать строки UTF-8 и затем не нуждаться в кодировании на выходе. Если им необходимо интегрироваться с Windows Unicode API, они внутренне используют кодировку WTF-8, которая довольно дешево может преобразовать в UCS2, например UTF-16, и обратно.
В любой момент вы можете конвертировать между Unicode и байтами и жевать байты по мере необходимости. Затем вы можете позже запустить этап проверки и убедиться, что все прошло, как задумано. Это делает написание протоколов действительно быстрым и действительно удобным. По сравнению с постоянным кодированием и декодированием, с которыми вам приходится иметь дело в Python, только для поддержки индексации строк O (1).
Помимо действительно хорошей модели хранения для Unicode, он также имеет множество API для работы с Unicode. Либо как часть языка, либо в отличном индексе crates.io. Это включает сворачивание регистра, категоризацию, регулярные выражения Unicode, нормализацию Unicode, хорошо соответствующие API-интерфейсы URI / IRI / URL, сегментацию или просто такие простые вещи, как сопоставление имен.
В чем обратная сторона? Вы не можете произносить «föo» [1] и ожидать, что «ö» вернется. Но в любом случае это плохая идея.
В качестве примера того, как работает взаимодействие с ОС, вот пример приложения, которое открывает файл в текущем рабочем каталоге и печатает его содержимое и имя файла:
use std::env;
use std::fs;
fn example() -> Result<(), Box<Error>> {
let here = try!(env::current_dir());
println!("Contents in: {}", here.display());
for entry in try!(fs::read_dir(&here)) {
let path = try!(entry).path();
let md = try!(fs::metadata(&path));
println!(" {} ({} bytes)", path.display(), md.len());
}
Ok(())
}
fn main() {
example().unwrap();
}
Все операции ввода-вывода используют эти объекты Path, которые также были показаны ранее, которые правильно инкапсулируют внутренний путь операционной системы. Они могут быть байтами, юникодом или чем-то еще, что использует операционная система, но их можно правильно отформатировать, вызвав для них .display(), которые возвращают объект, который может отформатировать себя в строку. Это удобно, потому что это означает, что вы никогда случайно не потеряете плохие строки, как, например, в Python 3. Есть четкое разделение проблем.
Распространение и библиотеки
Rust поставляется с комбинацией инструментов virtualenv + pip + setup, которая называется «Cargo». Что ж, не совсем virtualenv, поскольку по умолчанию он может работать только с одной версией Rust, но в остальном он работает так, как вы ожидаете. Даже лучше, чем в мире Python, вы можете зависеть от разных версий библиотек и от репозиториев git или индекса crates.io. Если вы получили Rust с веб-сайта, он поставляется с командой Cargo, которая делает все, что вы ожидаете.
Rust как замена Python?
Я не думаю, что между Python и Rust есть прямая связь. Например, Python блестит в научных вычислениях, и я не думаю, что это то, чем Rust сможет заняться в ближайшем будущем, просто из-за того, сколько работы это потребует. Точно так же нет смысла писать сценарии оболочки в Rust, если это можно сделать на Python. При этом я думаю, что, как и многие программисты на Python, которые начали изучать Go, еще больше людей начнут рассматривать Rust в некоторых областях, в которых они ранее использовали Python.
Это очень мощный язык, стоящий на прочном основании, с очень либеральной лицензией, с очень дружелюбным сообществом и демократическим подходом к языковому развитию.
Поскольку Rust требует очень небольшой поддержки во время выполнения, его очень легко использовать с помощью ctypes и CFFI с Python. Я вполне мог представить себе будущее, в котором будет пакет Python, который позволит распространять бинарный модуль, написанный на Rust и вызываемый из Python, без какой-либо дополнительной работы со стороны разработчика.