On the surface
Elixir and Ruby look alike. This is very much understandable, since José Valim was involved in Ruby ecosystem before he switched to become a language designer and maintainer. Both languages are strongly and dynamically typed, share similar method/function/module definition syntax, and overall layout of source files is similar.
Digging deeper
Despite the surface similarities, the two languages do not share much more than the syntax. Elixir is functional, Ruby - object-oriented. Elixir does not allow mutable state, in Ruby it’s all over the place.
When I started writing more Elixir code, I found that the overall workflow differs too. I tend to catch more bugs at runtime in Ruby programs. When I write Elixir, the bugs are often caught at the compilation phase. Most of those bugs are related to invalid function arity, or attempting to call functions that do not exist (typos, missing imports etc.).
Compilation
First step before running Elixir program is to compile it. Ruby does have the compilation phase as well. It’s not much different, although it’s hidden from the developer during the normal workflow. Before Ruby’s program is executed, source code is converted to AST, then to bytecode. Only if those phases succeed, program is executed. In both languages the same basic principle applies.
And yet - I tend to catch more errors during compilation of Elixir than of Ruby code. Why?
Function/method calls
Ruby borrows a lot from Smalltalk. It’s object’s model is virtually clone of the object model in Smalltalk. The same is true about method dispatch.
In fact, in Ruby we often talk about sending messages to objects.
obj.foo()
is could be very well written as obj.send(:foo)
. The
method dispatch in Ruby works by sending messages consisting of method
name and a list of parameters to given objects. If the object implements
given method, it handles it and responds. If it does not - the message
is passed up in the inheritance chain until is handled by code
implemented in one of the ancestors. The method can also be not found
anywhere in the parent classes of the object it was dispatched to. In
such case, another cycle is started - now in the search of special
method called method_missing
. Fair share of Ruby’s metaprogramming
capabilities are due to this smart - yet not very performant - method
dispatch algorithm.
On the surface, Elixir’s function calls code is as relaxed as Ruby’s method dispatch. If you write the following code, it will compile properly, and only throw an error at runtime:
defmodule Foo do
def foo(_a, _b, _c) do
IO.puts "Foo.foo reporting for duty"
end
end
defmodule Bar do
def call_foo do
Foo.foo()
end
end
Let’s compile and run it:
➜ mix compile
Compiled lib/foobar.ex
➜ mix run -e "Bar.call_foo()"
** (UndefinedFunctionError) undefined function Foo.foo/0
This looks equally bad as in Ruby. However, you don’t usually write code
like that. Instead, you would most likely use import
mechanism. Let’s
amend the 2nd module definition to reflect that:
defmodule Bar do
import Foo, only: [foo: 0]
def call_foo do
foo()
end
end
This gives us helpful compilation error:
➜ mix compile
== Compilation error on file lib/foobar.ex ==
** (CompileError) lib/foobar.ex:8: cannot import Foo.foo/0 because it
doesn't exist
Moreover, if we import the function with proper arity, yet try call the function named the same yet with different arity, we’ll get similar error:
defmodule Bar do
import Foo, only: [foo: 3]
def bar do
foo()
end
end
produces compilation error as well:
➜ mix compile
== Compilation error on file lib/foobar.ex ==
** (CompileError) lib/foobar.ex:11: undefined function foo/0
This suggests us that importing a function before executing it, not only releases us from the obligation to prefix it with module name - but makes our code safer since we will catch missing function and arity errors at compile time.
Having said the above, you probably should not import all functions before calling them. It makes it difficult to distinguish between local and external functions.
When we work with functions defined in the current module, we get the benefit of compilation errors by default. Consider the following code:
defmodule Bar do
def other_function(_a) do
end
def bar do
other_function()
end
end
Let’s compile it:
➜ mix compile
== Compilation error on file lib/foobar.ex ==
** (CompileError) lib/foobar.ex:12: undefined function
other_function/0
XREF on Elixir >= 1.3.0
Elixir 1.3 brings new exciting compile-time checks. When you try to use external functions from other modules, that either do not exist or are bad arity, you will get compilation-time warnings. Note that this will warn you:
IO.pssssstt "This does not exist"
but this will not:
mod = IO
mod.pssssst "This does not exist but does not warn at compile time!"
So yeah, passing around modules in variables may lead to some tricky to debug issues.
Metaprogramming
Both languages come with strong metaprogramming capabilities. Yet in Ruby, in order to catch a bug in “code that writes code”, you have to run it.
In Elixir, main mechanism to implement metaprogramming - is to use macros. Macros are executed at compile time. If the macro contains a bug, it is quite likely the compilation fails. Let’s consider the following:
defmodule Foo do
defmacro make_fun(name) do
quote do
def unquote(:"#{name}")() do
print(unquote(name))
end
end
end
end
defmodule Bar do
require Foo
Foo.make_fun("foo")
def print(name) do
IO.puts name
end
end
The above simple macro will create Bar.foo/0
function for us. Let’s
try it:
➜ mix compile
Compiled lib/foobar.ex
Generated foobar app
➜ mix run -e "Bar.foo()"
foo
All looks good. Now, let’s make an error in macro definition by using
puts
as function name instead of print
:
defmodule Foo do
defmacro make_fun(name) do
quote do
def unquote(:"#{name}")() do
puts(unquote(name))
end
end
end
end
Let’s compile it:
➜ foobar mix compile
== Compilation error on file lib/foobar.ex ==
** (CompileError) lib/foobar.ex:14: undefined function puts/1
Compile-time error! Good.
In reality, you more often use macros than write them from scratch. But the benefits of the compilation errors often surface anyway, since most macros are somehow dependent on the structure of the code in module you are using the macro in. It may be invalid options passed to macro, not implemented functions or other sort of problems that will surface as soon as you attempt to use macro at compilation time.
Why compilation-time errors are a good thing?
The earlier you get the feedback about a bug, the better. If the error is caught at runtime, you do need to make sure you actually execute the code before you push it to production. This means writing more extensive tests or do more manual checks.
By doing more checks at compile time, and expecially because of it’s completely different metaprogramming system, Elixir allows me to catch more bugs earlier.
Post by Hubert Łępicki
Hubert is partner at AmberBit. Rails, Elixir and functional programming are his areas of expertise.