Skip to content

Commit b05fec0

Browse files
authored
Use evaluation when compiling modules (#15087)
1 parent 9ba89f4 commit b05fec0

7 files changed

Lines changed: 151 additions & 76 deletions

File tree

lib/elixir/lib/code.ex

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,12 @@ defmodule Code do
312312

313313
@available_compiler_options @boolean_compiler_options ++
314314
@list_compiler_options ++
315-
[:on_undefined_variable, :infer_signatures, :no_warn_undefined]
315+
[
316+
:on_undefined_variable,
317+
:infer_signatures,
318+
:no_warn_undefined,
319+
:module_definition
320+
]
316321

317322
@doc """
318323
Lists all required files.
@@ -1702,9 +1707,6 @@ defmodule Code do
17021707
17031708
Available options are:
17041709
1705-
* `:docs` - when `true`, retains documentation in the compiled module.
1706-
Defaults to `true`.
1707-
17081710
* `:debug_info` - when `true`, retains debug information in the compiled
17091711
module. This option can also be overridden per module using the `@compile`
17101712
directive. Defaults to `true`.
@@ -1720,6 +1722,9 @@ defmodule Code do
17201722
via the `:test_elixirc_options` project configuration, as there is
17211723
typically no need to store debug chunks for test files.
17221724
1725+
* `:docs` - when `true`, retains documentation in the compiled module.
1726+
Defaults to `true`.
1727+
17231728
* `:ignore_already_consolidated` (since v1.10.0) - when `true`, does not warn
17241729
when a protocol has already been consolidated and a new implementation is added.
17251730
Defaults to `false`.
@@ -1738,17 +1743,33 @@ defmodule Code do
17381743
via the `:test_elixirc_options` project configuration, as there is typically no
17391744
need to infer signatures for test files.
17401745
1741-
* `:relative_paths` - when `true`, uses relative paths in quoted nodes,
1742-
warnings, and errors generated by the compiler. Note disabling this option
1743-
won't affect runtime warnings and errors. Defaults to `true`.
1746+
* `:module_definition` (since v1.20.0) - stores if the module definition should
1747+
be `:compiled` (the default) or `:interpreted`. Note this does not affect the
1748+
`.beam` file written to disk, only how the contents inside `defmodule` are
1749+
executed. Using the `:interpreted` mode may offer better compilation times for
1750+
large projects, especially on machines with high core count, however, it comes
1751+
with some downsides:
1752+
1753+
* Errors during compilation may have less precise stacktraces
1754+
1755+
* Anonymous functions within `defmodule` can have only up to 20 arguments.
1756+
If this is an issue, you can use maps or tuples to group the data.
1757+
Note the functions themselves inside `defmodule`, such as the ones defined
1758+
inside `def` and friends, can still have up to 255 arguments
17441759
17451760
* `:no_warn_undefined` (since v1.10.0) - list of modules and `{Mod, fun, arity}`
17461761
tuples that will not emit warnings that the module or function does not exist
17471762
at compilation time. Pass atom `:all` to skip warning for all undefined
17481763
functions. This can be useful when doing dynamic compilation. Defaults to `[]`.
17491764
1750-
* `:tracers` (since v1.10.0) - a list of tracers (modules) to be used during
1751-
compilation. See the module docs for more information. Defaults to `[]`.
1765+
* `:on_undefined_variable` (since v1.15.0) - either `:raise` or `:warn`.
1766+
When `:raise` (the default), undefined variables will trigger a compilation
1767+
error. You may be set it to `:warn` if you want undefined variables to
1768+
emit a warning and expand as to a local call to the zero-arity function
1769+
of the same name (for example, `node` would be expanded as `node()`).
1770+
This `:warn` behavior only exists for compatibility reasons when working
1771+
with old dependencies, its usage is discouraged and it will be removed
1772+
in future releases.
17521773
17531774
* `:parser_options` (since v1.10.0) - a keyword list of options to be given
17541775
to the parser when compiling files. It accepts the same options as
@@ -1759,14 +1780,12 @@ defmodule Code do
17591780
and `compile_file/2` but not `string_to_quoted/2` and friends, as the
17601781
latter is used for other purposes beyond compilation.
17611782
1762-
* `:on_undefined_variable` (since v1.15.0) - either `:raise` or `:warn`.
1763-
When `:raise` (the default), undefined variables will trigger a compilation
1764-
error. You may be set it to `:warn` if you want undefined variables to
1765-
emit a warning and expand as to a local call to the zero-arity function
1766-
of the same name (for example, `node` would be expanded as `node()`).
1767-
This `:warn` behavior only exists for compatibility reasons when working
1768-
with old dependencies, its usage is discouraged and it will be removed
1769-
in future releases.
1783+
* `:relative_paths` - when `true`, uses relative paths in quoted nodes,
1784+
warnings, and errors generated by the compiler. Note disabling this option
1785+
won't affect runtime warnings and errors. Defaults to `true`.
1786+
1787+
* `:tracers` (since v1.10.0) - a list of tracers (modules) to be used during
1788+
compilation. See the module docs for more information. Defaults to `[]`.
17701789
17711790
It always returns `:ok`. Raises an error for invalid options.
17721791
@@ -1806,6 +1825,15 @@ defmodule Code do
18061825
:ok
18071826
end
18081827

1828+
def put_compiler_option(:module_definition, value) do
1829+
if value not in [:interpreted, :compiled] do
1830+
raise "compiler option :module_definition should be either :interpreted or :compiled, got: #{inspect(value)}"
1831+
end
1832+
1833+
:elixir_config.put(:module_definition, value)
1834+
:ok
1835+
end
1836+
18091837
def put_compiler_option(:infer_signatures, value) do
18101838
value =
18111839
cond do

lib/elixir/src/elixir.erl

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010
-export([start/2, stop/1, config_change/3]).
1111
-export([
1212
string_to_tokens/5, tokens_to_quoted/3, string_to_quoted/5, 'string_to_quoted!'/5,
13-
env_for_eval/1, quoted_to_erl/2, eval_forms/3, eval_quoted/3,
14-
eval_quoted/4, eval_local_handler/2, eval_external_handler/3,
15-
emit_warnings/3
13+
env_for_eval/1, quoted_to_erl/2, eval_forms/3, eval_quoted/3, eval_quoted/4,
14+
erl_eval/3, eval_local_handler/2, eval_external_handler/3, emit_warnings/3
1615
]).
1716
-include("elixir.hrl").
1817
-define(system, 'Elixir.System').
@@ -92,16 +91,17 @@ start(_Type, _Args) ->
9291
{no_halt, false},
9392

9493
%% Compiler options
94+
{debug_info, true},
9595
{docs, true},
9696
{ignore_already_consolidated, false},
9797
{ignore_module_conflict, false},
98-
{initial_dbg_callback, InitialDbgCallback},
9998
{infer_signatures, [elixir]},
99+
{initial_dbg_callback, InitialDbgCallback},
100+
{module_definition, compiled},
101+
{no_warn_undefined, []},
100102
{on_undefined_variable, raise},
101103
{parser_options, [{columns, true}]},
102-
{debug_info, true},
103104
{relative_paths, true},
104-
{no_warn_undefined, []},
105105
{tracers, []}
106106
| URIConfig
107107
],
@@ -324,33 +324,7 @@ eval_forms(Tree, Binding, OrigE, Opts) ->
324324
end;
325325

326326
_ ->
327-
Exprs =
328-
case Erl of
329-
{block, _, BlockExprs} -> BlockExprs;
330-
_ -> [Erl]
331-
end,
332-
333-
%% We use remote names so eval works across Elixir versions.
334-
LocalHandler = {value, fun ?MODULE:eval_local_handler/2},
335-
ExternalHandler = {value, fun ?MODULE:eval_external_handler/3},
336-
337-
{value, Value, NewBinding} =
338-
try
339-
%% ?elixir_eval_env is used by the external handler.
340-
%%
341-
%% The reason why we use the process dictionary to pass the environment
342-
%% is because we want to avoid passing closures to erl_eval, as that
343-
%% would effectively tie the eval code to the Elixir version and it is
344-
%% best if it depends solely on Erlang/OTP.
345-
%%
346-
%% The downside is that functions that escape the eval context will no
347-
%% longer have the original environment they came from.
348-
erlang:put(?elixir_eval_env, NewE),
349-
erl_eval:exprs(Exprs, ErlBinding, LocalHandler, ExternalHandler)
350-
after
351-
erlang:erase(?elixir_eval_env)
352-
end,
353-
327+
{value, Value, NewBinding} = erl_eval(Erl, ErlBinding, NewE),
354328
PruneBefore = if Prune -> length(Binding); true -> -1 end,
355329

356330
{DumpedBinding, DumpedVars} =
@@ -359,6 +333,28 @@ eval_forms(Tree, Binding, OrigE, Opts) ->
359333
{Value, DumpedBinding, NewE#{versioned_vars := DumpedVars}}
360334
end.
361335

336+
%% Evaluate Erlang code with careful handling of local and external functions
337+
erl_eval(Expr, Binding, Env) ->
338+
%% We use remote names so eval works across Elixir versions
339+
LocalHandler = {value, fun ?MODULE:eval_local_handler/2},
340+
ExternalHandler = {value, fun ?MODULE:eval_external_handler/3},
341+
342+
try
343+
%% ?elixir_eval_env is used by the external handler.
344+
%%
345+
%% The reason why we use the process dictionary to pass the environment
346+
%% is because we want to avoid passing closures to erl_eval, as that
347+
%% would effectively tie the eval code to the Elixir version and it is
348+
%% best if it depends solely on Erlang/OTP.
349+
%%
350+
%% The downside is that functions that escape the eval context will no
351+
%% longer have the original environment they came from.
352+
erlang:put(?elixir_eval_env, Env),
353+
erl_eval:expr(Expr, Binding, LocalHandler, ExternalHandler)
354+
after
355+
erlang:erase(?elixir_eval_env)
356+
end.
357+
362358
eval_local_handler(FunName, Args) ->
363359
{current_stacktrace, Stack} = erlang:process_info(self(), current_stacktrace),
364360
Opts = [{module, nil}, {function, FunName}, {arity, length(Args)}, {reason, 'undefined local'}],
@@ -405,10 +401,8 @@ eval_external_handler(Ann, FunOrModFun, Args) ->
405401
%% Add file+line information at the bottom
406402
Bottom =
407403
case erlang:get(?elixir_eval_env) of
408-
#{file := File} ->
409-
[{elixir_eval, '__FILE__', 1,
410-
[{file, elixir_utils:characters_to_list(File)}, {line, erl_anno:line(Ann)}]}];
411-
404+
#{'__struct__' := 'Elixir.Macro.Env'} = E ->
405+
'Elixir.Macro.Env':stacktrace(E#{line := erl_anno:line(Ann)});
412406
_ ->
413407
[]
414408
end,

lib/elixir/src/elixir_compiler.erl

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
%% Elixir compiler front-end to the Erlang backend.
66
-module(elixir_compiler).
7-
-export([string/3, quoted/3, bootstrap/0, file/2, compile/4]).
7+
-export([string/3, quoted/3, bootstrap/0, file/2, compile/4, interpret/3]).
88
-include("elixir.hrl").
99

1010
string(Contents, File, Callback) ->
@@ -20,7 +20,7 @@ quoted(Forms, File, Callback) ->
2020

2121
elixir_lexical:run(
2222
Env,
23-
fun (LexicalEnv) -> maybe_fast_compile(Forms, LexicalEnv) end,
23+
fun (LexicalEnv) -> optimize_defmodule(Forms, LexicalEnv) end,
2424
fun (#{lexical_tracker := Pid}) -> Callback(File, Pid) end
2525
),
2626

@@ -33,17 +33,37 @@ file(File, Callback) ->
3333
{ok, Bin} = file:read_file(File),
3434
string(elixir_utils:characters_to_list(Bin), File, Callback).
3535

36-
%% Evaluates the given code through the Erlang compiler.
37-
%% It may end-up evaluating the code if it is deemed a
38-
%% more efficient strategy depending on the code snippet.
39-
maybe_fast_compile(Forms, E) ->
40-
case (?key(E, module) == nil) andalso allows_fast_compilation(Forms) andalso
36+
%% In case the forms only holds defmodules, we optimize
37+
%% it by expanding them directly.
38+
optimize_defmodule(Forms, E) ->
39+
case (?key(E, module) == nil) andalso only_defmodule(Forms) andalso
4140
(not elixir_config:is_bootstrap()) of
42-
true -> fast_compile(Forms, E);
41+
true -> expand_defmodule(Forms, E);
4342
false -> compile(Forms, [], [], E)
4443
end,
4544
ok.
4645

46+
%% A version of compilation that uses eval (interpreted)
47+
interpret(Quoted, ArgsList, #{line := Line} = E) ->
48+
{Expanded, SE, EE} = elixir_expand:expand(Quoted, elixir_env:env_to_ex(E), E),
49+
elixir_env:check_unused_vars(SE, EE),
50+
51+
{Vars, TS} = elixir_erl_var:from_env(E),
52+
{ErlExprs, _} = elixir_erl_pass:translate(Expanded, erl_anno:new(Line), TS),
53+
54+
ListBinding = lists:zipwith(fun({_, Var}, Arg) -> {Var, Arg} end, Vars, ArgsList),
55+
Binding = maps:from_list(ListBinding),
56+
57+
{value, Result, _} =
58+
try
59+
elixir:erl_eval(ErlExprs, Binding, E)
60+
catch
61+
Kind:Reason:Stacktrace ->
62+
erlang:raise(Kind, Reason, Stacktrace ++ 'Elixir.Macro.Env':stacktrace(E))
63+
end,
64+
65+
{Result, SE, EE}.
66+
4767
compile(Quoted, ArgsList, CompilerOpts, #{line := Line} = E) ->
4868
Block = no_tail_optimize([{line, Line}], Quoted),
4969
{Expanded, SE, EE} = elixir_expand:expand(Block, elixir_env:env_to_ex(E), E),
@@ -107,16 +127,16 @@ retrieve_compiler_module() ->
107127
return_compiler_module(Module, Purgeable) ->
108128
elixir_code_server:cast({return_compiler_module, Module, Purgeable}).
109129

110-
allows_fast_compilation({'__block__', _, Exprs}) ->
111-
lists:all(fun allows_fast_compilation/1, Exprs);
112-
allows_fast_compilation({defmodule, _, [_, [{do, _}]]}) ->
130+
only_defmodule({'__block__', _, Exprs}) ->
131+
lists:all(fun only_defmodule/1, Exprs);
132+
only_defmodule({defmodule, _, [_, [{do, _}]]}) ->
113133
true;
114-
allows_fast_compilation(_) ->
134+
only_defmodule(_) ->
115135
false.
116136

117-
fast_compile({'__block__', _, Exprs}, E) ->
118-
lists:foldl(fun(Expr, _) -> fast_compile(Expr, E) end, nil, Exprs);
119-
fast_compile({defmodule, Meta, [Mod, [{do, Block}]]}, NoLineE) ->
137+
expand_defmodule({'__block__', _, Exprs}, E) ->
138+
lists:foldl(fun(Expr, _) -> expand_defmodule(Expr, E) end, nil, Exprs);
139+
expand_defmodule({defmodule, Meta, [Mod, [{do, Block}]]}, NoLineE) ->
120140
E = NoLineE#{line := ?line(Meta)},
121141

122142
Expanded = case Mod of

lib/elixir/src/elixir_module.erl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,12 @@ build(Module, Line, File, E) ->
472472

473473
eval_form(Line, Module, DataBag, Block, Vars, Prune, E) ->
474474
%% Given Elixir modules can get very long to compile due to metaprogramming,
475-
%% we disable expansions that take linear time to code size.
476-
{Value, ExS, EE} = elixir_compiler:compile(Block, Vars, [no_bool_opt, no_ssa_opt], E),
475+
%% we disable expansions that have linear time to code size.
476+
{Value, ExS, EE} =
477+
case elixir_config:get(module_definition) of
478+
interpreted -> elixir_compiler:interpret(Block, Vars, E);
479+
compiled -> elixir_compiler:compile(Block, Vars, [no_bool_opt, no_ssa_opt], E)
480+
end,
477481
elixir_overridable:store_not_overridden(Module),
478482
EV = (elixir_env:reset_vars(EE))#{line := Line},
479483
EC = eval_callbacks(Line, DataBag, before_compile, [EV], EV),

lib/elixir/test/elixir/code_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,36 @@ defmodule Code.SyncTest do
751751
defp refute_cached(_path), do: :ok
752752
end
753753

754+
test "evaluates module definitions" do
755+
Code.put_compiler_option(:module_definition, :interpreted)
756+
757+
defmodule CodeTest.EvalModule do
758+
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
759+
assert Enum.find(stacktrace, &(elem(&1, 0) == :erl_eval))
760+
end
761+
after
762+
Code.put_compiler_option(:module_definition, :compiled)
763+
end
764+
765+
test "evaluates module definitions with stacktraces" do
766+
Code.put_compiler_option(:module_definition, :interpreted)
767+
768+
try do
769+
defmodule CodeTest.EvalModuleRaise do
770+
Enum.map(1..10, fn x -> x <> "example" end)
771+
end
772+
rescue
773+
e ->
774+
assert e.__struct__ == ArgumentError
775+
assert Enum.find(__STACKTRACE__, &(elem(&1, 0) == Code.SyncTest.CodeTest.EvalModuleRaise))
776+
assert Enum.find(__STACKTRACE__, &(elem(&1, 0) == :erl_eval))
777+
else
778+
_ -> flunk("defmodule should have failed")
779+
end
780+
after
781+
Code.put_compiler_option(:module_definition, :compiled)
782+
end
783+
754784
test "prepend_path" do
755785
path = Path.join(__DIR__, "fixtures")
756786
true = Code.prepend_path(path)

lib/iex/lib/iex/evaluator.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ defmodule IEx.Evaluator do
448448
defp prune_stacktrace(stack) do
449449
stack
450450
|> Enum.reverse()
451-
|> Enum.drop_while(&(elem(&1, 0) != :elixir_eval))
451+
|> Enum.drop_while(&(elem(&1, 0) != :elixir_compiler))
452452
|> Enum.reverse()
453453
|> case do
454454
[] -> stack

lib/iex/test/iex/interaction_test.exs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,9 @@ defmodule IEx.InteractionTest do
174174
test "exception" do
175175
exception = Regex.escape("** (ArithmeticError) bad argument in arithmetic expression")
176176

177-
assert capture_iex("1 + :atom\n:this_is_still_working") =~
178-
~r/^#{exception}.+\n:this_is_still_working$/s
179-
180-
refute capture_iex("1 + :atom\n:this_is_still_working") =~ ~r/erl_eval/s
177+
result = capture_iex("1 + :atom\n:this_is_still_working")
178+
assert result =~ ~r/^#{exception}.+\n:this_is_still_working$/s
179+
refute result =~ ~r/erl_eval/s
181180
end
182181

183182
test "exception while invoking conflicting helpers" do

0 commit comments

Comments
 (0)