Feature #22213
openAllow no-argument and chained calls of Proc#refined
Description
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:
and activating all the given modules for chained calls?
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:
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:
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
Proc#refined on an already refined Proc is not memoized: the cache would be keyed on short-lived copies, and entries are retained for the VM's lifetime, so it would grow without bound. A performance warning is emitted under Warning[:performance] = true, and passing all the modules in a single call is more efficient.