
Привет! Мы – часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). На протяжении трех лет мы поддерживаем и развиваем несколько больших python-приложений. Чуть больше года назад перед нами встала задача написать еще одно большое приложение – API к основному хранилищу новостей, и мы сделали это на Rust.
В статье мы расскажем о том, что заставило нас отойти от привычного стека технологий, и покажем, какие плюсы по сравнению с Python есть у Rust.
Мы не ответим на вопрос, почему выбор пал именно на Rust, а не Go, например, или на какой-либо другой язык. Также мы не будем сравнивать производительность Python- и Rust-приложений – эти темы достойны отдельного обсуждения.
Этот материал написали cbmw и AndreyErmilov
Содержание:
- Первая часть (типы, пользовательские типы и полиморфизм, перечисления, Option и Result, паттерн-матчинг, трейты и протоколы, обобщенное программирование)
- Вторая часть (многопоточность, асинхронность, функциональная парадигма и заключение – «Зачем же питонисту Rust») – готовится к публикации и выйдет чуть позже
Если не хочется читать эту статью или невтерпеж ждать второй части материала, можно посмотреть видео нашего выступления.
Типы
Первое различие, с которым сталкиваются разработчики, Rust – язык со статической типизацией.
Можно по-разному смотреть на динамическую и статическую типизацию, но, на наш взгляд, основное отличие демонстрирует изображение ниже:

