Снова привет! Мы – @cbmw и @AndreyErmilov, часть команды разработки «Рамблер/Медиа» (портал «Рамблер»). И это вторая часть наших размышлений по поводу сравнения Python и Rust (первая часть). Если не хочется читать эту статью или лень возвращаться к первой части материала, можно посмотреть видео нашего выступления: Интересно, что использование типизации в Rust не ограничивается только тем, о чем мы говорили в первой части. Одна из областей, в которой хорошо видны ее преимущества, – многопоточное программирование. Для начала давайте рассмотрим достаточно классический пример: Этот код наглядно иллюстрирует ошибку «состояние гонки». Разберем чуть подробнее, что тут происходит. В коде мы видим запуск двух потоков, которые в цикле инкрементируют значение общего счетчика. Нетрудно догадаться, что если один поток успевает записать новое значение в тот момент, когда другой поток уже считал старое значение счетчика, но не записал новое, то информация о записи первым потоком просто потеряется – ее перезатрет второй поток. Надо проверить. Запустим код: Видно, что 2-я, 3-я и 4-я итерации потеряли часть инкрементов. Надо это исправить. Один из вариантов решения проблемы «состояние гонки» – это использование примитивов синхронизации, в данном случае попробуем использовать Mutex. Все, что нам нужно, – создать сам Mutex и передать его в качестве аргумента в каждый тред: Кроме того, нужно изменить саму функцию taskof_thread, выполняющуюся в каждом из потоков: Мы обернули вызов increment_global функциями взятия и освобождения Mutex и тем самым заблокировали параллельное изменение счетчика из нескольких потоков. Теперь остальные треды будут ждать окончания выполнения инкрементации, если она уже была запущена в каком-либо потоке. Проверим, что все починилось: Да, теперь все работает корректно. Ну и по традиции рассмотрим этот пример в Rust. Если попробовать переписать Python-код «в лоб», получится следующее: Этот код делает все то же, что и Python, за одним небольшим исключением – вы не сможете его скомпилировать. Компилятор нам прямо сообщает, что изменение переменной static mut может привести к «состоянию гонки». Да, безусловно, мы можем заставить этот код скомпилироваться, обернув нужные вызовы в unsafe-блоки и переложив ответственность за проверку работы кода с компилятора на разработчика. Не будем приводить этот код тут, можете сами поэкспериментировать с этим. В таком случае код будет работать аналогично неисправленной Python-версии. Тут явно видны различия в подходах при работе с многопоточным кодом в Python и Rust. Теперь попробуем исправить Rust-версию и проанализируем различия: Мы использовали аналогичный подход с использованием Mutex, но в тоже время тут скрывается важное отличие. В Rust Mutex – не просто отдельный тип, никак не связанный с теми данными, к которым он блокирует доступ. В Rust он представляет из себя обертку этих данных, и получить доступ к ним можно только путем взятия этого Mutex. Он отпускается  автоматически при выходе из области видимости.  Такой подход исключает возможность по ошибке забыть заблокировать Mutex при доступе к данным или, например, заблокировать другой Mutex, который к этим данным отношения не имеет. В Python это остается вполне возможным, и контроль за этим лежит целиком на плечах разработчика. Да, код становится чуть сложнее, но все еще вполне сопоставим с Python-вариантом. На наш взгляд, это является весьма малой платой за удобство и гарантии, предоставляемые компилятором Rust. Еще стоит упомянуть, что threading.Lock в Python можно использовать в качестве контекстного менеджера, что улучшит ситуацию, но не исправит ее полностью. Как минимум, он не позволит связать его с данными, которые защищены этим локом. Но на этом различия в реализации многопоточности в Python и Rust не заканчиваются. Помимо примитивов, являющихся частью системы типов, компилятор предоставляет некоторые гарантии контроля возможности возникновения гонки данных и «состоянию гонки», хотя и не исключает последнее полностью.  Но и это еще не все. Rust предоставляет достаточно большой набор библиотек для работы с многопоточностью, и одна из них – это rayon. Не будем вдаваться в подробности работы, просто хотелось бы показать небольшой пример распараллеливания кода с ее использованием. Последовательный код: Параллельный код: Подход, описанный выше, применим и при работе с асинхронным кодом. В целом, если говорить об асинхронщине более обобщенно, можно назвать подходы в Python и Rust достаточно похожими. Рассмотрим небольшой пример кода, который (абсолютно) бесполезен на Python: И на Rust: По коду можно заметить сходство в использовании методов и построении асинхронного кода. Мы сознательно не будем погружаться в различия и сходства реализации асинхронщины, сейчас нам интересно рассмотреть это на более высоком уровне. В целом, создается впечатление, что перейти с Python на Rust не составляет большой проблемы, и, отчасти, это правда. Однако, так было не всегда: привычный Python разработчикам async/await появился лишь в стабильной версии 1.39.0, вышедшей 7 ноября 2019 года, то есть чуть больше года назад. До этого асинхронный код в стабильном Rust представлял из себя последовательность из комбинаторов: Что выглядит для уже привыкших к async/await Python-разработчиков, достаточно чужеродно. Однако в таком подходе есть и плюсы, и минусы, и, вероятно, многие, сталкивающиеся с функциональными языками, увидят в этом много знакомого. Стоит отметить, что комбинаторы доступны и сейчас, наряду с await вы вполне можете сочетать оба этих подхода. Помимо всего описанного выше в Rust, в отличие от Python, широко используется концепция каналов. Из сходств можно отметить множественность реализаций event loop-ов. Правда, ни один из языков не лишен проблем при написании кода, абстрагированного от библиотек, реализующих event loop. В тексте выше мы не раз уже касались функциональных возможностей языков, и вот настало время посмотреть на это чуть пристальнее. На полноту и объективность тут рассчитывать не приходится – сама по себе тема слишком бездонна, но попробуем оценить ее в разрезе нашего опыта. Это первая концепция, которая присутствует и в Python, и в Rust. Как уже стало привычным по ходу статьи, попробуем сравнить несколько вариантов кода: Выше в примере Python мы сразу использовали модуль typing и, соответственно, описали сигнатуру функции map_each, выполняющую следующее: она принимает два аргумента, один из которых – список со строками, а второй – Callable-объект, или говоря проще, функция. Она принимает в качестве аргумента строку, а возвращает – число. Выглядит все достаточно приятно. Посмотрим, что в Rust: Выглядит похоже, но есть одно, важное различие: Python не поддерживает многострочные lambda-функции. Это резко ограничивает выразительность и возможности всего функционального подхода. Да, безусловно, это можно пережить, пользуясь, например, обычными функциями, но удобства это определенно не добавляет. Кроме того, важным отличием являются принципы реализации «комбинаторов». В Rust большая часть из них – часть трейта Iterator, тогда как в Python это, как правило, самостоятельные функции. Исходя из вышеописанного и того, что трейт Iterator реализован для многих стандартных типов и может быть реализован для ваших типов, вытекает и различие в использовании этих методов. В Rust они достаточно органично объединяются в цепочки, описывая сложные трансформации данных. Выше пример кода, отбрасывающего ошибочные результаты из вектора и преобразующего их в вектор целочисленных значений.  Данный код, собственно, строится на том, что уже знакомый нам Result реализует трейт IntoIterator.  Еще один пример, иллюстрирующий возможности Rust: Этот код  уже делает чуть больше: происходит итерация по массиву со строками, попытка преобразования каждого элемента массива в i32 и, в случае ошибки, вывода сообщения об ошибке в консоль. Далее происходит отбрасывание некорректных результатов, умножение каждого значения на два и сложение полученных элементов.  В чем же прелесть таких цепочек? На мой взгляд, это читаемость и простота поддержки. Представим, что нам нужно добавить дополнительное преобразование в пример выше перед вызовом .fold. Очевидно, что добиться этого достаточно просто дополнительным вызовом map. Да, в Python есть концепция Comprehensions, но производить какие-то достаточно сложные манипуляции с данными внутри самого comprehension не совсем удобно. Сама концепция известна и достаточно широко используется и в Python, и в Rust. Вот только система типов Rust позволяет делать некоторые проверки, недоступные в Python.  По традиции рассмотрим пример, и на этот раз начнем с Rust: Выше мы видим код, описывающий замыкание sum, которое, в свою очередь, принимает два параметра и складывает их.  Результат сложения записывается в вектор holder и возвращается из замыкания. Важно отметить, что holder является внешним по отношению к самому замыканию, то есть он олицетворяет некоторый глобальный стейт в этом примере. Попробуем скомпилировать и увидим ошибку: То есть компилятор запрещает нам изменять внешнее состояние в замыкании пока само замыкание не будет объявлено как mut. Исправляется это достаточно банально: Да, полностью отличить чистые функции на уровне типов это не позволяет, но зато можно понять, какие из замыканий явно изменяют внешнее состояние при своем вызове, что уже хорошо. Хотя, возможно, многим хотелось бы большего. Ситуация тут в целом схожа с ситуацией с изменяемыми и неизменяемыми типами в Rust и Python. В случае Rust компилятор явно просит указывать, какие конкретные наборы данных будут изменяемыми, а какие – нет. Что кажется чуть более гибким подходом, чем тот, что используется в Python, где изменяемость определяется самим типом данных и, по сути, изменить ее нельзя. И это последняя концепция, которую мы возьмем на рассмотрение. В целом ситуация с рекурсией схожа в обоих языках.  Отсутствие оптимизации хвостовой рекурсии характерно и Rust, и Python. Однако llvm (backend rust) позволяет генерировать код с учетом этой оптимизации.  Вполне возможно, что однажды в Rust появится и эта оптимизация с гарантированным применением при соблюдении всех условий.  Кроме того, различия кроются непосредственно и в ошибках при рекурсивных вызовах. В Python RecursionLimit является обычным исключением и позволяет при необходимости перехватить его и продолжить выполнение приложения. В Rust при превышении уровня вложенности рекурсивных вызовов может произойти ошибка переполнения стека. Как мы уже говорили, питонисту очень многое приходится делать самому — проверять типы, держать в голове, где будет None, а где число, искать в документации, какие исключения может выбросить функция. Это сложно и поэтому возникает большая часть ошибок, о которых мы говорили в начале. Mypy помогает избавиться только от их части, и компилятор Rust сильно выигрывает в этом у mypy, но при этом можно столкнуться с другими подводными камнями, которые мы пока не рассматривали.  У Rust достаточно высокий порог входа. Для его использования нужно осознать многие концепции, которые мы описали выше, и те, которых пока не касались. И некоторые из них действительно сложные. Спустя некоторое время начинаешь осознавать эти концепции, они уже не кажутся такими сложными и не так сильно замедляют процесс разработки. Но это время должно пройти, и этот порог необходимо преодолеть. В итоге мы пришли к следующему выводу: разработка приложения на Python действительно привлекательна своей высокой скоростью. Но потом, когда разработанное приложение нужно поддерживать, вылезают все те проблемы, которые были проигнорированы во время разработки. В то же время Rust не позволяет игнорировать возможные варианты, требуя больше времени на разработку, но и благодаря этому потом тратится гораздо меньше усилий на поддержку и рефакторинг.