Project

General

Profile

Actions

Feature #19764

open

Introduce defp keyword for defining overloadable, pattern matched methods

Added by zeke (Zeke Gabrielse) 10 months ago. Updated 9 months ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:114137]

Description

Pattern matching has become one of my favorite features of Ruby, if not my favorite. It changed the way I write and express my thoughts through clean, maintainable code. And I'd like to use it more.

I propose a new keyword, defp, for defining a method which applies pattern matching to its arguments.

defp call(String => s unless s in /^[a-z]/)
  puts "string: #{s.inspect} (capitalized)"
end

defp call(String => s)
  puts "string: #{s.inspect}"
end

defp call(Hash(foo:, bar:) => h)
  puts "hash: #{h.inspect}"
end

defp call(**nil)
  puts "no keyword args"
end

call("Example") # => string: "Example" (capitalized)
call("test")    # => string: "test"
call(foo: 1, bar: 2) 
# => hash: { :foo => 1, :bar => 2 }

Internally, this could be represented as the following case..in pseudocode:

def call(...)
  case ...
  in String => s unless s in /foo/
    puts "string: #{s.inspect} (not foo)"
  in String => s
    puts "string: #{s.inspect}"
  in Hash(foo:, bar:) => h
    puts "hash: #{h.inspect}"
  in **nil
    puts "no keyword args"
  else
    raise NoMatchingMethod
  end
end

As you could imagine, this could be used to refactor a lot of code, making the developer's intent much clearer. From complex methods that use case statements for taking varied arguments (I'm sure all our code bases contain such case statements), to defining smaller, simpler methods that handle particular argument patterns.

In addition, not only can this improve code quality, but it brings in method overloads, and it also adds a way to define more typing to the language -- something that RBS has tried to do, to mixed reactions -- but in a more Ruby-like way that Rubyists are already learning and loving.

Thoughts?

Original idea by Victor Shepelev: https://zverok.space/blog/2023-05-05-ruby-types.html

Further discussion: https://news.ycombinator.com/item?id=35834351


Related issues 1 (1 open0 closed)

Has duplicate Ruby master - Feature #20318: Pattern matching `case ... in` support for triple-dot argumentsOpenActions
Actions #1

Updated by zeke (Zeke Gabrielse) 10 months ago

  • Description updated (diff)

Updated by Dan0042 (Daniel DeLorme) 10 months ago

I think there's two ideas here that need to be considered separately. One is method overloading, and the other is pattern matching in the method signature.

Method overloading has never existed in ruby. Why is it not possible to have both def foo(x) and def foo(x,y)? I don't know if it just happened that way or if it was a conscious design decision by Matz. The idea of automatic dispatching without any boilerplate code is certainly appealing. But when needed I find it's not too much trouble to write a case statement with dispatch to different sub-methods. I have written code like this before, but not very often. Not often enough that I can say it's worth the extra complexity of building it into the language.

