Unlike pulling a rabbit out of a hat, "magic" in programming is often performed under the guise of productivity. In this post, we'll look at what defines a magical programming experience for better or worse.
If you were in Rails around 2007 you might be quick to describe it as "auto-magical," and this was a good thing. Magic meant freedom from the tyranny of endless boilerplate, freedom from hours of tedious configuration, freedom to be productive.
In time, the programming community and ecosystem has grown and changed. There was a period of violent anti-Rails phobia, and the pendulum now seems to be swinging back in its favor. Either way, the one constant complaint that comes up is "it's too magical."
What exactly does this phrase mean, and what could we maybe do about it? Rails is where I've got the bulk of my magic experience, so I'll focus on that.
Magical Experiences by Example
Magic is all about doing the unexpected. You don't expect an assistant to survive being sawn in half, but there they are, smiling and waving.
Rails does this really well. If you name your things correctly and follow conventions, it is able to do an extraordinary amount of predicting what you want and doing it right the first time.
One example is routes. Web apps need to tie in the code to an HTTP endpoint somehow. Rails uses a routes file to direct a request to a specific browser action.
Rails.application.routes.draw do resources :users end
That's all you need to set up these eight endpoints:
$ rake routes Prefix Verb URI Pattern Controller#Action users GET /users(.:format) users#index POST /users(.:format) users#create new_user GET /users/new(.:format) users#new edit_user GET /users/:id/edit(.:format) users#edit user GET /users/:id(.:format) users#show PATCH /users/:id(.:format) users#update PUT /users/:id(.:format) users#update DELETE /users/:id(.:format) users#destroy
Even cooler is that it now seems like Rails knows that you've got a User
model and that linking to an instance of that model should send them to the appropriate place. For example:
<% @user = User.where(id: 10).first %> <%= link_to "A User", @user %>
This would generate a link that looks kinda like this:
<a href="users/10">A User</a>
Wow. If the user hasn't been saved to a database and doesn't have an id, they'll be sent to users/new
. That's even more magical.
This same line of code is smart enough to do the right thing out of the box and tie in a very complicated system of Models, Views, Controllers, and Routes. I love it. This is a positively magical experience.
Rails does this all over the place, from has_many
declarations from Active Record, to command line scaffold generating. When you get it right, the experience is unparalleled.
So why then would Rails be "too magical" if it is a helpful positive experience? What would "just magical enough" look like?
Anti-Magic
Another word for magic might be implicit behavior. I didn't tell my router about a User
model -- it implied that if I passed one in that it should route to that endpoint. The opposite of magic would then be explicitness; nothing gets done unless you explicitly do it.
In this world, a magician would bring the audience to the rabbit farm.
Rails adds a few helper methods to all objects via Active Support. One of the crowd favorites is blank?
. It will not only check an object for nil
or false
(the only two false-y values in Ruby), but it will also check it for empty?
since we often want to skip behavior of empty collections.
This is magic that works well most of the time.
[].blank? # => true User.new.blank? # => false user = User.where(name: "does not exist").first user.blank? # => true
Another bit of magic is Active Record auto creating methods on model instances for you. For example, if you have a User
model with a name string, Rails is smart enough to add that accessor for us. User.new(name: "Schneems").name
works with no configuration needed. Other helper methods are also added, like:
User.new(name: "schneems").name? # => true User.new.name? # => false
Now what happens when we blend our magic together? Say we're running a bitcoin-backed checking company; we might have a Check
model where we store written checks. Because we want to handle all our edge cases like a 12-year-old Preston Waters receiving a check without an amount, we add a blank
field to our Check
model.
check = Check.new(amount: 1_000_000, blank: true) check.blank? # => true
Oops, looks like our Active Record magic killed our Active Support magic. If you know how both of those things work and where they come from, then this example is pretty contrived and almost expected. If you don't know what library or where adds what methods, it can be difficult to debug.
This is the "too magical" behavior that people talk about. It kills productivity, it isn't expected, and it may go undetected and cause us to lose all our bitcoin "money" via a bug. Our programmers won't even think to test that case. When you finally realize there's a bug, how do you know where to look? If you don't know about Ruby's introspection ability, you may be stuck for hours.
It's frustrating and demoralizing. It hurts.
Three Rules for Non-Awful Programming Magic
Three things make up a non-magical programming experience:
High-level documentation. How do I use your framework?
Method or class-level documentation. How do I use your API?
Errors. When things go wrong, do I get helpful errors?
Let's break all that down.
High-level documentation
I want high-level documentation like http://guides.rubyonrails.org. For most libraries, this type of guide is small enough that it fits directly in the README. I want examples of common use cases and how this software handles them. Often "you're doing it wrong" is a response to a misuse of a particular bit of code.
If your library was a business, and issues were support tickets, it should be a goal to answer every invalid bug report with a link to documentation. Not in an RTFM kinda mean way, but in an "it was possible for you to self serve without having to wait for me" way.
In other words, can a good programmer who is familiar with your documentation navigate your library successfully with little to no surprise?
Maintainers should document common problems and their appropriate solutions. Since most libraries aren't run as a business, it also means users should be active in adding documentation for use cases they didn't find in the guides. This is part of the fee you pay for free software, but don't worry. You will make dividends on it when you realize you have the same problem a year later, and Past You already documented the perfect fix.
Low-level documentation
Programming doesn't end where tutorials and guides stop. APIs need to be documented. In Ruby, that's at the class and method level.
Take for example our previous link_to
helper: http://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to
It has a high-level description -- what does it do? It shows method signatures, enumerates the options, and adds some small examples. When you poke under the hood, link_to
does some truly magical things, but you don't need to know that to understand how to use it. If the API is properly documented, you should be able to use it.
Again, if you're finding docs inadequate or missing, pay it forward to yourself and submit a documentation patch. Maybe check out DocsDoctor to kickstart your documentation habit.
Good errors make good interfaces
I love a good error message. It tells you exactly how you screwed up and how to fix it. I believe a good error message is the true litmus test for magical or nonmagical code. Nick Sutterer posted this graphical representation of my feelings:
How great would it have been if we had been notified when we added a blank
field to our model it error-ed? What if it told us about the possible conflict and gave us an escape hatch to force the behavior? Here's a positive example:
$ rails g scaffold users [WARNING] The model name 'users' was recognized as a plural, using the singular 'user' instead. Override with --force-plural or setup custom inflection rules for this noun before running the generator.
Here the framework realized we were deviating from a Rails convention we may not have been aware of. It automatically did the right thing and gave us a way to force the behavior if we are a power user and really wanted to stray off the Omakase path.
Rails has a ton of these helpful errors and is getting more every day -- I've been adding them since 2012. When there are errors that can't be detected, that is when we have a bad time.
One example is that Active Record has a validate and a validates class method. They do different things, and there's really no way you can infer based on arguments which the user wanted. It's not magic by traditional standards, but it fails my small test by failing silently when misused.
Having read the guides, API docs, and running code using the wrong method, it's still possible (easy even) to use the wrong method and not realize it for hours, maybe even weeks. This should be an indication to the maintainers that this interface is lacking.
If we can't detect incorrect usage (I've tried), maybe we should deprecate one of the methods and make it more instructional. Perhaps we change validate
to validates_with_custom_method
so it's more than a one-character difference.
Making behavior less magical through feedback and errors isn't always the easiest, but it's the price we pay for all that productivity when things work as planned.
(Don't) Fool Us
Next time you throw up your hands and scream "too magical," ask if a feature follows the three rules:
High-level documentation. How do I use your framework?
Method or class-level documentation. How do I use your API?
Errors. When things go wrong, do I get helpful errors?
If not, what could be done to wrangle it into compliance?
Anyone can add docs to any project -- that's an easy rule to fix. Good exceptions can be hard to add. You might not have the experience to add it to a project. I consider lack of informative failure modes a bug, and they should be reported like one. How did you find the bad behavior? What did you expect? What happened instead? What type of an error or message could have helped? Open an issue and provide some helpful and constructive feedback.
Working with any library or framework is a tradeoff. By choosing someone else's library, you're making a bet that it will be faster for you to learn how to use that software than it would be for you to write your own.
Hiding complexity is what these libraries are supposed to do. When done well, it's a feature; when done poorly, it is torture. Maintainers and users can both work together to pull back the curtain and make magical programming fun again.