tom


Coffee and Software Development.


Lists in Elixir are linked lists. I recently had a situation where I needed to shift all elements in the list one to the right, bringing the last element to the list as the new first element (head).


Here are the three ways I propose to right-shift a list, bringing the last element to the new head.


TLDR;

If you need to shift a list to the right and bring the last element to the new head position, this seems to be an effective way to do so, keeping in mind the linked nature of Elixir lists.

  def shift_non_inline(list) when is_list(list) do
    Enum.reverse(list) 
    |> shift_non_inline_helper() 
  end

  def shift_non_inline_helper([head | tail]), do: [head | Enum.reverse(tail)]

Tested Functions

defmodule Benchmark do
  
  ## 1 ##
  def shift(list) when is_list(list) do 
    Enum.reverse(list) 
    |> then(fn [head | tail] -> [head | Enum.reverse(tail)] end)
  end

  ## 2 ##
  def shift_non_inline(list) when is_list(list) do
    Enum.reverse(list) 
    |> shift_non_inline_helper() 
  end

  def shift_non_inline_helper([head | tail]), do: [head | Enum.reverse(tail)]

  ## 3 ##
  def shift_pop(list) when is_list(list) do
    {new_head, rest} = list |> List.pop_at(-1)
    [new_head | rest]
  end
end


The logic for the first two functions are identical. shift/1 uses Kernel.then/2 to pattern match in the pipeline by using a function declared in the pipeline. Both the initial two first reverse the list, get the head (original last element) of that list, and construct a new list where the head is the element just retrieved and the tail is the remainder of the reversed list, re-reversed (back in order).


The third method uses Elixir List function. It pops the last element off the list and returns the last element as the new head followed by the remainder.


All are very simple, but require list traversal to complete.


To test all three methods, ranges (to lists) with max sizes 100 and 10_000_000 were used. I ran the benchmarks using Benchee.

list = 1..10_000_000 |> Enum.to_list()
list_small = 1..100 |> Enum.to_list()

Benchee.run (
  %{
    "shift" => fn -> Benchmark.shift(list) end,
    "shift_pop" => fn -> Benchmark.shift_pop(list) end,
    "shift_non_inline" => fn -> Benchmark.shift_non_inline(list) end,
  }
)

Benchee.run (
  %{
    "shift_small" => fn -> Benchmark.shift(list_small) end,
    "shift_pop_small" => fn -> Benchmark.shift_pop(list_small) end,
    "shift_non_inline_small" => fn -> Benchmark.shift_non_inline(list_small) end
  }
)

In both the small and large lists, the shift operation completed the fastest with the dual reverse as opposed to the pop. The method which did not use Kernel.then() performed better in both instances.

Large

Name                       ips        average  deviation         median         99th %
shift_non_inline          4.48      223.44 ms    ±35.77%      215.30 ms      453.21 ms
shift                     3.19      313.32 ms    ±76.04%      228.98 ms      907.35 ms
shift_pop                 2.97      336.72 ms    ±33.13%      339.53 ms      581.26 ms

Comparison: 
shift_non_inline          4.48
shift                     3.19 - 1.40x slower +89.88 ms
shift_pop                 2.97 - 1.51x slower +113.28 ms

Small

Name                             ips        average  deviation         median         99th %
shift_non_inline_small        1.50 M      668.03 ns  ±4273.35%         581 ns         985 ns
shift_small                   1.48 M      675.72 ns  ±4184.02%         584 ns        1007 ns
shift_pop_small               0.71 M     1399.05 ns  ±2227.75%        1225 ns        2016 ns

Comparison: 
shift_non_inline_small        1.50 M
shift_small                   1.48 M - 1.01x slower +7.69 ns
shift_pop_small               0.71 M - 2.09x slower +731.02 ns