8 months of Ruby
Following in the vein of my last language retrospective, 8 months of Go, I wanted to share my experiences of working in Ruby for the last 8 months. Disclaimer: Ruby has never been my first choice of language, but like Go, I’ve gone into this with an open mind.
I’ve played with Ruby on and off for 20 years (I wrote a rudimentary early version of instagram back in 2003 in Ruby for friends and I really regret not pursuing that more ha). Most often though I use Ruby for CI scripts because it’s installed on machines by default and is better than shell scripting when it comes to slightly non trivial tooling.
For many years I shied away from taking jobs that used Ruby because… well … I like types! Why work in a language without them? My entire blog is basically a paean of types. However, I don’t like being afraid of something and I made a very conscious choice to work in an untyped language, be it Python or Ruby, for my next main job.
I’ll be honest, I don’t hate it as much as I thought I would. There are some things I really like about it actually and just much as that I don’t like - some expected and some new and unexpected!
Let’s start with the things I like.
Pros
terse and expressive
This is Ruby’s biggest sell - the fact that the language is terse, concise, and extremely expressive. In the hands of a power user it’s actually fun. I love the simple module and class syntax, writing abstractions feels almost like when I’m sketching code out… but it’s runnable! This reminds me a lot of F#. I wrote an F# example once that read like like a sentence and a co-worker of mine was amazed that it wasn’t just pseudocode, it was actual code that did what the sentence said.
I recently had to write some AST processing to transform a higher level DSL into an elasticsearch query and Ruby made it extremely easy to build a nice little API to model boolean and query logic. It felt joyful!
def build(schema = nil)
case @tree
when And
operator = @tree.not ? :must_not : :must
{
bool: {
operator => @tree.terms.map do |term|
QueryBuilder.new(term).build(schema)
end,
},
}
when Or
{
bool: {
should: @tree.terms.map do |term|
QueryBuilder.new(term).build(schema)
end,
},
}
...
transforming data is easy
Ruby comes with a lot of modern collection tooling. Dealing with maps, arrays, lazy iterators, it’s all built in and easy to use. I love that you can make any class enumerable just by implementing the each
function. Most of my day to day involves mapping, filtering, and slicing of collections - simple but obvious tasks that not every language makes easy to do. It’s quite nice to just have it available, and I remember why Ruby made a big splash in the tech world in the early 2000’s and 2010’s. It was a language that embraced modernity and gave you the boilerplate free tooling to do what you need to do without a lot of overhead.
meta programming is wildly powerful
Message passing is a concept that a lot of people struggle with, but I find it makes more sense when you imagine it as an actor model of delegation. You can call class A
with method “foo
” and if it decides “I don’t want to handle this” it can capture that and re-invoke something else instead for you. Neat. But why would you want to do this?
Well, imagine you want to proxy all methods that start with elastic_
to the Elastic processor and all methods that start with rds_
to the rds handler but have it be composed and auto updated anytime anyone adds new methods? This is really easy now
class Proxy
def initialize(elastic, rds)
@elastic = elastic
@rds = rds
end
def method_missing(method_name, *args)
case method_name
when /elastic_.*/
@elastic.send(method_name, args)
when /rds_.*/
@rds.send(method_name, args)
end
end
end
Wild! You get basically a method invocation for any missing method and can do whatever you want there.
In the right hands this is crazy powerful and allows for very expressive and interesting code.
zeitwerk and rails
I really like in the zeitwerk and rails model of not having to include imports. The forced naming convention of module namespaces map to directories and classes map to file names (all snake case) makes it really easy to find where things should be. Granted this isn’t a problem in literally any other language because IDE’s manage all the imports and lots of languages require convention based file locations, but its still nice in Ruby too.
Rails also has a lot of great extensions and utilities that I sort of don’t even think about. I honestly don’t know sometimes if methods I’m calling are because of rails monkey patching or not, and to a certain extent I’m not sure I care. Rails mostly just works and stays out of the way and that’s fine with me.
pry and the REPL
Normally I prefer to do all my debugging in my IDE using attached breakpoints. I’m a sucker for gutter icons and right clicking on a test to step into it. But my work has moved to a model where we run all code in k8s remotely. This is kind of cool cause they can set up a bunch of pre-baked infra, and ensure consistent runtime environments. However, remote debugging is often complex here and in other languages this might be more frustrating. In Ruby though we can just debug… via Ruby! You can drop into an effective debugger via the pry shell by putting a binding.pry
statement where you want the debugger to pop up.
You can customize actions and script things as well, because it’s all just Ruby. The debugger is fully fledged, you can inspect locals, step into classes, add more breakpoints, etc. I’ve found it to be a pleasant, albeit different, experience to what I’m used to.
library ecosystem
Ruby is like stefon - it’s got everything. There seems to be a Gem that does just about anything you want, which is kind of nice. It reminds me a bit of the Node ecosystem where there’s a zillion packages to do a zillion things. Ruby’s seems a bit more slimmed down and curated, and the packages themselves seem to be higher quality than a majority of Node packages. I haven’t wanted for packages that do what I need in Ruby and it’s been easy and painless to consume them.
sorbet
I couldn’t help myself, I needed types anyways. Thankfully sorbet (from Stripe) adds a meta layer of runtime and static checking to ruby projects. I remember when they were working on this back when I was at Stripe and I’m glad that it’s been open sourced and is pretty widely used. Chime, Spotify, Stripe, and many others use Sorbet in production.
Sorbet is a pretty weak guarantee, and all things the same a bad type engine, but for a language with zero types… sorbet is a life saver. Teams that embrace sorbet gain a lot of benefit if anything not having to do manual runtime checks of input arguments is a big time saver anyways.
rubocop
I love linters. I used ESLint aggressive in the past and Rubocop is reasonably robust for what it does. It has quite a few false positives though, but overall it’s better than nothing. Also being able to format code with the linter settings is a huge win, as I hate having formatting arguments. Just automate it and be done.
delegation for composition
This is just such a cool language feature, to be able to compose objects together and automatically proxy methods from one to another via
delegate :<the method to expose>, to: <some other object>
Which replaces writing
def proxy_method
@some_object.proxy_method
end
It’s a small touch but this kind of composition is really effective as you can start with a proxy and then later implement the method yourself and it’s drop in compatible. Creating composition layering like this is super easy and avoids a lot of the noisy boilerplate that other languages might have.
rspec
RSpec is a test runner tool for Ruby and it reminds me a lot of Jest. For the most part it’s just easy and stays out of the way which is why I like it. I’ve had to write custom matchers a few times and it was trivial to extend RSpec (much simpler than Jest was) and overall does what I want with aplomb.
Cons
Fair warning, there is a lot I don’t like. ¯\_(ツ)_/¯
no types
I knew this one going in, so I can’t really blame Ruby. But types are just so important to software it still hurts my brain to think that actual languages don’t have it. I get why Ruby happened, if you time travel to the last 90’s the prevailing languages were Java and C/C++. Talk about verbose monsters! Getting even a basic “hello world” required tons of files, compilation, etc. Then forget about modern utilities like map or filter. Hell Java didn’t have anything like that till their streams release with Java 8 till 2014!
But in the modern world this is a huge travesty to be missing types. Types are a part of the meta information about the problem domain you are solving. They are information, and to wantonly discard it is short sighted. I could probably write a whole novel about why types are good, but I will just say that missing types (and bolting it on with Sorbet) makes for a very painful experience in the real world, especially with larger projects, multiple developers, and actual real money at stake.
hash obsessed
Back to the lack of types, Ruby loves its untyped hash bag. So much so that there are tons of collection utilities to deal with it. I worked in Node for years and the amount of times I used an untyped bag of object
I could count on one hand. It really just shouldn’t need to be done. Granted Ruby has structs which allow you to create immutable paired data but a lot of people just don’t use them. But there are multiple flavors of structs - sorbet, dry-struct, ruby Data object, and they all … are slightly different!
This leads to people passing around untyped mutable object bags everywhere that has god knows what in it, sometimes adding fields (or even worse removing fields!) through a call chain.
Reasoning about systems that overuse hashes is an exercise in futility. I’m just too stupid to be a compiler, and so you have to step through code via unit tests which eats up endless amounts of limited time.
Hashes are fine when they are used as an informal object pairing inside of a function, or closed inside of a class (maybe) but exposing them as contract results of an API bleeds more and more loose shenanigans through the system which begets brittle systems that are hard to maintain.
fractured type system (rbs vs sorbet/rbi)
But there is hope! In Ruby 3 they introduced type annotations via the RBS syntax, similar to .h files in the C world. While this apparently isn’t meant to be user-generated (it’s supposed to be machine used) it’s the start of the ruby world moving to a place where types can at least be opt-in.
However, this now creates a fractured world where a lot of the big players are using sorbet but the language is moving to rbs. This isn’t great for unification of things, and in fact I find that this fracturing is endemic in the ruby world. There’s 500 versions of things and everyone does stuff their own way.
dynamic exploration
Due to the dynamic nature of Ruby (see some of the things I listed in pros) the language service is not that great. Discovery of what methods or fields are available via just hitting the .<tab>
in your IDE is often non-existent. Granted you sort of learn the fields and methods you use on the regular, but self discovery doesn’t exist. You instead have to do a lot of reading of source code, poking around in a REPL, and hoping that the human managed documentation is correct (often it’s not).
Since there’s no compiler and there’s no way to validate that anything you write is correct or not, the only way forward it to demand 100% code coverage. Any line not covered is a line that could blow up. This is a very stark contrast to type checked languages where enforcing 100% coverage is a fools errand - you end up writing tests that are mostly garbage just to satisfy the requirement… oh wait, that happens in Ruby too! In fact, because you can literally mock out the .new
method on a class object people tend to skirt inversion of control and often mock (needlessly) just to get the coverage to pass.
Unfortunately this creates a false sense of security. Either you trust your tests or you don’t, and if you don’t then you end up afraid to make changes. Teams that are afraid to make changes won’t be incentivized to make improvements and the whole thing is self fulfilling.
impossible to require contract adherence
A lot of my complaints about Ruby are about contracts because contracts, and enforcement of contracts, is how you can support large scale teams and projects with grace. Without being able to explicitly say “you cannot call me without filling this stuff in” you end up in a world where people have to fail to explore (either tests failing or production failing). I would much rather lead people to water by not allowing their code to be valid until the contracts are satisfied (compilers are great!). Just because something looks like a duck and quacks like a duck doesn’t make it a duck.
Not being able to adhere to contracts also cripples semantic refactoring via tooling like in an IDE. If the IDE can’t figure out that xyz uses abc then it can’t refactor things for you. This means that refactoring now all has to happen by hand, which de-incentivizes the activity. That then means that doing the organic cleanup that has to happen in a codebase often goes without, and as with all organic things code rots. If you make doing the right thing hard and time consuming, people won’t do it. The easy thing has to be the right thing.
sorbet (generics, tapioca)
I keep coming back to this because I want to believe so badly that sorbet is going to provide the guard rails and safety that I want but I have to accept that it’s a very poor mechanism and frankly not that robust. Unlike typescript which allowed libraries to bundle type definitions out of band sorbet has no way to bundle that data with a library. You end up having to use another tool from Shopify called tapioca to re-compile and scan the type annotations from all your Gems.
This means anytime you update a gem, you have to manually run some tooling. It also forces your repo to house all the generated type information which frankly should be hidden away in a Gem and not versioned in my repository.
On top of that sorbet has no way to validate any kind of generic or higher kinded type, so even trying to do basic signatures like
sig { params(data: T::Array[String]).void }
is mostly useless because all sorbet can guarantee to you is that data
is an array, but not an array of String
.
Shapes is a nice thing of sorbet, allowing you to structurally type object bags, but it doesn’t allow you to model missing fields and there is no concept of type narrowing (like with typescript). The end result is a lackluster experience, especially having come from the insane powers of typescript.
At this point I have resigned that sorbet is yardoc++ with some runtime checks. I still use it, because a large part of the time it is able to validate types but it really misses the mark on being actually fully fledged.
rails conventions outside of MVC
I’m on board with auto loading, and directory conventions with rails. What I find to be particularly annoying though is that top level folders of rails projects are automatically in the root namespace. What this means is you can have logical data of a module split across mulitple disparate locations. For example, this class
module Foo
class Bar
end
end
can live anywhere in the following format /<root folder>/foo/bar.rb
.
Why is this an issue? Well imagine you want to co-locate a module’s read/write/entities/logic/etc. The rails folder structure with model, controller, etc is very much tied to an old school MVC idea.
I find this makes it hard to properly organize your code because not everything slots in so neatly in the real world. Code is less organized by function and more by domain. I want to co-locate top level entrypoints like API handlers, but otherwise have things delegated to domain function. For example:
/core
/foo
.. all the things foo needs
/bar
.. all the things bar needs
/api
.. calls things in core
/db
.. shared db things
/common
.. stuff everyone uses
I know you can get this with rails but the fact that it forces a convention that doesn’t encourage your own folder modeling is frustrating. So you end up with code in folders that doesn’t quite make sense, and its split across unclear lines. Maybe this is a reflection of my personal experiences, but I haven’t ever dealt with this level of disorganization in other languages.
symbols vs strings inconsistencies
Ruby in the old days didn’t inter strings, so every instance of a hardcoded string was a new allocation. Instead they had the concept of symbols, the funky stuff with a leading colon: :this_is_a_symbol
and is meant to basically act as a single instance of that “stringlike” object. Every where you see that same symbol it’s the same memory representation. That’s fine, a nice clear distinction! Use symbols as object keys/etc and strings to be strings.
Well later Ruby decided that this was dumb, and now strings are automatically frozen so that this distinction no longer matters. But what we are left with is inconsistent usages of when things are symbols and when things are strings. You now often have hashes that intermix the two
{
:symbol => {
"string" => {}
},
"string2" => {}
}
Which makes your object pattern access wildly inconsistent. To work around this people often deep_stringify_keys
or deep_symbolize_keys
and then sprinkle on .to_s
or .to_sym
to access keys and get results. This just adds more feed for the fodder about ruby being slow since we’re iterating on objects and converting things to work around shape nuisances.
I’ve seen so many failed instances in production due to string/symbol inconsistencies its amazing this is something a language allows you to do.
I’m sure that the argument is “if you know what you are doing you you can create nice API’s and be consistent” but in the real world most people don’t know what they are doing and need guardrails to make sure they do the right thing.
slow
Ruby is just slow. Time and time again I’m shocked at how slow it is when you start to throw real data at it. If you have to process a few thousand items but loop over them a couple of times (really trivial honestly) the time can start to creep up.
I recently had a situation where we had to process 10k items, which included mapping them once, running each item item through a string template, and then serializing it all to JSON. This process took over 20 seconds. After doing benchmarking I found that all the time was just spent in ruby iteration time, so there was very little I could do to speed this process up.
Usually I don’t care that things are slow, but this was… hilariously slow. Doing the same thing in node, or go, or java, would have taken milliseconds.
procs and lambda gotchas
I don’t know if I’m missing something or if this is due to a side effect of implementation, but requiring to .call
a lambda or a proc really makes it annoying to pass around. This forces a distinction between a function, method, or proc/lambda and that nuance isn’t necessary in any other language or ecosystem. I’m not sure what the reasoning there is.
On top of that the various return semantics of procs vs lambdas is convoluted where in one model it returns from the lambda itself (like every other language in the world) and in another it forces a return call of the pattern… which is weird. This variance continues to illustrate the lack of consistency in the Ruby world where there’s a nuanced flavor of everything but no coherence in language design. It’s almost like they did it because they could.
case when pattern matching… sort of
The case statement is pretty cool allowing you to do some basic pattern matching in ruby as I illustrated earlier in the method missing example. But deep down inside this works by abusing a triple equals operator, which makes the case statement both powerful (in that you can customize this behavior) and insane (in that it’s not discoverable at all that this is what drives the behavior). Often I reach for a case statement but find that the triple equals of the thing I’m comparing doesn’t work and I’m forced to do an ugly if tree.
Conclusion
Given the choice would I choose Ruby to build anything on my own? Definitely not. But would I accept a job where they work in Ruby? If the bar was high, the team philosophies matched mine, and the product (and compensation) was stellar I would. But all things being equal I think I’ll stick to typed languages - kotlin, typescript, java, go, c#, etc.
Ruby was an extremely attractive, modern, option in 2015 but now feels like a poorly curated frankenstien and I keep bumping up into these rough edges which takes the joy out of the things that are joyful in Ruby.