Feature #22097
Updated by shugo (Shugo Maeda) 4 months ago
## Abstract
I propose `Proc#with_refinements` to support block-level refinements.
```ruby
module StringExt
refine String do
def shout = upcase + "!"
end
end
original = ->(s) { s.shout }
refined = original.with_refinements(StringExt)
p refined.call("hello") # "HELLO!"
p original.call("hello") # NoMethodError
```
## Background and Motivation
I previously proposed `Proc#using` in [Feature #16461], but it introduced semantic complexities because it mutated existing blocks.
Instead of mutating the existing block, `Proc#with_refinements` returns a new `Proc` object with its own isolated call sites.
This approach makes its semantics much simpler than `Proc#using`, and it avoids thread-safety issues and plays nicely with inline caches.
## Limitation
Similar to `Proc#binding`, `Proc#with_refinements` raises ArgumentError if the receiver is not created from a Ruby block.
```ruby
:to_s.to_proc.with_refinements(StringExt) #=> ArgumentError
```
## Implementation
I've opened a pull request: https://github.com/ruby/ruby/pull/17248
A PoC for JRuby is also available at: https://github.com/jruby/jruby/pull/9486 https://github.com/shugo/jruby/pull/2
### Data structure changes
* Added a bit field `has_refinements` to `rb_proc_t`.
* Added a hidden instance variable to `Proc` to store a `cref` with the applied refinements.
* Added a single-entry cache `refinement_memo` to `rb_iseq_constant_body`.
### Deep copy of iseq and caching
`Proc#with_refinements` performs a deep copy of the receiver's `iseq` to isolate its call sites from the original `Proc`.
While a deep copy can be an expensive operation, the single-entry cache in `rb_iseq_constant_body` mitigates this overhead effectively for most practical use cases where the same refinements are applied repeatedly.
### Overhead for code not using Proc#with_refinements
* Memory footprint: Neither internal structure grows in size. `has_refinements` is a 1-bit field added to rb_proc_t's existing bit field, and `refinement_memo` shares a union with `mandatory_only_iseq` in rb_iseq_constant_body.
* Execution speed: The common `Proc#call` path is kept frameless and only adds a single `has_refinements` bit check.
* GC: The mark/free/memsize functions add a single branch per `iseq` to select the union member.
Benchmark results: https://gist.github.com/shugo/ddfe92f28ea31e6527a2f270e6daee7c
Here's an excerpt from the results, where `compare-ruby` is master and `built-ruby` is the branch for this feature (focusing on Proc/Block operations):
| |compare-ruby|built-ruby|
|:------------------------------------|-----------:|---------:|
|vm_proc | 47.215M| 46.149M|
| | 1.02x| -|
|vm_yield | 1.649| 1.754|
| | -| 1.06x|