И почему я думаю, что вам тоже следует прекратить его использовать

Более пяти лет в качестве профессионала я работал в основном над проектами, созданными с использованием Ruby. Большинство систем, над которыми я работал, использовали Ruby on Rails и варьировались по размеру от микросервиса до монолита.

В течение нескольких из этих лет я постоянно разочаровывался в динамичной природе Ruby, особенно в монолите десятилетней давности. Я заканчивал тем, что отлаживал проблему, пытаясь понять, какие объекты передаются в метод, и обнаруживал, что мне приходится переходить через множество классов, чтобы решить ее, или выполнять код и использовать отладчик.

Ни то, ни другое не приносит особого удовольствия, поэтому, когда в 2019 году компания Sorbet открыла исходный код Sorbet, мне не терпелось попробовать. На первый взгляд, это был ответ на несколько разочарований, которые у меня были при работе с Ruby. Внезапно у нас могут быть типы для аргументов метода, которые будут статически проверяться Sorbet.

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

Что такое сорбет?

Для тех, кто не знаком, Sorbet — это средство проверки типов для Ruby. По умолчанию в Ruby нет проверки типов, поэтому вы можете написать что-то вроде этого:

def add_numbers(a, b)
  a + b
end

add_numbers('1', 2)

И если бы вы выполнили это, вы бы получили прекрасную ошибку, поскольку вы не можете добавить строку к целому числу (если только вы не используете JavaScript, а тогда технически это возможно):

`+': no implicit conversion of Integer into String (TypeError)

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

Однако с помощью Sorbet вы можете определить типы для сигнатур методов, например:

sig {params(a: Integer, b: Integer).returns(Integer)}
def add_numbers(a, b)
  a + b
end

add_numbers('1', 2)

Если вы затем запустите средство проверки типов Sorbet, вы увидите следующую ошибку:

editor.rb:9: Expected Integer but found String("1") for argument a
     9 |add_numbers('1', 2)
                    ^^^

Довольно круто, правда? Он точно указал, в чем проблема, и нам даже не нужно было запускать наш код. Если у вас есть поддержка IDE для Sorbet, она будет выделена в вашем редакторе, избавляя вас от необходимости вручную запускать проверку типов.

Вот почему Sorbet меня взволновал: мне нравилось работать с типизированными языками, такими как C#, и теперь благодаря Sorbet у меня появилась возможность проверки типов в Ruby. Итак, где все пошло не так?

Мои проблемы с сорбетом

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

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

Код становится раздутым

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

Языки, которые имеют статическую типизацию, добавляют очень мало раздувания при определении типов. Возьмите этот код С#, например:

public struct Coords
{
    public int x, y;

    public Coords(int p1, int p2)
    {
        x = p1;
        y = p2;
    }
}

В этом примере объявления типов не добавили ничего раздутого, и код прекрасно читается. Напишем тот же код, но на Ruby с Sorbet:

class Coords
  extend T::Sig

  sig {returns(Integer)}
  attr_accessor :x, :y

  sig {params(x: Integer, y: Integer).void}
  def initialize(x, y)
    @x = x
    @y = y
  end
end

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

Имейте в виду, это очень маленький класс. Если у вас есть атрибуты разных типов, что является распространенным явлением, каждый атрибут необходимо сгруппировать по типу. Таким образом, если бы x и y были разными типами, у вас было бы вдвое больше кода.

Точно так же и для методов нет ничего, кроме инициализатора. Во многих классах, возможно, в модели или сервисе, у вас может быть более 5 методов, каждый со своими собственными сигнатурами. Если вы объедините это с более длинными типами (например, вашими собственными классами, которые будут иметь пространство имен), вам иногда придется разбивать определения параметров на несколько строк, что сделает их еще менее читабельными. В некоторых случаях сигнатуры методов становились длиннее самих методов!

Зависимости становятся кошмаром

Любое приложение, которое вы создаете, большое или маленькое, будет иметь некоторую форму зависимостей. Будь то фреймворк, на котором он построен, или дополнительные, обеспечивающие ключевую функциональность, у вас будет что-то.

По мере увеличения размера приложения будут увеличиваться и его зависимости. В Ruby они представлены в виде драгоценных камней. Однако у большинства драгоценных камней нет типов Sorbet, а это означает, что вам нужно сгенерировать или вывести их типы, используя что-то вроде Tapioca.

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

Вдобавок ко всему, мы действительно боролись с конфликтующими версиями между Sorbet, Tapioca и другими драгоценными камнями. Инженеры могли часами решать проблемы, пытаясь объединить новые версии драгоценных камней.

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

Трудно заручиться поддержкой инженеров

Последнее, что я хочу затронуть, — это получение бай-ина для Sorbet. Ruby имеет богатую историю; он был выпущен в середине 90-х, поэтому здесь много инженеров с многолетним опытом работы с Ruby.

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

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

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

Чтобы убедиться, что мы дали Sorbet честное слово, мы также реализовали его на гораздо меньшем сервисе и добавили определения типов во все 100% кода. Это было лучше, чем частичное внедрение в монолите, и вы могли видеть потенциальные проблемы раньше, но две проблемы, описанные выше, остались.

Разговаривая с инженерами, они не обнаруживали, что Sorbet работает на них. Они чувствовали, что это работает против них. Для меня это признак того, что Sorbet не для нас.

Ruby 3 и то, что почти было

Я не мог написать статью о Sorbet и не коснуться Ruby 3. Sorbet был выпущен, когда Ruby 3 все еще находился в активной разработке, а Ruby 2 была текущей используемой основной версией.

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

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

Вы бы написали свой код как обычно в файле .rb, а затем, при желании, определили бы файл .rbs, в котором вы определяете типы. Вот и все, в Ruby нет встроенной проверки типов, и вы можете написать совершенно неправильные типы в файле RBS, и ничего не пойдет не так. Вам все равно понадобится средство проверки типов, такое как Sorbet.

Я действительно думаю, что Ruby ошибся в этом вопросе, и, похоже, это во многом зависит от твердого мнения Матца (создателя Ruby) о размещении типов в файлах Ruby. Это привело к тому, что поддержка типов в 3 была в значительной степени ограничена, что, я думаю, в конечном итоге укусит язык, поскольку я считаю, что популярность типизированных языков будет только расти, как это было в течение многих лет.

Краткое содержание

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

Хотя хотелось бы услышать и другие мнения. Я знаю, что Stripe, естественно, находит это полезным и широко использует его, и я считаю, что Shopify тоже его использует. Возможно, они решили эти проблемы или согласились на удобочитаемость в обмен на проверку типов? Как и в большинстве вещей в программировании, почти всегда есть компромисс.