Biblioteca de fórmulas
Na fintech, geralmente precisamos verificar se condições aritméticas simples são satisfeitas, por exemplo, se a taxa de câmbio será maior que o valor esperado ou não. Essas condições mudam com muita frequência, e precisávamos inventar algum tipo de bicicleta para adicionar novas verificações e executar as existentes em tempo real. Imagine que vários milhares de clientes esperam receber notificações quando a taxa de câmbio de alguns pares de moedas atingir uma proporção de dois para um. Seria muito simples se pudéssemos tornar as condições estáticas:
def notify?(rate) when rate > 2.0, do: true def notify?(_), do: false
Permitimos que os clientes adicionem tais verificações dinamicamente. Portanto, precisamos de um mecanismo mais ou menos confiável para verificar as condições recém-adicionadas.
Sim, o
Code.eval_string/3
funciona de alguma forma, mas compila a condição a cada momento antes de verificar. Obviamente, isso é um desperdício de recursos sem motivo. O que é agravado pelo fato de recebermos e processarmos cerca de 10.000 cursos para diferentes pares de moedas a cada segundo.
Então, criamos fórmulas pré-compiladas. A pequena biblioteca de
formulae
cria um módulo para cada condição especificada e compila a fórmula inserida pelo usuário no código - uma vez.
A biblioteca
NB deve ser usada com cautela, porque os nomes dos módulos são armazenados na forma de átomos e sua criação incondicional cega para tudo o que o cliente deseja verificar pode levar a um ataque de DoS atômico na máquina virtual Erlang com um tempo de execução mais ou menos longo. Usamos a etapa máxima permitida de um valor de
0.01
, que fornece um máximo de 200 mil átomos no pior cenário.
Lazy Combinators
Mas o principal objetivo deste artigo não é falar sobre fórmulas pré-compiladas. Para alguns casos limítrofes de análise da taxa de câmbio, precisávamos calcular as permutações de uma lista bastante longa. De repente, a biblioteca padrão do Elixir não fornece uma solução pronta para uso. Bem, eu decidi copiar as assinaturas combinatórias do Ruby (
Array#combination
e primos). Infelizmente, isso não foi tão fácil para longas listas. As combinações pararam na região de trinta elementos da lista, permutação - ainda mais cedo.
Bem, espera-se que já esteja aqui; então comecei a jogar uma implementação lenta usando o Stream. Acabou, e não é tão fácil quanto eu pensava. Eu vim com algo como o código abaixo
list = ~w[abcde]a combinations = Stream.transform(Stream.with_index(list), :ok, fn {i1, idx1}, :ok -> {Stream.transform(Stream.with_index(list), :ok, fn {_, idx2}, :ok when idx2 <= idx1 -> {[], :ok} {i2, idx2}, :ok -> {Stream.transform(Stream.with_index(list), :ok, fn {_, idx3}, :ok when idx3 <= idx2 -> {[], :ok} {i3, idx3}, :ok -> {Stream.transform(Stream.with_index(list), :ok, fn {_, idx4}, :ok when idx4 <= idx3 -> {[], :ok} {i4, _idx4}, :ok -> {[[i1, i2, i3, i4]], :ok} end), :ok} end), :ok} end), :ok} end)
Isso funciona, mas apenas para um número conhecido de combinações. Bem, isso é facilmente superável: nesse caso, temos macros, certo?
No código acima, três padrões diferentes são exibidos. Uma ramificação bem-sucedida da qual descartamos a lista. Ejeção rápida de uma lista vazia. E a transformação do fluxo com o índice. Parece que poderíamos tentar criar um AST para o acima.
Este é um caso raro ao usar o
Kernel.SpecialForms.quote/2
provavelmente só complicará as coisas, por isso tomei o caminho de menor resistência: esculpiremos o bom e velho AST nu.
Comecei invocando
quote do:
no console desse código e, direto ao ponto, examinei o resultado. Sim, existem padrões. Bem, vamos lá.
Então, você precisa começar criando uma estrutura comum.
defmacrop mapper(from, to, fun), do: quote(do: Enum.map(Range.new(unquote(from), unquote(to)), unquote(fun))) @spec combinations(list :: list(), count :: non_neg_integer()) :: {Stream.t(), :ok} defmacro combinations(l, n) do Enum.reduce(n..1, {[mapper(1, n, &var/1)], :ok}, fn i, body -> stream_combination_transform_clause(i, l, body) end) end
Agora precisamos começar a pensar em termos não de código, mas de AST, para ver partes repetidas do modelo. Isso é divertido!
Vamos começar com macros auxiliares para simplificar o código:
def var(i), do: {:"i_#{i}", [], Elixir} def idx(i), do: {:"idx_#{i}", [], Elixir}
Peça interna AST rasgada de uma visão geral:
def sink_combination_clause(i) when i > 1 do {:->, [], [ [ {:when, [], [ {{:_, [], Elixir}, idx(i)}, :ok, {:<=, [context: Elixir, import: Kernel], [idx(i), idx(i - 1)]} ]} ], {[], :ok} ]} end
Todas as peças internas juntas:
def sink_combination_clauses(1, body) do [{:->, [], [[{var(1), idx(1)}, :ok], body]}] end def sink_combination_clauses(i, body) when i > 1 do Enum.reverse([ {:->, [], [[{var(i), idx(i)}, :ok], body]} | Enum.map(2..i, &sink_combination_clause/1) ]) end
E, finalmente, o invólucro externo em torno de tudo.
def stream_combination_transform_clause(i, l, body) do clauses = sink_combination_clauses(i, body) {{{:., [], [{:__aliases__, [alias: false], [:Stream]}, :transform]}, [], [ {{:., [], [{:__aliases__, [alias: false], [:Stream]}, :with_index]}, [], [l]}, :ok, {:fn, [], clauses} ]}, :ok} end
Todas as permutações são realizadas quase de forma idêntica, a única alteração é a condição nas chamadas internas. Foi fácil, né? Todo o código pode ser visualizado
no repositório .
App
Bom, então como podemos usar essa beleza? Bem, algo como isto:
l = for c <- ?a..?z, do: <<c>>
Agora podemos alimentar o
Flow
diretamente desse fluxo para paralelizar os cálculos. Sim, ainda será lento e triste, mas, felizmente, essa tarefa não é em tempo real, mas para análises que podem ser executadas à noite e que passam lentamente por todas as combinações e anotam os resultados em algum lugar.
Se você tiver dúvidas sobre o AST no Elixir - pergunte, eu comi um cachorro nele.