Runtime-calculated default values for structs

The Problem

Currently, if I define a struct in the following way:

defmodule MyStruct do
  # Both x and y will have the FIXED values until next recompilation
  defstruct x: :rand.uniform(100), y: :rand.uniform(100)
end

I have defaults calculated at compile-time. This means that if I do %MyStruct{} or struct!(MyStruct), I will have the same default values for x and y until the next recompilation. This behavior is explicitly explained in Elixir’s documentation.

This compile-time evaluation has a “cascade effect” throughout the ecosystem. The most notable example is Ecto’s default option in schema field definitions. Another example is Oban structured jobs (their argument schema definitions). Such behavior likely exists because these libraries rely on defining struct defaults under the hood, or simply repeat the existing pattern.

Often, you don’t need defaults evaluated at runtime. After initially creating a schema or struct, a lot of code can be written before the first runtime default (e.g., start_of_period: DateTime.utc_now()) is needed. And that’s the point when the real problem starts:

  • In the case of a simple struct:
    • A developer needs to introduce a function like new()
    • … and change all the places where the struct is created via %Struct{...} to use this function
    • … and find all the places where the struct is created via struct!(mod_name) where mod_name is a variable that could theoretically be defined as an atom in config.exs, which makes such updates tricky.
  • In the case of an Ecto schema:
    • In addition to what’s needed for a simple struct, a developer needs to update every changeset used for creation
    • Alternatively, they can rely on autogenerate, but this only creates values just before insertion, so no business logic can rely on that default before the record is persisted
    • For virtual fields, autogenerate simply does not work, and the developer must track each place where data is read from the database in order to populate virtual fields with dynamic defaults.

In summary: it may require an inadequate amount of work for introducing a mere dynamic struct field default.

The Motivation

Recently, I analyzed a 2M+ line Elixir production codebase from the perspective of compilation determinism. This allowed me to automatically detect errors like default: DateTime.utc_now(). The reality is that even with documentation stating that such values are compile-time, people make this mistake frequently. This makes me think that “defaults are evaluated at compile-time” is not a feature anticipated by developers, but rather a trap that leads to weird bugs. Especially if the developer is new to Elixir.

Also, the fact that defining a pre-calculated and dynamic default requires completely different approaches seems weird to me.

The Proposal

I believe that one of the traits of a convenient programming language is this principle:

Things with similar semantics should have similar syntax.

For example, making things private in Elixir is done by adding p to a definer: defp, defmacrop. Another example is defining named “callables”: runtime callables are defined using def and defp, while compile-time callables are defined using defmacro and defmacrop. This consistent approach reduces cognitive load when working with the language.

So I propose:

defmodule MyStruct do
  defdynstruct x: :rand.uniform(100), y: :rand.uniform(100)
end

The same syntax rules apply, but each time such a struct is created (via struct/1, struct!/2, etc.), the defaults are recalculated. Note: making the %MyStruct{...} literal syntax work with dynamic defaults requires compiler changes—this is discussed in the Implementation section. If you want some values to be pre-calculated and others to be dynamic, you can still do it:

defmodule MyStruct do
  @default_x :rand.uniform(100)
  defdynstruct x: @default_x, y: :rand.uniform(100)
end

This will allow libraries like Ecto to change default behavior to support dynamic values. If a developer needs the previous compile-time behavior, they can use module attributes (as shown with @default_x above) to cache values at compile time.

The Implementation

I tried to fix it without patching Elixir. Below is what I implemented, and it works for most cases:

