DEV Community

Ary Borenszweig
Ary Borenszweig

Posted on • Edited on

Why I love Ruby: almost everything is a call

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You define a function like this:

def method do
end
Enter fullscreen mode Exit fullscreen mode

You use an if like this:

if something do
end
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

In Julia you use @ to call macros:

macro sayhello()
  return :( println("Hello, world!") )
end

@sayhello()
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) { }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
naqvis profile image
Ali Naqvi

Talking about intuitiveness, don't know why Proc is treated differently in Crystal. Other languages treats function pointers as normal functions and one don't need special syntax or invoke special methods for that.

Collapse
 
asterite profile image
Ary Borenszweig

This is the same in Ruby too. The main reason is that you can invoke methods without parentheses. Let's say you have this:

def foo
  ->{ 1 }
end
Enter fullscreen mode Exit fullscreen mode

And suppose we could invoke a Proc just by using parentheses, like a regular method invocation. So it would be like this:

f = foo # this returns the Proc
f() # this would in theory invoke it
Enter fullscreen mode Exit fullscreen mode

But then there are some ambiguities with the above... The main one is this:

f = foo() # does this return the Proc and executes it, or it just returns the proc?
Enter fullscreen mode Exit fullscreen mode

Another ambiguity is what happens if there's an f method in the same scope:

def f
  2
end

f = foo
f() # Does this call the `f` Proc, or the `f` method?
Enter fullscreen mode Exit fullscreen mode

If we decide it calls the method, then if f didn't exist and it now exists (someone added it after we wrote those two lines), the meaning would change. If we decide it calls the f Proc, then all foo_bar() calls must be treated like assuming they are procs, if there's a foo_bar local variable in the current scope. But maybe once the language was popular they thought about doing that but it would be a huge breaking change. It's very common to have local variables and methods be named the same in Ruby.

In Crystal we made it the same as in Ruby. I actually think having a call method simplifies things: you can be sure what you have is a Proc. Also procs in Crystal aren't used that much...

Collapse
 
betaziliani profile image
Beta Ziliani • Edited

The problem with defining a minimal set of keywords is that you can't do certain thinks. Take Crystal's one-liner if:

do_something if some_condition
Enter fullscreen mode Exit fullscreen mode

That can't be done if if is just a function. And the do everywhere isn't super idiomatic either. I like Crystal being on the side of the developer :-)

Collapse
 
zw963 profile image
Billy

I guess, just guess, if we having a different syntax for invoking macros, compile time will reduced slightly?