Misc Blog

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
Twitter
@nicolashoban

10 months 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.

The end

More posts