Erlang (и Elixir) часто в шутку называют самым труъ-ООП языком программирования. При этом под объектной ориентацией (ОО) понимают определение, которое дал Алан Кей, автор языка программирования Smalltalk и один из отцов-основателей ООП. Согласно ему, объекты представляют собой изолированные сущности с защищённым (скрытым) состоянием, которые общаются между собой только путём передачи сообщений. Согласитесь, как будто бы описание процессов Erlang/Elixir!

Широкое же распространение другого определения ("Encapsulation, Inheritance, Polymorphism") связано с популярностью С++ и Java, а также языков, на которых они оказали влияние. Если отбросить аспект наследования, в рамках этого определения объекты — это экземпляры классов, которые обладают одновременно внутренним состоянием (переменные экземпляра) и поведением (методы класса/экземпляра). Как пример можно привести следующую интерактивную сессию Ruby:

irb(main):001:0> str = String.new("Some string")
=> "Some string"
irb(main):002:0> str.split
=> ["Some", "string"]

Здесь мы создаём объект, который обладает некоторым состоянием (внутреннее представление строки "Some string"). Кроме того, он знает, как разбивать строку по разделителям (метод String#split).

Можно ли добиться такого же поведения (хотя бы визуально) в Elixir? Ответ — можно!

Представляю вашему вниманию StringObject:

iex(1)> str = StringObject.new :"The quick brown fox jumps over the lazy dog"
# ... output omitted
iex(2)> str.size
43
iex(3)> str.size == str.length
true
iex(4)> str.split
["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]
iex(5)> str.split(~r/the/i)
["", " quick brown fox jumps over ", " lazy dog"]
iex(6)> str.split(~r/the/i, trim: true)
[" quick brown fox jumps over ", " lazy dog"]
iex(7)> str.starts_with?("a quick")
false
iex(8)> str.ends_with?(["cat", "dog"])
true
iex(9)> str.upcase
"THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
iex(10)> str.downcase
"the quick brown fox jumps over the lazy dog"
iex(11)>

Что же представляет из себя StringObject?

Возможно, вы уже догадались.

Внимательный читатель может заметить, что в "конструктор" StringObject.new/1 передаётся не строка, а атом.

Опытный же BEAM-ниндзя знает, что вызовы функций в BEAM бывают локальными (local), когда вызывается функция текущего модуля (call(args)), и внешними (remote, external), когда при вызове явно указывается, функция какого модуля вызывается (module.call(args)). На этом разделении, которое сохраняется и на уровне инструкций виртуальной машины, и строится функциональность горячей перезагрузки (hot code reloading), которым так знаменита виртуальная машина Erlang, но это тема другого поста.

Также мало кто знает, что хотя обычно в Elixir коде для модулей используются названия в CamelCase (которые, кстати, раскрываются компиляторам в атомы с префиксом Elixir.), в качестве имени модуля может выступать любой атом — и StringObject, и :"мама мыла раму".

Если совместить эти факты, станет очевидно, что переменная str содержит название модуля, которое передаётся в "конструктор". Как (и где) этот модуль определён? С помощью науки метапрограммирования!

defmodule StringObject do
  # ... snip

  def new(module) do
    Module.create(module, @proto, __ENV__)

    module
  end

  # ... snip
end

Как видно из определения "конструктора", он принимает название нового модула как атом, генерирует модуль "на лету" с помощью функции Module.create/2 и возвращает свой аргумент. Рассмотрим AST генерируемого модуля (атрибут @proto):

defmodule StringObject do
  @proto (quote do
            import Utils, only: [delegate_method: 2]

            delegate_method length, to: String
            delegate_method size, to: String, as: :length
            delegate_method split, to: String
            delegate_method split(pattern), to: String
            delegate_method split(pattern, opts), to: String
            delegate_method starts_with?(pattern), to: String
            delegate_method ends_with?(pattern), to: String
            delegate_method upcase, to: String
            delegate_method downcase, to: String

            defp this do
              to_string(__MODULE__)
            end
          end)

  def new(module) do
    Module.create(module, @proto, __ENV__)

    module
  end

  # ... snip
end

Помимо импорта и вызовов Utils.delegate_method/2, к которым мы вернёмся позже, есть определение одной приватной функции this/0. Эта функция возвращает строковое представление имени текущего модуля. Т.е. для модуля, представленного атомом :"мама мыла раму", функция вернёт строку "мама мыла раму".

Макрос Utils.delegate_method/2 же работает почти аналогично макросу Kernel.defdelegate/2, но отличается тем, что передаёт "внутреннее представление" текущего "объекта" (функция this/0) дополнительным первым аргументом:

defmodule Utils do
  defmacro delegate_method(head, opts) do
    head = Macro.escape(head, unquote: true)

    quote bind_quoted: [head: head, opts: opts] do
      {name, args} = Macro.decompose_call(head)

      as = Keyword.get(opts, :as, name)
      to = Keyword.fetch!(opts, :to)

      def unquote(name)(unquote_splicing(args)) do
        unquote(to).unquote(as)(this(), unquote_splicing(args))
      end
    end
  end
end

Например, для split(pattern) генерируется следующее определение:

def split(pattern) do
  String.split(this(), pattern)
end

Таким образом, вызов str.split(~r/the/i) в исходном примере превращается в

String.split("The quick brown fox jumps over the lazy dog", ~r/the/i)

Полный код модуля:

defmodule StringObject do
  @proto (quote do
            import Utils, only: [delegate_method: 2]

            delegate_method length, to: String
            delegate_method size, to: String, as: :length
            delegate_method split, to: String
            delegate_method split(pattern), to: String
            delegate_method split(pattern, opts), to: String
            delegate_method starts_with?(pattern), to: String
            delegate_method ends_with?(pattern), to: String
            delegate_method upcase, to: String
            delegate_method downcase, to: String

            defp this do
              to_string(__MODULE__)
            end
          end)

  def new(module) do
    Module.create(module, @proto, __ENV__)

    module
  end
end

defmodule Utils do
  defmacro delegate_method(head, opts) do
    head = Macro.escape(head, unquote: true)

    quote bind_quoted: [head: head, opts: opts] do
      {name, args} = Macro.decompose_call(head)

      as = Keyword.get(opts, :as, name)
      to = Keyword.fetch!(opts, :to)

      def unquote(name)(unquote_splicing(args)) do
        unquote(to).unquote(as)(this(), unquote_splicing(args))
      end
    end
  end
end

Не только "строки"

Этот трюк работает потому, что атомы взаимооднозначно конвертируются в строки. Но можно расширить этот подход и на произвольные значения, если "пробросить" аргумент "конструктора" внутрь модуля при его определении:

defmodule Array do
  def new(list) do
    module = String.to_atom("Object_#{System.unique_integer()}")

    ast =
      quote do
        import Utils, only: [delegate_method: 2]

        delegate_method first, to: List
        delegate_method last, to: List
        delegate_method reduce(acc, fun), to: List, as: :foldl
        delegate_method delete(element), to: List
        delegate_method select(predicate), to: Enum, as: :filter
        delegate_method map(fun), to: Enum

        def this, do: unquote(list)
      end

    Module.create(module, ast, __ENV__)

    module
  end
end

Пример IEx-сессии:

iex(1)> x = Array.new([1,2,3,4,5])
:"Object_-576460752303422973"
iex(2)> x.first
1
iex(3)> x.last
5
iex(4)> x.reduce(0, &Kernel.+/2)
15
iex(5)> x.delete(3)
[1, 2, 4, 5]
iex(6)> x.select(&(rem(&1, 2) == 1))
[1, 3, 5]
iex(7)> x.map(&(&1 * 2))
[2, 4, 6, 8, 10]
iex(8)>

Заключение

Является ли такой подход (генерация нового модуля под каждое значение) практичным? Очевидно, что нет. Во-первых, число атомов в Erlang/Elixir ограничено (:erlang.system_info(:atom_limit)), и они не собираются сборщиком мусора. Во-вторых, бесконтрольная генерация модулей ещё хуже, чем генерация атомов, так как они занимают гораздо больше места в памяти, ну и потому, что их лимит даже меньше лимита атомов (см. значение limit):

iex(1)> IO.puts Regex.scan(~r/=index_table:module_code([^=]+)/, :erlang.system_info(:info), capture: :first)
=index_table:module_code
size: 1024
limit: 65536
entries: 592