DEV Community

loading...

Discussion on: An Introduction to Yield in Ruby, and filtering class instances

Collapse
baweaver profile image
Brandon Weaver

A few asides before I get into it:

  • You can use ruby after triple backticks to get Ruby syntax highlighting instead of just white text. It helps a lot for readability.
  • parens and explicit return statements aren't always necessary
  • DRY can be overdone, everything in moderation, and premature abstraction can bite you.
  • Ruby comments start with # instead of //
  • Ruby tends towards snake_case_names over camelCaseNames

Now then, article wise.

It may be good to mention that yield is effectively the same as this:

def hello2(&block)
  block.call
end

yield just implies it. block_given? is also a nice feature for checking if the caller actually gave a block function or not.

Functions vs Yield blocks

Point One

Not sure what you mean here? A yielded block is a function, and can be reused. You may want to clarify that function is really a method, because function can be more commonly understood to mean proc or lambda instead in Ruby.

Point Two

yielded functions are closures, which means they can see the context where they were created.

def testing_one
  a = 1
  yield
end

b = 2
testing_one { { a: defined?(a), b: defined?(b) } }
# => {:a=>nil, :b=>"local-variable"}

Now when you mention not being able to change Integers that's more because they're primitive values. This will work the same across Procs, blocks, lambdas, methods, and anything else in Ruby.

For Strings though, those are mutable unless you have frozen strings on:

s = "foo"
# => "foo"
testing_one { s << "bar" }
# => "foobar"
s
# => "foobar"

s.freeze
# => "foobar"
testing_one { s << "baz" }
# FrozenError: can't modify frozen String

Point Three

return will end any function, whether or not a yield is involved. Now inside a function, return does some real interesting and potentially confusing things depending on what type it is:

a = proc { return 1; 2 }
# => #<Proc:0x00007f81c33877b8@(pry):22>
a.call
# LocalJumpError: unexpected return

b = lambda { return 1; 2 }
# => #<Proc:0x00007f81c0549ea0@(pry):24 (lambda)>
b.call
# => 1

a2 = proc { next 1; 2 }
# => #<Proc:0x00007f81c1d52920@(pry):26>
a2.call
# => 1

Can't say I ever really understood that one myself, keeps catching me whenever I start using proc for some reason in my code so I tend to go for lambda / -> when possible. lambda also is more explicit about arguments and arity than a proc is, so it's easier to tell something broke.

Remember though that block functions are Procs:

testing_two {}
=> Proc

Point Four

This comes back to why, for me, I prefer the explicit passing of a block. Less magic and more easily understandable from a glance. Just remember that's a stylistic choice for me, do what makes more sense for your team.

Using Yield with Class Iterators

Enumerable is a very common use of this:

class Collection
  include Enumerable

  def initialize(items)
    @items = items
  end

  def each
    @items.each { |item| yield item }
  end
end

...which gets us all of the fun Enumerable methods like map and others.

Now to your example:

def filterPeople()
  people = Person.all.filter do |person|
    yield(person)
  end
  return people
end

A few quick cleanups and we have:

def filterPeople()
  Person.all.filter { |person| yield(person) }
end

filter will return an Array making the assignment redundant. The name for a function used to filter is a predicate.

Now a more pragmatic example of this might be to use the Enumerable trick from above:

class Person
  class << self
    include Enumerable

    def each
      all.each { |person| yield person }
    end
  end
end

This will allow you to directly control the People you get back:

Person.filter { |person| person.age > 18 }

Second Implementation

Your second implementation makes this a bit more complicated:

def filterPeople(args=nil)
  people = Person.all.filter do |person|
    yield(person,args)
  end
  return people
end

minAge = 14
maxAge = 18
filterPeople([minAge,maxAge]){|person,args|person.age > args[0] && person.age < args[1]}

...but you got real close to something fun in Ruby in the process. Let's assume we still have an Enumerable person class out there like above.

We're going to get into some metaprogramming, so buckle up, it's a trip.

For this one we'll need Object#send, ===, to_proc, and Hash to do some fun things with kwargs. If you've ever seen ActiveRecord, this will look familiar:

Person.where(age: 18..40, occupation: 'Driver')

We can do that using === and filter in Ruby by making our own class which responds to ===:

class Query
  def initialize(**conditions)
    @conditions = conditions
  end

  # Define what it means for a value to match a query
  def ===(value)
    # Do all of our conditions match?
    @conditions.all? do |match_key, match_value|
      # We use the key of the hash as a method name to extract a value,
      # then `===` it against our match value. That means the match value
      # could be anything like a `Regexp`, `Range`, or a lot more.
      match_value === value.send(match_key)
    end
  end

  # Make `call`, like `block.call`, point to `===`
  alias_method :call, :===

  # This will make more sense in the example below, but we can treat our class
  # like a function by doing this
  def to_proc
    -> value { self.call(value) }
  end

Why the to_proc and the ===? It allows us to do both of these:

# using `to_proc` via `&` to treat our Query class as a function
Person.select(&Query.new(age: 18..40, occupation: 'driver'))

# using `===` to call our query for a case statement:
case person
when Query.new(age: 18..30)
  # something
when Query.new(occupation: 'Driver')
  # something else
else
  # something else again
end

This pattern is super powerful, and ended up expanded in a gem I wrote called Qo. The problem is you have to do some more lifting to get it to work with Array and Hash values to match against them.

Ruby has a lot of potential for flexibility, especially when you know about === and to_proc and how to leverage them.

Other thoughts

Keep writing, it takes a bit to really get into all the fun stuff, and feel free to ask questions on any of that, I got a bit carried away in responding.