Elixir for Python Developers
Learn the essentials of the Elixir programming language from the point of view of a Python developer. Discover your next programming language now!
- Name
- Nicolas
- @nicolashoban
1 year ago
Python and Elixir both offer powerful tools for building full-stack applications. Whether you’re already familiar with Python or Elixir, choosing the right framework can have a significant impact on your development journey.
In this post, we will compare Python and Elixir and by the end, you should have a clearer understanding of the differences between each language and which one might be the best fit for your needs and goals. So, let’s dive in and explore the basics.
Variables
In Python:
name = "John"
print(name) # Output: John
In Elixir:
name = "John"
IO.puts name # Output: John
Lists
In Python:
fruits = ["apple", "banana", "orange"]
print(fruits[0]) # Output: apple
In Elixir:
fruits = ["apple", "banana", "orange"]
IO.puts Enum.at(fruits, 0) # Output: apple
Functions
In Python:
def add_numbers(a, b):
return a + b
print(add_numbers(2, 3))
# Output: 5
In Elixir:
defmodule Math do
def add_numbers(a, b) do
a + b
end
end
IO.puts Math.add_numbers(2, 3)
# Output: 5
Both Python and Elixir support defining functions, but in Elixir we need to define a module to contain the function. We also don’t need to use the return
keyword in Elixir - the last expression in the function is automatically returned.
Dictionaries (Maps)
In Python:
person = {"name": "John", "age": 30}
print(person["name"]) # Output: John
In Elixir:
person = %{ name: "John", age: 30 }
IO.puts person[:name] # Output: John
Loops
In Python:
for i in range(1, 4):
print(i)
# Output: 1 2 3
In Elixir:
1..3 |> Enum.each(fn i -> IO.puts i end)
# Output: 1 2 3
Conditional statements
In Python:
age = 25
if age >= 18:
print("You are an adult")
else:
print("You are a minor")
# Output: You are an adult
In Elixir:
age = 25
if age >= 18 do
IO.puts "You are an adult"
else
IO.puts "You are a minor"
end
# Output: You are an adult
Immutability
Everything is immutable in Elixir. When we perform an operation on a data structure, we create a new copy of the structure with the modification applied, rather than modifying the original structure in place.
In Python:
list1 = [1, 2, 3]
list2 = list1.copy()
list2.pop(1)
print(list1)
print(list2)
# Output: [1, 2, 3] [1, 3]
In Elixir:
list1 = [1, 2, 3]
list2 = List.delete_at(list1, 1)
IO.inspect list1
IO.inspect list2
# Output: [1, 2, 3] [1, 3]
This demonstrates how we can achieve immutability in Python by creating copies of data structures before modifying them, rather than modifying the original structures in place.
Piping from one function to another
In Python:
name = "John"
print(name.upper()[::-1])
# Output: NHOJ
In Elixir:
name = "John"
|> String.upcase()
|> String.reverse()
|> IO.puts()
# Output: NHOJ
Modules
In Python:
class Math:
@staticmethod
def add_numbers(a, b):
return a + b
print(Math.add_numbers(2, 3))
# Output: 5
In Elixir:
defmodule Math do
def add_numbers(a, b) do
a + b
end
end
IO.puts Math.add_numbers(2, 3)
# Output: 5
Both Python and Elixir support modules, but in Elixir we can define functions inside modules without using the self
keyword. We can also use the do
keyword to define the body of the function.
Anonymous functions
In Python:
add_numbers = lambda a, b: a + b
print(add_numbers(2, 3))
# Output: 5
In Elixir:
add_numbers = fn(a, b) -> a + b end
IO.puts add_numbers.(2, 3)
# Output: 5
Pattern matching
Pattern matching is not a built-in feature in Python. It is more commonly used in functional programming languages like Elixir and Erlang.
Function pattern matching
In Python:
def add_numbers(a, b):
if isinstance(a, int) and isinstance(b, int):
return a + b
elif isinstance(a, str) and isinstance(b, str):
return a + b
else:
raise ValueError("Invalid argument types")
print(add_numbers(2, 3))
print(add_numbers("Hello", "world"))
print(add_numbers(2, "Hello"))
# Output: 5 Helloworld ValueError: Invalid argument types
In Elixir:
defmodule AddNumbers do
def add(a, b) when is_integer(a) and is_integer(b), do: a + b
def add(a, b) when is_binary(a) and is_binary(b), do: a <> " " <> b
def add(_a, _b), do: raise "Invalid argument types"
end
IO.puts AddNumbers.add(2, 3)
IO.puts AddNumbers.add("Hello", "World")
IO.puts AddNumbers.add(2, "Hello")
# Output: 5 HelloWorld ** (RuntimeError) Invalid argument types
Both Python and Elixir support destructuring, but in Elixir we can use pattern matching to extract values from a list or tuple.
Ranges
In Python:
numbers = list(range(1, 6))
print(numbers[0])
print(numbers[-1])
# Output: 1 5
In Elixir:
numbers = 1..5 |> Enum.to_list()
IO.puts Enum.at(numbers, 0)
IO.puts Enum.at(numbers, -1)
# Output: 1 5
Keyword lists
In Python:
options = {"color": "red", "size": "medium"}
print(options["color"])
print(options["size"])
# Output: red medium
In Elixir:
options = [color: "red", size: "medium"]
IO.puts Keyword.get(options, :color)
IO.puts Keyword.get(options, :size)
# Output: red medium
Classes (Structs)
In Python:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("John", 30)
print(person.name)
print(person.age)
# Output: John 30
In Elixir:
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "John", age: 30}
IO.puts person.name
IO.puts person.age
# Output: John 30
Remember that Elixir is a functional language, and so does not have an exact equivalent to classes in Python, which are a feature of object-oriented programming. A struct in Elixir is basically a Map with predefined keys.
Guard clauses
In Python:
def add_numbers(a, b):
if a < 0 or b < 0:
raise ValueError("Invalid argument")
return a + b
print(add_numbers(2, 3))
print(add_numbers(-2, 3))
# Output: 5 ValueError: Invalid argument
In Elixir:
def add_numbers(a, b) when a >= 0 and b >= 0 do
a + b
end
def add_numbers(_, _), do: raise "Invalid argument"
IO.puts add_numbers(2, 3)
IO.puts add_numbers(-2, 3)
# Output: 5 ** (RuntimeError) Invalid argument
Tuples
Tuples are intended as fixed-size containers for multiple elements. A tuple may contain elements of different types, which are stored contiguously in memory.
In Python:
person = ("John", 30)
print(person[0])
print(person[1])
# Output: John 30
In Elixir:
person = {"John", 30}
IO.puts elem(person, 0)
IO.puts elem(person, 1)
# Output: John 30
Substrings
In Python:
greeting = "Hello, world!"
print(greeting[0:5])
# Output: Hello
In Elixir:
greeting = "Hello, world!"
IO.puts String.slice(greeting, 0..4)
# Output: Hello
List comprehensions
In Python:
numbers = [1, 2, 3, 4, 5]
even_numbers = [n for n in numbers if n % 2 == 0]
print(even_numbers)
# Output: [2, 4]
In Elixir:
numbers = [1, 2, 3, 4, 5]
even_numbers = for n <- numbers, rem(n, 2) == 0, do: n
IO.inspect even_numbers
# Output: [2, 4]
Both Elixir and Python support list comprehensions. In Elixir, we use the for
keyword to define the comprehension, and we can use the <-
syntax to iterate over a list and a conditional expression to filter the values. In Python, we use square brackets to define the list comprehension, and we can use an if
statement to filter the values.
Binary pattern matching
In Python:
import re
greeting = "Hello, world!"
if re.match(r"^Hello", greeting):
print("Greeting starts with Hello")
else:
print("Greeting does not start with Hello")
# Output: Greeting starts with Hello
In Elixir:
greeting = "Hello, world!"
case String.match?(greeting, "Hello") do
true -> IO.puts "Greeting starts with Hello"
false -> IO.puts "Greeting does not start with Hello"
end
# Output: Greeting starts with Hello
Module attributes
In Python:
class Math:
PI = 3.14
print(Math.PI)
# Output: 3.14
In Elixir:
defmodule Math do
@pi 3.14
def pi, do: @pi
end
IO.puts Math.pi()
# Output: 3.14
try/catch blocks
In Python:
try:
print(5 / 0)
except ZeroDivisionError:
print("You can't divide by zero!")
# Output: You can't divide by zero!
In Elixir:
try do
IO.puts(5 / 0)
rescue
ArithmeticError ->
IO.puts("You can't divide by zero!")
_ ->
IO.puts "Caught exception"
end
# Output: You can't divide by zero!
The _
means to ignore the exception variable. You could replace that with a variable name and print it out.
Private functions
In Python:
class Example:
def public_function(self):
return self._private_function()
def _private_function(self):
return "Hello, World!"
e = Example()
print(e.public_function()) # This will print "Hello, World!"
In this Python example, _private_function
is intended to be private, but it can still be called from outside the class, like so: e._private_function()
.
In Elixir, you can truly define private functions using defp
(instead of def
). These functions cannot be accessed outside of the module in which they’re defined. Here’s an example:
In Elixir:
defmodule Example do
def public_function do
private_function()
end
defp private_function do
"Hello, World!"
end
end
IO.puts Example.public_function() # This will print "Hello, World!"
Parallel processes
In Python using the multiprocessing
module:
import multiprocessing
import time
def task1():
print("Task 1 started")
time.sleep(1)
print("Task 1 finished")
def task2():
print("Task 2 started")
time.sleep(0.5)
print("Task 2 finished")
def task3():
print("Task 3 started")
time.sleep(2)
print("Task 3 finished")
if __name__ == "__main__":
processes = [
multiprocessing.Process(target=task1),
multiprocessing.Process(target=task2),
multiprocessing.Process(target=task3)
]
for process in processes:
process.start()
for process in processes:
process.join()
In Elixir:
defmodule Parallel do
def run_in_parallel(tasks) do
tasks
|> Enum.map(&Task.async(fn -> &1.() end))
|> Enum.map(&Task.await(&1))
end
end
task1 = fn -> IO.puts "Task 1 started"; :timer.sleep(1000); IO.puts "Task 1 finished" end
task2 = fn -> IO.puts "Task 2 started"; :timer.sleep(500); IO.puts "Task 2 finished" end
task3 = fn -> IO.puts "Task 3 started"; :timer.sleep(2000); IO.puts "Task 3 finished" end
Parallel.run_in_parallel([task1, task2, task3])
Both Elixir and Python provide ways to run tasks in parallel. In Elixir, we use the Task
module to spawn asynchronous tasks, and in Python, we use the multiprocessing
module to create separate processes for each task. The tasks are executed concurrently, allowing for parallel processing.
Streams
Streams are lazy, composable enumerables.
The concept of streams differs quite a bit between Python and Elixir because they have fundamentally different designs.
In Python, streams typically refer to working with I/O, such as reading from or writing to files. Here’s an example of reading a file line by line, which is a sort of “streaming” operation because you’re not loading the entire file into memory at once:
with open('file.txt', 'r') as f:
for line in f:
print(line)
On the other hand, Elixir has a built-in Stream
module which provides lazy operations, meaning computations are deferred and performed only when needed. This allows you to handle large collections (or even infinite ones) without consuming a lot of memory. Here’s a simple example where a list is lazily transformed:
0..10_000_000
|> Stream.map(&(&1 * 2))
|> Enum.take(5)
# Output: [0, 2, 4, 6, 8]
In this example, we’re defining a range from 0 to 10 million, then using a stream to map over the range and multiply each number by 2. But thanks to the laziness of streams, no computation happens until the Enum.take(5)
line, which only processes the first 5 elements of the stream.
For a real-world example involving I/O, such as reading a file line by line, you would use Elixir’s File.stream! function:
File.stream!('file.txt')
|> Stream.each(&IO.puts/1)
|> Stream.run
Importing functions
In Python:
# Importing a module
import math
# Using a function from the module
print(math.sqrt(16)) # Output: 4.0
# Importing a specific function
from math import sqrt
# Using the function directly
print(sqrt(16)) # Output: 4.0
In Elixir:
# Importing a module
import List
# Using a function from the module
IO.puts(hd([1, 2, 3])) # Output: 1
# Without import, you use the full module name
IO.puts(List.hd([1, 2, 3])) # Output: 1
Macros
In Python, there aren’t macros in the same sense as in Elixir or other languages with macro systems, like Lisp or Rust. Python does offer some similar capabilities through decorators and metaclasses, but these are not macros in the traditional sense. Here’s a Python decorator as an example:
def trace(func):
def wrapper(*args, **kwargs):
print(f'TRACE: calling {func.__name__}() '
f'with {args}, {kwargs}')
original_result = func(*args, **kwargs)
print(f'TRACE: {func.__name__}() '
f'returned {original_result!r}')
return original_result
return wrapper
@trace
def say(name, line):
return f'{name}: {line}'
print(say('Jane', 'Hello, World'))
In the above Python code, trace is a decorator, which is a kind of metaprogramming function that modifies the behavior of the function it decorates.
In Elixir, on the other hand, macros are a powerful feature used for metaprogramming, allowing for code that writes code. This is possible because Elixir treats code as data and has a macro system similar to Lisp. Here’s an Elixir macro as an example:
defmodule Example do
defmacro log(expr) do
quote do
IO.puts("About to execute: #{unquote(expr)}")
result = unquote(expr)
IO.puts("Execution result: #{result}")
result
end
end
end
defmodule Test do
require Example
def test do
Example.log(1 + 2)
end
end
Test.test()
# Output:
# About to execute: 1 + 2
# Execution result: 3
In this Elixir code, log is a macro that prints an expression before it’s evaluated and then prints the result of the evaluation.
Remember that macros are a powerful tool but with great power comes great responsibility. They can make the code harder to understand and debug if not used properly, so they are often recommended to be used sparingly and judiciously.
Processes
Python and Elixir treat processes quite differently.
In Python, to work with processes, you would typically use the multiprocessing module, which allows you to create processes, communicate between them, and synchronize their operation. Here’s an example:
from multiprocessing import Process
def f(name):
print('hello', name)
if __name__ == '__main__':
p = Process(target=f, args=('world',))
p.start()
p.join()
In this Python example, we’re creating a new process that calls the function f
with the argument 'world'
. The start()
method starts the process’s activity, and the join() method blocks until the process has completed.
Elixir, as part of the Erlang ecosystem, provides lightweight processes which are not the same as OS processes or threads. Elixir processes are extremely lightweight in terms of memory and CPU usage, and it’s not uncommon to have hundreds of thousands of processes running concurrently in an Elixir application. Here’s an example of creating a new process in Elixir:
defmodule Hello do
def say_it(name) do
IO.puts("hello #{name}")
end
end
spawn(Hello, :say_it, ["world"])
In this Elixir example, the spawn/3
function is used to create a new process. The function Hello.say_it
will be called with the argument "world"
in this new process.
One major difference is that Python’s multiprocessing involves actual OS processes, while Elixir uses lightweight processes within the Erlang VM. This makes Elixir’s process creation and communication more efficient, but the two types of processes have different characteristics and uses. Python’s processes are more isolated from each other and can utilize multiple CPU cores, while Elixir’s processes are more about concurrency and fault tolerance.