As I mentioned in my previous post, in my new role at
Shopify, I've been doing backend development in Ruby. Previously I had been
working nearly exclusively with Python for over ten years, so I was a bit
nervous about the move.
In addition to my regular work, I also tried to solve other types of
problems using Ruby. Advent of code was a
really great way to learn a new language.
After nearly two years in the new role, I'd like to share some of my
experiences, hopefully as a way to help and encourage others who would
like to learn a new language, or are worried about moving into a new role,
but don't know some specific technology.
Early pains
The first few weeks with Ruby were pretty tough. Luckily, Ruby shares some
similarities with Python that make it a bit more approachable at first
glance:
- Dynamically typed, interpreted language
- Class / method syntax is similar
Method calls don't need ()
One of the first things that tripped me up was not understanding that using
() to call a method is not required in Ruby.
def greet
puts "Hello, world!"
end
greet
# => "Hello, world!"
In fact, () are optional when passing arguments as well!
def greet(name)
puts "Hello, #{name}"
end
greet "Chris"
# => "Hello, Chris"
This can be used to build some very nice DSL features, resulting in code
that is much more readable. e.g. Rail's
delegate method
require "rails" # to get delegate
class Message
def greet
puts "Hello!"
end
end
class Greeter
delegate :greet, to: :message
attr_accessor :message
def initialize
@message = Message.new
end
end
Greeter.new.greet
# => "Hello!"
Implicit return
Like Rust, the result of the last expression in Ruby is used as the return
value of a function.
def add_one(x)
x + 1
end
add_one(2)
# => 3
Strings & Symbols
Ruby has a concept called symbols,
which are a kind of identifier using the :
prefix. They're often used to
reference method names, since you can't get a reference to a method just by
accessing it by name like in Python. E.g. obj.foo
will call the foo
method on obj
in Ruby, whereas it will give you a reference to the foo
method in Python. The equivalent in Ruby would be obj.method(:foo)
Symbols are also used for named parameters in method calls. E.g.
def greet(message:)
puts "Hello #{message}"
end
greet(message: "world!")
# => "Hello world!"
However, where symbols presented me with the steepest learning curve is how
they're handled in Hash (i.e. dict) literals.
a = "key"
h = {
a: 1,
a => 2,
}
# => { :a=>1, "key"=>2 }
It's extremely easy to get the two ways of defining a value mixed up. I've
wasted an embarrassing number of hours on bugs caused by mixing up strings
and symbols as the keys in hashes.
Range expressions
Ruby has nice built-in syntax for ranges:
(0..5).to_a
# => [0, 1, 2, 3, 4, 5]
(0...5).to_a
# => [0, 1, 2, 3, 4]
These are super convenient, but I almost always forget which form is
inclusive and which is exclusive.
A-ha! moments
Blocks
Before really learning Ruby, I remember trying to read up on what blocks
were...and not really getting it.
The best way I can come up with to explain them now is that they're a kind
of anonymous function / closure.
Part of my confusion was not understanding that there are a few ways of
defining and calling blocks.
These are equivalent:
mymap(0...5) do |x|
x ** 2
end
# is the same as
mymap(0...5) { |x| x ** 2 }
The block's arguments are passed via the identifiers between the pipe (|
)
symbols.
In both cases, the mymap
method is being passed a block, which in our
case gets executed once per element (but that's completely up to the
implementation of mymap
). The block can be named as an explicit
function parameter, and this could be written as:
def mymap(obj, &block)
result = []
for i in obj
result.push(block.call(i))
end
result
end
The block can also be passed implicitly (check using the
block_given?
method) and called via yield
:
def mymap(obj)
result = []
for i in obj
result.push(yield i)
end
result
end
Once I wrapped my head around blocks, I found them to be very useful!
"&:" idiom
Ruby has this really neat shorthand for creating a block that calls a
method on an object: "&:
". It's used like this:
["hello", "world"].map(&:upcase)
# => ["HELLO", "WORLD"]
There are a few things going on here:
-
:upcase
is a Symbol referring to the upcase
method on the object
-
&
tries to convert its argument to a kind of closure using the
argument's .to_proc
method.
Symbol#to_proc
returns a closure that
calls the given method on the passed in object.
The net result is something equivalent to
["hello", "world"].map { |s| s.send(:upcase) }
# OR
["hello", "world"].map { |s| s.upcase }
Brian Storti explains this in much more detail in his blog post.
Enumeration
Ruby has fantastic enumeration primitives built in, just checkout the
Enumerable module. Most of the
basic container types in Ruby support Enumerable
; when combined with blocks,
this makes filtering and transforming data in Ruby a joy.
It also fits my brain better than Python's generator / comprehension
expressions. When writing Python code to transform data, I often found that
I was writing some code, then backing the cursor up to the beginning of the
line.
In Ruby the data and logic flow from left to right, which makes it easy to
chain thing together.
(0...5).map { |x| x ** 2 }
In Python the logic is on left but the data is on the right.
If I want to get only the square numbers that are even, I would simply
add this in Ruby:
(0...5).map { |x| x ** 2 }.filter(&:even?)
Whereas in Python I would need to introduce more code before/after the
expression I already have to achieve the same result:
[y for y in [x**2 for x in range(5)] if y % 2 == 0]
tl;dr
Ruby is a really nice language. I'm glad I've had the opportunity to learn
it!
Python has a philosophy of "There should be one-- and preferably only one
--obvious way to do it.". I think this
helps with Python's readability at the cost of expressibility, elegance,
and conciseness in some cases.
In Ruby there are often multiple ways of achieving the same thing. It has a
richer vocabulary for expressing ideas in code. This richness allows for
more elegant code in many cases, although this perhaps requires more effort
on the part of the reader to understand the code.