Elixir Objects
Содержание
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