Search code examples
elixirerlang-otpgen-server

Using atoms from declared structs in elixir works on repl but not in application


I want to use String.to_existing_atom in elixir to avoid memory leaks.

This 100% works on the REPL:

iex(1)> defmodule MyModule do
...(1)> defstruct my_crazy_atom: nil
...(1)> end
{:module, MyModule,
 <<70, 79, 82, ...>>,
 %MyModule{my_crazy_atom: nil}}

So now the atom my_crazy_atom exists. I can verify this:

iex(2)> String.to_existing_atom "my_crazy_atom"
:my_crazy_atom

Compared to:

iex(3)> String.to_existing_atom "my_crazy_atom2"
** (ArgumentError) argument error
    :erlang.binary_to_existing_atom("my_crazy_atom2", :utf8)

But I have some code that looks like this:

defmodule Broadcast.Config.File do
  defstruct channel_id: nil, parser: nil
end

From a method call after starting a GenServer process, I can decode with Poison's

keys: :atoms! 

or even just call

String.to_existing_atom("parser")

in the same place in the code and I get an error:

** (Mix) Could not start application broadcast: exited in: 
Broadcast.Application.start(:normal, [])
    ** (EXIT) an exception was raised:
        ** (ArgumentError) argument error
            :erlang.binary_to_existing_atom("parser", :utf8)

Oddly, if I instantiate the struct and inspect it, then the issue goes away!

IO.puts inspect %Broadcast.Config.File{}
String.to_existing_atom("parser")

What's going on here? Is this some kind of ordering thing?


Solution

  • This happens because Elixir by default lazy loads modules from the compiled .beam files when they're first used. (Your code will work if start_permanent is set to true in mix.exs, which is set to true by default in the :prod environment, because then Elixir eagerly loads all modules of the package.)

    In the code below, the atom :my_crazy_atom will be present in the Blah module's code but it's not present in Foo. If you start a REPL session and run Foo.to_existing_atom, the Blah module is not loaded which causes String.to_existing_atom("my_crazy_atom") to fail.

    # Credits: @mudasobwa
    defmodule Blah do
      defstruct my_crazy_atom: nil
    end
    
    defmodule Foo do
      def to_existing_atom, do: String.to_existing_atom("my_crazy_atom")
    end
    

    As you've observed, if you create a struct manually once, all subsequent calls to String.to_existing_atom("my_crazy_atom") return the correct atom. This is because when you create a struct, Elixir will load the .beam file of that module which will also load all the atoms that are used by that module.

    A better way to load a module (as compared to creating a struct) is to use Code.ensure_loaded/1 to load the module:

    {:module, _} = Code.ensure_loaded(Blah)