Elixir Syntax: Functions
Elixir Syntax Series
Function Definitions
In Elixir, functions are defined using def
for public functions and defp
for private functions, each ending with an end
. Unlike languages that can return void
, Elixir functions always return the last evaluated expression.
Most languages identify functions by name, but Elixir and Erlang identify functions by name and arity. The function below is identified as add/2
:
def add(a, b) do
a + b
end
Type Safety
In Elixir, the +
operator is strictly for numerical operations. Attempting to use it with other types will result in a failure. Many programming languages employ defensive programming to validate input values. However, in the Erlang/Elixir ecosystem, such practices are considered an antipattern. The prevailing approach is to code for the expected positive cases, allowing other cases to fail fast.
Type Constraints
Type constraints in Elixir are managed using pattern matching with the when
guard clause:
def add(a, b) when is_integer(a) and is_integer(b) do
a + b
end
This syntax ensures that both a
and b
are integers. If they do not match, the function will not be invoked. To handle additional types, you would define multiple function clauses for add/2
, each tailored to specific data types:
def add(a, b) when is_float(a) and is_float(b) do
a + b
end
def add(a, b) when is_binary(a) and is_binary(b) do
String.to_integer(a) + String.to_integer(b)
end
Now, add/2
can handle integers, floats, and binaries (strings), converting them as necessary.
Composition
The add/3
function sums three numbers by composing with the add/2
function:
def add(a, b, c) do
add(a, add(b, c))
end
In this example, we don’t need to check the types of a
, b
, and c
again, as they will be validated in the add/2
functions.
While you’ll still see the nested composition pattern in Erlang, Elixir provides a pipe operator |>
, which takes the result of the previous expression and passes it as the first argument to the next function:
def add(a, b, c) do
add(a, b) |> add(c)
end
Note: This version is functionally equivalent to the one above. The pipe operator is just allowing for left-to-right flow of data, making the code more readable.
Anonymous Functions
In JavaScript, the introduction of arrow function syntax has significantly streamlined function declarations, facilitated implicit returns, and simplified handling of the this
context. As a result, modern JavaScript codebases frequently adopt named anonymous functions, utilizing them extensively across a codebase.
Contrastingly, in Elixir, anonymous functions (defined using the fn
keyword) are generally reserved for concise blocks of logic used within a localized scope. While you can define anonymous functions at the module level, it’s more idiomatic in Elixir to define named functions using def
.
Named Anonymous Functions
Modern JavaScript codebases frequently utilize named anonymous functions at the module level, such as:
const addOne = (n) => add(1, n);
const addOneList = (list) => list.map((n) => addOne(n));
In Elixir, while it is possible to create named anonymous functions, their scope of use is limited to the block in which they are defined. This restriction makes the typical JavaScript pattern of module-level function definitions unworkable in Elixir.
For example, the following will not work outside its immediate defining block:
add_one = fn n -> add(1, n) end
Instead, the named anonymous function must be invoked using the dot (.) syntax within the function that declared it:
def add_one_list(list) when is_list(list) do
add_one = fn n -> add(1, n) end
Enum.map(list, fn n -> add_one.(n) end)
end
In Elixir, while named anonymous functions are feasible, they are less common compared to JavaScript. A more typical pattern in Elixir would be to define explicit functions:
def add_one(list) when is_list(list) do
Enum.map(list, fn n -> add_one(n) end)
end
def add_one(n) do
add(1, n)
end
The order of functions is crucial; placing a less restrictive function before a more specific one will lead to unexpected behavior as the first function will always be executed.
Eta Reduction
In lambda calculus, eta reduction is a transformation that simplifies a function λx. f x
to just f
, provided that x
does not appear free in f
. In simpler terms, if a function merely passes arguments directly to another function, it can be eliminated.
Eta reduction can streamline JavaScript code by removing unnecessary function wrappers:
const addOneList = (list) => list.map((n) => addOne(n));
can be reduced to:
const addOneList = (list) => list.map(addOne);
Here, the anonymous function wrapper is eliminated, allowing addOne
to be passed directly as a callback to map
.
Elixir facilitates eta reduction with a shorthand syntax using the capture operator &
:
def add_one_list(list) when is_list(list) do
Enum.map(list, fn n -> add_one(n) end)
end
can be reduced to:
def add_one_list(list) when is_list(list) do
Enum.map(list, &add_one/1)
end
In this case, the shorthand &add_one/1
directly references the add_one/1
function, making the code more concise and clear.
Additionally, the &
operator can be used to create anonymous functions inline:
Enum.map([1, 2, 3], &(&1 + 1)) # [2, 3, 4]
Default Values
Elixir includes the \\
operator, which is used to assign default values to function arguments.
def multiply(a, b \\ 1, c \\ 1) do
a * b * c
end
Behind the scenes, it generates additional function clauses to handle the omitted arguments:
def multiply(a) do
multiply(a, 1, 1)
end
def multiply(a, b) do
multiply(a, b, 1)
end
def multiply(a, b, c) do
a * b * c
end
This allows multiply/3
to be called with one, two, or three arguments.
Note: Required parameters must come first to avoid ambiguity. Parameters with default values should be placed after all required parameters.
Optional Values
Here is a common pattern for adding optional values.
function randomNumber(opts = {}) {
const min = opts.min || 1;
const max = opts.max || 100;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
randomNumber(); // Generates a number between 1 and 100
randomNumber({ min: 10 }); // Generates a number between 10 and 100
randomNumber({ max: 50 }); // Generates a number between 1 and 50
randomNumber({ min: 20, max: 30 }); // Generates a number between 20 and 30
This JavaScript code generates a random number, but includes an optional object where the caller can add min
and max
values. The function also includes min
and max
defaults.
Here is that logic transcribed to Elixir:
def random_number(opts \\ []) do
min = Keyword.get(opts, :min, 1)
max = Keyword.get(opts, :max, 100)
:rand.uniform(max - min + 1) + min - 1
end
random_number() # Generates a number between 1 and 100
random_number(min: 10) # Generates a number between 10 and 100
random_number(max: 50) # Generates a number between 1 and 50
random_number(min: 20, max: 30) # Generates a number between 20 and 30
Notice that instead of the optional values being injected into an object, Elixir uses a keyword list. The opts
parameter defaults to an empty list, allowing optional values to be passed as key-value pairs. The Keyword.get/3
function retrieves the values from the keyword list, with the third argument specifying the default value if the key is not present.
This pattern allows the caller to provide only the necessary options, while ensuring sensible defaults are applied for any missing values. Like the JavaScript version, this Elixir code maintains flexibility and clarity by using keyword lists for optional parameters.
Conclusion
Functions are fundamental units of abstraction and composition in Elixir, capable of being assigned to variables, passed as arguments, and returned from other functions.