The other day while working on the Blocks section for the Crystal's tutorial, I came across something interesting about blocks, methods and overloading.
Overloading
Let's start by reviewing the concept of method overloading in Crystal.
In Crystal we can define methods with the same name but some difference and for the compiler they will be seen as different methods.
Here is an example:
def transform(str : String) : Int32
str.size
end
def transform(n : Int32) : Int32
n + 1
end
transform("Crystal") # => 7
transform(41) # => 42
In the above example we have two methods with the same name transform
but the difference is the type restriction of the parameter.
Here is another example:
def transform(str : String)
yield str
end
def transform(str : String)
str.capitalize
end
transform("Crystal") { |str| "Hello #{str}" } # => "Hello Crystal"
transform("crystal") # => "Crystal"
In this last example, the difference between both methods is that the first one receives a block, in addition to the String
parameter. To make this difference more clear, we can add the block parameter explicitly:
def transform(str : String, & : String -> String)
yield str
end
def transform(str : String)
str.capitalize
end
transform("Crystal") { |str| "Hello #{str}" } # => "Hello Crystal"
transform("crystal") # => "Crystal"
The following is the list of differences that allows overloading a method:
- The number of parameters
- The type restrictions applied to parameters (first example)
- The names of required named parameters
- Whether the method accepts a block or not (last example)
π€ What if ...
As I was writing about blocks, I wonder if the type restrictions over a block parameter could allow method overloading.
For example, are the following transform_string
methods considered different?
def transform_string(word : String, & : String -> String)
block_result = yield word
puts block_result
end
def transform_string(word : String, & : Int32 -> String)
block_result = yield word.size
puts block_result
end
transform_string "crystal" do |word|
word.capitalize
end
transform_string "crystal" do |number|
"#{number}"
end
Note: The first method defines a block parameter of type String -> String
. And the second method defines a block of type Int32 -> String
.
The output was:
Error: undefined method 'capitalize' for Int32
Oops! Not the expected output π
The problem is that, for the compiler, the second method is the only definition for transform_string
(meaning, the first definition is simply overridden by the second one). And this happens because the compiler does not use blocks for method overloading. Here is the why, explained in a comment by Johannes:
Block arguments are defined by the method that yields, not by how the method is called; the yielding method canβt behave differently depending on the block. The compiler needs to find the method before it looks at the block, which means block arguments cannot be used to find the method.
But, what if ...
Blocks and Procs
After talking to Beta and Johannes, they proposed a solution very close to what we are trying to implement:
We can use a Proc (created from a captured block) as the methods' argument.
π€―
First, let's see an example on how a Proc
is created from a captured block:
proc = Proc(Int32, String).new { |x| x.to_s }
typeof(proc) # Proc(Int32, String)
# when can invoke it using `call`:
proc.call 42 # => "42"
So now we need to change the transform_string
definitions and implementations, like this:
def transform_string(word : String, block : String -> String)
block_result = block.call word
puts block_result
end
def transform_string(word : String, block : Int32 -> String)
block_result = block.call word.size
puts block_result
end
We have replaced the block parameter with a Proc
parameter, and related to this change, we are using the method call
to invoke the Proc
π.
Let's see if this works as expected:
def transform_string(word : String, block : String -> String)
typeof(block) # => Proc(String, String)
block_result = block.call word
puts block_result
end
def transform_string(word : String, block : Int32 -> String)
typeof(block) # => Proc(Int32, String)
block_result = block.call word.size
puts block_result
end
proc_string_string = Proc(String, String).new do |word|
word.capitalize
end
transform_string("crystal", proc_string_string)
proc_int32_array = Proc(Int32, String).new do |number|
"#{number}"
end
transform_string("crystal", proc_int32_array)
The output:
Crystal
"7"
It worked! Yeah! π€π
Let's rewrite the code in a more concise way:
def transform_string(word : String, block : String -> String)
typeof(block) # => Proc(String, String)
puts block.call word
end
def transform_string(word : String, block : Int32 -> String)
typeof(block) # => Proc(Int32, String)
puts block.call word.size
end
transform_string "crystal", Proc(String, String).new { |word| word.capitalize }
transform_string "crystal", Proc(Int32, String).new { |number| "#{number}" }
And it's working perfectly! π€©
π€ What if ... (part 2 πΉ)
We got our code working but at the same time we discovered that the compiler can not distinguish two methods differing only in the blockβs type.
So letβs think on how we could improve Crystal to allow it.
Here is a proposal: maybe we can give more information to the compiler about the parameter's type restriction when defining the block π€. Something like this:
transform_string "crystal" do |word : String|
word.capitalize
end
This way the compiler can "see" that we are defining a block, whose input is a String
and also returns a String
and so can select the correct implementation for transform_string
:
def transform_string(word : String, &block : String -> String)
puts yield word
end
transform_string "crystal" do |word : String|
word.capitalize
end
Farewell and see you later
Let's recap:
We have reviewed some interesting concepts (method overloading, Blocks and Procs) and we have a way to overload methods using the type restrictions of the Blocks Proc parameter.
Hope you enjoyed it! π
Thanks, thanks, thanks to Beta and Johannes for reviewing this post and improving the code and text!! πππ
Top comments (0)