Best way to implement "enum" like python, c, java?

Hi everyone,

trying to encode some information about music (a different structure than the harmonex package).
I have an earlier implemenation of what I want in python (from a previous project). It heavily uses enums to describe the structure of pitch classes, intervals, chords etc.

eg one of them:

"""
Simple mapping of note name to MIDI value mod 12.
MIDI 0th octave begins at 12 + this value. (so C0 = 12, A0 = 21, etc)
"""
import aenum
Pitch : 'Pitch' = IntEnum('Pitch', {
  # note, this order is NOT random
  # raw note names are preferred to enharmonic equivalents
  # flats are preferrable to sharps (jazz musician here)
  'C': 0,  'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11,
  'Db':1,  'Gb':6, 'Ab':8, 'Bb':10, 'Eb': 3,
  'C#':1, 'D#':3, 'G#':8, 'A#':10,  'F#': 6,
  # these are just weird, so they exist only as aliases for B/C E/F respectively.
  'Fb':4,'E#':5,'B#':0, 'Cb':11
})

Is there something equivalent in erlang/elixir that allows me to have similar properties as an enum like this? “Just use a map” only allows me to do lookups in one direction - i also want to be able to do them in the other direction. So would want to be able to do:

> Pitch.C
Pitch.C (as opposed to 0 as the representation)
> Pitch("A#") => Pitch.Aę–› #found a unicode character that elixir accepts that looks like a sharp
> Pitch(:Cę–›) => Pitch.Cę–› 
> Pitch(12) => Pitch.C #appropriate instance, mod 12
> Pitch(10) => Pitch.Bb # chooses the first instance when there are more than one option
> Pitch.C => Directly choose the right instance
> Pitch.C = Pitch.Cę–› => :false
> import Pitch; import Interval
> C + m2 => Pitch.Cę–› 
> major_scale = [U, M2, M3, P4, P5, M6, M7] # these are Interval.U, Interval.M2 etc
> minor_11 = ~I(m3 P5 m7 M9 P11) => [m3, P5, m7, m9, P11] # again, Interval.m3, Interval.P5 etc

The shorthand import structure are very useful to not have to type out Pitch._ and Interval._ every time.

Is this what @ is for?

defmodule Pitch
@C = 0
@Cs= 1
@Db= 1
@D = 2
...
end

?

Since the order is important, an Elixir/erlang map is not the right structure since order is not defined

If the list is known at compile time (ie its static) then you can compile your mappings into functions. For example:

defmodule Pitch do
  @pitches [{"C", 0}, {"D", 2}, ...]

  for {note, midi_value} <- @pitches do
    def pitch(unquote(note)), do: unquote(midi_value)
    def pitch(unquote(midi_value)), do: unquote(note)
  end

end

Since functions clauses are resolved in lexical order, this implementation does rely on the keys and the values being unique.

8 Likes

how are you unquoting outside of a quote do ... end block?

In Elixir this is referred to as an unquote fragment.

You may also find this post helpful.

1 Like

Just to illustrate how to achieve the bi-directional structure you were looking for.

defmodule Pitch do
  list = [
    # flats are preferrable to sharps (jazz musician here)
    {"C", 0},
    {"D", 2},
    {"E", 4},
    {"F", 5},
    {"G", 7},
    {"A", 9},
    {"B", 1},
    {"Db", 1},
    {"Gb", 6},
    {"Ab", 8},
    {"Bb", 10},
    {"Eb", 3},
    {"C#", 1},
    {"D#", 3},
    {"G#", 8},
    {"A#", 10},
    {"F#", 6},
    # these are just weird, so they exist only as aliases for B/C E/F respectively.
    {"Fb", 4},
    {"E#", 5},
    {"B#", 0},
    {"Cb", 11}
  ]

  map = Enum.into(list, %{})

  @two_way_map Enum.reduce(list, map, fn {k, v}, acc -> Map.put_new(acc, v, k) end)

  def two_way_map(key) when is_binary(key) or is_integer(key) do
    Map.get(@two_way_map, key)
  end
end

iex(1)> Pitch.two_way_map("A#")
10
iex(2)> Pitch.two_way_map(10)  
"Bb"
iex(3)> Pitch.two_way_map(110)
nil

Elegant solution.
The only one caveat is that since the numbers are not unique you will get a warning saying a previous clause always matches

Yes, you are right and I’m definitely not sure my approach is the best one but I like it for its expressiveness and simplicity.

3 Likes

Spent some time working on @kip’s solution and ended up with

