Feature #22213
Updated by shugo (Shugo Maeda) 2 months ago
## Abstract
Currently, `Proc#refined` rejects no-argument and chained calls, because I wasn't sure what the desired behavior should be.
How about returning the receiver for no-argument calls:
```ruby
original = -> {}
refined = original.refined
p refined.equal?(original) #=> true
```
and activating all the given modules for chained calls?
```ruby
module M
refine String do
def shout = upcase + "!"
end
end
module M2
refine Integer do
def doubled = self * 2
end
end
original = ->(s, i) { [s.shout, i.doubled] }
refined = original.refined(M).refined(M2) # the same result as original.refined(M, M2)
refined.call("hi", 3) #=> ["HI!", 6]
```
## Motivation
This change is necessary to make `Proc#refined` composable.
A no-argument call of `Proc#refined` is a no-op, but it's useful when the arguments are calculated at runtime:
```ruby
def foo(prc, *modules)
...
refined = prc.refined(*modules)
...
end
```
If an empty module list were rejected, a guard like `unless modules.empty?` would be needed.
A chained call of `Proc#refined` is useful to extend existing DSLs:
```ruby
require "packrat_parser"
require "mathn"
class MathParser < PackratParser
def define(sym, &block)
# PackratParser#define also calls Proc#refined
super(sym, &block.refined(Math::N))
end
end
parser = MathParser.new {
...
define :multitive do
[:primary, "*", :multitive].map { |x, _, y|
x * y
} |
[:primary, "/", :multitive].map { |x, _, y|
x / y
} |
:primary
end
...
}
parser.parse("1 / 2") #=> (1/2)
```
## Formal semantics
With this proposal, `Proc#refined` can be formalized as a monoid action on Procs created from a block, where the acting monoid is the sequences of modules under concatenation, with the empty sequence as its unit. Writing ≃ for behavioral equivalence, the action satisfies the unit and associativity laws:
```
prc.refined ≃ prc # unit law
prc.refined(*ms).refined(*ns) ≃ prc.refined(*ms, *ns) # associativity law
```
Note that the unit law holds for any Proc, since `refined` with no modules returns the receiver itself.
I've written a formalization in the Rocq Prover: https://github.com/shugo/ruby-refinements-semantics
It abstracts away the details irrelevant to refinements.
## Design decisions
### The receiver or a copy for no-argument calls
Returning a copy was also considered, but I chose to return the receiver, following precedents such as `Data#with` and `Enumerator::Lazy#lazy`. Mutable containers tend to return a copy even for a no-op, but `Proc#refined` is closer to the former group.
### Extend or replace for chained calls
If a chained call replaced the receiver's refinements instead of extending them, `prc.refined(M).refined(M2)` would activate only `M2`. That breaks the associativity law above, and makes chaining useless for the DSL case in Motivation. Extending also matches nested `using`, where refinements activated later take precedence.
## Implementation
Pull request: https://github.com/ruby/ruby/pull/18052
The copy of the block is deferred until the returned `Proc#refined` on an already refined Proc is first called, so a Proc that is never called is never copied. Since the associativity law above makes `prc.refined(a).refined(b)` behaviorally equivalent to `prc.refined(a, b)`, a chained call is memoized like a single call of all the modules, and the two share a single cached copy. Only a Proc whose block is itself a copy, such as one obtained by chaining from a Proc that has already been called, is not memoized, because memoized: the cache is would be keyed on the block short-lived copies, and entries are retained for the VM's lifetime; a lifetime, so it would grow without bound. A performance warning is emitted for such a call under `Warning[:performance] = true`. true`, and passing all the modules in a single call is more efficient.