[Python, Rust] Rust глазами Python-разработчика
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Привет! Мы – часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). На протяжении трех лет мы поддерживаем и развиваем несколько больших python-приложений. Чуть больше года назад перед нами встала задача написать еще одно большое приложение – API к основному хранилищу новостей, и мы сделали это на Rust.
В статье мы расскажем о том, что заставило нас отойти от привычного стека технологий, и покажем, какие плюсы по сравнению с Python есть у Rust.
Мы не ответим на вопрос, почему выбор пал именно на Rust, а не Go, например, или на какой-либо другой язык. Также мы не будем сравнивать производительность Python- и Rust-приложений – эти темы достойны отдельного обсуждения.
Этот материал написали cbmw и AndreyErmilov
Содержание:
- Первая часть (типы, пользовательские типы и полиморфизм, перечисления, Option и Result, паттерн-матчинг, трейты и протоколы, обобщенное программирование)
- Вторая часть (многопоточность, асинхронность, функциональная парадигма и заключение – «Зачем же питонисту Rust») – готовится к публикации и выйдет чуть позже
Если не хочется читать эту статью или невтерпеж ждать второй части материала, можно посмотреть видео нашего выступления.
Извините, данный ресурс не поддреживается. :(
Типы
Первое различие, с которым сталкиваются разработчики, Rust – язык со статической типизацией.
Можно по-разному смотреть на динамическую и статическую типизацию, но, на наш взгляд, основное отличие демонстрирует изображение ниже:
В случае с Python множество ошибок типизации мы видим уже на проде – в интерфейсе Sentry. В Rust такие ошибки отлавливаются еще на этапе сборки и это просходит, как правило, локально или в CI.
Учитывая, что ошибки, связанные с несоответствием типов, в наших приложениях составляют подавляющее большинство, статическая типизация Rust выглядит как достаточно весомый плюс. Можно было бы тут и остановиться, но многие, думаю, слышали, что в последнее время в Python активно развивается опциональная статическая типизация. Почему бы не попробовать проверить такие проблемы еще до их попадания в прод?
Тут на сцену выходит mypy, как самое зрелое решение в этой области. Сам создатель языка Python активно принимает участие в разработке mypy. И это замечательный инструмент, позволяющий проанализировать код и найти те самые проблемы с типизацией. Давайте рассмотрим его детально.
Начнем с крайне простого примера:
from typing import List
def last(items: List[int]) -> int:
return items.pop()
Этот код делает тривиальную штуку – забирает крайний правый элемент из списка и передает его в качестве возвращаемого значения функции.
С точки зрения mypy и нотации типов этот код является вполне корректным:
➜ mypy --strict types-01.py
Success: no issues found in 1 source file
А теперь давайте рассмотрим аналогичный код в Rust:
fn last(mut items: Vec<i32>) -> i32 {
items.pop()
}
И посмотрим, к чему приведет попытка его скомпилировать:
➜ types git:(master) ✗ cargo run
error[E0308]: mismatched types
|
1 | fn last(mut items: Vec<i32>) -> i32 {
| items.pop()
| ^^^^^^^^^^^ expected `i32`,
| found enum `std::option::Option`
|
= note: expected type `i32`
found enum `std::option::Option<i32>`
Ошибка компиляции явно говорит о том, что метод .pop() в каких-то случаях может вернуть None. И действительно, если мы в качестве аргумента передадим пустой вектор, так и произойдет.
Но почему mypy не предупредил нас о потенциальной ошибке? Дело в том, что в Python при пустом списке произойдёт Exception, который никак не учитывается и не отражается в нотации типов. Это кажется достаточно большой проблемой, которая не позволяет использовать возможности статической типизации в полной мере. В целом существование исключений и их игнорирование в системе нотации типов перекладывает ответственность за корректность кода на разработчика.
Отлично, давайте перепишем Python-код по аналогии с Rust, не вызывая исключения:
from typing import List, Optional
def last(array: List[int]) -> Optional[int]:
if len(array) == 0:
return None
return array.pop()
Такой код будет корректным и не вызовет исключений, однако многочисленные проверки очень сильно увеличивают кодовую базу и сводят на нет всю простоту и лаконичность, которой славится Python. Кроме того, идея отказа от исключений в Python выглядит инородно, поскольку это одна из концептуальных составляющих языка.
Да, безусловно, есть попытки осуществить это. Хороший пример – библиотека returns.
В целом она выглядит как хорошая попытка реализовать использующийся в Rust подход путем отказа от вызовов исключений. Это, в свою очередь, позволяет более безопасно с точки зрения типов описывать какую-то изолированную или бизнес-логику, что само по себе уже является огромным плюсом.
Пользовательские типы и полиморфизм
Типы являются не только способом избежать ошибок, но и удобными строительными блоками, которые помогают писать красивый и понятный код. Давайте посмотрим, как это работает в Rust.
Рассмотрим задачу. У нас есть разные сущности – расстояние, которое измеряется в километрах и метрах, и время, которое измеряется в часах и секундах. Мы хотим уметь получать скорость. Опишем структуры:
/// Distance, km
struct Kilometer(f64);
/// Distance, m
struct Meter(f64);
/// Time, h
struct Hour(f64);
/// Time, s
struct Second(f64);
/// Speed, km/h
struct KmPerHour(f64);
/// Speed, km/s
struct KmPerSecond(f64);
/// Speed, m/h
struct MeterPerHour(f64);
/// Speed, m/s
struct MeterPerSecond(f64);
Реализуем операцию деления для километров и метров и в каждом случае будем получать свой тип:
/// Speed, km/h
impl Div<Hour> for Kilometer {
type Output = KmPerHour;
fn div(self, rhs: Hour) -> Self::Output {
KmPerHour(self.0 / rhs.0)
}
}
/// Speed, km/s
impl Div<Second> for Kilometer {
type Output = KmPerSecond;
fn div(self, rhs: Second) -> Self::Output {
KmPerSecond(self.0 / rhs.0)
}
}
/// Speed, m/h
impl Div<Hour> for Meter {
type Output = MeterPerHour;
fn div(self, rhs: Hour) -> Self::Output {
MeterPerHour(self.0 / rhs.0)
}
}
/// Speed, m/s
impl Div<Second> for Meter {
type Output = MeterPerSecond;
fn div(self, rhs: Second) -> Self::Output {
MeterPerSecond(self.0 / rhs.0)
}
}
Проверим, что наш код работает. Rust в зависимости от типов, которые мы делим и на которые мы делим, определит, какого типа будет скорость.
fn main() {
let distance = Meter(100.);
let duration = Second(50.);
let speed = distance / duration; // MeterPerSecond
assert_eq!(speed.0, 2.);
let distance = Kilometer(180.);
let duration = Hour(3.);
let speed = distance / duration; // KmPerHour
assert_eq!(speed.0, 60.);
}
Реализуем тоже самое на Python.
Опишем структуры:
@dataclass
class Hour:
"""Time, h."""
value: float
@dataclass
class Second:
"""Second, s."""
value: float
@dataclass
class KmPerHour:
"""Speed, km/h."""
value: float
@dataclass
class KmPerSecond:
"""Speed, km/s."""
value: float
@dataclass
class MeterPerHour:
"""Speed, m/h."""
value: float
@dataclass
class MeterPerSecond:
"""Speed, m/s."""
value: float
Сделаем реализацию деления только для километров и представим, что сделали так же и для метров. Нам нужно использовать overload чтобы показать, как в зависимости от типа входного параметра меняется тип результата:
from typing import overload
@dataclass
class Kilometer:
value: float
@overload
def __truediv__(self, other: Hour) -> KmPerHour: ...
@overload
def __truediv__(self, other: Second) -> KmPerSecond: ...
def __truediv__(self,
other: Union[Hour, Second]
) -> Union[KmPerHour, KmPerSecond]:
if isinstance(other, Hour):
return KmPerHour(self.value / other.value)
elif isinstance(other, Second):
return KmPerSecond(self.value / other.value)
...
Проверим код, используя mypy:
➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file
А теперь случайно ошибемся в возвращаемом типе: при делении на секунды будем возвращать километры в час:
if isinstance(other, Hour):
return KmPerHour(self.value / other.value)
elif isinstance(other, Second):
return KmPerHour(self.value / other.value)
Запустим mypy:
➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file
Mypy не видит в коде с ошибкой никакой проблемы, потому что мы по-прежнему возвращаем одно из корректных значений, описанных в Union[KmPerHour, KmPerSecond].
Явно укажем, что ожидаем получить при делении на секунды именно км/с, и снова запустим mypy.
speed: KmPerSecond = Kilometer(1.0) / Second(1.0)
assert isinstance(speed, KmPerHour)
➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file
Понятно, почему это происходит, но не понятно, как избежать подобных ошибок с mypy.
Перечисления
Перечисления существуют во многих языках. Посмотрим, как в Python и Rust происходит работа с ними.
Создадим перечисление, описывающее возможные состояния пользователя:
from enum import Enum, auto
class UserStatus(Enum):
PENDING = auto()
ACTIVE = auto()
INACTIVE = auto()
DELETED = auto()
Сделаем тоже самое в Rust:
enum UserStatus {
Pending,
Active,
Inactive,
Deleted,
}
В это простом примере оба варианта выглядят одинаково. Но в Rust мы можем связать статус пользователя с дополнительной информацией.
enum UserStatus {
Pending(DateTime<Utc>),
Active(i32),
Inactive(i32),
Deleted,
}
В примере для статуса Pending мы храним информацию о том, как долго мы ожидаем подтверждения от пользователя; для активного и неактивного пользователей храним их идентификаторы.
Доставать находящиеся внутри перечисления типы мы можем с помощью паттерн-матчинга, про который поговорим чуть позже.
Возможность внутри вариантов перечислений хранить значения сильно влияет на то, как Rust-разработчики пишут код – перечисления являются одним из наиболее часто используемых возвращаемых типов. На их основе возникли типы Option и Result, про которые мы сейчас поговорим.
Option и Result
Мы уже встречались с типом Option, когда доставали из вектора с числами крайне правый элемент. Result похож на Option, но может содержать в себе два типа, а не один: успешный результат выполнения операции или ошибку.
pub enum Option<T> {
None,
Some(T),
}
pub enum Result<T, E> {
Ok(T),
Err(E),
}
Давайте на примере разберем, как использование Option влияет на корректность работы приложения.
let mut vec = vec![1, 2, 3];
let last_element = vec.pop();
assert_eq!(last_element, Some(3));
Когда мы достали из вектора правый элемент, то получили не число, а значение типа Option, содержащее в варианте Some нужное число. Мы не сможем его сложить с другим числом, т.к. в этом случае мы потерям информацию о возможном варианте None.
let mut vec = vec![1, 2, 3];
let last_element = vec.pop();
assert_eq!(last_element, Some(3));
let four = last_element + 1;
// Cannot add `std::option::Option<{integer}>` to `{integer}`
Чтобы использовать полученное из вектора число мы можем прибегнуть к паттерн-матчингу, который мы рассмотрим еще ниже. А сейчас проверим, как аналогичный код работает в Python. Мы используем написанную нами функцию last(), чтобы возвращаемый тип был Optional.
from typing import List, Optional
def last(array: List[int]) -> Optional[int]:
if len(array) == 0:
return None
return array.pop()
numbers = [1, 2, 3]
last_element = last(numbers)
four = last_element + 1
➜ 01-types poetry run mypy --strict typing-04-1.py
typing-04-1.py:12: error: Unsupported operand types for + ("None" and "int")
typing-04-1.py:12: note: Left operand is of type "Optional[int]"
Mypy, как и комплиятор Rust, не позволит нам сложить опциональное значение с числом. Но для этого программисту нужно будет самостоятельно указать, что возвращаемое значение Optional.
Паттерн-матчинг
Раз уж мы упомянули pattern-matching, давайте, наконец, раскроем эту концепцию чуть подробнее.
Для начала рассмотрим следующий Python-код:
class UserStatus(Enum):
PENDING = auto()
ACTIVE = auto()
INACTIVE = auto()
DELETED = auto()
def serialize(user_status: UserStatus) -> str:
if user_status == UserStatus.PENDING:
return 'Pending'
elif user_status == UserStatus.ACTIVE:
return 'Active'
elif user_status == UserStatus.INACTIVE:
return 'Inactive'
elif user_status == UserStatus.DELETED:
return 'Deleted'
Все, что этот код делает, – преобразует элементы перечесления UserStatus в строковое представление. Выглядит это достаточно просто.
А теперь рассмотрим аналогичный вариант на Rust:
enum UserStatus {
Pending,
Active,
Inactive,
Deleted,
}
fn serialize(user_status: UserStatus) -> &'static str {
match user_status {
UserStatus::Pending => "Pending",
UserStatus::Active => "Active",
UserStatus::Inactive => "Inactive",
UserStatus::Deleted => "Deleted",
}
}
Разница в том, что в случае, когда разработчик по какой-то причине (например, если добавляется новый статус пользователя при рефакторинге) не опишет один из исходных вариантов перечисления в функции serialize, Rust ему об этом скажет:
fn serialize(user_status: UserStatus) -> &'static str {
match user_status {
UserStatus::Pending => "Pending",
UserStatus::Active => "Active",
}
}
// Error: non-exhaustive patterns: `Inactive` and `Deleted` not covered
Это и есть одно из отличительных свойств pattern-matching в Rust. При его использовании в коде компилятор заставляет рассмотреть все варианты.
И возвращаясь к функции last, которую мы приводили в начале: при обработке Option, являющегося результатом вызова функции, компилятор не даст забыть обработать ситуацию, при которой результатом выполнения станет None.
Соответственно, аналогичное правило касается и типа Result:
let number = "5";
let parsed: Result<i32, ParseIntError> = number.parse();
let message = match parsed {
Ok(value) => format!("Number parsed successfully: {}", value),
Err(error) => format!("Can't parse a number. Error: {}", error),
};
assert_eq!(message, "Number parsed successfully: 5");
В случае если нам нужно определить некоторое дефолтное поведение, Rust предоставляет следующую конструкцию:
fn fibonacci(n: u32) -> u32 {
match n {
0 => 1,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
В этом примере мы видим, что описаны только два конкретных значения, а для всех остальных рекурсивно вызывается функция fibbonacci.
Трейты и протоколы
В этом разделе мы сравним возможности недавно появившихся в Python протоколов и трейтов Rust. Возможно не все используют протоколы, и чтобы сравнение было полезным, сделаем краткий обзор основных идей протоколов.
Представим, что нам нужно написать функцию-валидатор, которая принимает список экземпляров класса Image и возвращает список из булевых значений. True будет обозначать, что изображение валидное и весит не больше, чем MAX_SIZE, False – невалидное. Напишем код:
from typing import List
MAX_SIZE = 512_000
class Image:
def __init__(self, image: bytes) -> None:
self.image = image
def validate(images: List[Image]) -> List[bool]:
return [len(image) <= MAX_SIZE for image in images]
Если мы запустим mypy, то увидим следующую ошибку:
➜ 01-types poetry run mypy --strict p-01-2.py
p-01-2.py:8: error: Argument 1 to "len" has incompatible type "Image"; expected "Sized"
Found 1 error in 1 file (checked 1 source file)
Mypy сообщает, что ожидается класс типа Sized, а мы вместо этого передали Image. Из документации становится понятно: все, что реализует магический метод __len__, является Sized.
В Python мы давно привыкли к утиной типизации, и требование реализовать метод __len__ кажется вполне понятным. Сделаем это.
from typing import List
MAX_SIZE = 512_000
class Image:
def __init__(self, image: bytes) -> None:
self.image = image
def __len__(self) -> int:
return len(self.image)
def validate(images: List[Image]) -> List[bool]:
return [len(image) <= MAX_SIZE for image in images]
После добавления __len__ mypy определит код как корректный.
Итого – Sized это и есть протокол, а про наш класс Image можно сказать, что он реализует протокол Sized.
Но давайте рассмотрим тему протоколов немного подробнее и усложним задачу – будем валидировать различные документы по их статусу – были ли они проверены и можно ли их публиковать. Функция validate будет возвращать только те документы, которые прошли проверку.
from abc import abstractmethod
from typing import List, Protocol
class SupportsReview(Protocol):
@abstractmethod
def approved(self) -> bool: ...
class Article(SupportsReview):
def approved(self) -> bool:
return True
class PhotoGallery(SupportsReview):
def approved(self) -> bool:
return True
class Test(SupportsReview):
def approved(self) -> bool:
return True
def validate(documents: List[SupportsReview]) -> List[SupportsReview]:
return [
document for document in documents
if document.approved()
]
documents = [Article(), PhotoGallery(), Test()]
approved_documents = validate(documents)
assert len(approved_documents) == 3
В этом коде мы описываем протокол SupportsReview и валидатор работает со всеми классами, реализующими этот протокол. Если бы один из классов не поддерживал SupportsReview, то mypy сообщил бы, что в documents у нас есть значение неподходящего типа.
Сравнивая протоколы в Python с трейтами в Rust, мы увидим, что они очень похожи. Давайте напишем тоже самое на Rust.
Начнем с создания трейта Review:
trait Review {
fn approved(&self) -> bool;
}
Создадим структуры и реализуем для них трейт Review:
struct Article;
impl Review for Article {
fn approved(&self) -> bool {
true
}
}
struct PhotoGallery;
impl Review for PhotoGallery {
fn approved(&self) -> bool {
true
}
}
struct Test;
impl Review for Test {
fn approved(&self) -> bool {
true
}
}
Опишем функцию validate и запустим код:
fn validate(documents: Vec<Box<dyn Review>>) -> Vec<Box<dyn Review>> {
documents
.into_iter()
.filter(|document| document.approved())
.collect::<Vec<_>>()
}
fn main() {
let documents: Vec<Box<dyn Review>> = vec![
Box::new(Article),
Box::new(PhotoGallery),
Box::new(Test),
];
let approved = validate(documents);
assert_eq!(approved.len(), 3);
}
Код на Rust выглядит менее понятно, чем код на Python за счет появления типов Box и описания поддержки трейта Review, как dyn Review. Это важный момент – за все приходится платить, и это плата за статическую типизацию.
Обобщенное программирование
Мы обсудили протоколы и выяснили, что с их помощью мы можем накладывать ограничения на типы, с которым работаем. Но что делать, если нам нужно описать для типа более одного ограничения и указать, что при этом везде должен быть один и тот же тип? На помощь нам приходят дженерики. Рассмотрим, как строится работа с ними в Python и сравним с Rust.
Реализуем узел бинарного дерева поиска:
from typing import Generic, TypeVar, Optional
T = TypeVar('T')
class Node(Generic[T]):
def __init__(self, value: T,
left: Optional['Node'[T]] = None,
right: Optional['Node'[T]] = None,
) -> None:
self.value = value
self.left = left
self.right = right
if __name__ == '__main__':
root = Node(2)
root.left = Node(1)
root.right = Node(3)
Мы описали обобщенный тип T, который может храниться внутри узла. Запустим mypy и убедимся, что все корректно описано.
➜ 01-types poetry run mypy --strict generics-01-1.py
Success: no issues found in 1 source file
Ошибемся в одном значении внутри узла и посмотрим, как mypy отловит эту ошибку:
root = Node(2)
root.left = Node(1)
root.right = Node('Hello!') # Тут ошибка
При создании корня дерева mypy определил тип T как int и не должен позволить нам создать другой узел с типом str.
generics-01-1.py:18: error: Argument 1 to "Node" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
Mypy верно поймал ошибку.
Но достаточно ли нам для описания узла дерева текущего определения? На данный момент мы наложили только одно ограничение – все типы внутри дерева должны быть одинаковыми. Но чтобы реализовать бинарное дерево поиска, необходимо уметь сравнивать значения внутри. Например, сейчас мы можем в узлы положить None и при этом код будет определяться, как корректный.
Давайте наложим на тип T дополнительное ограничение – T должен реализовывать протокол сравнения. Поищем протокол Comparable.
К сожалению, разговоры про этот протокол шли еще в 2015 году, но он так и не появился. Реализуем его самостоятельно:
C = TypeVar('C')
class Comparable(Protocol):
def __lt__(self: C, other: C) -> bool: ...
def __gt__(self: C, other: C) -> bool: ...
def __le__(self: C, other: C) -> bool: ...
def __ge__(self: C, other: C) -> bool: ...
И добавим в бинарное дерево поиска:
...
T = TypeVar('T', bound=Comparable)
class Node(Generic[T]):
def __init__(self, value: T,
left: Optional['Node'[T]] = None,
right: Optional['Node'[T]] = None,
) -> None:
self.value = value
self.left = left
self.right = right
def add(self, node: 'Node'[T]) -> None:
if node.value <= self.value:
self.left = node
else:
self.right = node
if __name__ == '__main__':
root = Node(2)
root.add(Node(1))
root.add(Node(3))
Mypy проверяет код и подтверждает, что все корретно. Попробуем ошибиться и проверим, как mypy отловит ошибку:
root = Node(None)
root.add(Node(None))
root.add(Node(None))
➜ 01-types poetry run mypy --strict generics-01-4.py
generics-01-4.py:35: error: Value of type variable "T" of "Node" cannot be "None"
generics-01-4.py:36: error: Value of type variable "T" of "Node" cannot be "None"
generics-01-4.py:37: error: Value of type variable "T" of "Node" cannot be "None"
Found 3 errors in 1 file (checked 1 source file)
Ошибка поймана, все работает.
Теперь реализуем тоже самое на Rust.
struct Node<T>
where T: Ord
{
pub value: T,
pub left: Option<Box<Node<T>>>,
pub right: Option<Box<Node<T>>>,
}
impl<T> Node<T>
where T: Ord
{
pub fn add(&mut self, node: Node<T>) {
if node.value <= self.value {
self.left = Some(Box::new(node))
} else {
self.right = Some(Box::new(node))
}
}
}
fn main() {
let mut root = Node { value: 2, left: None, right: None };
let node_1 = Node { value: 1, left: None, right: None };
let node_3 = Node { value: 3, left: None, right: None };
root.add(node_1);
root.add(node_3);
}
Код похож на тот, который мы делали в Python, но трейт сравнения нам не нужно писать самостоятельно. Он уже есть, и мы просто описываем его where T: Ord.
Это отличие не кажется принципиальным, и можно сделать вывод, что протоколы и дженерики в Python не уступают Rust.
К сожалению, это не так.
from typing import TypeVar, Generic, Sized, Hashable
T = TypeVar('T', Hashable, Sized)
class Base(Generic[T]):
def __init__(self, bar: T):
self.bar: T = bar
class Child(Base[T]):
def __init__(self, bar: T):
super().__init__(bar)
На этот код mypy выведет:
➜ 01-types poetry run mypy --strict generics-01-6.py
generics-01-6.py:13: error: Argument 1 to "__init__"
of "Base" has incompatible type "Hashable"; expected "T"
generics-01-6.py:13: error: Argument 1 to "__init__"
of "Base" has incompatible type "Sized"; expected "T"
Found 2 errors in 1 file (checked 1 source file)
Этот пример скопирован из issue mypy на гитхабе и висит там уже достаточно давно.
Mypy – прекрасный проект, и работа, которая ведется над ним, достойна уважения и восхищения. Но пока опциональная статическая типизация в Python выглядит недостаточно мощным инструментом, позволяющим избавиться от всех ошибок, связанных с несоответствием типов. Rust же позволяет сделать это.
Продолжение следует ️
===========
Источник:
habr.com
===========
Похожие новости:
- [Информационная безопасность, Python, CTF] HackTheBox. Прохождение Laser. Jetdirect, RPC и кража SSH
- [Python, Программирование, Профессиональная литература] Классические задачи Computer Science на языке Python. Обзор книги (перевод)
- [Разработка под iOS] Как мы делаем App Clips?
- [Python, Визуализация данных, Машинное обучение] Мы скачали 10 миллионов Jupyter-ноутбуков с Github — и вот что мы выяснили
- [Python, Scala, Big Data] Big Data Tools EAP 12 Is Out: Experimental Python Support and Search Function in Zeppelin Notebooks
- [Python, IT-инфраструктура, Администрирование баз данных, DevOps, Микросервисы] Vault+Pydantic: продолжение саги, локальная разработка
- [Программирование, Rust] Rust crashcourse. Итераторы (перевод)
- [Разработка на Raspberry Pi, Робототехника, DIY или Сделай сам] Как мы сделали простого WebRTC робота в домашних условиях
- [Python, Django, Микросервисы, Kubernetes] Микросервисы на монолите
- [Python, IT-инфраструктура, Терминология IT] Немного про трекинг и сервис переходов Admitad
Теги для поиска: #_python, #_rust, #_rust, #_python, #_mypy, #_tipizatsija (типизация), #_blog_kompanii_rambler_group (
Блог компании Rambler Group
), #_python, #_rust
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 17:26
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Привет! Мы – часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). На протяжении трех лет мы поддерживаем и развиваем несколько больших python-приложений. Чуть больше года назад перед нами встала задача написать еще одно большое приложение – API к основному хранилищу новостей, и мы сделали это на Rust. В статье мы расскажем о том, что заставило нас отойти от привычного стека технологий, и покажем, какие плюсы по сравнению с Python есть у Rust. Мы не ответим на вопрос, почему выбор пал именно на Rust, а не Go, например, или на какой-либо другой язык. Также мы не будем сравнивать производительность Python- и Rust-приложений – эти темы достойны отдельного обсуждения. Этот материал написали cbmw и AndreyErmilov Содержание:
Если не хочется читать эту статью или невтерпеж ждать второй части материала, можно посмотреть видео нашего выступления. Извините, данный ресурс не поддреживается. :( Типы Первое различие, с которым сталкиваются разработчики, Rust – язык со статической типизацией. Можно по-разному смотреть на динамическую и статическую типизацию, но, на наш взгляд, основное отличие демонстрирует изображение ниже: В случае с Python множество ошибок типизации мы видим уже на проде – в интерфейсе Sentry. В Rust такие ошибки отлавливаются еще на этапе сборки и это просходит, как правило, локально или в CI. Учитывая, что ошибки, связанные с несоответствием типов, в наших приложениях составляют подавляющее большинство, статическая типизация Rust выглядит как достаточно весомый плюс. Можно было бы тут и остановиться, но многие, думаю, слышали, что в последнее время в Python активно развивается опциональная статическая типизация. Почему бы не попробовать проверить такие проблемы еще до их попадания в прод? Тут на сцену выходит mypy, как самое зрелое решение в этой области. Сам создатель языка Python активно принимает участие в разработке mypy. И это замечательный инструмент, позволяющий проанализировать код и найти те самые проблемы с типизацией. Давайте рассмотрим его детально. Начнем с крайне простого примера: from typing import List
def last(items: List[int]) -> int: return items.pop() Этот код делает тривиальную штуку – забирает крайний правый элемент из списка и передает его в качестве возвращаемого значения функции. С точки зрения mypy и нотации типов этот код является вполне корректным: ➜ mypy --strict types-01.py
Success: no issues found in 1 source file А теперь давайте рассмотрим аналогичный код в Rust: fn last(mut items: Vec<i32>) -> i32 {
items.pop() } И посмотрим, к чему приведет попытка его скомпилировать: ➜ types git:(master) ✗ cargo run
error[E0308]: mismatched types | 1 | fn last(mut items: Vec<i32>) -> i32 { | items.pop() | ^^^^^^^^^^^ expected `i32`, | found enum `std::option::Option` | = note: expected type `i32` found enum `std::option::Option<i32>` Ошибка компиляции явно говорит о том, что метод .pop() в каких-то случаях может вернуть None. И действительно, если мы в качестве аргумента передадим пустой вектор, так и произойдет. Но почему mypy не предупредил нас о потенциальной ошибке? Дело в том, что в Python при пустом списке произойдёт Exception, который никак не учитывается и не отражается в нотации типов. Это кажется достаточно большой проблемой, которая не позволяет использовать возможности статической типизации в полной мере. В целом существование исключений и их игнорирование в системе нотации типов перекладывает ответственность за корректность кода на разработчика. Отлично, давайте перепишем Python-код по аналогии с Rust, не вызывая исключения: from typing import List, Optional
def last(array: List[int]) -> Optional[int]: if len(array) == 0: return None return array.pop() Такой код будет корректным и не вызовет исключений, однако многочисленные проверки очень сильно увеличивают кодовую базу и сводят на нет всю простоту и лаконичность, которой славится Python. Кроме того, идея отказа от исключений в Python выглядит инородно, поскольку это одна из концептуальных составляющих языка. Да, безусловно, есть попытки осуществить это. Хороший пример – библиотека returns. В целом она выглядит как хорошая попытка реализовать использующийся в Rust подход путем отказа от вызовов исключений. Это, в свою очередь, позволяет более безопасно с точки зрения типов описывать какую-то изолированную или бизнес-логику, что само по себе уже является огромным плюсом. Пользовательские типы и полиморфизм Типы являются не только способом избежать ошибок, но и удобными строительными блоками, которые помогают писать красивый и понятный код. Давайте посмотрим, как это работает в Rust. Рассмотрим задачу. У нас есть разные сущности – расстояние, которое измеряется в километрах и метрах, и время, которое измеряется в часах и секундах. Мы хотим уметь получать скорость. Опишем структуры: /// Distance, km
struct Kilometer(f64); /// Distance, m struct Meter(f64); /// Time, h struct Hour(f64); /// Time, s struct Second(f64); /// Speed, km/h struct KmPerHour(f64); /// Speed, km/s struct KmPerSecond(f64); /// Speed, m/h struct MeterPerHour(f64); /// Speed, m/s struct MeterPerSecond(f64); Реализуем операцию деления для километров и метров и в каждом случае будем получать свой тип: /// Speed, km/h
impl Div<Hour> for Kilometer { type Output = KmPerHour; fn div(self, rhs: Hour) -> Self::Output { KmPerHour(self.0 / rhs.0) } } /// Speed, km/s impl Div<Second> for Kilometer { type Output = KmPerSecond; fn div(self, rhs: Second) -> Self::Output { KmPerSecond(self.0 / rhs.0) } } /// Speed, m/h impl Div<Hour> for Meter { type Output = MeterPerHour; fn div(self, rhs: Hour) -> Self::Output { MeterPerHour(self.0 / rhs.0) } } /// Speed, m/s impl Div<Second> for Meter { type Output = MeterPerSecond; fn div(self, rhs: Second) -> Self::Output { MeterPerSecond(self.0 / rhs.0) } } Проверим, что наш код работает. Rust в зависимости от типов, которые мы делим и на которые мы делим, определит, какого типа будет скорость. fn main() {
let distance = Meter(100.); let duration = Second(50.); let speed = distance / duration; // MeterPerSecond assert_eq!(speed.0, 2.); let distance = Kilometer(180.); let duration = Hour(3.); let speed = distance / duration; // KmPerHour assert_eq!(speed.0, 60.); } Реализуем тоже самое на Python. Опишем структуры: @dataclass
class Hour: """Time, h.""" value: float @dataclass class Second: """Second, s.""" value: float @dataclass class KmPerHour: """Speed, km/h.""" value: float @dataclass class KmPerSecond: """Speed, km/s.""" value: float @dataclass class MeterPerHour: """Speed, m/h.""" value: float @dataclass class MeterPerSecond: """Speed, m/s.""" value: float Сделаем реализацию деления только для километров и представим, что сделали так же и для метров. Нам нужно использовать overload чтобы показать, как в зависимости от типа входного параметра меняется тип результата: from typing import overload
@dataclass class Kilometer: value: float @overload def __truediv__(self, other: Hour) -> KmPerHour: ... @overload def __truediv__(self, other: Second) -> KmPerSecond: ... def __truediv__(self, other: Union[Hour, Second] ) -> Union[KmPerHour, KmPerSecond]: if isinstance(other, Hour): return KmPerHour(self.value / other.value) elif isinstance(other, Second): return KmPerSecond(self.value / other.value) ... Проверим код, используя mypy: ➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file А теперь случайно ошибемся в возвращаемом типе: при делении на секунды будем возвращать километры в час: if isinstance(other, Hour):
return KmPerHour(self.value / other.value) elif isinstance(other, Second): return KmPerHour(self.value / other.value) Запустим mypy: ➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file Mypy не видит в коде с ошибкой никакой проблемы, потому что мы по-прежнему возвращаем одно из корректных значений, описанных в Union[KmPerHour, KmPerSecond]. Явно укажем, что ожидаем получить при делении на секунды именно км/с, и снова запустим mypy. speed: KmPerSecond = Kilometer(1.0) / Second(1.0)
assert isinstance(speed, KmPerHour) ➜ 01-types poetry run mypy --strict typing-02-2.py
Success: no issues found in 1 source file Понятно, почему это происходит, но не понятно, как избежать подобных ошибок с mypy. Перечисления Перечисления существуют во многих языках. Посмотрим, как в Python и Rust происходит работа с ними. Создадим перечисление, описывающее возможные состояния пользователя: from enum import Enum, auto
class UserStatus(Enum): PENDING = auto() ACTIVE = auto() INACTIVE = auto() DELETED = auto() Сделаем тоже самое в Rust: enum UserStatus {
Pending, Active, Inactive, Deleted, } В это простом примере оба варианта выглядят одинаково. Но в Rust мы можем связать статус пользователя с дополнительной информацией. enum UserStatus {
Pending(DateTime<Utc>), Active(i32), Inactive(i32), Deleted, } В примере для статуса Pending мы храним информацию о том, как долго мы ожидаем подтверждения от пользователя; для активного и неактивного пользователей храним их идентификаторы. Доставать находящиеся внутри перечисления типы мы можем с помощью паттерн-матчинга, про который поговорим чуть позже. Возможность внутри вариантов перечислений хранить значения сильно влияет на то, как Rust-разработчики пишут код – перечисления являются одним из наиболее часто используемых возвращаемых типов. На их основе возникли типы Option и Result, про которые мы сейчас поговорим. Option и Result Мы уже встречались с типом Option, когда доставали из вектора с числами крайне правый элемент. Result похож на Option, но может содержать в себе два типа, а не один: успешный результат выполнения операции или ошибку. pub enum Option<T> {
None, Some(T), } pub enum Result<T, E> { Ok(T), Err(E), } Давайте на примере разберем, как использование Option влияет на корректность работы приложения. let mut vec = vec![1, 2, 3];
let last_element = vec.pop(); assert_eq!(last_element, Some(3)); Когда мы достали из вектора правый элемент, то получили не число, а значение типа Option, содержащее в варианте Some нужное число. Мы не сможем его сложить с другим числом, т.к. в этом случае мы потерям информацию о возможном варианте None. let mut vec = vec![1, 2, 3];
let last_element = vec.pop(); assert_eq!(last_element, Some(3)); let four = last_element + 1; // Cannot add `std::option::Option<{integer}>` to `{integer}` Чтобы использовать полученное из вектора число мы можем прибегнуть к паттерн-матчингу, который мы рассмотрим еще ниже. А сейчас проверим, как аналогичный код работает в Python. Мы используем написанную нами функцию last(), чтобы возвращаемый тип был Optional. from typing import List, Optional
def last(array: List[int]) -> Optional[int]: if len(array) == 0: return None return array.pop() numbers = [1, 2, 3] last_element = last(numbers) four = last_element + 1 ➜ 01-types poetry run mypy --strict typing-04-1.py
typing-04-1.py:12: error: Unsupported operand types for + ("None" and "int") typing-04-1.py:12: note: Left operand is of type "Optional[int]" Mypy, как и комплиятор Rust, не позволит нам сложить опциональное значение с числом. Но для этого программисту нужно будет самостоятельно указать, что возвращаемое значение Optional. Паттерн-матчинг Раз уж мы упомянули pattern-matching, давайте, наконец, раскроем эту концепцию чуть подробнее. Для начала рассмотрим следующий Python-код: class UserStatus(Enum):
PENDING = auto() ACTIVE = auto() INACTIVE = auto() DELETED = auto() def serialize(user_status: UserStatus) -> str: if user_status == UserStatus.PENDING: return 'Pending' elif user_status == UserStatus.ACTIVE: return 'Active' elif user_status == UserStatus.INACTIVE: return 'Inactive' elif user_status == UserStatus.DELETED: return 'Deleted' Все, что этот код делает, – преобразует элементы перечесления UserStatus в строковое представление. Выглядит это достаточно просто. А теперь рассмотрим аналогичный вариант на Rust: enum UserStatus {
Pending, Active, Inactive, Deleted, } fn serialize(user_status: UserStatus) -> &'static str { match user_status { UserStatus::Pending => "Pending", UserStatus::Active => "Active", UserStatus::Inactive => "Inactive", UserStatus::Deleted => "Deleted", } } Разница в том, что в случае, когда разработчик по какой-то причине (например, если добавляется новый статус пользователя при рефакторинге) не опишет один из исходных вариантов перечисления в функции serialize, Rust ему об этом скажет: fn serialize(user_status: UserStatus) -> &'static str {
match user_status { UserStatus::Pending => "Pending", UserStatus::Active => "Active", } } // Error: non-exhaustive patterns: `Inactive` and `Deleted` not covered Это и есть одно из отличительных свойств pattern-matching в Rust. При его использовании в коде компилятор заставляет рассмотреть все варианты. И возвращаясь к функции last, которую мы приводили в начале: при обработке Option, являющегося результатом вызова функции, компилятор не даст забыть обработать ситуацию, при которой результатом выполнения станет None. Соответственно, аналогичное правило касается и типа Result: let number = "5";
let parsed: Result<i32, ParseIntError> = number.parse(); let message = match parsed { Ok(value) => format!("Number parsed successfully: {}", value), Err(error) => format!("Can't parse a number. Error: {}", error), }; assert_eq!(message, "Number parsed successfully: 5"); В случае если нам нужно определить некоторое дефолтное поведение, Rust предоставляет следующую конструкцию: fn fibonacci(n: u32) -> u32 {
match n { 0 => 1, 1 => 1, _ => fibonacci(n - 1) + fibonacci(n - 2), } } В этом примере мы видим, что описаны только два конкретных значения, а для всех остальных рекурсивно вызывается функция fibbonacci. Трейты и протоколы В этом разделе мы сравним возможности недавно появившихся в Python протоколов и трейтов Rust. Возможно не все используют протоколы, и чтобы сравнение было полезным, сделаем краткий обзор основных идей протоколов. Представим, что нам нужно написать функцию-валидатор, которая принимает список экземпляров класса Image и возвращает список из булевых значений. True будет обозначать, что изображение валидное и весит не больше, чем MAX_SIZE, False – невалидное. Напишем код: from typing import List
MAX_SIZE = 512_000 class Image: def __init__(self, image: bytes) -> None: self.image = image def validate(images: List[Image]) -> List[bool]: return [len(image) <= MAX_SIZE for image in images] Если мы запустим mypy, то увидим следующую ошибку: ➜ 01-types poetry run mypy --strict p-01-2.py
p-01-2.py:8: error: Argument 1 to "len" has incompatible type "Image"; expected "Sized" Found 1 error in 1 file (checked 1 source file) Mypy сообщает, что ожидается класс типа Sized, а мы вместо этого передали Image. Из документации становится понятно: все, что реализует магический метод __len__, является Sized. В Python мы давно привыкли к утиной типизации, и требование реализовать метод __len__ кажется вполне понятным. Сделаем это. from typing import List
MAX_SIZE = 512_000 class Image: def __init__(self, image: bytes) -> None: self.image = image def __len__(self) -> int: return len(self.image) def validate(images: List[Image]) -> List[bool]: return [len(image) <= MAX_SIZE for image in images] После добавления __len__ mypy определит код как корректный. Итого – Sized это и есть протокол, а про наш класс Image можно сказать, что он реализует протокол Sized. Но давайте рассмотрим тему протоколов немного подробнее и усложним задачу – будем валидировать различные документы по их статусу – были ли они проверены и можно ли их публиковать. Функция validate будет возвращать только те документы, которые прошли проверку. from abc import abstractmethod
from typing import List, Protocol class SupportsReview(Protocol): @abstractmethod def approved(self) -> bool: ... class Article(SupportsReview): def approved(self) -> bool: return True class PhotoGallery(SupportsReview): def approved(self) -> bool: return True class Test(SupportsReview): def approved(self) -> bool: return True def validate(documents: List[SupportsReview]) -> List[SupportsReview]: return [ document for document in documents if document.approved() ] documents = [Article(), PhotoGallery(), Test()] approved_documents = validate(documents) assert len(approved_documents) == 3 В этом коде мы описываем протокол SupportsReview и валидатор работает со всеми классами, реализующими этот протокол. Если бы один из классов не поддерживал SupportsReview, то mypy сообщил бы, что в documents у нас есть значение неподходящего типа. Сравнивая протоколы в Python с трейтами в Rust, мы увидим, что они очень похожи. Давайте напишем тоже самое на Rust. Начнем с создания трейта Review: trait Review {
fn approved(&self) -> bool; } Создадим структуры и реализуем для них трейт Review: struct Article;
impl Review for Article { fn approved(&self) -> bool { true } } struct PhotoGallery; impl Review for PhotoGallery { fn approved(&self) -> bool { true } } struct Test; impl Review for Test { fn approved(&self) -> bool { true } } Опишем функцию validate и запустим код: fn validate(documents: Vec<Box<dyn Review>>) -> Vec<Box<dyn Review>> {
documents .into_iter() .filter(|document| document.approved()) .collect::<Vec<_>>() } fn main() { let documents: Vec<Box<dyn Review>> = vec![ Box::new(Article), Box::new(PhotoGallery), Box::new(Test), ]; let approved = validate(documents); assert_eq!(approved.len(), 3); } Код на Rust выглядит менее понятно, чем код на Python за счет появления типов Box и описания поддержки трейта Review, как dyn Review. Это важный момент – за все приходится платить, и это плата за статическую типизацию. Обобщенное программирование Мы обсудили протоколы и выяснили, что с их помощью мы можем накладывать ограничения на типы, с которым работаем. Но что делать, если нам нужно описать для типа более одного ограничения и указать, что при этом везде должен быть один и тот же тип? На помощь нам приходят дженерики. Рассмотрим, как строится работа с ними в Python и сравним с Rust. Реализуем узел бинарного дерева поиска: from typing import Generic, TypeVar, Optional
T = TypeVar('T') class Node(Generic[T]): def __init__(self, value: T, left: Optional['Node'[T]] = None, right: Optional['Node'[T]] = None, ) -> None: self.value = value self.left = left self.right = right if __name__ == '__main__': root = Node(2) root.left = Node(1) root.right = Node(3) Мы описали обобщенный тип T, который может храниться внутри узла. Запустим mypy и убедимся, что все корректно описано. ➜ 01-types poetry run mypy --strict generics-01-1.py
Success: no issues found in 1 source file Ошибемся в одном значении внутри узла и посмотрим, как mypy отловит эту ошибку: root = Node(2)
root.left = Node(1) root.right = Node('Hello!') # Тут ошибка При создании корня дерева mypy определил тип T как int и не должен позволить нам создать другой узел с типом str. generics-01-1.py:18: error: Argument 1 to "Node" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file) Mypy верно поймал ошибку. Но достаточно ли нам для описания узла дерева текущего определения? На данный момент мы наложили только одно ограничение – все типы внутри дерева должны быть одинаковыми. Но чтобы реализовать бинарное дерево поиска, необходимо уметь сравнивать значения внутри. Например, сейчас мы можем в узлы положить None и при этом код будет определяться, как корректный. Давайте наложим на тип T дополнительное ограничение – T должен реализовывать протокол сравнения. Поищем протокол Comparable. К сожалению, разговоры про этот протокол шли еще в 2015 году, но он так и не появился. Реализуем его самостоятельно: C = TypeVar('C')
class Comparable(Protocol): def __lt__(self: C, other: C) -> bool: ... def __gt__(self: C, other: C) -> bool: ... def __le__(self: C, other: C) -> bool: ... def __ge__(self: C, other: C) -> bool: ... И добавим в бинарное дерево поиска: ...
T = TypeVar('T', bound=Comparable) class Node(Generic[T]): def __init__(self, value: T, left: Optional['Node'[T]] = None, right: Optional['Node'[T]] = None, ) -> None: self.value = value self.left = left self.right = right def add(self, node: 'Node'[T]) -> None: if node.value <= self.value: self.left = node else: self.right = node if __name__ == '__main__': root = Node(2) root.add(Node(1)) root.add(Node(3)) Mypy проверяет код и подтверждает, что все корретно. Попробуем ошибиться и проверим, как mypy отловит ошибку: root = Node(None)
root.add(Node(None)) root.add(Node(None)) ➜ 01-types poetry run mypy --strict generics-01-4.py
generics-01-4.py:35: error: Value of type variable "T" of "Node" cannot be "None" generics-01-4.py:36: error: Value of type variable "T" of "Node" cannot be "None" generics-01-4.py:37: error: Value of type variable "T" of "Node" cannot be "None" Found 3 errors in 1 file (checked 1 source file) Ошибка поймана, все работает. Теперь реализуем тоже самое на Rust. struct Node<T>
where T: Ord { pub value: T, pub left: Option<Box<Node<T>>>, pub right: Option<Box<Node<T>>>, } impl<T> Node<T> where T: Ord { pub fn add(&mut self, node: Node<T>) { if node.value <= self.value { self.left = Some(Box::new(node)) } else { self.right = Some(Box::new(node)) } } } fn main() { let mut root = Node { value: 2, left: None, right: None }; let node_1 = Node { value: 1, left: None, right: None }; let node_3 = Node { value: 3, left: None, right: None }; root.add(node_1); root.add(node_3); } Код похож на тот, который мы делали в Python, но трейт сравнения нам не нужно писать самостоятельно. Он уже есть, и мы просто описываем его where T: Ord. Это отличие не кажется принципиальным, и можно сделать вывод, что протоколы и дженерики в Python не уступают Rust. К сожалению, это не так. from typing import TypeVar, Generic, Sized, Hashable
T = TypeVar('T', Hashable, Sized) class Base(Generic[T]): def __init__(self, bar: T): self.bar: T = bar class Child(Base[T]): def __init__(self, bar: T): super().__init__(bar) На этот код mypy выведет: ➜ 01-types poetry run mypy --strict generics-01-6.py
generics-01-6.py:13: error: Argument 1 to "__init__" of "Base" has incompatible type "Hashable"; expected "T" generics-01-6.py:13: error: Argument 1 to "__init__" of "Base" has incompatible type "Sized"; expected "T" Found 2 errors in 1 file (checked 1 source file) Этот пример скопирован из issue mypy на гитхабе и висит там уже достаточно давно. Mypy – прекрасный проект, и работа, которая ведется над ним, достойна уважения и восхищения. Но пока опциональная статическая типизация в Python выглядит недостаточно мощным инструментом, позволяющим избавиться от всех ошибок, связанных с несоответствием типов. Rust же позволяет сделать это. Продолжение следует ️ =========== Источник: habr.com =========== Похожие новости:
Блог компании Rambler Group ), #_python, #_rust |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 17:26
Часовой пояс: UTC + 5