Project

General

Profile

Actions

Feature #22213

closed

Allow no-argument and chained calls of Proc#refined

Feature #22213: Allow no-argument and chained calls of Proc#refined

Added by shugo (Shugo Maeda) 22 days ago. Updated 10 days ago.

Status:
Closed
Assignee:
-
Target version:
-
[ruby-core:126165]

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:

original = -> {}
refined = original.refined
p refined.equal?(original) #=> true

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:

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:

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.


Related issues 1 (0 open1 closed)

Related to Ruby - Feature #22097: Add Proc#with_refinementsClosedshugo (Shugo Maeda)Actions

Updated by shugo (Shugo Maeda) 22 days ago Actions #1

Updated by shugo (Shugo Maeda) 19 days ago Actions #2

  • Description updated (diff)

I've updated the implementation to defer the copy of the block until the returned Proc is first called: https://github.com/ruby/ruby/pull/18052/changes/d4a6282429f59f45f2c5f482618cdcaf63ff0df4
A Proc that is never called is never copied, and a chained call is now memoized as a whole: by the associativity law, prc.refined(a).refined(b) shares its cached copy with prc.refined(a, b), so the previous limitation that chained calls are not memoized is gone. I've updated the Implementation section of the description accordingly.

Updated by matz (Yukihiro Matsumoto) 11 days ago Actions #3 [ruby-core:126288]

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.

Updated by shugo (Shugo Maeda) 10 days ago Actions #4

  • Status changed from Open to Closed

Applied in changeset git|39e43e1c203cda09f0cbbfc1991f895a91043ddf.


[Feature #22213] Allow no-argument and chained calls of Proc#refined

Since prc.refined(*ms).refined(*ns) is behaviorally equivalent to
prc.refined(*ms, *ns), the copy of the block is deferred until
the first call and the memo is shared by these behaviorally
equivalent Procs. A Proc that is never called is no longer copied
at all.

Co-Authored-By: Claude Opus 5
Co-Authored-By: Claude Fable 5

Updated by shugo (Shugo Maeda) 10 days ago Actions #5

I forgot to mention the change to Proc#==: it now compares not only the underlying iseq but also the sequence of modules passed to Proc#refined.

The behavior of Proc#== is unchanged for Procs that don't use Proc#refined.

In CRuby, prc.refined(A).refined(B) == prc.refined(A, B) returns true as long as the intermediate Proc has not been called before the chained call, but I don't think other implementations should be required to guarantee this.

So the spec only adds cases that must return false, such as prc.refined(A) == prc.refined(B).

Actions

Also available in: PDF Atom