Why Python programmers might want to look at Elixir (despite the Rubyish syntax that will probably initially repulse them)
Concurrency
-
Elixir, like Erlang, runs on the BEAM VM, which doesn't have Python's limitation of being unable to run language bytecode in more than one thread at a time. You can spawn a million processes (much lighter-weight than OS processes), and the BEAM VM will automatically distribute them across your cores. Processes are preempted by the VM after they perform about 2000 reductions (a reduction is roughly equivalent to a function call.) Garbage collection is handled on a per-process basis, and you can also have per-process memory limits that may be useful for stopping unruly users.
-
Python 3 introduced async/await syntax on functions for making non-blocking concurrent code look more like readable synchronous code. But this requires that you to manage two types of functions: synchronous functions that you don't need to
await
, and asynchronous ones that you do need toawait
before using the actual return value. When you need to call anasync
function, your own function must becomeasync
as well. Asynchronous functions spread, and more of the language needs to be changed to supportasync
/await
.Elixir and Erlang avoid this entirely by using a VM that preempts processes as needed. Your functions consist of just normal-looking blocking code.
This vastly-improved concurrency situation is similar to what you have on the operating system you're using. It is probably running over a hundred processes, which generally don't know about each other and are automatically preempted by the kernel.
Macros
Elixir has built-in support for transforming code at compile time using macros. Here is a dead-simple macro that transforms a filename string into the contents of that file:
defmacro content(filename) do
File.read!(filename)
end
This ensures that the file is included in the compiled BEAM code, so that it does not need to be shipped along with the application.
Protocols
Elixir, like Clojure, supports protocols, which allow you to define a protocol and then implement it for multiple types. When calling a function on a protocol, Elixir dispatches to the correct implementation based on the type. You can also implement an existing protocol like Enumerable
on your own data types.
Protocols may look similar to Python's double-underscore methods like __iter__
, but these methods in Python are reserved for the language itself. Because these methods are not namespaced, when a library invents its own double-underscore method, it risks conflicting with another library that has a different idea about what the method should do. It is also impossible to add a new __method__
to built-in objects like str
.
Atoms
Atoms ("symbols" in Ruby, "keywords" in Clojure) are very useful as ad-hoc symbols. In Python, I've often had to use strings, unique integers, or object()
to achieve the same ends, all of which are worse than atoms: integers and object()
don't repr
to something reasonable, and symbols should not look like strings or support string operations.
Structs
Elixir has structs, and they work properly: unlike namedtuple
, different types with the same values are unequal. They can have default values and required keys.
Immutable data structures
Elixir, like Clojure, has immutable maps and sets based on hash array mapped tries. You can share them without worrying about mutation, or keep many similar versions around with less copying and wastage of memory.
Language syntax
Elixir has some weird-looking syntax that is easy to get fixated on: do
and end
everywhere, \\
for default arguments, and fun.()
for calling anonymous function fun
. But there are other things that may be immediately appealing:
-
Pipe operator: Elixir allows you to write an expression as a series of pipes:
File.read!("/etc/hosts") |> String.trim_trailing("\n") |> String.split("\n") |> Enum.map(&(String.split(&1, "\t")))
This works just like Clojure's
->
thread-first operator.In Python, you may get lucky with a series of method calls that obviate the need for piping. But in other cases, you'll need to interrupt a nice chain of methods with a function that wraps the expression. Elixir's pipes avoid this annoyance because you can use any function, not just the methods on the last object.
-
Sophisticated pattern matching that allows you to pull values out of nested maps, lists, and tuples. Python has
[a, b, [c, d, e]] = x
syntax that works for an iterablex
, but there is nothing similar for pulling values out of keyed objects. -
Case expressions, in contrast to
if
andelse
,case
lets you write tabular-looking code where it is easier to see how a value is transformed to another:stat = File.stat("/var/cache/apt/pkgcache.bin", time: :posix) updated = case stat do {:ok, info} -> info.mtime {:error, _} -> 0 end
(We don't care about the details of the error, so we bind it to
_
.){message, color} = case result do :unmet -> {"[unmet]", [:inverse, :red]} :ran_meet -> {"[ran meet]", [:inverse, :magenta]} :met -> {"[met]", [:inverse, :green]} end
This one could be replicated with a
dict
in Python because it doesn't take advantage of pattern matching, but it would be somewhat odd to do so. -
All functions return the last expression, and there is no early return, other than throwing an exception.
Standard library
-
Elixir's standard library consists almost entirely of high-quality code that is educational to read. It can be browsed here.
-
Unicode strings are stored as UTF-8 in Erlang binaries, and they can be processed either as Unicode or as bytes, without a conversion step.
-
String functions like
length
andreverse
operate on grapheme clusters ("user-perceived characters"), not codepoints. -
Elixir's
inspect
(repr
in Python) has built-in support for pretty-printing, syntax highlighting, limits on the number of items printed, and the option of either swallowing or re-raising exceptions thrown inInspect
implementations. -
Trivial, but it has a replace_suffix that I've often needed in Python. There are a lot of little things in the Elixir standard library that make for a less-annoying programming experience.
ExUnit / mix test
-
Elixir's test runner, ExUnit, includes some nice features for controlling which tests are run. I tag some of my really slow tests with
@tag :slow
and usually skip them with--exclude slow:true
. -
Test cases can be configured to run in parallel with other test cases, using
use ExUnit.Case, async: true
. -
The test order is randomized by default, and the randomization seed is printed at the end, in case an order-dependent failure needs to be reproduced.
Hiding your precious codes
If you ship your code in closed-source desktop software or onto untrusted cloud servers, in some cases you may want to increase the effort required to decompile and reverse-engineer your software. Python 2 and 3 bytecode is relatively easy to decompile, using existing tools, into source code that looks similar to the original code. BEAM code (compiled without debug_info
) should be much harder to turn back into source code, and there seem to be no publicly-available tools to do so. This is not a complete roadblock to reverse-engineering because the virtual machine can be debugged, and because the area of interest may be small enough that more tedious reverse-engineering still makes economic sense.
Elixir strips the abstract_code
chunks from escripts by default since commit 3ba85ad570df6ea47756652b68a74fc94e9de7d8 (2017-03-03).