StructRuntimeDefaults module
defmodule StructRuntimeDefaults do
  @moduledoc """
  Provides runtime evaluation of default values for Elixir structs and Ecto schemas.

  The only thing it cannot handle is when `%StructName{...}` syntax is used for defining a value:
  Elixir compiler replaces it with the struct literal at compile time.

  Such calls should be avoided in favor of `struct!(StructName)` and `struct!(SomeName, ...)`.
  It's _okish_ to do it only in production code and leave tests and test factories to use `%StructName{}` syntax
  when diverse values are not important for testing.

  Such usage is easier to detect and refactor than finding all code paths that create the struct
  (e.g., via `struct/2`, `%StructName{}`, `Repo.get/3`, etc).

  !!! __Use this approach only when other approaches are insufficient!__ !!!

  ## Important Warning

  This implementation relies on Elixir and Ecto internals by overriding `__struct__/0`,
  `__struct__/1`, and `__schema__/1`. While these can be considered "mostly stable" interfaces, they are
  implementation details that could theoretically change in future versions.

  The Elixir compiler can occasionally miss auto-recompilation of modules that depend on the
  modified struct/schema module. After adding or changing runtime defaults, consider running
  a full recompilation if you notice that the changes are not taking effect.
  This problem has happened in the scope of test factories at least once.

  Static default values always take precedence over runtime defaults.

  ## Usage for Simple Structs

      defmodule MyStruct do
        use StructRuntimeDefaults

        defstruct other_data: [], current_time: nil

        struct_runtime_defaults(%{current_time: DateTime.utc_now()})
      end

  ## Usage for Ecto Schemas

      defmodule MySchema do
        use Ecto.Schema
        use StructRuntimeDefaults

        schema "my_table" do
          field :created_date, :date
          timestamps()
        end

        ecto_schema_runtime_defaults(%{created_date: Date.utc_today()})
      end
  """

  defmacro __using__(_ \\ []) do
    quote do
      import unquote(__MODULE__), only: [struct_runtime_defaults: 1, ecto_schema_runtime_defaults: 1]
    end
  end

  defmacro struct_runtime_defaults(defaults) do
    quote do
      # These are the Single Source of Truth (SSOT) for struct creation in Elixir
      # https://github.com/elixir-lang/elixir/blob/31905ca1364c8b841b49f68b7d2cbacbacb7de02/lib/elixir/lib/kernel.ex#L5626
      defoverridable __struct__: 0
      defoverridable __struct__: 1

      def __struct__ do
        Map.merge(super(), unquote(defaults))
      end

      def __struct__(kv) do
        original = super(kv)
        runtime_defaults = unquote(defaults)

        Map.merge(original, runtime_defaults, fn
          _key, nil, rt_def_val -> rt_def_val
          _key, orig_val, _rt_def_val -> orig_val
        end)
      end
    end
  end

  defmacro ecto_schema_runtime_defaults(defaults) do
    quote do
      struct_runtime_defaults(unquote(defaults))

      # Ecto schema support
      #
      # Ecto uses __schema__(:loaded) to initialize structs when loading from DB
      # it's critical to override this to apply runtime defaults on load when _virtual fields_ are involved.
      # https://github.com/elixir-ecto/ecto/blob/2bdbcb6a2c3022ae931ccb9c3e1920596a2da68a/lib/ecto/schema/loader.ex?plain=1#L12
      #
      # Unfortunately, overriding __schema__/1 was not enough at the time of writing because Ecto
      # relies on its own metadata about fields to set defaults and did not rely on __struct__/0 or __struct__/1 in some cases.
      defoverridable __schema__: 1

      def __schema__(atom) do
        case atom do
          :loaded ->
            original = super(:loaded)
            runtime_defaults = unquote(defaults)

            Map.merge(original, runtime_defaults, fn
              _key, nil, rt_def_val -> rt_def_val
              _key, orig_val, _rt_def_val -> orig_val
            end)

          _ ->
            super(atom)
        end
      end
    end
  end
end

Working on this allowed me to understand how dynamic defaults can be achieved. In the current Elixir implementation, the __struct__/0 and __struct__/1 functions are the Single Source of Truth for struct creation. However, AST with default values is evaluated at compile-time. If we change it to be evaluated dynamically inside __struct__(...), we will achieve the desired behavior. The module above proves that it works. With one exception though: the %Struct{...} syntax.

