The Basic Type Your Language Might Be Missing
An Introduction to Atoms in Elixir
As a new Elixir programmer, I was surprised to learn about a fundamental data type called atoms that I had never encountered in other programming languages. Atoms are a unique data type in Elixir that simplify message passing and make code more readable. In this blog post, I'll explain what atoms are, how they are used in Elixir, how they compare to enums in other programming languages, and show some code examples to help you get started.
What are Atoms?
At their core, atoms are just constant values identified by their name. The name is always an Elixir atom, which is a symbol whose name is its value. The syntax for an atom is a colon followed by a sequence of letters, digits, and underscores:
:hello_world
Atoms are a core part of Elixir and are used for several purposes, including naming processes, identifying statuses, and labeling data types. One of their most significant advantages in Elixir, though, is their use for message passing.
How are Atoms Used for Message Passing?
When a process sends a message to another process in Elixir, it uses the send
function. The first argument of a send
function is always the name of the process that will receive the message. But how does the receiving process know what the message is about? That's where atoms come in!
Atoms are used as tags in message passing. When a process receives a message, it can use pattern matching to extract the tag from the message and use it to decide what to do with the message. Here is an example of sending a message with a tag to another process, and the receiving process using the tag for pattern matching:
# Sending process
send(receiver_pid, {:print_message, :hello_world})
send(receiver_pid, {:print_message, :warn_for_timeout})
# Receiving process
receive do
{:print_message, :hello_world} -> IO.puts("Hello, World!")
{:print_message, :warn_for_timeout} -> IO.puts("Your session might expire soon")
# other patterns...
end
In this example, the tag :hello_world
is used to pattern-match the message in the receiving process. The message itself could be any type of data, but the tag ensures that the right process receives and handles the message.
Comparing Atoms to Enums
Other programming languages often use enumerations (enums) to represent a set of constant values, just like atoms in Elixir. Enums are types whose values are a fixed set of constant values, identified by a name. Here's a comparison between Elixir's atoms and C's enums:
# Elixir's atoms
:success
:failure
:error
:any_arbitrary_tag
// C's enum
enum Result {
Success = 0,
Failure = 1
};
Enums work well for simple cases, but they have several disadvantages when compared to atoms. For example, in C, enums are integers, which can lead to bugs and errors when combining or comparing them. In contrast, Elixir's atoms are a unique data type, so they can't be mistaken for other types or combined in error.
Additionally, atoms in Elixir are easily readable and writable, whereas enums in C can be verbose and rigid. For example, if we wanted to add a new status to our Result
enum, we would need to modify the definition of the enum itself. In Elixir, we can add a new atom without modifying existing code.
Using Atoms and Enums in Elixir and C
Let's look at an example of how atoms can help simplify Elixir code compared to C code. Imagine we're building a simple game with two players. We'll use atoms to represent the players' names and the moves they can make:
# Elixir atoms would look like this:
:player1
:player2
:rock
:paper
:scissors
In Elixir, we can use these atoms to simplify our code and ensure correctness:
# Elixir
def make_move(player, move) do
case move do
:rock ->
IO.puts("#{player} made a ROCK move!")
:paper ->
IO.puts("#{player} made a PAPER move!")
:scissors ->
IO.puts("#{player} made a SCISSORS move!")
_ ->
raise "Invalid move #{move}!"
end
end
make_move(:player1, :rock)
# "player1 made a ROCK move!"
make_move(:player2, :paper)
# "player2 made a PAPER move!"
make_move(:player1, :invalid)
# ERROR: Invalid move :invalid!
In contrast, let's look at how we would represent the same data in C using enums:
// C
enum Players { player1, player2 };
enum Moves { rock, paper, scissors };
void make_move(enum Players player, enum Moves move) {
switch (move) {
case rock:
printf("%s made a ROCK move!\n", player ? "player2" : "player1");
break;
case paper:
printf("%s made a PAPER move!\n", player ? "player2" : "player1");
break;
case scissors:
printf("%s made a SCISSORS move!\n", player ? "player2" : "player1");
break;
default:
fprintf(stderr, "Invalid move %d!\n", move);
exit(1);
}
}
make_moves(player1, rock); // "player1 made a ROCK move!"
make_move(player2, paper); // "player2 made a PAPER move!"
make_move(player1, invalid); // ERROR: "Invalid move 3!"
In this example, the use of enums results in more verbose code, making it harder for newcomers to read and write. Additionally, enums can be difficult to work with when creating new moves or players, as adding new constants requires modifying the original enum definition.
Conclusion
In this post, we introduced a unique type of data called atoms in Elixir. Fundamentally, an atom in Elixir is a constant value identified by its name. We saw how atoms simplify message passing by providing a unique tag for pattern matching. We also compared the use of atoms in Elixir to enumerations in C and saw how using atoms in Elixir simplifies code and improves readability. As a new Elixir programmer, I'm excited to experiment with the power of atoms and see what else they have to offer!