An Interest In:
Web News this Week
- April 1, 2024
- March 31, 2024
- March 30, 2024
- March 29, 2024
- March 28, 2024
- March 27, 2024
- March 26, 2024
Sorbetting a gem, or the story of the first adoption
NOTE: this post was written a few days after the first public Sorbet release (on 2019-06-20). If you're reading this months after that happened, most of the things described here could be no longer relevant.
Stripe has finally open-sourced their long-awaiting static type checker for RubySorbet. I've been waiting for this for more than a year since it was first announced.
To be honest, I'm not a big fan of type annotations, or more precisely, I'm on the "I hate type annotations" side.
Amr Abdelwahab()@amrabdelwahab08:08 AM - 21 Jun 2019
But as a member of Ruby community, I'm glad to see something like this happening: this is a huge leap forward for Ruby itself (especially, compared to other evolutionary features like pipeline operator). Stripe team did a great job !
After reading about Brandon's first impression (highly recommend to check it out), I decided to give Sorbet a try and integrate it into one of my gems.
tl;dr type checker works great, but not ideal; annotations are ugly; tooling leaves much to be desired.
Rubanok meets Sorbet
I decided to use rubanok
for this experiment: it's the simplest gem of mine in terms of the amount of code and metaprogramming.
You can find the adoption PR here: https://github.com/palkan/rubanok/pull/5.
bundle exec srb init
The first phase of the adoption is to add sorbet
gem to the bundle and generate all the required files (read more and see the screenshots of this process here).
Unfortunately, it didn't go so well:
$ bundle exec srb init Hey there!...Traceback (most recent call last): 15: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/bin/srb-rbi:232:in `<main>' ... 3: from /ruby/gems/2.6.0/gems/sorbet-0.4.4280/lib/gem_loader.rb:553:in `require' 2: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:4:in `<top (required)>' 1: from /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:6:in `<module:RSpec>' /ruby/gems/2.6.0/gems/rspec-rails-3.8.2/lib/rspec-rails.rb:8:in `<module:Rails>': uninitialized constant Rails (NameError)
RSpec Rails refers to Rails
constant in its code but doesn't require rails
anywhere in the code (it should be somewhere here).
As far as I understand, Sorbet evaluates each file for building .rbi
configs for your bundle and it doesn't know how to handle such exceptions.
That's an edge case that should be solved at the rspec-rails
side (would you like to open a PR?).
There is a similar open issue regarding optional dependencies as well.
Adding rails
as a dev dependency helped to solve this issue (honestly, don't why ).
After that, a new folder, sorbet/
, appeared in the project directory with a bunch of .rbi
files:
$ find sorbet -type f | wc -l 55
As the documentation suggests, you should put this directory into your version control system. Even though type signatures are not so heavy (as node_modules/
, for example), I hope this will change in the future. The raw size (w/o .git/
) of rubanok
increased by 2.1MB, from 124KB to ~2.2MB.
bundle exec srb tc
When you first run the type checking command, you should not see any errors:
$ bundle exec srb tcNo errors! Great job.
That's because Sorbet adds the # typed: false
magic comment to all the source files by default. This strictness level only checks for critical problems (constants, syntax, invalid signatures).
Gradual type checking implies that you make your source code type-aware step-by-step by changing # typed: false
to # typed: true
(or even # typed: strong
).
Problem #1. Unsupported features.
I started by enabling type checks for the core Rubanok classRule:
$ be srb tclib/rubanok/rule.rb:62: Method Rubanok::Rule#empty? redefined without matching argument count. Expected: 0, got: 1 https://srb.help/4010 62 | def empty?(val) ^^^^^^^^^^^^^^^ lib/rubanok/rule.rb:56: Previous definition 56 | def empty?
That's strange; I don't have duplicate method definitions, RuboCop could catch this easily. What made Sorbet think so? This:
using(Module.new do refine NilClass do def empty? true end end refine Object do def empty? false end endend)def empty?(val) return false unless Rubanok.ignore_empty_values val.empty?end
Looks like Sorbet doesn't recognize anonymous modules and treats their contents as the "parent" module contents. Hopefully, it seems to be an easy fix and a good first contribution:
#
Thank you for a great issue report with a great reproducer!For those interested to contribute, this should be super easy to fix:https://github.com/sorbet/sorbet/blob/master/dsl/ClassNew.cc is already handling Class.new
, it should additionally handle Module.new
.
Note that this problem has nothing with refinements themselves (although I think they are not supported at all and will not be anytime soon).
I quickly fixed this by moving the refinement outside of the Rule class. But at what cost? That made it less clear why and where we need this refinement. The code became a bit more entangled. And that's just the beginning...
Problem #2. Limitations of flow-sensitivity
After toggling # typed: true
for another class, Plane, I found another interesting case:
$ bundle exec srb tclib/rubanok/plane.rb:50: Method <= does not exist on NilClass component of T.nilable(Class) https://srb.help/7003 50 | if superclass <= Plane ^^^^^^^^^^^^^^^^^^^ Autocorrect: Use `-a` to autocorrect lib/rubanok/plane.rb:50: Replace with T.must(superclass) 50 | if superclass <= Plane ^^^^^^^^^^lib/rubanok/plane.rb:51: Method rules does not exist on Class component of T.nilable(Class) https://srb.help/7003 51 | superclass.rules.dup ^^^^^^^^^^^^^^^^lib/rubanok/plane.rb:51: Method rules does not exist on NilClass component of T.nilable(Class) https://srb.help/7003 51 | superclass.rules.dup ^^^^^^^^^^^^^^^^ Autocorrect: Use `-a` to autocorrect lib/rubanok/plane.rb:51: Replace with T.must(superclass) 51 | superclass.rules.dup ^^^^^^^^^^
The "violating" code:
def rules return @rules if instance_variable_defined?(:@rules) @rules = if superclass <= Plane superclass.rules.dup else [] endend
Is this a bug? Don't think so: as far as I know, the only case when superclass
returns nil
is BasicObject
. Or if we redefine the .superclass
method
The suggested solutionusing T.must(superclass)
is not a good fit: I don't want my code to have dirty hacks only to satisfy the type system.
I've tried to make Sorbet happy the other wayby unwrapping the superclass
value:
def rules return @rules if instance_variable_defined?(:@rules) @rules = if superclass && superclass <= Plane superclass.rules.dup else [] endend
That didn't have any effectstill the same errors. I've tried again:
def rules return @rules if instance_variable_defined?(:@rules) @rules = if superclass if superclass <= Plane superclass.rules.dup else [] end else [] endend
Still the same :( The last attempt (I thought):
def rules return @rules if instance_variable_defined?(:@rules) x = superclass @rules = if x if x <= Plane x.rules.dup else [] end else [] endend
Almost works:
$ bundle exec srb tclib/rubanok/plane.rb:53: Method rules does not exist on Class https://srb.help/7003 53 | x.rules.dup ^^^^^^^Errors: 1
But why it cannot infer the superclass class from the x <= Plane
check?
If you check the complete list of constructs that affect Sorbets flow-sensitive typing, you can find that only Class#<
is supported but not Class#<<
OK. Let's replace x <= Plane
with x < Plane
(this is actually a breaking change: someone could define global rules on Rubanok::Plane
class itself, which is not a good idea but...).
Problem #3. Signatures vs modules.
Adding signatures for Rule and Plane went pretty smooth (LOC increased from 159 to 196). And I didn't have to change any code.
Then I turned on type checking for DSL modules, Mapping and Matching.
These modules implement the particular Rubanok transformations and extend the Rubanok::Plane
class.
The first problem occurred with a pretty standard Ruby code. Here is the simplified example:
class Rule sig do params( fields: T::Array[Symbol], activate_on: T::Array[Symbol] ).void end def initialize(fields, activate_on: fields) # ... endendmodule Mapping def map(*fields, **options, &block) rule = Rule.new(fields, options) endend
This code raises the following type error:
$ be srb tclib/rubanok/dsl/mapping.rb:25: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019 25 | rule = Rule.new(fields, options) ^^^^^^^^^^^^^^^^^^^^^^^^^
Seems legit: do not allow passing an arbitrary hash as the known keyword arguments.
Let's try to add a signature for the #map
method using shapes:
sig do params( fields: Symbol, options: { activate_on: T::Array[Symbol] }, block: T.proc.void ).returns(Rule)enddef map(*fields, **options, &block) rule = Rule.new(fields, options)end
(Expectedly) didn't help:
$ bundle exec srb tc./lib/rubanok/mapping.rb:34: Passing a hash where the specific keys are unknown to a method taking keyword arguments https://srb.help/7019 34 | rule = Rule.new(fields, options) ^^^^^^^^^^^^^^^^^^^^^^^^^ Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}] originating from: ./lib/rubanok/mapping.rb:33: 33 | def map(*fields, **options, &block) ^^^^^^^^^
This Got T::Hash[Symbol, {activate_on: T::Array[Symbol]}]
looks suspicious. Where did it found a hash with Symbol keys? No idea.
I gave up and duplicated the keywords for the #map
method:
sig do params( fields: Symbol, activate_on: T::Array[Symbol], block: T.proc.void ) .returns(T::Array[Rubanok::Rule])enddef map(*fields, activate_on: fields, &block) rule = Rule.new(fields, activate_on: activate_on) # ...end
This doesn't seem right to me: now I need to think about keeping these signatures in sync in three different places (the type checker will definitely help here), there is a chance that I will lose this very crucial activate_on: fields
default value (the type checker cannot help here).
If you know how to add signatures without changing the code itselfplease, leave a comment!
The second problem with modules relates to the fact that they are only meant for extending the Rubanok::Plane
class; thus they "know" a few things about the Plane API and use it. For example, they use #rules
method:
def map(*fields, activate_on: fields, &block) rule = Rule.new(fields, activate_on: activate_on) # ... rules << ruleend
Sorbet has no idea of our intent; hence, it reports this error:
lib/rubanok/dsl/mapping.rb:38: Method rules does not exist on Rubanok::DSL::Mapping https://srb.help/7003 38 | rules << rule
I couldn't find anything similar to this situation in the docs, only the section devoted to interfaces turned out to be useful: I marked the module as abstract!
and defined an abstract #rules
method:
sig { abstract.returns(T::Array[Rubanok::Rule]) }def rulesend
It made this error disappeared. Bonus: see what happens if you remove or rename the Plane.rules
method:
$ bundle exec srb tclib/rubanok/plane.rb:36: Missing definition for abstract method Rubanok::DSL::Mapping#rules https://srb.help/5023 36 | class << self ^^^^^ lib/rubanok/dsl/mapping.rb:47: defined here 47 | def rules ^^^^^^^^^
Problem #4. Metaprogramming.
Metaprogramming is what makes Ruby such a powerful language (and makes me love Ruby).
Ruby without metaprogramming is not Ruby.
On the other hand, this is one of the things that makes static type checking so tricky. I don't expect a type checker to be so smart and know how to deal with any meta stuff; I only need from it a canonical way to handle the situations like the one described below.
The #match
method provided by Matching module generates dynamic methods, which rely on a couple of Plane instance methods:
define_method(rule.to_method_name) do |params = {}| clause = rule.matching_clause(params) next raw unless clause apply_rule! clause.to_method_name, clause.project(params)end
Sorbet didn't like it:
$ bundle exec srb tclib/rubanok/dsl/matching.rb:106: Method raw does not exist on Rubanok::DSL::Matching https://srb.help/7003 106 | next raw unless clause ^^^lib/rubanok/dsl/matching.rb:108: Method apply_rule! does not exist on Rubanok::DSL::Matching https://srb.help/7003 108 | apply_rule! clause.to_method_name, clause.project(params) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^Errors: 3
The trick with adding abstract methods didn't work out (since we need to add instance methods, not singleton ones).
Re-generating hidden definitions* didn't work either.
I didn't find anything better than adding a custom RBI file to the repo:
# sorbet/meta.rbi# typed: truemodule Rubanok::DSL::Matching sig { returns(T.untyped) } def raw end sig { params(method_name: String, data: T.untyped).returns(T.untyped) } def apply_rule!(method_name, data) endend
Yet another hack. "Enough," I thought, and didn't even try to enable type checking for Rails controller integration concern.
* I tried to check how hidden definitions work with a more straightforward example:
# typed: trueclass A def x "y" end define_method(:xx) do x * 2 endend
After running bundle exec srb rbi hidden-definitions
I found the following line in sorbet/hidden-definitions/hidden.rbi
:
class A def xx(); endend
So, Sorbet found this define_method
. And, for some reason, it also changed # typed: true
to # typed: false
. After turning it back, I got:
$ bundle exec srb tclib/rubanok/a.rb:9: Method x does not exist on T.class_of(A) https://srb.help/7003 9 | x * 2 ^ lib/rubanok/a.rb:4: Did you mean: A#x? 4 | def x ^^^^^Errors: 1
As we see from the error message, Sorbet treats #xx
as a class method. There is a relevant open issue: #64.
Problem #5. Runtime checks.
So far, I only tried to make static checks pass but haven't tried to run the code:
$ bundle exec rspecNameError: uninitialized constant Rubanok::Rule::T
Right, we have signatures in our code, which we haven't loaded yet.
I added the require "sorbet-static"
line to the main project file. And I was a bit surprised:
$ bundle exec rspecLoadError: cannot load such file -- sorbet-static
My bad: I assumed that you could use Sorbet without runtime checks and that's what sorbet-static
gem is for.
As it turned out, there is no way to avoid
sorbet-runtime
if you have signatures in your code.
I started to hate type annotations more: I don't want to add an additional dependency to the gem, even if the overhead of type checking is <10% (which is still more than 0%):
Dmitry Petrashko@darkdimius@cm_richards @sorbet_ruby @_solnic_ @stripe We run it in production. We measure overhead. We page if overhead is >=7% of cpu time.21:08 PM - 20 Jun 2019
OK. Let's play this game 'till the end.
After adding sorbet-runtime
I was able to run the code and even caught one "problem":
$ bundle exec rspecFailure/Error: rules << ruleRuntimeError: You must use `.implementation` when overriding the abstract method `rules`. Abstract definition: Rubanok::DSL::Matching at /Users/palkan/dev/rubanok/lib/rubanok/dsl/matching.rb:119 Implementation definition: #<Class:Rubanok::Plane> at /Users/palkan/dev/rubanok/lib/rubanok/plane.rb:53
Why didn't static analysis caught this?
Problem #6. Debugging.
I'm a heavy user of binding.pry
.
When I was debugging the code with type signatures, I found it's very hard to step into the method:
Can you find where is the original method hiding?
In conclusion, or it's just the beginning
Sorbet is one of the most important things that happened to Ruby in the last few years.
But it's still far from bringing the development happiness. And it's you who can help it become better: give it a try, report issues or say "Thanks!" to people behind it (e.g., Dmitry Petrashko and Paul Tarjan).
Will I use Sorbet?
Likely, yes. But only in the way described below.
After adding all the signatures to the codebase (full adoption), I want to be able to dump them into a .rbi
file and clean up the codebase. Thus, my Ruby code stays the same: less verbose and more readable.
That should break neither static nor runtime checks, i.e., if sorbet-runtime
is loaded, runtime checks are activated, otherwisenot. Static checks should work just because of the presence of the RBI files.
P.S. I'm only talking about libraries development right now.
P.P.S. I want to try to adopt Steep as well and compare the process to the one described above. We'll see which one I like more.
Original Link: https://dev.to/evilmartians/sorbetting-a-gem-or-the-story-of-the-first-adoption-3j3p
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To