When the Elixir compiler sees %Struct{...} in the code, it runs __struct__(...) at compile-time and puts the calculated value directly into the final code. This behavior cannot be overridden without modifying the Elixir compiler itself. It means that it’s not possible to make universal “dynamic default values for structs” without changing the Elixir compiler.

I’m happy to try creating a PR for Elixir, but I need a green light from the community and core team. I also need alignment on the desired syntax.

So, implementing it for Elixir looks like merely defining defdynstruct that

  • mostly works like defstruct, but
  • places default values evaluation inside __struct__
  • and marks such structs to not be inlined as calculated literals by the compiler

I expect low performance cost, a small amount of code for implementation, and no impact on existing Elixir codebases.

Next Steps

Before proposing this to the Elixir core team mailing list, I want to collect community feedback. I’m specifically seeking input on:

  1. Syntax: do you like proposed defdynstruct syntax?
  2. Missing use cases: Does this solve the problem? Do you see situations where proposed approach is not enough?
  3. Missing implementation concerns: What potential issues or edge cases are missed in the proposal? I found %Struct{} syntax, are there others?
  4. Compatibility: Are there any compatibility concerns that I missed?

Let me know your thoughts! And if you simply like the idea, like the post to show support. Thank you!

1 Like

Technically, estructura has this functionality already.

Personally I’m not a fan of this proposal. Structs are a tool for user defined types and those imo should align with the behaviour of native types, while allowing users to add their own names. None of the native datatypes like integer or list or map come with any sideeffects on creation, so why should structs do? All of those native datatypes need to have their creation code replaced once you need their values to be dynamically determined at creation time. Yes the implementation of structs would allow for more dynamic behaviour, but in an ideal world those implementation details wouldn’t exist in the first place. They’re just a crutch because the beam doesn’t have user defined types at the vm level - hence erlang doing essentially the same with tuple based records.

5 Likes

Well, it doesn’t. =)

Documentation of the lib is extremely outdated and examples simply do not compile. All what I was able to compile and find in tests - do not solve mentioned problems.

If you can share an example that at least generates a new field value each time %MyStruct{} or struct!(MyStruct) is called it would be awesome! Might be I’m missing something that can help me achieve what I need without patching Elixir.

Definitely an interesting perspective! Let me elaborate:

None of them has concept of “default value of an element”. They simply has no similar surface for having a side-effect on creation. Saying this, I started to think where concept of default value is represented in Elixir. Couple of the examples I can think of are:

  • Map.get/3 has static default when value not found
  • Map.put_new_lazy/3 has dynamic default (as a function)

Not sure if those examples are applicable, but it at least some evidence that idea of “dynamic defaults” is not uncommon in Elixir.

Struct is the only user-defined data type. It’s not uncommon to have dynamic defaults for such things in other languages.

  • Ruby: use constructor
  • Python: use constructor (afaik)
  • Golang: structs cannot have defaults at all. A “factory function” is needed to set defaults and they are dynamic.

So, I’d vote for either removing defaults from structs at all to enforce creation of factory functions like in Golang or allow for dynamic defaults to make this feature complete. I expect no one will agree on the first way. =)

So, the answer to the question “why should structs do?” is that they already provide mechanism for defaults, but it’s incomplete. It has restriction that makes it inferior to almost any other popular language. My proposal is about lifting this restriction.

It’s from theoretical perspective. From practical perspective I do not like that it leads to error-prone and/or inconvenient library interfaces.

TBH I do not see how this contradicts with the proposal. =/ I do not propose anything that does things worse than they are from that perspective.

Huh?

I have created an example by copy-pasting the code from the page I linked. estructura/examples/with_formulae/test/with_formulae_test.exs at master · am-kantox/estructura · GitHub

I have been developing enough code to strictly avoid messing with the standard syntax. Estructura is designed to rely on Access only, as you can see in the example I linked above.

