Глубокая пенетрация в статическую типизацию в Ruby на примере RBS

 Публичный пост для комнаты «Тех»
28 апреля 2025  130

Из покон веков, были языки со строгой типизацией. Медленные, ужасные, но зато как запилил на них свое приложение, так и работает годами. Не согласны были разработчики с таким положением дел, и появились динамические языки. Тут вам и Perl, и PHP, и JavaScript и даже Ruby, ну и другие, разумеется.

Шли годы, проекты росли. Рубокопы и тестирование не вытягивало. Решили разработчики добавить немного типов. Сначала был TypeScript, позже последовал Python, ну и вот... вот теперь очередь дошла и до Ruby.

Хотя, конечно, не теперь, а достаточно давно. Где-то в районе 2018-ого, Stripe представили свое решение статической типизации для Ruby - sorbet. Идею подхватили в Shopify и за все эти года прилично так вложились в документацию, фичи, перформанс и туулинг.

Параллельно с этим, команда Ruby, вместе а резилом версии 3.0, анонсировала и свой вариант - RBS. Вот конкретно об RBS и пойдет сегодня речь.

Как работает?

В отличие от TypeScript (где это, в целом, отдельный язык) или Python, было решено не вносить никаких изменений в синтаксис самого языка. Stripe этого сделать и не могли, а вот команда Ruby решила, что язык дает большую свободу и внедрение типов будет сильным ограничением.

Поэтому, оба варианта статической типизации распространяются в виде 2-х компонентов:

  • gem, который добавляет специальный синтаксис или DSL для покрытия исходного кода типами данных
  • проверщик типов - в целом, тоже gem, который сравнивает заранее добавленные типы с самим исходным кодом. Если на пальцах, то работает как rubocop.

Sorbet предлагает покрывать исходный код типами данных через специальный Ruby DSL:

sig { params(order: Order).returns(String) }
def process_order(order)
  # логика метода
end

На продакшене, блок переданный sig, естественно, не вызывается и, по заявлениям разработчиков, не влияет на произвотительность. Я не проверял, так как работаю только с RBS. (буду рад любым поправкам в комментариях).

RBS работает несколько иначе. Из коробки предлагается писать *.rbs файлы. Что-то типа header-файлов из C. В нем можно описать код с помощью специального синтаксиса.

def add_numbers: (Integer, Integer) -> Integer

Помимо такого подхода, есть еще возможность указывать типы данных прямо в коде (с помощью gem rbs-inline), на манер Sorbet, но в случае RBS, мы используем обычные комментарии, то есть в production среду никакие дополнительные зависимости утекать не будут. Вскоре, данный gem будет смерджен с основным rbs (предположительно с релизом RBS 4.0).

# @rbs (Integer,  Integer) -> Integer
def add_numbers(a, b)
  a + b
end

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

Есть так же дополнения как для VS Code, так и для RubyMine, который помогают с autocomplete'ом кода, и в целом показывают внутри IDE какие типы данных используются.

Как начать?

Итак, чтобы начать работать с RBS и статической типизацией в Ruby нам нужно будет добавить 2 gems в Gemfile:

  • rbs - просто добавьте, без вопросов 😉
  • steep - утилита, которая будет проверять наши типы данных

Следом придется создать папку, где мы будем хранить наши *.rbs файлы с сигнатурами типов. Ну и наконец, нужно будет выполнить bundle exec steep init, которая создаст Steepfile. В нем, на манер Gemfile нужно будет указать как мы хотим проверять наше приложение.

Зачем это надо? В зависимости от наших нужд, мы можем сказать, что мы заинтересованы в проверке только 1 файла на Ruby, или наоборот, мы хотим проверять весь проект. Можно также указать, как быть с неопределенными типами данных - ругаться, что мы забыли что-то определить или так оставить. Ну и так далее.

D = Steep::Diagnostic
target :lib do # название конфигурации
  signature "sig" # где лежат сигнатуры
  check "lib"  # что проверять
  configure_code_diagnostics(D::Ruby.default)   
end

В целом все, но расходиться пока рано.

Как писАть?

В зависимость от целей, использовать статическое типизирование в Ruby можно по-разному, однако общий принцип такой:

  1. пишем код
  2. покрываем типами (либо в виде комментариев, если используем rbs-inline, либо прямо сразу в файлах *.rbs)
  3. запускаем steep (bundle exec steep check) и проверяем все ли ок
  4. и так далее

А зачем?

В отличие от того же TypeScript, в Ruby статическое типизирование является полностью опциональным. По-умолчанию, нет ни намека на то, что где-то тут можно указать, какого типа параметры принимает наш метод.

Главный принцип - используй типы данных там, где это необходимо или хочется.

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

Так в каких случаях будет полезно использовать статическое типизирование?

"Живая" документация

В зависимости от принятого в проекте стиля, код может быть неплохо так документирован. RDoc, Yard да или просто комментарии в произвольном стиле. Итог, в целом, всегда один - при очередном изменение, какие-то правки не будут отражены в документации и современем она станет устаревать.

При использовании rbs-inline, все комментарии будут проверяться Steep и поэтому всегда будут оставаться up-to-date.

# This method processes names. Whatever.
#
# @rbs first_name: String
# @rbs last_name: String
# @rbs &block: (String) -> String
# @rbs return: Array[String]
def process_names(first_name:, last_name:, &block)
  [
    block.call(first_name),
    block.call(last_name)
  ]
