-
Notifications
You must be signed in to change notification settings - Fork 581
Use T.noreturn for "does not take a block"
#9789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
This suggests an even cooler implementation where we can expand support for this to non-`# typed: strict` files, but that'll be a more invasive change so I'm punting it.
|
We have a policy of testing changes to Sorbet against Stripe's codebase before Stripe employees can see the build results here: → https://go/builds/bui_TdZquRh5C5VFvv |
elliottt
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I think this is a great change! I love that we can control whether or not a block argument is expected without having to have the definition in a typed: strict file now.
The only thing that I think could cause confusion is that the block param is now typed as NilClass in the body of the method. I think it's worth making this change anyway, but I wasn't sure if you had thoughts on how we could avoid the confusion that might arise from error messages mentioning NilClass.
| auto blockLoc = args.blockLoc(gs); | ||
| if (file.exists() && file.data(gs).strictLevel >= core::StrictLevel::Strict && | ||
| blockParam.isSyntheticBlockParameter() && blockLoc.exists() && !blockLoc.empty()) { | ||
| if (file.exists() && blockType.isBottom() && blockLoc.exists() && !blockLoc.empty()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like that this doesn't rely on the typed sigil of the file now 🎉
| extend T::Sig | ||
|
|
||
| sig { params(blk: T.noreturn).returns(Integer) } | ||
| def takes_no_block(&blk) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the VM behave differently if we define a block param or if we skip it? It's a little odd that we have to define a block param in order to declare that we don't accept one, and I wonder if there's any potential runtime overhead for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the VM behave differently if we define a block param or if we skip it?
Yes, defining and not using a block param slows the program down:
❯ hyperfine 'ruby unused_block_param.rb' 'ruby no_block_param.rb'
Benchmark 1: ruby unused_block_param.rb
Time (mean ± σ): 2.379 s ± 0.043 s [User: 2.287 s, System: 0.017 s]
Range (min … max): 2.330 s … 2.457 s 10 runs
Benchmark 2: ruby no_block_param.rb
Time (mean ± σ): 1.502 s ± 0.037 s [User: 1.419 s, System: 0.015 s]
Range (min … max): 1.447 s … 1.591 s 10 runs
Summary
ruby no_block_param.rb ran
1.58 ± 0.05 times faster than ruby unused_block_param.rb
unused_block_param.rb
# typed: true
def foo(&blk)
end
i = 0
while i <= 100_000_000
foo
i += 1
end
**no_block_param.rb**
```ruby
# typed: true
def foo
end
i = 0
while i <= 100_000_000
foo
i += 1
endIt's a little odd that we have to define a block param in order to declare that we don't accept one
You can declare and type this block parameter manually, but the user does not have to. All methods with signatures (including all those in # typed: strict files) whose method definitions do not declare block parameters and thus whose sigs don't provide a type for those block parameters will get an implicit T.noreturn type.
This test just proves that it's possible to write that annotation manually, if you wanted.
I wonder if there's any potential runtime overhead for this.
The thing I wonder is whether it's possible to have runtime overhead in a method that uses the block param.
In all my tests, whether or not you explicitly name the block parameter doesn't matter for performance, as long as you don't do something like blk.call.
If there is a slowdown in this case, that's a case against annotations, because the act of adding an annotation slows things down. This is a separate issue from this PR, but there are two options:
- Maybe it's sufficient to land the changes in this PR: Allow
&to specify anonymous block parameter types #9768 (because you can't materialize aProcobject if the block parameter isn't a syntactically valid variable name. - If that's not sufficient, maybe we could extend the spirit of those changes to allow specifying a type for
"&": ...in the signature, even if the block parameter was omitted in the method def.
update It looks like there is overhead
❯ rg -A 1 def
yield_no_name.rb
3:def foo
4- yield
named_but_yield.rb
3:def foo(&blk)
4- yield
blk_call.rb
4:def foo(&blk)
5- blk.call
| def self.implicit_yield | ||
| # ^^^^^^^^^^^^^^^^^^^^^^^ error: uses `yield` but does not mention a block parameter | ||
| yield | ||
| # ^^^^^ error: Method `call` does not exist on `NilClass` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The errors changing to being about NilClass values is a little misleading. Is there any way that we could catch these and give a more specific message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could, though if you don't pass a block to a method, the block parameter is nil at runtime.
The alternative I can think of seems overkill: we could LoadArg to a special type like Sorbet::Private::Static::NoBlock and attempt to catch uses of that type in dispatchCallSymbol. But it would leak into all calls to isSubType if we don't also guard all of those calls.
I will say that the "does not exist on NilClass" is at least much less confusing than the first version of this that I threw together, which left the type as T.noreturn... the dead code errors were very confusing (and it also caused problems with hover, because it would make code look dead, but the dead code errors would be silent).
I see you approved this, so I'm going to go ahead and land this, but if you have feedback async for how you think we should improve this lmk.
This reverts commit c6519ff.
* Reapply "Use T.noreturn for "does not take a block" (#9789)" (#9800) This reverts commit 8985e1c. * Add test showing behavior * Also check whether it's a subtype of bottom The `== nilClass` check is redundant with the later `isSubType` of check, but the `nilClass` check is an integer equality comparison because ClassType's are inlined types, so we may as well short circuit after that cheat check.
Motivation
This solves for the case of missing errors on the fast path at the same time as
opening the door to one day expand our "does not take a block" logic to more
than just
# typed: strictfiles, by removing our reliance onisSyntheticBlockParameterTest plan
See included automated tests.