I might not have described my point very well. My argument is that all elixir syntax for the different datatype we have evaluate to a statically known value. To me %Struct{} is no different to writing 4 or {:ok, 20}. I’m not getting 20 when writing 4. I’m very aware of the fact that struct definitions can be changed, but at least all of the possible changes are scoped to compiling the definition again. That’s the price to be payed for user-defined data, the user can define the data. Maybe explained a different way: %Struct{} == %Struct{} is a property I don’t want to have violated just like [] == [] or {:ok, 20} == {:ok, 20} always holds true. Structs outliving their code definition – and thereby becoming %{__struct__: Struct, …} – is imo bad enough already.

3 Likes

This example does not compile: Estructura — estructura v1.11.0

I like convenience that this library brings, but I found documentation confusing. I haven’t found dedicated section about “calculated” with examples how to use it with functions, etc. Had to dig into tests and use search in code.

I cannot do this on a big production project and dependencies. Elixir articles and books already suggest to use factory functions when you need dynamic default. But the problem is when in 100+ places you already use %Struct{...} explicitly or implicitly. And when libraries do “no dynamic defaults” restriction just because they want to reuse struct defaults.

My proposal allow to inject dynamic defaults in a big project just by replacing defstruct with defdynstruct.

Well, this is a strong argument.

Might be creation via %Struct{...} should be forbidden for “dynamic structs” at compilation? So, if you need to create dynamic struct you have to use struct!(Struct, ...) thing. Different results of an explicit function call is more expected.

WDYT?

Once there’s the need to switch from %Struct{} to struct!(Struct, …) it could also be switched to dynstruct!(Struct, …) as well imo. Given enough evidance of problems in the wild maybe there is some benefit of a built in solution, but that’s a discussion I don’t have arguments for.

Yeah. Let me summarize:

  1. Often engineers do %Struct{} for creating a new instance of a struct by default. There is no established tradition to create factory functions like in Golang.
  2. Elixir allows to set only “static” defaults. So %Struct{} already has some implicitness, but at least it is “idempotent” after compilation.
  3. For defining a dynamic default each place where %Struct{}, struct(...), etc is used should be refactored.
    • In big codebases it can lead to inadequately expensive refactoring for just introducing a mere dynamic default.
    • creation of a struct can be inside library code so you may be not able to inject default value at all.
  4. Cultural problem: libraries repeat “no dynamic defaults” restriction that does not makes developers life easier, usually opposite.
  5. Such limitation is uncommon in other popular languages I can recall.

Original proposal solves mentioned problems, but breaks %Struct{} == %Struct{} behavior. But without breaking it we cannot make %Struct{} respect dynamic defaults. And without this we cannot make adding dynamic defaults cheap in big codebases.

:red_question_mark: Might be loosing this behavior is acceptable sacrifice? Or we’re missing some smart move here?

I think the time/effort required to fix the bad code to use MyStruct.new etc is exaggerated. It’s got to be less than 30mins. Surely we should spend the time to fix our bugs.

4 Likes

Yeah, Lazy should have been deprecated and removed, I simply did not have time to bother.

That’s because the library is not about calculated values (btw, they are truly calculated, not dynamic defaults; when any of values it depends on has changed, the value in calculated field(s) is also re-calc’ed.)

This library is about handy generation od property-data for property-based testing in the first place, it just supports calculated values by accident.

I am not sure how would you expect dependencies to automagically convert to support dynamic defaults, let alone it might break them. For a big production project, I would typically comment out the declaration of the struct, collect all compilation errors, and fix them one by one. Changing them to a constructor would probably suffice your needs, but I might be mistaken.

Isn’t 29 mins for adding a dynamic default… too much? It’s less than minute in many other languages and a pretty common feature.

And making defaults dynamic in Ecto is definitely more than 30 mins.

For example Estructura. With my current proposal I would just use it with defdynstruct. It still generates __schema__ functions, etc. I expect all the features of Estructura to just work. Am I right or it somehow relies on %A{} == %A{}? (that’s the only promise that is broken by defdynstruct).

Is it adequate price for introducing dynamic default?

Even though I do understand the ask, I’m not sure it’s a good idea. Especially the %A{} == %A{} argument is a very strong one. Thus we shouldn’t touch the raw struct.