end

Существуют плагины, которые помогут сконвертировать rbs-inline комментарии в RDoc, если в этом есть необходимость.

В примере выше мы не только знаем, что и куда идет и чем все заканчивается, но и уверены, что комментарии содержат 100% точную информацию.

Уверенность, что программа "запустится"

... еще до запусков тестов. В сравнении с языками программирования со строгой типизацией, как Java, где если программа скомпиллировалась, ну, она как минимум запуститься. В Ruby, без тестов и/или ручного тестирования, невозможно понять, будет ли программа хотя бы даже стартовать.

Статическое типизирование может в этом помочь. Оно подскажет когда мы передаем не те данные.

Пример, ответ от REST запроса. Это скорее всего будет Hash со сложной структурой, ну или Array подобного плана. Тут вот и начинаются гадания: ключи String или Symbol? глубина вложенности Hash'a, в конце-концов, Array или Hash? :)

RBS поможет сразу ответить на эти вопросы.

Уверенный рефакторинг

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

Как и в случае тестов, Steep выругается, если сигнатуры типов более не совпадают с кодом. Ну а дальше нужно будет решить - править сигнатуры или править код.

В VS Code вместе с steep расширением или в RubyMine, при наведении на метод или класс, или в процессе написания кода, можно посмотреть, какой тип данных используется / требуется. Это существенно ускоряет работу и придает уверенности, что изменения не сломают то, что не нужно.

Как работать с legacy?

Ну может и не совсем с legacy, но, в целом, со старым кодом.

Здесь можно разделить проблему на 2 составляющие - сторонние gems и наш уже существующий код.

Все методы языка Ruby уже покрыты сигнатурами типов, при условии наличия rbs gem в вашем Gemfile.

Часть популярных gems, такие как devise, rails, httparty и другие также покрыты сигнатурами через rbs collection. Для того, чтобы их использовать, нужно инициализировать файл конфигурации bundle exec rbs collection init. А потом сделать bundle exec rbs collection install. Данная команда скачает сигнатуры для всех зависимостей из Gemfile для которых не стоит required: false и положит их в папку .gem_rbs_collection.

Последним штрихом здесь будет, указать в Steepfile, откуда брать дополнительные сигнатуры:

D = Steep::Diagnostic
target :lib do # название конфигурации
  signature "sig" # где лежат наши сигнатуры
  signature ".gem_rbs_collection" # где лежат сигнатуры сторонних gems
  check "lib"  # что проверять
  configure_code_diagnostics(D::Ruby.default)   
end

Не все gems, к сожалению, покрыты типами данных. Здесь вам придется поработать самим. Однако, как показывает практика, не всегда нужны детальные сигнатуры. К примеру, в одном проекте, я покрывал типами данных только логику на чистом Ruby. Лишь в одном месте мне понадобились типы данных для Rails logger и ActiveRecord, что я ручками покрыл за 5 минут.

Что касается нашего кода, то тут есть тоже 2 пути - самому покрывать код или воспользоваться генератором rbs prototype rb <указать путь к файл> > sig/<путь к файлу rbs>. Он создаст boilerplate, где, конечно, почти все типы данных будут указаны как untyped, но это лучше, чем ничего.

Есть экспериментальные утилиты, вроде typeprof которые смогут "угадать" тип данных и вернуть вам уже "правильные" сигнатуры. Работает неплохо, но есть нюансы.

Но?

Как уже было упомянуто, статическое типизирование в Ruby необязательно к применению. В отличие от того же Java, Ruby будет все равно какой тип данных вы передаете до тех пор, пока Ruby знает, как с этим работать. Интерпретатору будет также все равно и на то, какие типы данных вы указали с помощью RBS или Sorbet. Здесь все опирается на вас. Добавить steep в CI, понять для себя, в каком случае использовать статическую типизацию, а в каком нет.

Я часто встречаю комментарий, вроде "статическая типизация убивает динамизм Ruby". Нет, динамизм в Ruby убивает каждый из нас, когда принимает тот или иной стиль написания кода. Rubocop это ограничение. Rails это ограничение. Выбор между RSpec или Minitest, использовать ли TDD, или "писАть как получиться", следовать паттернам программирования или программировать в стиле "I feel lucky" - все это ограничения. Точно такие же, как и статическое типизирование.

При 100% использовании RBS, безусловно, придется порубить можество случаев metaprogramming в языке. Но никто не требует такого подхода.

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

В другом же проекте, я добавил использование RBS для plain Ruby файлов (тех, что не завязаны на Rails).

В третьем проекте я покрыл 100% кода типами, но даже там, я допустил использование untyped для динамически генерированных методов, классов и данных, то бишь metaprogramming.

Ну и наконец, я часто использую, как я это называю, TDD - type-driven development 😀. Когда я сначала делаю примерную структуру внутри *.rbs файлов с нужными типами, и уже потом дописываю нужный код. Это позволяет заранее понять high level picture о том, как и какие данные как будут перетекать и где и в каком виде я получу финальный результат.


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

Откомментируйте первым 👇

😎

Автор поста открыл его для большого интернета, но комментирование и движухи доступны только участникам Клуба

Что вообще здесь происходит?


Войти  или  Вступить в Клуб