Skip to content

Commit 5ce4b61

Browse files
authored
Add :reduce to for comprehensions (#8531)
1 parent bd6f2eb commit 5ce4b61

File tree

5 files changed

+175
-24
lines changed

5 files changed

+175
-24
lines changed

lib/elixir/lib/kernel/special_forms.ex

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,7 +1376,7 @@ defmodule Kernel.SpecialForms do
13761376
filters or inside the block, are not reflected outside of the
13771377
comprehension.
13781378
1379-
## Into
1379+
## The `:into` and `:uniq` options
13801380
13811381
In the examples above, the result returned by the comprehension was
13821382
always a list. The returned result can be configured by passing an
@@ -1396,18 +1396,57 @@ defmodule Kernel.SpecialForms do
13961396
String.upcase(line)
13971397
end
13981398
1399-
## Uniq
1400-
1401-
`uniq: true` can also be given to comprehensions to guarantee that
1402-
that results are only added to the collection if they were not returned
1399+
Similarly, `uniq: true` can also be given to comprehensions to guarantee
1400+
the results are only added to the collection if they were not returned
14031401
before. For example:
14041402
1405-
iex> for(x <- [1, 1, 2, 3], uniq: true, do: x * 2)
1403+
iex> for x <- [1, 1, 2, 3], uniq: true, do: x * 2
14061404
[2, 4, 6]
14071405
1408-
iex> for(<<x <- "abcabc">>, uniq: true, into: "", do: <<x - 32>>)
1406+
iex> for <<x <- "abcabc">>, uniq: true, into: "", do: <<x - 32>>
14091407
"ABC"
14101408
1409+
## The `:reduce` option
1410+
1411+
While the `:into` option allows us to customize the comprehension behaviour
1412+
to a given data type, such as putting all of the values inside a map or inside
1413+
a binary, it is not always enough.
1414+
1415+
For example, imagine that you have a binary with letters where you want to
1416+
count how many times each lowercase letter happens, ignoring all uppercase
1417+
ones. For instance, for the string `"AbCabCABc"`, we want to return the map
1418+
`%{"a" => 1, "b" => 2, "c" => 1}`.
1419+
1420+
If we were to use `:into`, we would need a data type that computes the
1421+
frequency of each element it holds. While there is no such data type in
1422+
Elixir, you could implement one yourself.
1423+
1424+
A simpler option would be to use comprehensions for the mapping and
1425+
filtering of letters, and then we invoke `Enum.reduce/3` to build a map,
1426+
for example:
1427+
1428+
iex> letters = for <<x <- "AbCabCABc">>, x in ?a..?z, do: <<x>>
1429+
iex> Enum.reduce(letters, %{}, fn x, acc -> Map.update(acc, x, 1, & &1 + 1) end)
1430+
%{"a" => 1, "b" => 2, "c" => 1}
1431+
1432+
While the above is straight-forward, it has the downside of traversing the
1433+
data at least twice. If you are expecting long strings as inputs, this can
1434+
be quite expensive.
1435+
1436+
Luckily, comprehensions also support the `:reduce` option, which would allow
1437+
us to fuse both steps above into a single step:
1438+
1439+
iex> for <<x <- "AbCabCABc">>, x in ?a..?z, reduce: %{} do
1440+
...> acc -> Map.update(acc, <<x>>, 1, & &1 + 1)
1441+
...> end
1442+
%{"a" => 1, "b" => 2, "c" => 1}
1443+
1444+
When the `:reduce` key is given, its value is used as the initial accumulator
1445+
and the `do` block must be changed to use `->` clauses, where the left side
1446+
of `->` receives the accumulated value of the previous iteration and the
1447+
expression on the right side must return the new accumulator value. Once there are no more
1448+
elements, the final accumulated value is returned. If there are no elements
1449+
at all, then the initial accumulator value is returned.
14111450
"""
14121451
defmacro for(args), do: error!([args])
14131452

lib/elixir/src/elixir_erl_for.erl

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,30 @@
33
-include("elixir.hrl").
44

55
translate(Meta, Args, Return, S) ->
6-
Ann = ?ann(Meta),
76
{Cases, [{do, Expr} | Opts]} = elixir_utils:split_last(Args),
87

8+
case lists:keyfind(reduce, 1, Opts) of
9+
{reduce, Reduce} -> translate_reduce(Meta, Cases, Expr, Reduce, S);
10+
false -> translate_into(Meta, Cases, Expr, Opts, Return, S)
11+
end.
12+
13+
translate_reduce(Meta, Cases, Expr, Reduce, S) ->
14+
Ann = ?ann(Meta),
15+
{TReduce, SR} = elixir_erl_pass:translate(Reduce, S),
16+
{TCases, SC} = translate_gen(Meta, Cases, [], SR),
17+
CaseExpr = {'case', Meta, [ok, [{do, Expr}]]},
18+
{TExpr, SE} = elixir_erl_pass:translate(CaseExpr, SC),
19+
20+
InnerFun = fun
21+
({'case', CaseAnn, _, CaseBlock}, InnerAcc) -> {'case', CaseAnn, InnerAcc, CaseBlock}
22+
end,
23+
24+
SF = elixir_erl_var:mergec(SR, SE),
25+
{build_reduce(Ann, TCases, InnerFun, TExpr, TReduce, false, SF), SF}.
26+
27+
translate_into(Meta, Cases, Expr, Opts, Return, S) ->
28+
Ann = ?ann(Meta),
29+
930
{TInto, SI} =
1031
case lists:keyfind(into, 1, Opts) of
1132
{into, Into} -> elixir_erl_pass:translate(Into, S);
@@ -16,7 +37,7 @@ translate(Meta, Args, Return, S) ->
1637
TUniq = lists:keyfind(uniq, 1, Opts) == {uniq, true},
1738

1839
{TCases, SC} = translate_gen(Meta, Cases, [], SI),
19-
{TExpr, SE} = elixir_erl_pass:translate(wrap_expr(Expr, TInto), SC),
40+
{TExpr, SE} = elixir_erl_pass:translate(wrap_expr_if_unused(Expr, TInto), SC),
2041
SF = elixir_erl_var:mergec(SI, SE),
2142

2243
case inline_or_into(TInto) of
@@ -26,8 +47,8 @@ translate(Meta, Args, Return, S) ->
2647

2748
%% In case we have no return, we wrap the expression
2849
%% in a block that returns nil.
29-
wrap_expr(Expr, false) -> {'__block__', [], [Expr, nil]};
30-
wrap_expr(Expr, _) -> Expr.
50+
wrap_expr_if_unused(Expr, false) -> {'__block__', [], [Expr, nil]};
51+
wrap_expr_if_unused(Expr, _) -> Expr.
3152

3253
translate_gen(ForMeta, [{'<-', Meta, [Left, Right]} | T], Acc, S) ->
3354
{TLeft, TRight, TFilters, TT, TS} = translate_gen(Meta, Left, Right, T, S),

lib/elixir/src/elixir_expand.erl

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ expand({for, Meta, [_ | _] = Args}, E) ->
262262
{Args, []}
263263
end,
264264

265-
validate_opts(Meta, for, [do, into, uniq], Block, E),
265+
validate_opts(Meta, for, [do, into, uniq, reduce], Block, E),
266266

267267
{Expr, Opts} =
268268
case lists:keytake(do, 1, Block) of
@@ -272,16 +272,16 @@ expand({for, Meta, [_ | _] = Args}, E) ->
272272
form_error(Meta, ?key(E, file), ?MODULE, {missing_option, for, [do]})
273273
end,
274274

275-
case lists:keyfind(uniq, 1, Opts) of
276-
false -> ok;
277-
{uniq, Value} when is_boolean(Value) -> ok;
278-
{uniq, Value} -> form_error(Meta, ?key(E, file), ?MODULE, {for_invalid_uniq, Value})
279-
end,
280-
281275
{EOpts, EO} = expand(Opts, E),
282276
{ECases, EC} = lists:mapfoldl(fun expand_for/2, EO, Cases),
283-
{EExpr, EE} = expand(Expr, EC),
284277
assert_generator_start(Meta, ECases, E),
278+
279+
{EExpr, EE} =
280+
case validate_for_options(EOpts, false, false, false) of
281+
{ok, MaybeReduce} -> expand_for_do_block(Meta, Expr, EC, MaybeReduce);
282+
{error, Error} -> form_error(Meta, ?key(E, file), ?MODULE, Error)
283+
end,
284+
285285
{{for, Meta, ECases ++ [[{do, EExpr} | EOpts]]}, elixir_env:merge_and_check_unused_vars(E, EE)};
286286

287287
%% With
@@ -697,6 +697,42 @@ generated_case_clauses([{do, Clauses}]) ->
697697
RClauses = [{'->', ?generated(Meta), Args} || {'->', Meta, Args} <- Clauses],
698698
[{do, RClauses}].
699699

700+
%% Comprehensions
701+
702+
validate_for_options([{into, _} = Pair | Opts], _Into, Uniq, Reduce) ->
703+
validate_for_options(Opts, Pair, Uniq, Reduce);
704+
validate_for_options([{uniq, Boolean} = Pair | Opts], Into, _Uniq, Reduce) when is_boolean(Boolean) ->
705+
validate_for_options(Opts, Into, Pair, Reduce);
706+
validate_for_options([{uniq, Value} | _], _, _, _) ->
707+
{error, {for_invalid_uniq, Value}};
708+
validate_for_options([{reduce, _} = Pair | Opts], Into, Uniq, _Reduce) ->
709+
validate_for_options(Opts, Into, Uniq, Pair);
710+
validate_for_options([], Into, Uniq, {reduce, _}) when Into /= false; Uniq /= false ->
711+
{error, for_conflicting_reduce_into_uniq};
712+
validate_for_options([], _Into, _Uniq, Reduce) ->
713+
{ok, Reduce}.
714+
715+
expand_for_do_block(Meta, Expr, E, false) ->
716+
case Expr of
717+
[{'->', _, _} | _] -> form_error(Meta, ?key(E, file), ?MODULE, for_without_reduce_bad_block);
718+
_ -> expand(Expr, E)
719+
end;
720+
expand_for_do_block(Meta, Clauses, E, {reduce, _}) ->
721+
Transformer = fun({_, _, [Left, _Right]} = Clause, Acc) ->
722+
case Left of
723+
[_] ->
724+
{EClause, EAcc} = elixir_clauses:clause(Meta, fn, fun elixir_clauses:head/2, Clause, Acc),
725+
{EClause, elixir_env:merge_and_check_unused_vars(Acc, EAcc)};
726+
_ ->
727+
form_error(Meta, ?key(E, file), ?MODULE, for_with_reduce_bad_block)
728+
end
729+
end,
730+
731+
case Clauses of
732+
[{'->', _, _} | _] -> lists:mapfoldl(Transformer, E, Clauses);
733+
_ -> form_error(Meta, ?key(E, file), ?MODULE, for_with_reduce_bad_block)
734+
end.
735+
700736
%% Locals
701737

702738
assert_no_ambiguous_op(Name, Meta, [Arg], E) ->
@@ -1045,6 +1081,12 @@ format_error({invalid_args, Construct}) ->
10451081
io_lib:format("invalid arguments for \"~ts\"", [Construct]);
10461082
format_error({for_invalid_uniq, Value}) ->
10471083
io_lib:format(":uniq option for comprehensions only accepts a boolean, got: ~ts", ['Elixir.Macro':to_string(Value)]);
1084+
format_error(for_conflicting_reduce_into_uniq) ->
1085+
"cannot use :reduce alongside :into/:uniq in comprehension";
1086+
format_error(for_with_reduce_bad_block) ->
1087+
"when using :reduce with comprehensions, the do block must be written using acc -> expr clauses, where each clause expects the accumulator as a single argument";
1088+
format_error(for_without_reduce_bad_block) ->
1089+
"the do block was written using acc -> expr clauses but the :reduce option was not given";
10481090
format_error(for_generator_start) ->
10491091
"for comprehensions must start with a generator";
10501092
format_error(unhandled_arrow_op) ->

lib/elixir/test/elixir/kernel/comprehension_test.exs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ defmodule Kernel.ComprehensionTest do
226226
assert IO.iodata_to_binary(Process.get(:into_cont)) == "roohkpmmfi"
227227
end
228228

229+
test "for comprehensions of map into map" do
230+
enum = %{a: 2, b: 3}
231+
assert for({k, v} <- enum, into: %{}, do: {k, v * v}) == %{a: 4, b: 9}
232+
end
233+
234+
test "for comprehensions with reduce, generators and filters" do
235+
acc =
236+
for x <- 1..3, Integer.is_odd(x), <<y <- "hello">>, reduce: %{} do
237+
acc -> Map.update(acc, x, [y], &[y | &1])
238+
end
239+
240+
assert acc == %{1 => 'olleh', 3 => 'olleh'}
241+
end
242+
229243
## List generators (inlined by the compiler)
230244

231245
test "list for comprehensions" do
@@ -303,11 +317,6 @@ defmodule Kernel.ComprehensionTest do
303317
end) == <<7::size(1), 0::size(2), 1::size(1), 2::size(2), 3::size(1)>>
304318
end
305319

306-
test "map for comprehensions into map" do
307-
enum = %{a: 2, b: 3}
308-
assert for({k, v} <- enum, into: %{}, do: {k, v * v}) == %{a: 4, b: 9}
309-
end
310-
311320
test "list for comprehensions where value is not used" do
312321
enum = [1, 2, 3]
313322

@@ -317,6 +326,15 @@ defmodule Kernel.ComprehensionTest do
317326
end) == "1\n2\n3\n"
318327
end
319328

329+
test "list for comprehensions with reduce, generators and filters" do
330+
acc =
331+
for x <- [1, 2, 3], Integer.is_odd(x), <<y <- "hello">>, reduce: %{} do
332+
acc -> Map.update(acc, x, [y], &[y | &1])
333+
end
334+
335+
assert acc == %{1 => 'olleh', 3 => 'olleh'}
336+
end
337+
320338
## Binary generators (inlined by the compiler)
321339

322340
test "binary for comprehensions" do
@@ -430,4 +448,15 @@ defmodule Kernel.ComprehensionTest do
430448
nil
431449
end) == "1\n2\n3\n"
432450
end
451+
452+
test "binary for comprehensions with reduce, generators and filters" do
453+
bin = <<1, 2, 3>>
454+
455+
acc =
456+
for <<x <- bin>>, Integer.is_odd(x), <<y <- "hello">>, reduce: %{} do
457+
acc -> Map.update(acc, x, [y], &[y | &1])
458+
end
459+
460+
assert acc == %{1 => 'olleh', 3 => 'olleh'}
461+
end
433462
end

lib/elixir/test/elixir/kernel/expansion_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,26 @@ defmodule Kernel.ExpansionTest do
697697
end
698698
end
699699

700+
test "raise error on invalid reduce" do
701+
assert_raise CompileError,
702+
~r"cannot use :reduce alongside :into/:uniq in comprehension",
703+
fn ->
704+
expand(quote(do: for(x <- 1..3, reduce: %{}, into: %{}, do: (acc -> acc))))
705+
end
706+
707+
assert_raise CompileError,
708+
~r"the do block was written using acc -> expr clauses but the :reduce option was not given",
709+
fn -> expand(quote(do: for(x <- 1..3, do: (acc -> acc)))) end
710+
711+
assert_raise CompileError,
712+
~r"when using :reduce with comprehensions, the do block must be written using acc -> expr clauses",
713+
fn -> expand(quote(do: for(x <- 1..3, reduce: %{}, do: x))) end
714+
715+
assert_raise CompileError,
716+
~r"when using :reduce with comprehensions, the do block must be written using acc -> expr clauses",
717+
fn -> expand(quote(do: for(x <- 1..3, reduce: %{}, do: (acc, x -> x)))) end
718+
end
719+
700720
test "raise error for unknown options" do
701721
assert_raise CompileError, ~r"unsupported option :else given to for", fn ->
702722
expand(quote(do: for(_ <- 1..2, do: 1, else: 1)))

0 commit comments

Comments
 (0)