Ary Borenszweig
Posted on February 19, 2022
Right on the main page of the Ruby language there's this beautiful quote from Matz:
Ruby is simple in appearance, but is very complex inside, just like our human body.
It's Ruby's simplicity that I really like.
If you ask me "What do you see when you see Ruby code?" my answer is method calls.
Take a look at this code:
require "set"
class MySet
include Enumerable
attr_reader :inner_set
# ...
end
There are some things here:
- we
require
some external code - we declare a class with the
class
keyword - we include functionality from another module with
include
- we declare a read-only property with
attr_reader
It turns out, only class
is special here: it's a language construct that lets you define classes. But require is a method call defined by Ruby, and the same is true about include and attr_reader. They look like keywords, like something special, but they are just regular methods.
This ability of having almost everything look the same, in a consistent way, is what lets you, the Ruby user, also define things that look like language keywords, but are just ordinary methods.
In fact, it's very easy to define attr_reader
ourselves. Because attr_reader
already exists, lets use the name getter
for that:
class Module
def getter(name)
symbol = "@#{name}".to_sym
define_method(name) do
instance_variable_get(symbol)
end
end
end
class Point
getter :x
getter :y
def initialize(x, y)
@x = x
@y = y
end
end
point = Point.new 1, 2
point.x # => 1
point.y # => 2
And there we have it!
Well, using getter
is simple. Maybe defining it is a bit more complex... but it's definitely possible!
Maybe the additional feature of not having to put parentheses around method calls makes this look more like a keyword than a method. Imagine if parentheses were required for method calls:
require("set")
class MySet
include(Enumerable)
attr_reader(:inner_set)
# ...
end
They don't look like keywords anymore, right? My guess is that allowing to omit parentheses was done for exactly this reason, but of course you can omit parentheses anywhere because Ruby doesn't know if you are going to use something "like a keyword" or not.
This of course is used in Ruby a lot. Here's an example of Rails' ActiveRecord:
class User < ApplicationRecord
has_many :posts
end
To be honest, a snippet like that might have been one of the first code snippets I stumbled upon when I was learning Ruby together with Ruby on Rails. With this, you can kind of create your own mini-language for users. And all these languages will be relatively easy to use: they will all be based around method calls. Of course the names will vary, or the number of arguments. But in the end they are all method calls.
One other language that did this, and it actually did it better, or more consistently, is Elixir. In Elixir you define a module like this:
defmodule SomeModule do
# ...
end
You define a function like this:
def method do
end
You use an if like this:
if something do
end
Note that there's always a do
in the end. These are all functions defined by Elixir! (well, technically they are macros) For example, here are the docs of defmodule, from which you can jump to the source code. This is brilliant!
Like in Ruby, this also means that in Elixir you will be using the same small language (calls) for almost everything.
In other languages you might be able to do the same thing, but there might be other syntax involved. For example in Rust you call macros with a bang at the end:
format!("Hello, {}!", "world");
In Julia you use @
to call macros:
macro sayhello()
return :( println("Hello, world!") )
end
@sayhello()
In D you can use the mixin keyword to generate code at compile-time:
template GenStruct(string Name, string M1)
{
const char[] GenStruct = "struct " ~ Name ~ "{ int " ~ M1 ~ "; }";
}
mixin(GenStruct!("Foo", "bar"));
This might seem like a little thing, but from a user perspective you have to know what you are using. "Oh, I'm using a macro so I have to do it this way." In Ruby and Elixir it's just "I call it."
Carole King couldn't have said it better:
Winter, spring, summer or fall
All you have to do is call
Because with Ruby... you've got a friend 😌
For Crystal we considered having a different syntax for invoking macros, but in the end we used the same syntax as for method calls. And we really like the end result! Take a look at this Crystal code:
record Point, x : Int32, y : Int32
point = Point.new(1, 2)
It looks like record
is a keyword that defines a type with two properties. But it's actually a macro, here's the documentation.
As a comparison, Java 14 added records to the language (where did they get that name from?! 😮) by introducing a new keyword:
record Rectangle(float length, float width) { }
If Java had a way to reduce such boilerplate right in the language itself, it wouldn't need to introduce keywords: they could just do it with the language itself. As a bonus things would get automatically documented in the API docs.
One other benefit of having such constructs be methods or macros in a language is that, at least in Ruby and Crystal, a user can redefine them. For example zeitwerk redefines require and makes it work in a different, better way. In what other language can you do this?
Coming up next...
In the next blog post I'll be talking about being able to design that perfect API.
Posted on February 19, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.