On the other hand, having pattern matching in the method signature is something I would love to see. It can have so many uses.
def foo(nb => Integer) class validation
def foo(minutes => 0..59) range/structure validation
def foo(if: => condition) alias for keyword argument (#18402)
def foo(v => @hostname) DRY way to set instance variables (#15192 and many others)

Updated by rubyFeedback (robert heiler) 10 months ago

I do not have a particularly strong opinion either way (pro or con),
largely because I am using ruby more from an OOP-centric point of view,
so pattern matching, strong (mandatory) types and so forth aren't
quite the way how I use ruby usually.

I did want to say something about style, though. The threadstarter
made this statement:

"in a more Ruby-like way that Rubyists are already learning and loving."

I believe that style, beauty and elegance is (mostly) subjective, to some
extent, excluding perhaps simple objective components, such as number
of tokens required to express something in a given programming language.

For instance, to me personally - and I am sure others may agree or disagree -
the above may not be extremely "ruby-like" or elegant, such as:

defp call(Hash(foo:, bar:) => h)

So I believe reasoning primarily from a, mostly subjective, point of view will
not be correct at all times. People are different, so are their preferences;
matz pointed this out in the past. I believe this is one reason why ruby is
fairly flexible too, with the "more than one way to do something" philosophy
(and to some extent, syntax-wise). People can focus more on their own
preferences in how they want to write code, express ideas, and so forth. Ruby's
syntax is a lot more flexible than python's, for instance, which can be useful
for DSL-centric design "out of the box". I like ruby's flexibility here more
than python's "only one way to go about something" philosophy.

In regards to syntax, I believe one thing that also should be discussed
is whether a new def-keyword should be added in general; and, if so,
specifically for pattern matching. zverok did not make a suggestion for
this on the ruby issues tracker (I believe), so I would assume that for
now he only discussed it on the blog. From this context I would reason
that an additional discussion should be whether new def-centric keywords
should be added (or not)
. We have "def", which python also uses, and I think most
will agree that this is short, succinct, and to the point; and we have
define_method() which may be used for meta-programming like functionality
(I use this in a few project with instance_eval or class_eval sometimes,
for instance, batch-generation of HTML colour keywords, such as "def steelblue"
where I did not want to write like 500x "def", so I just use define_method
instead). So this should be part of a discussion, whether "defp" is necessary
as a new keyword due to pattern matching needs. I am not sure this is the
case, but either way my point is that it should be discussed as well, no
matter the outcome.

Also, Dan0042 gave some "def" examples, but I think these are different
from "defp" examples. So that also seems a bit a separate discussion.

Perhaps MRI itself should stay somewhat more conservative. We had unusual
ideas in the past, e. g. evil.rb and shapechanging classes/objects. Perhaps
we could have "sub-type" dialects of ruby - not necessarily in MRI, but as
add-ons to the language itself directly (similar to evil.rb, such as a
"functional" sub-project where all these ideas could be bundled; although
people can say that this could be a separate gem and perhaps it may be
bundled or not. The question then would be whether that should be made
available for all ruby users to use or not. Not everyone uses pattern
matching, for instance.)

Last but not least, since zverok's blog mentioned it: it's not necessarily
only matz that is not the best fan of (mandatory?) typing additions to
ruby. I, for instance, always feel that the typing makes ruby uglier. This
is also subjective of course, and as long as I can defend my code base
against mandatory typing I don't quite care as much anyway. People can
then use what they want - as long as I don't have to I have no real gripe
with it, even if I am not the biggest fan. But it's not correct to assume
only matz would not be the biggest fan - others may not be the biggest fans
either. You had a similar situation with the "it versus implicit numbering
of block arguments", by the way. People felt that "it" is more expressive
than implicit numbers and even if I may not be completely convinced that
this is the case, they do have a point too, at the least when one compares
"it" to _1 _2 _3. (Kokubun also made another good argument that _1 may
imply more than one number too, which is not always true, and I agree with
that point of view.)

PS: In regards to "defp", others may wonder why pattern matching gets its
own keyword. Why not other functionality too? We could perhaps reason in
favour of "defa", "defb" and so forth and I am not sure it may all be
necessary or make sense to have a proliferation of special-purpose keywords.

Updated by nobu (Nobuyoshi Nakada) 9 months ago

zeke (Zeke Gabrielse) wrote:

def call(...)
  case ...
  in String => s unless s in /foo/
    puts "string: #{s.inspect} (not foo)"
  in String => s
    puts "string: #{s.inspect}"
  in Hash(foo:, bar:) => h
    puts "hash: #{h.inspect}"
  in **nil
    puts "no keyword args"
  else
    raise NoMatchingMethod
  end
end

BTW, I found this would be able to extend.

def call(...)
in String => s unless s in /foo/
  puts "string: #{s.inspect} (not foo)"
in String => s
  puts "string: #{s.inspect}"
in Hash(foo:, bar:) => h
  puts "hash: #{h.inspect}"
in **nil
  puts "no keyword args"
end

Updated by baweaver (Brandon Weaver) 9 months ago

Going to go through a few points here, sorry for the long reply.

Taking - A gem implementation

I had hacked this behavior together at one point with Taking: https://github.com/baweaver/taking

Point = Struct.new(:x, :y)

def handle_responses(...) = case Taking.from(...)
  in Point[x, 10 => y]
    Point[x, y + 1]
  in 1, 2, 3
    :numbers
  in 'a', 'b'
    :strings
  in :a, :b
    :symbols
  in x: 0, y: 0
    :origin
  in x: 0, y: (10..)
    :north
  else
    false
end

# Array-like

handle_responses(1,2,3)
# => :numbers
handle_responses('a', 'b')
# => :strings
handle_responses(:a, :b)
# => :symbols
handle_responses(:nope?)
# => false

# Hash-like

handle_responses(x: 0, y: 0)
# => :origin
handle_responses(x: 0, y: 15)
# => :north
handle_responses(x: 10, y: 15)
# => :false

# Deconstructable Object

handle_responses(Point[1, 10])
# => Point[1, 11]

Precedent with Rescue

That said I could see a case for replicating the way rescue works currently, as it does establish a precedence:

def some_method(args)
  # body
rescue
  # handling code
end

I can see a case for doing that for pattern matching, amending the previous examples above:

def handle_responses
in Point[x, 10 => y]
  Point[x, y + 1]
in 1, 2, 3
  :numbers
in 'a', 'b'
  :strings
in :a, :b
  :symbols
in x: 0, y: 0
  :origin
in x: 0, y: (10..)
  :north
else
  false
end

Potential Issues and Pitfalls

Now while I generally like the idea there are a number of problems that this might present we must cover to be fair to the language. There are a lot of questions here, and not all of them need to be answered to justify such a feature, but would need to be answered insofar as implementations are concerned for core folks, and these certainly are not comprehensive.

Args vs Body

What happens if someone uses the top level or uses the regular arguments syntax?:

def some_method(a, b, c)
  # body ???
in pattern
end

Should pattern take precedence, or the top body? Should we even allow method arguments in the case there are patterns applied? Let's say we don't have arguments to the method in these cases and someone writes a method body outside of an in pattern right above the first one, what should we do then?

Super

How would we handle super with this? Argument forwarding and signatures could become very interesting here, especially if Sorbet or Steep get involved.

Typing

Speaking of, it would be real fun to make Sorbet and Steep play nicely with this. It'd pretty well crash the syntax as it exists today and require a decent amount of work to support.

Rescue

Would assume that this would remain top level and apply to every branch like so:

def some_method
in pattern
  # ...
rescue something
  # ...
end

Absurdity

What happens if we don't handle every case? What if there's no else? Should that raise an exception for an unhandled case?

At least with static languages the absurdity clause (all inputs must be handled) it's easier to guarantee. In Ruby this would be a decent bit harder to do so perhaps just raising exceptions on unhandled is the easiest.

Signatures

What happens if you use a mix of positional and keyword arguments? Pattern matching also has find patterns (*, arg, *) that might not translate cleanly. How do we translate those into arguments?

Perhaps in this case we don't and we pass it with ... much like the above, but then that brings up more fun with the next area.

Performance

This would be hard to optimize, and probably to JIT. It's possible but essentially with the above you end up with forwarding all arguments instead of a very restricted set you know you'd need.


Anyways, not saying don't do it, but this would be quite a task to really sort out all the ways it could be (ab)used and all the edge cases.

Actions #6

Updated by shyouhei (Shyouhei Urabe) 23 days ago

  • Has duplicate Feature #20318: Pattern matching `case ... in` support for triple-dot arguments added
Actions

Also available in: Atom PDF

Like10
Like0Like1Like0Like0Like0Like0