Exploring Method Arguments in Ruby: Part 3
In the first and second parts of this series we talked about positional and keyword arguments, but we still have some extra options so that our methods can do anything we want.
In this final part we are going to explore blocks, array decomposition, partially applied methods and a few syntax tricks. We’ll also study a few known methods to understand how everything is used in real world applications.
Block Argument
Sometimes it’s not enough to pass a variable to a method, sometimes we need to provide some customized code to modify what a method does. We can provide a block when calling a method following a special block syntax (really common in ruby):
def foo(&my_block)
my_block.call
end
foo do
puts 'hi'
end
# => hi
foo do
puts 2 + 2
end
# => 4
You can pass arguments to the block call:
def foo(&my_block)
my_block.call('hi')
end
foo do |message|
puts message
end
# => hi
The block argument can be combined with the rest of the argument types, but there can be only one block argument and it must be the last one.
def foo(arg1, &block)
puts "arg1 is: #{arg1.inspect}"
block.call(arg1.reverse)
end
foo('arg1') do |message|
puts message
end
# => arg1 is: arg1
# 1gra
Implicit Block Argument
If you are just calling the block and you don’t need it as a variable, you can use an implicit block and a special yield
statement to call it.
def foo(arg1)
puts "arg1 is: #{arg1.inspect}"
yield(arg1.reverse)
end
foo('arg1') do |message|
puts message
end
# => arg1 is: arg1
# 1gra
So, as we see, we can always pass a block argument even if a method doesn’t actually use it!.
When we write a method, we may need to know whether the user provided a block or not. If the block parameter is explicit we can just check that it isn’t nil, but we have a safer way of checking that also works with implicit blocks:
def foo(arg1)
if block_given?
yield(arg1)
else
puts "you have to provide a block"
end
end
foo('arg1')
# => you have to provide a block
foo('arg1') do |message|
puts message
end
# => arg1
Array Deconstruction
Sometimes we may have a method that accepts an array as one of the arguments. Usually we just use a normal argument and extract the values inside the method:
def foo(my_arr)
el1, el2 = my_arr
# ...
end
foo([1,2])
But we can do that when defining parameters so we don’t need that line and we get the benefit of being able to use the array elements when defining the default value of other arguments:
def foo((el1, el2), arg2 = el1 * 5)
puts el1
puts el2
puts arg2
end
foo([1, 2])
# => 1
# 2
# 5
foo([1, 2], 'something')
# => 1
# 2
# something
If our array includes more elements than the expected, the rest are discarded!
Ignoring Arguments
If you want to accept any number of arguments but not use them (imagine you are implementing a replacement of a library you are using and you have to respect the method calls but don’t really need the arguments) you can use the *
and **
operators with no name for the parameter:
def foo(*, **)
puts 'I ignore everything'
end
foo(1, 2, 'three', more_things: 'ignored')
# => I ignore everything
Arguments Delegation
If you are writing a wrapper around another method, you usually need to accept the same arguments, do something, and then call the original method. If we need to use exactly the same arguments and we don’t need them for our extra code, we can use a special argument for delegation introduced in Ruby 2.7 (the ...
operator):
def foo(...)
bar(...)
end
def bar(arg1, arg2)
puts "arg1 is: #{arg1.inspect}"
puts "arg2 is: #{arg2.inspect}"
end
foo(1, 2)
# => arg1 is: 1
# arg2 is: 2
Partially Applied Methods
We may have a process that does multiple calls to one method using mostly the same arguments and only changes some of them. To refactor those calls we can use the concept of function (or method) generators. The idea is to get our method, apply only some arguments, and then have a new method that requires only the rest of the arguments that were not applied yet.
Let’s say we have a generic method to multiply two numbers:
def multi(num1, num2)
puts num1 * num2
end
Now let’s imagine we want to have a method that multiplies by 3 because we are using that a lot. We can reuse that original method and only apply the number 3 using the curry
method on the method object:
multi3 = method(:multi).curry.call(3)
# ^ we use method(:mutli) to get the method object
# instead of calling the method
Now we can use that new method (it’s actually a Proc) to have different numbers multipied by 3:
multi3.call(2) # => 6
multi3.call(5) # => 15
# .() is a shorthand for .call()
multi3.(10) # => 30
Case Study 1: link_to
Let’s analyze Rails’ link_to
method.
# all arguments are optional, it just prints an empty "A" tag
def link_to(name = nil, options = nil, html_options = nil, &block)
# when we call this method with a block, our first argument is
# not the name for the link, it's actually the options, and the
# second argument is actually the html_options for the link
html_options, options, name = options, name, block if block_given?
# it sets default options here instead of using a default value for
# the parameter because the previous line changes what each
# parameter actually is depending if there a block given
options ||= {}
html_options = convert_options_to_data_attributes(options, html_options)
url = url_for(options)
html_options["href".freeze] ||= url
# it's a wrapper over 'content_tag' method
content_tag("a".freeze, name || url, html_options, &block)
end
Case Study 2: pluralize
Let’s analyize another example: the pluralize
helper method:
# it requires a count and a word in singular
# we have two ways to set the 'plural' of the word:
# - using the third positional argument
# - using the 'plural' keyword argument
# the keyword argument uses the positional as the default value!
# if we leave the plural form empty, it will use the I18n module
# to infere the plural form, we can also include a 'locale' keyword
# argument but it defaults to the current locale
def pluralize(count, singular, plural_arg = nil, plural: plural_arg, locale: I18n.locale)
word = if (count == 1 || count.to_s =~ /^1(\.0+)?$/)
singular
else
# if we didn't provide a plural form, it uses the string's pluralize method
plural || singular.pluralize(locale)
end
"#{count || 0} #{word}"
end
Conclusion
We have covered more ways to make our methods super flexible so that we can use and customize their behavior to our needs.
There are more small tricks that we can use, but it would make this article too long and repetitive, I recommend reading this official anouncement about the small differences introduced for Ruby 2.7 and Ruby 3.