Project

General

Profile

Actions

Feature #22213

open

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) about 21 hours ago.

Status:
Open
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

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.


Related issues 1 (0 open1 closed)

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

Updated by shugo (Shugo Maeda) about 21 hours ago Actions #1

Actions

Also available in: PDF Atom