Впечатления от Rust как Swift разработчика: управление памятью

Перевод | Автор оригинала: Spencer Kohan

Как и многие разработчики, я довольно давно интересовался Rust. Не только потому, что он появляется во многих заголовках на Hacker News, или из-за новаторского подхода языка к безопасности и производительности, но и потому, что люди, кажется, говорят об этом с особым чувством любви и восхищения. Вдобавок ко всему, Rust представляет для меня особый интерес, потому что он разделяет некоторые из тех же целей и функций, что и мой любимый язык: Swift. Так как я недавно нашел время, чтобы попробовать Rust в некоторых небольших личных проектах, я хотел уделить немного времени, чтобы задокументировать мои впечатления от языка, особенно в том, как он сравнивается со Swift.

Большая картинка

У Rust и Swift много общего: оба являются скомпилированными языками с мощными современными системами типов и ориентированы на безопасность. Такие функции, как алгебраические типы и первоклассная обработка необязательных значений, помогают переместить многие классы ошибок из среды выполнения во время компиляции на обоих этих языках.

Так чем же отличаются эти языки? Лучше всего я могу охарактеризовать разницу:

Swift позволяет легко писать безопасный код. Rust затрудняет написание небезопасного кода.

Эти два утверждения могут показаться эквивалентными, но между ними есть важное различие. У обоих языков есть инструменты для достижения безопасности, но они идут на разные компромиссы для ее достижения: Swift ставит во главу угла эргономику в ущерб производительности, в то время как Rust ставит во главу угла производительность в ущерб эргономике.

Компромисс: производительность vs эргономика

Самый большой способ продемонстрировать эту разницу в приоритете - это подход этих языков к управлению памятью. Я начну с Rust, потому что подход языка к управлению памятью - одно из его уникальных преимуществ.

В Rust память в основном управляется статически (да, есть и другие режимы управления памятью, такие как подсчет ссылок, но мы пока их игнорируем). Это означает, что компилятор Rust анализирует вашу программу и в соответствии с набором правил решает, когда следует выделить и освободить память.

Для обеспечения безопасности Rust использует новую стратегию, называемую проверкой займов. На практике это работает так: как программист каждый раз, когда вы передаете переменную (т.е. ссылку на ячейку памяти), вы должны указывать, является ли ссылка изменяемой или неизменной. Затем компилятор использует набор правил, чтобы гарантировать, что вы не можете изменить один фрагмент памяти в двух местах одновременно, тем самым делая доказанным, что ваша программа не имеет гонок данных.

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

Однако у него есть некоторые недостатки в том, что касается простоты использования. Из-за характера владения в Rust некоторые шаблоны проектирования просто не работают в Rust. Например, нетривиально реализовать что-то вроде двусвязного списка или глобальной переменной. Со временем это, вероятно, станет более интуитивно понятным, и есть обходные пути для этих проблем, но Rust определенно накладывает ограничения на программиста, которых нет на других языках.

Хотя о нем не так часто говорят, как о Rust, у Swift также есть интересная история, когда дело доходит до управления памятью.

Swift имеет два основных типа переменных: ссылочные типы и типы значений. Как правило, ссылочные типы выделяются в куче и управляются подсчетом ссылок. Это означает, что во время выполнения отслеживается количество ссылок на объект с подсчетом ссылок, и объект освобождается, когда счетчик достигает нуля. Подсчет ссылок в Swift всегда атомарен: это означает, что каждый раз, когда счетчик ссылок изменяется, должна быть синхронизация между всеми потоками ЦП. Это дает то преимущество, что исключает возможность ошибочного освобождения ссылки в многопоточном приложении, но требует значительных затрат производительности, поскольку синхронизация ЦП очень дорога.

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

Типы значений, напротив, обычно выделяются стеком, а их память управляется статически. Однако поведение типов значений в Swift сильно отличается от того, как Rust обрабатывает память. В Swift типы значений имеют так называемое поведение «копирование при записи», которое копируется по умолчанию, что означает, что каждый раз, когда тип значения записывается в новую переменную или передается в функцию, создается копия.

Копирование при записи Копирование по умолчанию позволяет достичь тех же целей, что и проверка заимствований: как программисту обычно не нужно беспокоиться о загадочном изменении значения из-за какого-то неожиданного побочного эффекта в другом месте программы. Это также требует немного меньшей когнитивной нагрузки, чем проверка заимствования, поскольку в Rust есть целые классы связанных с владением ошибок времени компиляции, которых просто нет в Swift. Однако за это приходится платить: эти дополнительные копии требуют дополнительного использования памяти и циклов ЦП для завершения.

В Rust также можно копировать значения, чтобы заглушить ошибки проверки заимствования, но это добавляет визуального шума, поскольку копии должны быть явно указаны.

Итак, здесь у нас есть хороший пример компромиссов, достигнутых этими двумя языками: Swift дает вам некоторые общие предположения о том, как следует управлять памятью, сохраняя при этом уровень безопасности. Это немного похоже на то, как программист на C++ может обращаться с памятью в соответствии с передовыми практиками, прежде чем задумываться над оптимизацией. Это позволяет очень легко приступить к написанию кода, не задумываясь о деталях низкого уровня, а также обеспечивает некоторые базовые гарантии безопасности и правильности во время выполнения, которые вы не получите на таких языках, как Python или даже Golang. Однако у него есть некоторые проблемы с производительностью, которые легко упасть, даже не осознавая этого, пока вы не запустите свою программу. Можно написать высокопроизводительный код Swift, но для этого часто требуется тщательное профилирование и оптимизация.

Rust, с другой стороны, предоставляет вам множество специальных инструментов для определения того, как следует управлять памятью, а затем накладывает некоторые жесткие ограничения на то, как вы их используете, чтобы избежать небезопасного поведения. Это дает вам очень хорошие характеристики производительности прямо из коробки, но требует, чтобы вы взяли на себя дополнительные когнитивные издержки, связанные с обеспечением соблюдения всех правил.

Мой вывод из этого состоит в том, что, хотя у этих языков действительно есть некоторые общие цели, у них есть принципиально разные характеристики, которые поддаются разным сценариям использования. Например, Rust кажется очевидным выбором для чего-то вроде встраиваемой разработки, где чрезвычайно важно оптимальное использование памяти и циклов ЦП и где цикл код-компиляция-запуск может быть медленнее, поэтому важно уловить все возможные проблемы при компиляции. время. В то время как Swift может быть лучшим выбором для чего-то вроде науки о данных или бессерверной логики, где производительность является второстепенной задачей, и важно работать ближе к проблемной области, не обращая внимания на многие низкоуровневые детали.

В любом случае, мне будет очень интересно следить за обоими этими языками в будущем, и я буду следить за этим постом с дополнительными наблюдениями о сравнении Swift и Rust.