Packages

An array-like data structure for Elixir

Current section

Files

Jump to
exarray lib ex_array array.ex
Raw

lib/ex_array/array.ex

defmodule ExArray.Array do
defstruct length: 0, contents: %{}
@moduledoc """
An `ExArray.Array` is a data structure that contains a collection of elements.
Elements are stored in a Map with their index as the key. This allows for fast
random access to elements by index, but slower insertion and deletion operations,
unless the element is being added or deleted to the end of the `Array`.
"""
@type t :: %__MODULE__{
length: non_neg_integer,
contents: map
}
# Public API #
# Construction Operations #
@doc """
Creates an `ExArray.Array` with a length of zero, containing no elements.
## Examples
iex> ExArray.Array.new()
%ExArray.Array{length: 0, contents: %{}}
"""
@spec new() :: t
def new do
%ExArray.Array{}
end
@doc """
Creates an `ExArray.Array` with the given list of elements.
## Examples
iex> ExArray.Array.new([1, 2, 3])
%ExArray.Array{length: 3, contents: %{0 => 1, 1 => 2, 2 => 3}}
"""
@spec new(list(any())) :: t
def new(list) when is_list(list) do
Enum.reduce(list, new(), fn element, acc -> add(acc, element) end)
end
# Add Operations #
@doc """
Adds the given element to the end of the Array
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add(4)
%ExArray.Array{length: 4, contents: %{0 => 1, 1 => 2, 2 => 3, 3 => 4}}
"""
@spec add(t, any()) :: t
def add(%__MODULE__{} = array, element) do
add_element_at(array, element, array.length)
end
def add_at!(%__MODULE__{} = array, index, element) when index == array.length do
add(array, element)
end
@doc """
Adds the given element to the Array at the given index.
If the given index is greater than the length of the Array,
an `ArgumentError` is raised. If the given index is negative, it will be evaluated
as an offset from the end of the Array.
If an element currently exists at the given index, it, and all following
elements, are shifted right.t()
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at!(1, 4)
%ExArray.Array{length: 4, contents: %{0 => 1, 1 => 4, 2 => 2, 3 => 3}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at!(-2, 4)
%ExArray.Array{length: 4, contents: %{0 => 1, 1 => 4, 2 => 2, 3 => 3}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at!(4, 4)
** (ArgumentError) Index 4 is out of bounds for length 3
"""
@spec add_at!(t, integer(), any()) :: t
def add_at!(%__MODULE__{} = array, index, element) when is_integer(index) do
case add_at(array, index, element) do
{:ok, array} -> array
{:error, :out_of_bounds} -> raise_out_of_bounds(index, array.length)
end
end
def add_at(%__MODULE__{} = array, index, element) when index == array.length do
{:ok, add(array, element)}
end
@doc """
Adds the given element to the Array at the given index.
If the given index is greater than the length of the Array,
an {:error, :out_of_bounds} tuple is returned. If the given index is negative,
it will be evaluated as an offset from the end of the Array.
If an element currently exists at the given index, it, and all following
elements, are shifted right.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at(1, 4)
{:ok, %ExArray.Array{length: 4, contents: %{0 => 1, 1 => 4, 2 => 2, 3 => 3}}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at(-2, 4)
{:ok, %ExArray.Array{length: 4, contents: %{0 => 1, 1 => 4, 2 => 2, 3 => 3}}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.add_at(4, 4)
{:error, :out_of_bounds}
"""
@spec add_at(t, integer(), any()) :: {:ok, t} | {:error, :out_of_bounds}
def add_at(%__MODULE__{} = array, index, element) when is_integer(index) do
index_safe_operation(array, index, fn i -> add_element_at(array, element, i) end)
end
# Retrieval Operations #
@doc """
Returns `{:ok, element}` if the given index is within the bounds of the Array,
otherwise returns `{:error, :out_of_bounds}`.
If the given index is negative, it will be evaluated as an offset from the end of the Array.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get(1)
{:ok, 2}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get(-1)
{:ok, 3}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get(4)
{:error, :out_of_bounds}
"""
@spec get(t, integer()) :: {:ok, any()} | {:error, :out_of_bounds}
def get(%__MODULE__{} = array, index) when is_integer(index) do
index_safe_operation(array, index, fn i -> Map.get(array.contents, i) end)
end
@doc """
Returns the element at the given index if it exists, otherwise raises an `ArgumentError`
If the given index is negative, it will be evaluated as an offset from the end of the Array.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get!(1)
2
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get!(-1)
3
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.get!(4)
** (ArgumentError) Index 4 is out of bounds for length 3
"""
@spec get!(t, integer()) :: any()
def get!(%__MODULE__{} = array, index) when is_integer(index) do
case get(array, index) do
{:ok, element} -> element
{:error, :out_of_bounds} -> raise_out_of_bounds(index, array.length)
end
end
# Removal Operations #
@doc """
Removes the last element from the Array and returns the resulting Array.
If the array is empty, the original Array is returned.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove()
%ExArray.Array{length: 2, contents: %{0 => 1, 1 => 2}}
iex> ExArray.Array.new([]) |> ExArray.Array.remove()
%ExArray.Array{length: 0, contents: %{}}
"""
@spec remove(t) :: t
def remove(%__MODULE__{} = array) when array.length == 0 do
array
end
def remove(%__MODULE__{} = array) do
{:ok, arr} = remove_at(array, array.length - 1)
arr
end
@doc """
Removes the element at the given index from the Array and returns the resulting Array.
If the given index is invalid, an {:error, :out_of_bounds} tuple is returned. If the given index is negative,
it will be evaluated as an offset from the end of the Array.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at(1)
{:ok, %ExArray.Array{length: 2, contents: %{0 => 1, 1 => 3}}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at(-1)
{:ok, %ExArray.Array{length: 2, contents: %{0 => 1, 1 => 2}}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at(4)
{:error, :out_of_bounds}
"""
@spec remove_at(t, integer()) :: {:ok, t} | {:error, :out_of_bounds}
def remove_at(%__MODULE__{} = array, index) when index == array.length - 1 do
{:ok, remove_element_at(array, index)}
end
def remove_at(%__MODULE__{} = array, index) when is_integer(index) do
index_safe_operation(array, index, fn i -> remove_element_at(array, i) end)
end
@doc """
Removes the element at the given index from the Array and returns the resulting Array.
If the given index is invalid, an `ArgumentError` is raised. If the given index is negative,
it will be evaluated as an offset from the end of the Array.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at!(1)
%ExArray.Array{length: 2, contents: %{0 => 1, 1 => 3}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at!(-1)
%ExArray.Array{length: 2, contents: %{0 => 1, 1 => 2}}
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.remove_at!(4)
** (ArgumentError) Index 4 is out of bounds for length 3
"""
@spec remove_at!(t, integer()) :: t
def remove_at!(%__MODULE__{} = array, index) do
case remove_at(array, index) do
{:ok, array} -> array
{:error, :out_of_bounds} -> raise_out_of_bounds(index, array.length)
end
end
# Transformation Operations #
@doc """
Returns a `List` of all the elements in the Array. Ordering is retained.
## Examples
iex> ExArray.Array.new([1, 2, 3]) |> ExArray.Array.to_list()
[1, 2, 3]
"""
@spec to_list(t) :: list(any())
def to_list(%__MODULE__{length: length, contents: contents}) do
for i <- 0..(length - 1), into: [], do: Map.get(contents, i)
end
# Protocol Implementations #
defimpl Enumerable, for: __MODULE__ do
alias ExArray.Array
def count(%Array{length: length}) do
{:ok, length}
end
def member?(_a, _b), do: {:error, __MODULE__}
def slice(%Array{} = arr) do
{:ok, arr.length, fn start, length, step -> do_slice(start, length, step, arr) end}
end
def reduce(%Array{} = arr, acc, fun) do
do_reduce(arr, acc, fun, 0)
end
defp do_reduce(arr, {_term, val}, _fun, index) when index == arr.length do
{:done, val}
end
defp do_reduce(arr, {:cont, acc}, fun, index) do
do_reduce(arr, fun.(Map.get(arr.contents, index), acc), fun, index + 1)
end
defp do_reduce(_arr, {:halt, acc}, _fun, _index) do
{:halted, acc}
end
defp do_reduce(arr, {:suspend, acc}, fun, index) do
{:suspended, acc, &do_reduce(arr, &1, fun, index)}
end
defp do_slice(start, length, step, arr) do
last_index = start + (length - 1) * step
do_slice_helper(last_index, length, step, 0, arr, [])
end
defp do_slice_helper(_position, amount, _step, current, _arr, acc) when current == amount do
acc
end
defp do_slice_helper(position, amount, step, current, arr, acc) do
do_slice_helper(position - step, amount, step, current + 1, arr, [
Map.get(arr.contents, position) | acc
])
end
end
defimpl Collectable, for: __MODULE__ do
alias ExArray.Array
def into(%Array{} = arr) do
collector_fun = fn
acc, {:cont, element} -> Array.add(acc, element)
acc, :done -> acc
_acc, :halt -> :halted
end
{arr, collector_fun}
end
end
# Private API #
# Adding to the end of the array
defp add_element_at(arr, element, index) when index == arr.length do
%__MODULE__{length: arr.length + 1, contents: Map.put(arr.contents, index, element)}
end
# Adding to any spot but the end of the array, requires re-indexing
@spec add_element_at(t(), any(), integer()) :: t()
defp add_element_at(arr, element, index) do
shifted_contents = shift_map_keys_after(arr.contents, index, &increment_one/1)
%__MODULE__{length: arr.length + 1, contents: Map.put(shifted_contents, index, element)}
end
# Removing the last element of the array
defp remove_element_at(arr, index) when index == arr.length - 1 do
{_element, with_removed_element} = Map.pop!(arr.contents, index)
%__MODULE__{length: arr.length - 1, contents: with_removed_element}
end
# Removing any element but the last element of the array, requires re-indexing
@spec remove_element_at(t(), integer()) :: t()
defp remove_element_at(arr, index) do
{_element, with_removed_element} = Map.pop!(arr.contents, index)
%__MODULE__{
length: arr.length - 1,
contents: shift_map_keys_after(with_removed_element, index, &decrement_one/1)
}
end
# Returns {:ok, result} if the index is within the bounds of the array,
# after evaluating the given operation function,
# otherwise returns {:error, :out_of_bounds}
@spec index_safe_operation(t(), integer(), (integer() -> any())) ::
{:ok, any()} | {:error, :out_of_bounds}
defp index_safe_operation(array, index, operation) do
index = if index < 0, do: array.length + index, else: index
case index < 0 or index >= array.length do
true -> {:error, :out_of_bounds}
false -> {:ok, operation.(index)}
end
end
@spec raise_out_of_bounds(integer(), integer()) :: no_return()
defp raise_out_of_bounds(index, length) do
raise(ArgumentError, "Index #{index} is out of bounds for length #{length}")
end
# Ideally we could only shift the keys that are after the index we are adding to
# Look into when it becomes more efficient to shift vs. creating a new map
@spec shift_map_keys_after(map, integer(), (integer() -> integer())) :: map()
defp shift_map_keys_after(map, index_after, mapping_fun) do
Map.new(map, fn
{k, v} when k < index_after -> {k, v}
{k, v} -> {mapping_fun.(k), v}
end)
end
defp increment_one(x), do: x + 1
defp decrement_one(x), do: x - 1
end