defmodule AEnum do
  defmacro defenum({:__aliases__, _, [name_atom]} = fullname, args, do: block)
  when is_list(args) do
    function_name = String.to_atom String.downcase Atom.to_string(name_atom)
    max = Enum.max(Enum.map args, fn {_,y} -> y end )
    quote do
      defmodule unquote(fullname) do
        defstruct value: 0

        unquote_splicing(Enum.map args, fn {variable, value} -> quote do
            def unquote(function_name)(unquote(variable)), do: %__MODULE__{value: unquote(value)}
            def unquote(function_name)(unquote(Atom.to_string(variable))), do: %__MODULE__{value: unquote(value)}
          end
        end)
        @doc"""
        This is the catch all clause - it just returns nil.
        """
        def unquote(function_name)(value)
        when is_integer(value) do
          %__MODULE__{value: trunc :math.fmod(value, unquote(max))}
        end
        def unquote(function_name)(a) do nil end

        unquote_splicing(Enum.map Enum.reduce(args, %{}, fn {k, v}, acc -> Map.put_new(acc, v, k) end), fn {value, variable} -> quote do
            def name(unquote(value)), do: unquote(variable)
          end
        end)
        def name(value) when is_integer(value) and value >= unquote(max) do
          name(trunc :math.fmod value, unquote(max))
        end
        def name(%__MODULE__{value: value}), do: name(value)


        defimpl String.Chars do
          def to_string(%@for{value: i}) when is_integer(i) do
            "%#{unquote(name_atom)}.#{@for.unquote(function_name)(i)}"
          end
        end

        defimpl Inspect do
          def inspect(%@for{value: v}, _opts) do
            "#{@for}.#{unquote(function_name)}(#{v})"
          end
        end

        unquote_splicing(elem(block, 2))
      end
    end
  end
end

(I broke up some of what I wrote above into a forward search pitch function and a backwards lookup name function. Doing them both in one function was just weird now that I decided to have a struct, which is the only way to get an implementation of to_string and inspect working).
used like so:


defmodule Music do
  require AEnum

  AEnum.defenum Pitch, [
      c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11,
      db: 1, eb: 3, gb: 6, ab: 8, bb: 10,
      "c#": 1, "d#": 3, "f#": 6, "g#": 8, "a#": 10,
      cb: 11, "b#": 0, fb: 4, "e#": 5
    ] do

    def note(%__MODULE__{value: value}, octave)
    when is_integer(octave) do
      note(value, octave)
    end

    def note(pitch, octave)
    when is_integer(pitch) and is_integer(octave) do
      12 * (octave + 1) + pitch
    end

    def sigil_P(s, [])
    when is_binary(s) do
      s |> String.split()
      |> Enum.map(&__MODULE__.pitch/1)
    end
  end
end

Then I can do

defmodule SomeModule
import Music.Pitch 

def major_scale do 
[:c, :d, :e, :f, :g, :a, :b] |> Enum.map(pitch)
end
end

I don’t understand this warning:

the String.Chars protocol has already been consolidated, an implementation for Music.Pitch has no effect. If you want to implement protocols after compilation or during tests, check the "Consolidation" section in the Protocol module documentation

I read the docs and it doesn’t explain how to make this warning change? Just explains that consolidation is used for improving lookup speed.

Let me know if this is sensible or downright strange

when are you calling this line?
If that happens after your code has been compile, you will get that warning. It usually happens in tests or from IEx

Yeah I learned that ElixirLS is just complaining and things work fine when I run with iex -S mix

I’m pretty happy with this final solution for now, so leaving it here in case it helps others:

defmodule AEnum do
  defmacro defenum({:__aliases__, _, [name_atom]} = fullname, args, do: block)
  when is_list(args) do
    function_name = String.to_atom String.downcase Atom.to_string(name_atom)
    quote do
      defmodule unquote(fullname) do
        defstruct value: 0
        @type t :: %__MODULE__{value: integer}
        
        def unquote(function_name)(%__MODULE__{value: v} = instance) do
          instance
        end
        unquote_splicing(Enum.map args, fn {variable, value} -> quote do
            def unquote(function_name)(unquote(variable)), do: %__MODULE__{value: unquote(value)}
            def unquote(function_name)(unquote(Atom.to_string(variable))), do: %__MODULE__{value: unquote(value)}
          end
        end)

        unquote_splicing(Enum.map Enum.reduce(args, %{}, fn {k, v}, acc -> Map.put_new(acc, v, k) end), fn {value, variable} -> quote do
            def name(unquote(value)), do: unquote(variable)
            def unquote(function_name)(unquote(value)) do %__MODULE__{value: unquote(value)} end
          end
        end)
        def name(%__MODULE__{value: value}), do: name(value)


        defimpl String.Chars do
          def to_string(%@for{value: i}) when is_integer(i) do
            "#{@for.name(i)}"
          end
        end

        defimpl Inspect do
          def inspect(%@for{value: v}, _opts) when is_integer(v) do
            "#{unquote(name_atom)}.#{@for.name(v)}"
          end
        end

        unquote(block)

        # catch all clauses go at the end so that other implementation can
        # add more clauses if they wish for other reasons.
        def unquote(function_name)(a), do: nil
        def name(a), do: nil
      end
    end
  end
1 Like