Project

General

Profile

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. 

Back