В случае с Python множество ошибок типизации мы видим уже на проде – в интерфейсе Sentry. В Rust такие ошибки отлавливаются еще на этапе сборки и это просходит, как правило, локально или в CI.
Учитывая, что ошибки, связанные с несоответствием типов, в наших приложениях составляют подавляющее большинство, статическая типизация Rust выглядит как достаточно весомый плюс. Можно было бы тут и остановиться, но многие, думаю, слышали, что в последнее время в Python активно развивается опциональная статическая типизация. Почему бы не попробовать проверить такие проблемы еще до их попадания в прод?
Тут на сцену выходит mypy, как самое зрелое решение в этой области. Сам создатель языка Python активно принимает участие в разработке mypy. И это замечательный инструмент, позволяющий проанализировать код и найти те самые проблемы с типизацией. Давайте рассмотрим его детально.
Начнем с крайне простого примера:
Этот код делает тривиальную штуку – забирает крайний правый элемент из списка и передает его в качестве возвращаемого значения функции.
С точки зрения mypy и нотации типов этот код является вполне корректным:
А теперь давайте рассмотрим аналогичный код в Rust:
И посмотрим, к чему приведет попытка его скомпилировать:
Ошибка компиляции явно говорит о том, что метод .pop()
в каких-то случаях может вернуть None. И действительно, если мы в качестве аргумента передадим пустой вектор, так и произойдет.
Но почему mypy не предупредил нас о потенциальной ошибке? Дело в том, что в Python при пустом списке произойдёт Exception, который никак не учитывается и не отражается в нотации типов. Это кажется достаточно большой проблемой, которая не позволяет использовать возможности статической типизации в полной мере. В целом существование исключений и их игнорирование в системе нотации типов перекладывает ответственность за корректность кода на разработчика.
Отлично, давайте перепишем Python-код по аналогии с Rust, не вызывая исключения:
Такой код будет корректным и не вызовет исключений, однако многочисленные проверки очень сильно увеличивают кодовую базу и сводят на нет всю простоту и лаконичность, которой славится Python. Кроме того, идея отказа от исключений в Python выглядит инородно, поскольку это одна из концептуальных составляющих языка.
Да, безусловно, есть попытки осуществить это. Хороший пример – библиотека returns.
В целом она выглядит как хорошая попытка реализовать использующийся в Rust подход путем отказа от вызовов исключений. Это, в свою очередь, позволяет более безопасно с точки зрения типов описывать какую-то изолированную или бизнес-логику, что само по себе уже является огромным плюсом.
Пользовательские типы и полиморфизм
Типы являются не только способом избежать ошибок, но и удобными строительными блоками, которые помогают писать красивый и понятный код. Давайте посмотрим, как это работает в Rust.
Рассмотрим задачу. У нас есть разные сущности – расстояние, которое измеряется в километрах и метрах, и время, которое измеряется в часах и секундах. Мы хотим уметь получать скорость. Опишем структуры:
Реализуем операцию деления для километров и метров и в каждом случае будем получать свой тип:
Проверим, что наш код работает. Rust в зависимости от типов, которые мы делим и на которые мы делим, определит, какого типа будет скорость.
Реализуем тоже самое на Python.
Опишем структуры:
Сделаем реализацию деления только для километров и представим, что сделали так же и для метров. Нам нужно использовать overload
чтобы показать, как в зависимости от типа входного параметра меняется тип результата:
Проверим код, используя mypy:
А теперь случайно ошибемся в возвращаемом типе: при делении на секунды будем возвращать километры в час:
Запустим mypy:
Mypy не видит в коде с ошибкой никакой проблемы, потому что мы по-прежнему возвращаем одно из корректных значений, описанных в Union[KmPerHour, KmPerSecond]
.
Явно укажем, что ожидаем получить при делении на секунды именно км/с, и снова запустим mypy.
Понятно, почему это происходит, но не понятно, как избежать подобных ошибок с mypy.
Перечисления
Перечисления существуют во многих языках. Посмотрим, как в Python и Rust происходит работа с ними.
Создадим перечисление, описывающее возможные состояния пользователя:
Сделаем тоже самое в Rust:
В это простом примере оба варианта выглядят одинаково. Но в Rust мы можем связать статус пользователя с дополнительной информацией.
В примере для статуса Pending
мы храним информацию о том, как долго мы ожидаем подтверждения от пользователя; для активного и неактивного пользователей храним их идентификаторы.
Доставать находящиеся внутри перечисления типы мы можем с помощью паттерн-матчинга, про который поговорим чуть позже.
Возможность внутри вариантов перечислений хранить значения сильно влияет на то, как Rust-разработчики пишут код – перечисления являются одним из наиболее часто используемых возвращаемых типов. На их основе возникли типы Option
и Result
, про которые мы сейчас поговорим.
Option и Result
Мы уже встречались с типом Option
, когда доставали из вектора с числами крайне правый элемент. Result
похож на Option
, но может содержать в себе два типа, а не один: успешный результат выполнения операции или ошибку.
Давайте на примере разберем, как использование Option
влияет на корректность работы приложения.
Когда мы достали из вектора правый элемент, то получили не число, а значение типа Option
, содержащее в варианте Some
нужное число. Мы не сможем его сложить с другим числом, т.к. в этом случае мы потерям информацию о возможном варианте None
.
Чтобы использовать полученное из вектора число мы можем прибегнуть к паттерн-матчингу, который мы рассмотрим еще ниже. А сейчас проверим, как аналогичный код работает в Python. Мы используем написанную нами функцию last()
, чтобы возвращаемый тип был Optional
.
Mypy, как и комплиятор Rust, не позволит нам сложить опциональное значение с числом. Но для этого программисту нужно будет самостоятельно указать, что возвращаемое значение Optional
.
Паттерн-матчинг
Раз уж мы упомянули pattern-matching, давайте, наконец, раскроем эту концепцию чуть подробнее.
Для начала рассмотрим следующий Python-код:
Все, что этот код делает, – преобразует элементы перечисления UserStatus в строковое представление. Выглядит это достаточно просто.
А теперь рассмотрим аналогичный вариант на Rust:
Разница в том, что в случае, когда разработчик по какой-то причине (например, если добавляется новый статус пользователя при рефакторинге) не опишет один из исходных вариантов перечисления в функции serialize, Rust ему об этом скажет:
Это и есть одно из отличительных свойств pattern-matching в Rust. При его использовании в коде компилятор заставляет рассмотреть все варианты.
И возвращаясь к функции last
, которую мы приводили в начале: при обработке Option, являющегося результатом вызова функции, компилятор не даст забыть обработать ситуацию, при которой результатом выполнения станет None.
Соответственно, аналогичное правило касается и типа Result
:
В случае если нам нужно определить некоторое дефолтное поведение, Rust предоставляет следующую конструкцию:
В этом примере мы видим, что описаны только два конкретных значения, а для всех остальных рекурсивно вызывается функция fibbonacci
.
Трейты и протоколы
В этом разделе мы сравним возможности недавно появившихся в Python протоколов и трейтов Rust. Возможно, не все используют протоколы, и чтобы сравнение было полезным, сделаем краткий обзор основных идей протоколов.
Представим, что нам нужно написать функцию-валидатор, которая принимает список экземпляров класса Image
и возвращает список из булевых значений. True
будет обозначать, что изображение валидное и весит не больше, чем MAX_SIZE
, False
– невалидное. Напишем код:
Если мы запустим mypy, то увидим следующую ошибку:
Mypy сообщает, что ожидается класс типа Sized
, а мы вместо этого передали Image
. Из документации становится понятно: все, что реализует магический метод __len__
, является Sized
.

