Process the n-dimensional list #
In data processing, I constantly have to work with the n-dimensional list, which something looks like these:
# 2 dimensional list (matrix)
[
[1, 2],
[3, 4]
]
As an elixir alchemist, use Enum.map/2 to transform list is our second nature.
However, when dealing with nested lists, transforming elements at specific depths requires
multiple nested mapping operations. Consider these approaches:
[[1, 2], [3, 4]]
|> Enum.map(fn row ->
Enum.map(row, fn i ->
i * 10
end)
end)
$> [[10, 20], [30, 40]]
And the things get worse when we like to work with 3-dimensional list or deeper:
[
[
[1, 2]
],
[
[3, 4]
]
]
|> Enum.map(fn row ->
Enum.map(fn column ->
Enum.map(fn i ->
i * 10
end)
end)
end)
Function Lifting through Functors in Haskell #
In Haskell, a lazy evaluation functional programming language, lists
implement the Functor typeclass. This allows us to ’lift’ functions
that work on single values into a functoin works on lists, using fmap.
For example, if we have a function (+ 1) that adds 1 to a single number,
fmap (+ 1) lifts this function to work on an entire list of numbers, transforming each element.
This is conceptually similar to Enum.map/2 in Elixir.
And the key feature of function lifting is its composability through functor composition. Consider how the same function can be lifted to operate on increasingly nested structures:
-- Function operating on a single value
(+ 1) 1
--> 2
-- Function lifted to operate on a list (1-dimensional)
fmap (+ 1) [1, 2]
--> [2, 3]
-- Function lifted to operate on a nested list (2-dimensional)
(fmap . fmap) (+ 1) [[1, 2], [3, 4]]
--> [[2, 3], [4, 5]]
-- Function lifted to operate on a doubly nested list (3-dimensional)
(fmap . fmap . fmap) (+ 1) [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
--> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]
The composition of fmap with itself (fmap . fmap) allows us to reach deeper into nested
structures while preserving their dimensionality. Each additional composition of fmap adds
another level of penetration into the nested structure.
Using Kernel.get_in/3
#
In Elixir, we can achieve similar behavior using Kernel.update_in/3 with Access.all/1.
This approach allows us to traverse and transform nested lists at specific depths:
# list
[1, 2] |> update_in([Access.all()], &(&1 + 1)) #=> [2, 3]
# 2 dimensional list
[[1, 2], [3, 4]] |> update_in([Access.all(), Access.all()], &(&1 + 1))
#=> [[2, 3], [4, 5]]
# 3 dimensional list
[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|> update_in([Access.all(), Access.all(), Access.all()], &(&1 + 1))
#=> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]
While this approach works, it requires repetitive use of Access.all() for each level
of nesting, making it syntactically verbose.
Dimensional Mapping with Ndim #
The Ndim library, named for its ability to handle n-dimensional lists,
provides a more concise syntax for transforming nested lists. It offers both
dimension-specific functions and a general-purpose mapping function:
# Transform elements in a 2-dimensional list
[[1, 2], [3, 4]]
|> Ndim.d2map(&(&1 + 1))
#=> [[2, 3], [4, 5]]
# Transform elements in a 3-dimensional list
[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|> Ndim.d3map(&(&1 + 1))
#=> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]
In addition to the dimension-specific functions (d2map/2 through d5map/2),
Ndim provides a general-purpose dmap/3 that can operate on lists of any dimension:
# Transform elements at any specified dimension
nested_list |> Ndim.dmap(dimension, transformation_function)
# Example: Transform a 6-dimensional list
deeply_nested_list |> Ndim.dmap(6, &(&1 + 1))
Converting N-dimensional Lists to Coordinate Maps #
The Ndim library also provides functionality to transform n-dimensional lists into coordinate maps, where each value is keyed by its dimensional coordinates. Currently supports 2-dimensional and 3-dimensional structures:
# Converting a 2-dimensional list to a coordinate map
[[1, 2], [3, 4]]
|> Ndim.to_coordinate_map()
#=> %{{0, 0} => 1, {0, 1} => 2, {1, 0} => 3, {1, 1} => 4}
# Converting a 3-dimensional list to a coordinate map
[[[1, 2]], [[3, 4]]]
|> Ndim.to_coordinate_map()
#=> %{{0, 0, 0} => 1, {0, 0, 1} => 2, {1, 0, 0} => 3, {1, 0, 1} => 4}
This coordinate mapping is particularly useful for:
- Sparse matrix operations
- Direct coordinate-based access
- Grid-based calculations
Hope you like it, and happy hacking!