Daniel Orner
Posted on April 16, 2021
I've been an ardent Rubyist for over eight years now. I've dabbled in other languages, but at this point Ruby is the thing I'm most comfortable in.
Over time, languages and programming styles change and so do the people who use them. While the power and elegance of Ruby still makes it my go-to for writing things, a recent foray into Go is making me rethink some of the things I've been taking for granted.
Why Go?
Go has been used at Flipp for some time now, although not widely in my team. I wanted to use Go to create a command-line executable, something that Ruby unfortunately isn't capable of doing. (There are options, such as ruby-packer, but it seems like a "heavy" solution and doesn't seem to fit the Ruby paradigm.)
Also, I was curious about Go as a whole, so I figured it was a great opportunity to learn more.
Language Philosophies
Both Ruby and Go have extremely strong philosophies underpinning them. The guiding principle of Ruby is programmer happiness. To achieve that aim, Ruby tries to be as flexible as possible, providing multiple ways of achieving the same thing in an effort to speak to each developer's preferred way of thinking. It supports lots of dynamism, allowing developers to "reopen" any class or module, or create whole new ways of interacting with code such as creating new DSLs.
I found it really interesting when reading up on Go that it claims to have a similar philosophy. In their FAQ, it says "[Go was] designed by thinking about what programmers do and how to make programming, at least the kind of programming we do, more effective, which means more fun."
Having said that, it's pretty clear that Go is more of a polemic against systems languages such as C. When they say "more fun", they mainly mean more fun than C.
Go's real philosophy is a kind of extremist minimalism. The language is brutally strict about features that are and aren't allowed, keeping the feature set to an absolute minimum. The reasoning behind this is mainly that the language's raisons d'être are all about performance, speed, and simplicity. New features that might make the code itself slower or more complex are kiboshed.
As an example of the philosophical difference, here's how you would find an element inside an array in Ruby:
arr = [1, 2, 3]
arr.index(2) # 1
And here's how you'd find it in Go:
arr := []int{1, 2, 3}
result := 0
for i, v := range(arr) {
if (v == 2) {
result = i
}
}
Ruby, filtering out even numbers:
arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered = arr.select(&:even?)
Go:
arr := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
filtered := int[]{}
for i, v := range(arr) {
if (v % 2 == 0) {
filtered = append(filtered, v)
}
}
However, language design is only part of the story behind a language. Just as important is the ecosystem: the reusable code modules/packages, the people that manage them, which ones are "popular", what "best practices" are, etc.
One of the things I love the most about Ruby is that it tends to coalesce around one or two really popular libraries. Rails is the big one obviously, but over time you see libraries designed for a particular purpose "winning" over other things. This includes things like linting/code analysis (Rubocop), authentication (Devise), testing (RSpec and Minitest) and more. The emphasis is on making something good great rather than making a lot of different good things.
In Go, it seems like packages themselves are nowhere near as popular as the language, compared to what I see elsewhere. I haven't been in the ecosystem long, but it seems to be rare that articles about Go coalesce on a particular solution similar to Rails or something like lodash or express in the JavaScript world. Instead, the main feeling when asked "How do I do X" in Go seems to be "do it yourself". The kind of thing that in Ruby would be derided as "plumbing" is "real code" in Go; the goal is to make you think about every single line you write.
Go's lack of generics make it difficult to make an all-purpose utility library to make these sorts of things easier, so maybe once they land in a year or two the rawness of the language could be mitigated. But for now, Go and Ruby feel like as far apart as they could be.
What can we learn?
The strictness of Go means that there likely aren't too many things that we could import into it from Ruby and similar languages. And nor should we; one of the refrains of the Go philosophy is that languages should be different rather than similar, and Go is outspoken in its adherence to its own unique identity.
However, I think that some of the surprising advantages I found in Go can be moved over to Ruby without much work other than a slight shift in mindset. (There are other amazing things Go provides that Ruby simply can't do, and in fact might start affecting the beautiful things about it - this post is not about all the nice things in Go but the things that we can do with our Ruby code to gain some of the same advantages.)
Keep code and data separate
Here is how you define a method on a type in Go. (This example intentionally has a code smell, where I'm sending an e-mail from something that shouldn't be able to or care about it.)
type Product struct {
Id string
}
func (product *Product) sendEmail(customerEmail string) {
sendProductEmail(product.Id, customerEmail)
}
// ...
product = Product{Id: "some-product-id"}
product.sendEmail("me@example.com")
The usage of the method looks the way we expect it, but wait... something's missing from the function body. Where's the this
?
The answer is that there isn't one. Not because the idea doesn't exist (it does - it's called a receiver and Ruby has the same idea) but because the syntax calls out a very important fact, which is that methods and data do not need to live together in an object.
To make this abundantly clear, let's realize that we made a stupid mistake and move the sendEmail
function from the product to a top-level function:
func sendEmail(product *Product, customerEmail email) {
sendProductEmail(product.Id, customerEmail)
}
// ...
product = Product{Id: "some-product-id"}
sendEmail(product, "me@example.com")
What's changed in our function body?
The answer: Absolutely nothing. The definition and usage changed, but the function body is identical. This is a very strong hint that methods work on data. And whether the methods and data "live together" in an object or not, the exact same thing is happening.
Keeping methods out of objects helps to reduce the urge to introduce side effects. And that means you can often model your functions as pure input/output, which has a whole whack of advantages.
As an example, take a Ruby implementation of the above.
class Product
attr_accessor :id
def send_email(customer_email)
send_product_email(@id, customer_email)
end
end
Let's do the same refactor now:
class Product
attr_accessor :id
end
def send_email(product, customer_email)
send_product_email(product.id, customer_email)
end
We've had to change our function body to accommodate this now.
So how can we achieve the same flexibility in Ruby? Simple. Treat data as data and methods as methods. Prefer static methods that work on data. This has the added advantage of enabling you to "pipe" data more easily without having to fall back to solutions I've always found odd like tap
and yield_self
.
class Product
attr_accessor :id
def self.send_email(product, email)
send_product_email(product.id, email)
end
end
Product.send_email(my_product, 'me@example.com')
The functions stay in the class definition (Go doesn't have class functions, which IMO makes it a bit harder to organize) but are static and don't use or change any object state.
For example, let's say we've decided to move this function to a new module which deals with all the e-mail sending. The function doesn't change at all any more - in fact, even the pattern of calling it stays the same. The only thing that changes is the class name.
class Product
attr_accessor :id
end
class EmailService
def self.send_email(product, email)
send_product_email(product.id, email)
end
end
EmailService.send_email(my_product, 'me@example.com')
Obviously this won't work 100% of the time, but I've found this pattern to be much easier to work with.
Use types and not hashes
Go is a statically typed language, in its own maverick way. It doesn't support inheritance, and mainly works through structs and interfaces (which is a great feature that Ruby can approximate, much less elegantly, via "duck-typing" and respond_to?
calls).
Ruby is dynamically typed, and in a typical Ruby project you'll see classes, OpenStructs (a class that basically makes a hash "pretend" to be an object by using dot-notation), and other solutions, but in many cases you're using hashes to store your data.
There are some real benefits to static typing, not the least that it makes IDE's much smarter at telling you when you're doing something wrong. Even better, types implicitly document what your data looks like much better than hashes.
Ruby is slowly starting to drift towards better type functionality, with first Sorbet and then RBS. But you don't need any static typing tool in your own code to do things smarter.
It's not like Ruby doesn't already support structs! Check the difference here.
# @param json [Hash]
def create_dashboard(json={})
DashboardCreator.call(
name: json[:name],
graphs: json[:graphs],
owner: json[:owner]
)
end
In my opinion, it doesn't matter if DashboardCreator.call
is defined with keyword arguments or an options hash. The fact that the input json
is a hash means that your code has no idea if, for example, :name
is a valid key into your JSON.
Hashes are maps. Maps are key-value pairs. They're meant for lookups. For some reason, Ruby has latched onto this idea that they should be used as options, keywords, or attributes. This is confusing and error-prone.
Now here's the same thing with structs.
# @attr graphs [Array<Graph>]
# @attr name [String]
# @attr owner [String]
Dashboard = Struct.new(:graphs, :name, :owner)
# @param dashboard [Dashboard]
def create_dashboard(dashboard)
DashboardCreator.call(dashboard)
end
Note how I've packaged up all the data into a single object which is "strongly typed", or as strong as Ruby can get for now. We (and our IDEs) now know absolutely what that object looks like.
Now there might be cases where all we've done here is push up the error cases where our parsed JSON doesn't match what we expect. For example, we might need a method that looks like this:
# @param json [Hash]
# @return [Dashboard]
def parse_dashboard(json)
raise "No graphs!" if json[:graphs].nil? ||
!json[:graphs].respond_to?(:size)
Dashboard.new(
name: json[:name],
graphs: json[:graphs],
owner: json[:owner],
keyword_init: true
)
end
The thing is, we've moved the error into a method whose only job is to parse JSON. We can do further validations here. But importantly, we don't encode our expectations about JSON into all the downstream functions. Only one thing cares about JSON itself. Everything else cares about dashboards.
Simplify, simplify
Ruby's dynamism allows for some truly concise and beautiful-looking code. But like always, you need to make sure your toolbox is using the right tools for the right situations.
When you're building a framework, using the more advanced metaprogramming features like define_method
and method_missing
can work well and provide a useful interface. But using it in your code can cause a world of pain.
Just because you can do something in Ruby doesn't mean you must do it. I've moved over to preferring code generation over code reflection. In other words, using generators to create the code you care about rather than deciding what your code should do at runtime.
For example, let's say you have a web parser that has to fetch a number of item fields by using different XPath or CSS selectors. We want to define a parent class that defines all these fields and child classes that separately specify how to fetch them for different websites. Here are a couple of different ways to achieve this.
First, how we want to use this code:
Item = Struct.new(:name, :description, :price)
class Site1Parser < Parser
def initialize(item)
@item = item
end
def name(browser)
browser.xpath("/some/path/to/name")
end
end
parser = Site1Parser.new
parser.item.name # the value parsed from the site
Way number 1 uses method_missing
to set attributes to an item:
class Parser
attr_accessor :item
def parse
browser = Browser.new
Item.members.each { |f| self.send(f, browser) }
end
def method_missing(method, *args, **kwargs, &block)
if item.respond_to?(method)
item.send("#{method}=", block.call(args[0]))
return
end
super
end
end
Option 2 goes a bit more explicit by actually defining methods using define_method
:
class Parser
attr_accessor :item
def parse
browser = Browser.new
Item.members.each { |f| self.send(f, browser) }
end
Item.members.each do |field|
define_method(field) do |browser|
raise "Not implemented yet!"
end
end
end
Leaving alone the possible debugger confusion, when we humans are looking at either of these options, we need a lot of information in addition to the body of the method. What are valid values for method
or field
? What possible arguments could be passed? What does self
mean at this point?
What if instead, we created a generator which created code for you? Something that might look like this?
class Parser
attr_accessor :item
def parse
browser = Browser.new
self.name(browser)
self.description(browser)
self.price(browser)
end
def name(browser)
raise "Not implemented yet!"
end
def description(browser)
raise "Not implemented yet!"
end
def price(browser)
raise "Not implemented yet!"
end
end
In this case, rather than writing all this code yourself, you rely on the generator to do it for you. Some benefits of this approach:
- The code is much easier to understand, because you can actually look at each method and see exactly what it calls, without having to step through it or check to see what
Item.members
returns. - Your codebase becomes searchable, both by you and by code analysis tools. Your function calls are explicit and you can logically step through them without confusion.
- You can more easily override specific methods as necessary when they're already there for you to do so. This is the kind of thing object oriented design is really good at. And you can easily tell what methods you have to override.
Obviously you run into other problems on the framework side (when you add a field to Item
, how do you make sure you can update the code without blowing away anything you've changed in it?) which might involve more advanced abstract syntax tree munging. But I'll leave this here as a thought experiment, nevertheless.
Conclusion
Both Ruby and Go are amazing languages in their own way, and both provide numerous benefits in different directions. There are definitely things I wish I could change about Go, but the nice thing about Ruby is that I know I can change some things about it all on my own!
Posted on April 16, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 7, 2024