[Proposal] Add Stream.unzip/1

16 views
Skip to first unread message

Nilanjan De

unread,
Sep 23, 2025, 7:49:37 AM (4 days ago) Sep 23
to elixir-lang-core
I am learning Elixir and was going through this tutorial which mentions the Stream.unzip/1 function but I noticed that Stream.unzip/1 does not exist in the std library.
 
Was it present earlier and was deprecated or was it never added or was it decided not to add it for some reason?

If it makes sense to add this to the std library, happy to send a PR for review.
https://github.com/n1lanjan/elixir/commit/f720477f21feebc938ed9effd58bf16fd04e0089 

```diff
diff --git a/lib/elixir/lib/stream.ex b/lib/elixir/lib/stream.ex
index 81704f1e3..79622cd95 100644
--- a/lib/elixir/lib/stream.ex
+++ b/lib/elixir/lib/stream.ex
@@ -1383,6 +1383,38 @@ def zip_with(enumerables, zip_fun) do
     R.zip_with(enumerables, zip_fun)
   end

+  @doc """
+  Opposite of `zip/2`. Lazily splits a stream of two-element tuples into two streams.
+
+  It returns a tuple with two streams. Each stream enumerates the corresponding
+  element of the input tuples.
+
+  Each returned stream enumerates the input independently. Enumerating both
+  streams will traverse the input twice. If your input is a resource or costs
+  to enumerate, consider materializing once with `Enum.unzip/1`.
+
+  This function expects elements to be two-element tuples. Otherwise, it will
+  fail at enumeration time.
+
+  ## Examples
+
+      iex> {left, right} = Stream.unzip([{:a, 1}, {:b, 2}, {:c, 3}])
+      iex> Enum.to_list(left)
+      [:a, :b, :c]
+      iex> Enum.to_list(right)
+      [1, 2, 3]
+
+  """
+  @doc since: "1.19.0"
+  @spec unzip(Enumerable.t({left, right})) :: {Enumerable.t(left), Enumerable.t(right)}
+        when left: term, right: term
+  def unzip(enumerable) do
+    {
+      map(enumerable, fn {left, _right} -> left end),
+      map(enumerable, fn {_left, right} -> right end)
+    }
+  end
+
   ## Sources

   @doc """
diff --git a/lib/elixir/test/elixir/stream_test.exs b/lib/elixir/test/elixir/stream_test.exs
index ed62a1cfa..297e4b729 100644
--- a/lib/elixir/test/elixir/stream_test.exs
+++ b/lib/elixir/test/elixir/stream_test.exs
@@ -1342,6 +1342,53 @@ test "zip_with/2 does not leave streams suspended on halt" do
     assert Process.get(:stream_zip_with) == :done
   end

+  test "unzip/1 is lazy" do
+    {left, right} = Stream.unzip([{:a, 1}])
+    assert lazy?(left)
+    assert lazy?(right)
+  end
+
+  test "unzip/1 basic" do
+    {left, right} = Stream.unzip([{:a, 1}, {:b, 2}, {:c, 3}])
+    assert Enum.to_list(left) == [:a, :b, :c]
+    assert Enum.to_list(right) == [1, 2, 3]
+  end
+
+  test "unzip/1 enumerates the input independently for each side" do
+    Process.put(:stream_unzip_calls, 0)
+
+    source =
+      Stream.map([{:a, 1}, {:b, 2}], fn tuple ->
+        Process.put(:stream_unzip_calls, Process.get(:stream_unzip_calls) + 1)
+        tuple
+      end)
+
+    {left, right} = Stream.unzip(source)
+    assert Enum.to_list(left) == [:a, :b]
+    assert Enum.to_list(right) == [1, 2]
+    assert Process.get(:stream_unzip_calls) == 4
+  end
+
+  test "unzip/1 roundtrips with zip/2" do
+    concat = Stream.concat(1..3, 4..6)
+    cycle = Stream.cycle([:a, :b, :c])
+    zipped = Stream.zip(concat, cycle)
+
+    {left, right} = Stream.unzip(zipped)
+    assert Enum.to_list(Stream.zip(left, right)) == Enum.to_list(zipped)
+  end
+
+  test "unzip/1 raises on non-tuple elements at enumeration time" do
+    {left, _right} = Stream.unzip([:a, :b, :c])
+    assert_raise FunctionClauseError, fn -> Enum.to_list(left) end
+  end
+
+  test "unzip/1 on empty input" do
+    {left, right} = Stream.unzip([])
+    assert Enum.to_list(left) == []
+    assert Enum.to_list(right) == []
+  end
+
   test "zip_with/2 closes on inner error" do
     zip_with_fun = &List.to_tuple/1
     stream = Stream.into([1, 2, 3], %Pdict{})
```

José Valim

unread,
Sep 23, 2025, 7:56:21 AM (4 days ago) Sep 23
to elixir-l...@googlegroups.com
I am not sure if it ever existed. The issue with the implementation above and any other streaming unzip implementation is that it will ultimately traverse the enumerable twice. If that's an okay compromise for your use case, then you can call `Stream.map` yourself. But generally speaking, it does break the properties of the Stream module.


--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/elixir-lang-core/2c29ac4a-736a-4cc5-b4db-f2022dd8411bn%40googlegroups.com.

Nilanjan De

unread,
Sep 23, 2025, 9:08:31 AM (4 days ago) Sep 23
to elixir-l...@googlegroups.com
Indeed my implementation does traverse the enumerable twice. Trying to implement this in a single traversal is problematic since one side may be consumed without the other, therefore, a single-pass must either buffer unboundedly for the slower side or block the faster side, neither of which is not desirable. I agree the “two streams” shape can surprise, that’s why I mentioned the double traversal explicitly in the docstring.

Re: “breaking Stream properties”: From my reading of the docs and code, I think Stream guarantees laziness and composability, not a universal single-pass across multiple, independent consumers. For example, Stream.cycle/1 re-enumerates its source by design; some ops (take) with negative counts also fully consume the input (with bounded memory) before producing, (which is problematic on infinite streams hence called out in the docs). These are examples which show that Stream doesn’t promise “emit as you read once.”.

José Valim

unread,
Sep 23, 2025, 9:30:00 AM (4 days ago) Sep 23
to elixir-l...@googlegroups.com
Yes, some of them may consume it at all or cycle, but these are somewhat clear (in my opinion). Negative indexes by definition imply going to the end of the collection. Cycle by definition implies multiple traversals. But I don't think unzip necessarily implies multiple traversals and we are not gaining anything compared to calling Stream.map  (compared to Enum.unzip, which actually does it in a single pass).


Reply all
Reply to author
Forward
0 new messages