Rust глазами Python-разработчика

ly9tgy2i3tv7h6rovap4iwpoyd8.jpeg

Привет! Мы — часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). На протяжении трех лет мы поддерживаем и развиваем несколько больших python-приложений. Чуть больше года назад перед нами встала задача написать еще одно большое приложение — API к основному хранилищу новостей, и мы сделали это на Rust.

В статье мы расскажем о том, что заставило нас отойти от привычного стека технологий, и покажем, какие плюсы по сравнению с Python есть у Rust.

Мы не ответим на вопрос, почему выбор пал именно на Rust, а не Go, например, или на какой-либо другой язык. Также мы не будем сравнивать производительность Python- и Rust-приложений — эти темы достойны отдельного обсуждения.

Этот материал написали cbmw и AndreyErmilov


  1. Первая часть (типы, пользовательские типы и полиморфизм, перечисления, Option и Result, паттерн-матчинг, трейты и протоколы, обобщенное программирование)
  2. Вторая часть (многопоточность, асинхронность, функциональная парадигма и заключение — «Зачем же питонисту Rust») — готовится к публикации и выйдет чуть позже

Если не хочется читать эту статью или невтерпеж ждать второй части материала, можно посмотреть видео нашего выступления.

Первое различие, с которым сталкиваются разработчики, Rust — язык со статической типизацией.

Можно по-разному смотреть на динамическую и статическую типизацию, но, на наш взгляд, основное отличие демонстрирует изображение ниже:

wxlrinn5ushe2jeu9ih0ctnqlbe.png

В случае с 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 {
    items.pop()
}

И посмотрим, к чему приведет попытка его скомпилировать:

➜  types git:(master) ✗ cargo run
error[E0308]: mismatched types
  |
1 | fn last(mut items: Vec) -> i32 { 
  |     items.pop()
  |     ^^^^^^^^^^^ expected `i32`, 
  |                 found enum `std::option::Option`
  |
  = note: expected type `i32`
             found enum `std::option::Option`

Ошибка компиляции явно говорит о том, что метод .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 for Kilometer {
    type Output = KmPerHour;
    fn div(self, rhs: Hour) -> Self::Output {
        KmPerHour(self.0 / rhs.0)
    }
}
/// Speed, km/s
impl Div for Kilometer {
    type Output = KmPerSecond;
    fn div(self, rhs: Second) -> Self::Output {
        KmPerSecond(self.0 / rhs.0)
    }
}
/// Speed, m/h
impl Div for Meter {
    type Output = MeterPerHour;
    fn div(self, rhs: Hour) -> Self::Output {
        MeterPerHour(self.0 / rhs.0)
    }
}
/// Speed, m/s
impl Div 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),
    Active(i32),
    Inactive(i32),
    Deleted,
}

В примере для статуса Pending мы храним информацию о том, как долго мы ожидаем подтверждения от пользователя; для активного и неактивного пользователей храним их идентификаторы.

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

Возможность внутри вариантов перечислений хранить значения сильно влияет на то, как Rust-разработчики пишут код — перечисления являются одним из наиболее часто используемых возвращаемых типов. На их основе возникли типы Option и Result, про которые мы сейчас поговорим.

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

pub enum Option {
    None,
    Some(T),
}

pub enum Result {
    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 = 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.

szqyowlzfcrvp2wnifxxpq2uwos.png

В 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>) -> Vec> {
    documents
        .into_iter()
        .filter(|document| document.approved())
        .collect::>()
}

fn main() {
    let documents: Vec> = 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.

uvpgrqj20seppi-x6-k-c4uaowc.jpeg

К сожалению, разговоры про этот протокол шли еще в 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 
    where T: Ord
{
    pub value: T,
    pub left: Option>>,
    pub right: Option>>,
}

impl Node 
    where T: Ord 
{
    pub fn add(&mut self, node: Node) {
        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 же позволяет сделать это.

Продолжение следует ️

© Habrahabr.ru