Also, I don’t really see the big value of struct/2 over a A.new/1

Not to mention that a defdynstruct sounds very clunky.

You could have an annotation like @init :myinit (if you like with some syntactic sugar with name convention so you only need to define only an __init__ function), BUT I would even vote against that, because of the classical rule:

don’t surprise

The developer would need to know that you do dynamic initialisation. IMHO it’s better to be explicit.

As a library creator you should think carefully on whether you want to have statically or dynamically created structs. Provide a new/1 if you might want to extend it to a dynamic struct in the future (apply SOLID principles). In that case you might not want to expose the actual data structure.

2 Likes

Don’t know but don’t think so. This thread already took more time; so does reviewing the changes, testing etc.

Using a factory seems to be a pretty nice option; making the data structure an implementation detail.

I appreciate the initiative, I really do. For a language to survive and thrive it needs people to think about it, tinker with it, criticize it, etc. So thanks for that.

However, I’m not a fan of this proposal for two reasons:

  1. I like the fact that elixir does not have as many concepts to learn as other languages. We should be very careful with adding more complexity.
  2. The fact that %A{} == %A{} wouldn’t work anymore is killer.
2 Likes

:heart:

I see. %A{} != %A{} is kind of ugly thing, I agree. I like the principle “a cure should not be worse than a disease”. I proposed a cure, but it has this side effect. Therefore, if we agree that disease is worse than having %A{} != %A{}, the proposal has a chance. So, let me show symptoms of “disease” more explicit.

My initial problem was about “Ecto cannot do dynamic defaults”. There is no proper workaround. autogenerate has pretty different behavior and does not work for virtual fields. I was thinking about proposing something like this:

defmodule Reminder do
  use Ecto.Schema

  schema "users" do
    field :remind_at, :datetime, default_fn: &today_plus_10_days/0
    # ... rest of the fields ...
  end
end

Then I thought about implementation. Usually we create a record like this:

%Reminder{}
|> create_changeset()

Ecto also explicitly allows to do:

Repo.insert(%Reminder{})

Where this “default_fn” should be invoked? There is no guarantee that changeset will be used so we cannot put it into changeset. Patch all changeset functions and Repo functions to invoke “default_fns”? Even if we do this we have another problem: people do factories in the following way:

def reminder_factory(...) do
  %Reminder{
     user: build(:user)
   }
end

How to make “default_fn” to work here?

If even creators of Ecto (and ExMachina) fell into the trap that locked them from providing reliable dynamic defaults without huge refactoring - it means we have a problem in the language design.

:person_fencing: This is the disease I’m fighting with here. All the code in this message is idiomatic and comes from widely used libraries. It’s not about mere “struct creation with defaults”, it’s about preventing libraries from supporting dynamic defaults.

While I do not like introducing %A{} != %A{} I still see it as an acceptable tradeoff. Also, “blast radius” is controllable: you accept this tradeoff explicitly when prefer defdynstruct over defstruct.

The fact that default: DateTime.utc_today() is resolved at compile time actually also a surprise of a similar level. In my experience people constantly do this mistake. By using defdynstruct you replace one surprise with another, you do not increase overall amount of surprises (but I agree we increase variety).

Did I change how you see the problem? =)

Tbh I disagree with the idea of Repo.insert(%Reminder{}) needing to support dynamic defaults as well. Elixir is very much about being explicit. If you need your data to come from side effects I expect the code to tell me. %Reminder{} |> with_default_target_datetime() |> Repo.insert() is a lot better code than Repo.insert(%Reminder{}) automatically messing around with struct fields.

You’re argueing that struct defaults being a compile time thing prevent libraries from introducing dynamic field defaults. I’d counter that with dynamic defaults being something you want to have an explicit api for and once you do there’s no longer any limitation. I don’t expect ecto ever wanted to provide you with a dynamic defaults feature to begin with.

In the end your argument comes down to “I want to introduce dynamically calculated default values without changing my codebase” – not a great argument imo, such a change should be made explicit by changing code.

7 Likes