[ruby-core:126165] [Ruby Feature#22213] Allow no-argument and chained calls of Proc#refined
Issue #22213 has been reported by shugo (Shugo Maeda). ---------------------------------------- Feature #22213: Allow no-argument and chained calls of Proc#refined https://bugs.ruby-lang.org/issues/22213 * Author: shugo (Shugo Maeda) * Status: Open ---------------------------------------- ## 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 `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. -- https://bugs.ruby-lang.org/
Issue #22213 has been updated by matz (Yukihiro Matsumoto). Both behaviors are what I would expect, and I accept them. Returning the receiver for a no-argument call is right. refined with no modules asks for nothing, and there is no reason to pay for a copy to represent that; Data#with and Enumerator::Lazy#lazy set the precedent. It also removes the unless modules.empty? guard from every caller that computes the module list at runtime, which is the kind of bookkeeping the language should absorb rather than push onto the user. Extending rather than replacing for chained calls is also right. Replacement would make prc.refined(M).refined(M2) quietly drop M, and it would leave the DSL case in the Motivation with no way to add refinements to a block that a library has already refined. Extension matches nested using, where later refinements take precedence. Please go ahead. Matz. ---------------------------------------- Feature #22213: Allow no-argument and chained calls of Proc#refined https://bugs.ruby-lang.org/issues/22213#change-118381 * Author: shugo (Shugo Maeda) * Status: Open ---------------------------------------- ## 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 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 the cache is keyed on the block and entries are retained for the VM's lifetime; a performance warning is emitted for such a call under `Warning[:performance] = true`. -- https://bugs.ruby-lang.org/
participants (2)
-
matz (Yukihiro Matsumoto) -
shugo (Shugo Maeda)