В Python мы давно привыкли к утиной типизации, и требование реализовать метод __len__
кажется вполне понятным. Сделаем это.
После добавления __len__
mypy определит код как корректный.
Итого – Sized
это и есть протокол, а про наш класс Image
можно сказать, что он реализует протокол Sized
.
Но давайте рассмотрим тему протоколов немного подробнее и усложним задачу – будем валидировать различные документы по их статусу – были ли они проверены и можно ли их публиковать. Функция validate
будет возвращать только те документы, которые прошли проверку.
В этом коде мы описываем протокол SupportsReview
и валидатор работает со всеми классами, реализующими этот протокол. Если бы один из классов не поддерживал SupportsReview
, то mypy сообщил бы, что в documents
у нас есть значение неподходящего типа.
Сравнивая протоколы в Python с трейтами в Rust, мы увидим, что они очень похожи. Давайте напишем тоже самое на Rust.
Начнем с создания трейта Review
:
Создадим структуры и реализуем для них трейт Review
:
Опишем функцию validate
и запустим код:
Код на Rust выглядит менее понятно, чем код на Python за счет появления типов Box
и описания поддержки трейта Review
, как dyn Review
. Это важный момент – за все приходится платить, и это плата за статическую типизацию.
Обобщенное программирование
Мы обсудили протоколы и выяснили, что с их помощью мы можем накладывать ограничения на типы, с которым работаем. Но что делать, если нам нужно описать для типа более одного ограничения и указать, что при этом везде должен быть один и тот же тип? На помощь нам приходят дженерики. Рассмотрим, как строится работа с ними в Python и сравним с Rust.
Реализуем узел бинарного дерева поиска:
Мы описали обобщенный тип T
, который может храниться внутри узла. Запустим mypy и убедимся, что все корректно описано.
Ошибемся в одном значении внутри узла и посмотрим, как mypy отловит эту ошибку:
При создании корня дерева mypy определил тип T
как int
и не должен позволить нам создать другой узел с типом str
.
Mypy верно поймал ошибку.
Но достаточно ли нам для описания узла дерева текущего определения? На данный момент мы наложили только одно ограничение – все типы внутри дерева должны быть одинаковыми. Но чтобы реализовать бинарное дерево поиска, необходимо уметь сравнивать значения внутри. Например, сейчас мы можем в узлы положить None
и при этом код будет определяться, как корректный.
Давайте наложим на тип T
дополнительное ограничение – T
должен реализовывать протокол сравнения. Поищем протокол Comparable
.

К сожалению, разговоры про этот протокол шли еще в 2015 году, но он так и не появился. Реализуем его самостоятельно:
И добавим в бинарное дерево поиска:
Mypy проверяет код и подтверждает, что все корретно. Попробуем ошибиться и проверим, как mypy отловит ошибку:
Ошибка поймана, все работает.
Теперь реализуем тоже самое на Rust.
Код похож на тот, который мы делали в Python, но трейт сравнения нам не нужно писать самостоятельно. Он уже есть, и мы просто описываем его where T: Ord
.
Это отличие не кажется принципиальным, и можно сделать вывод, что протоколы и дженерики в Python не уступают Rust.
К сожалению, это не так.
На этот код mypy выведет:
Этот пример скопирован из issue mypy на гитхабе и висит там уже достаточно давно.
Mypy – прекрасный проект, и работа, которая ведется над ним, достойна уважения и восхищения. Но пока опциональная статическая типизация в Python выглядит недостаточно мощным инструментом, позволяющим избавиться от всех ошибок, связанных с несоответствием типов. Rust же позволяет сделать это.
Продолжение следует ️