Transdutores: Pipelines de processamento de dados eficientes em JavaScript

Eric Elliott Blocked Unblock Seguir Seguindo 22 de novembro de 2018 Fumaça Art Cubes to Smoke – Mattys Flicks – (CC BY 2.0)

Nota: Esta é parte da “Compor Software” serie s (agora um livro!) Sobre a aprendizagem de programação funcional e técnicas de software de composição em JavaScript ES6 + a partir do zero. Fique ligado. Há muito mais disso por vir!
<Anterior | << Começar de novo na Parte 1

Antes de aceitar os transdutores, você deve primeiro ter um forte entendimento da composição das funções e dos redutores .

Transduzir: Derivado do latim científico do século XVII, “transductionem” significa “mudar, converter”. É ainda derivado de "transducere / traducere", que significa "conduzir ao longo ou através de, transferência".

Um transdutor é um redutor de ordem superior composto. Ele toma um redutor como entrada e retorna outro redutor.

Transdutores são:

  • Composable usando composição de função simples
  • Eficiente para grandes coleções ou fluxos infinitos: somente enumera os elementos uma vez, independentemente do número de operações no pipeline
  • Capaz de transduzir sobre qualquer fonte enumerável (por exemplo, matrizes, árvores, fluxos, gráficos, etc …)
  • Utilizável para avaliação preguiçosa ou ansiosa sem alterações no pipeline do transdutor

Os redutores dobram várias entradas em saídas únicas, onde a “dobra” pode ser substituída por praticamente qualquer operação binária que produza uma única saída, como:

 // Soma: (1, 2) = 3 
const add = (a, c) => a + c;
 // Produtos: (2, 4) = 8 
const multiplique = (a, c) => a * c;
 // Concatenação de strings: ('abc', '123') = 'abc123' 
const concatString = (a, c) => a + c;
 // Concatenação de matriz: ([1,2], [3,4]) = [1, 2, 3, 4] 
const concatArray = (a, c) => [... a, ... c];

Os transdutores fazem praticamente a mesma coisa, mas, ao contrário dos redutores comuns, os transdutores são compostos usando a composição normal das funções. Em outras palavras, você pode combinar qualquer número de transdutores para formar um novo transdutor que liga cada transdutor componente em série.

Os redutores normais não podem compor, porque esperam dois argumentos e só retornam um único valor de saída, portanto, você não pode simplesmente conectar a saída à entrada do próximo redutor na série. Os tipos não se alinham:

 f: (a, c) => a 
g: (a, c) => a
h: ???

Transdutores têm uma assinatura diferente:

 f: redutor => redutor 
g: redutor => redutor
h: redutor => redutor

Porquê Transdutores?

Geralmente, quando processamos dados, é útil dividir o processamento em vários estágios independentes e compostos. Por exemplo, é muito comum selecionar alguns dados de um conjunto maior e processar esses dados. Você pode ser tentado a fazer algo assim:

 amigos constantes = [ 
{id: 1, nome: "Sting", nearMe: true},
{id: 2, nome: "Radiohead", nearMe: true},
{id: 3, nome: 'NIN', nearMe: false},
{id: 4, nome: "Echo", nearMe: true},
{id: 5, nome: "Zeppelin", nearMe: false}
];
 const isNearMe = ({nearMe}) => nearMe; 
 const getName = ({name}) => nome; 
 resultados const = amigos 
.filter (isNearMe)
.map (getName);
 console.log (resultados); 
// => ["Sting", "Radiohead", "Echo"]

Isso é bom para pequenas listas como esta, mas existem alguns problemas em potencial:

  1. Isso só funciona para matrizes. E quanto a fluxos potencialmente infinitos de dados vindos de uma assinatura de rede ou um gráfico social com amigos-de-amigos?
  2. Cada vez que você usa a sintaxe de encadeamento de pontos em uma matriz, o JavaScript cria um novo array intermediário antes de passar para a próxima operação na cadeia. Se você tem uma lista de 2.000.000 de “amigos” para percorrer, isso pode retardar as coisas em uma ordem de magnitude ou duas. Com os transdutores, você pode transmitir cada amigo através do pipeline completo sem acumular coleções intermediárias entre eles, economizando muito tempo e perda de memória.
  3. Com o encadeamento de pontos, você precisa criar diferentes implementações de operações padrão, como .filter() , .map() , .reduce() , .concat() e assim por diante. Os métodos de matriz são construídos em JavaScript, mas e se você quiser criar um tipo de dados personalizado e suportar um monte de operações padrão sem gravá-las todas a partir do zero? Os transdutores podem potencialmente trabalhar com qualquer tipo de dados de transporte: Escreva um operador uma vez, use-o em qualquer lugar que suporte transdutores.

Vamos ver como isso ficaria com os transdutores. Este código não funcionará ainda, mas siga em frente, e você mesmo poderá construir cada parte deste pipeline de transdutor:

 amigos constantes = [ 
{id: 1, nome: "Sting", nearMe: true},
{id: 2, nome: "Radiohead", nearMe: true},
{id: 3, nome: 'NIN', nearMe: false},
{id: 4, nome: "Echo", nearMe: true},
{id: 5, nome: "Zeppelin", nearMe: false}
];
 const isNearMe = ({nearMe}) => nearMe; 
 const getName = ({name}) => nome; 
 const getFriendsNearMe = compor ( 
filtro (isNearMe),
map (getName)
);
 const results2 = toArray (getFriendsNearMe, amigos); 

Os transdutores não fazem nada até que você toArray() para eles iniciarem e alimentarem alguns dados para serem processados, e é por isso que precisamos de toArray() . Ele fornece o processo transdutível e informa ao transdutor para converter os resultados em uma nova matriz. Você poderia dizer para transduzir para um fluxo, ou um observável, ou qualquer coisa que você gosta, em vez de chamar toArray() .

Um transdutor pode mapear números para cadeias de caracteres, ou objetos para matrizes, ou matrizes para matrizes menores, ou não alterar nada, mapeando { x, y, z } -> { x, y, z } . Os transdutores também podem filtrar partes do sinal para fora do fluxo { x, y, z } -> { x, y } ou até mesmo gerar novos valores para inserir no fluxo de saída, { x, y, z } -> { x, xx, y, yy, z, zz } .

Usarei as palavras "signal" e "stream" de maneira intercambiável nesta seção. Tenha em mente que quando digo "stream", não estou me referindo a nenhum tipo específico de dados: simplesmente uma seqüência de zero ou mais valores, ou uma lista de valores expressos ao longo do tempo.

Antecedentes e Etimologia

Em sistemas de processamento de sinal de hardware, um transdutor é um dispositivo que converte uma forma de energia em outra, por exemplo, ondas de áudio para elétricas, como em um transdutor de microfone. Em outras palavras, transforma um tipo de sinal em outro tipo de sinal. Da mesma forma, um transdutor no código converte de um sinal para outro.

O uso da palavra “transdutores” e o conceito geral de pipelines compostos de transformações de dados em software remontam pelo menos à década de 1960, mas nossas ideias sobre como elas deveriam funcionar mudaram de um idioma e contexto para o seguinte. Muitos engenheiros de software nos primeiros dias da ciência da computação também eram engenheiros elétricos. O estudo geral da ciência da computação naqueles dias geralmente lidava com design de hardware e software. Por isso, pensar em processos computacionais como “transdutores” não era particularmente novo. É possível encontrar o termo na literatura da ciência da computação no início – particularmente no contexto do Processamento Digital de Sinais (DSP) e programação de fluxo de dados.

Na década de 1960, o trabalho inovador estava acontecendo em computação gráfica no Laboratório Lincoln do MIT usando o sistema de computador TX-2, um precursor do sistema de defesa SAGE da Força Aérea dos EUA. O famoso Sketchpad de Ivan Sutherland, desenvolvido entre 1961 e 1962, foi um dos primeiros exemplos de delegação de protótipos de objetos e programação gráfica usando uma caneta de luz.

O irmão de Ivan, William Robert “Bert” Sutherland, foi um dos vários pioneiros na programação de fluxo de dados. Ele construiu um ambiente de programação de fluxo de dados no topo do Sketchpad, que descreveu “procedimentos” de software como gráficos direcionados de nós do operador com saídas vinculadas às entradas de outros nós. Ele escreveu sobre a experiência em seu artigo de 1966, "A especificação gráfica on-line de procedimentos de computador" . Em vez de arrays e processamento de array, tudo é representado como um fluxo de valores em um loop de programa interativo em execução contínua. Cada valor é processado por cada nó à medida que chega à entrada do parâmetro. Você pode encontrar sistemas semelhantes hoje no Blueprints Visual Scripting Environment do Unreal Engine ou no Native Instruments 'Reaktor , um ambiente de programação visual usado por músicos para criar sintetizadores de áudio personalizados.

Gráfico composto de operadores do artigo de Bert Sutherland

Até onde eu sei, o primeiro livro a popularizar o termo “transdutor” no contexto de processamento de fluxo baseado em software foi o livro de 1985 do MIT para um curso de ciência da computação chamado “Estrutura e Interpretação de Programas de Computador” ( SICP) por Harold Abelson e Gerald Jay Sussman, com Julie Sussman. No entanto, o uso do termo “transdutor” no contexto do processamento digital de sinais é anterior ao SICP.

Nota: O SICP ainda é uma excelente introdução à ciência da computação, proveniente de uma perspectiva de programação funcional. Ainda é meu livro favorito sobre o assunto.

Mais recentemente, os transdutores foram redescobertos independentemente e um protocolo diferente foi desenvolvido para Clojure por Rich Hickey (cerca de 2014), que é famoso por selecionar cuidadosamente palavras para conceitos baseados em etimologia. Neste caso, eu diria que ele acertou, porque os transdutores Clojure preenchem quase exatamente o mesmo nicho que os transdutores no SICP, e eles compartilham muitas características comuns. No entanto, eles não são estritamente a mesma coisa.

Os transdutores como um conceito geral (não especificamente a especificação de protocolo do Hickey) tiveram impacto considerável em ramos importantes da ciência da computação, incluindo programação de fluxo de dados, processamento de sinais para aplicações científicas e de mídia, redes, inteligência artificial, etc. Transdutores expressos em nosso código de aplicativo, eles estão começando a nos ajudar a entender melhor todos os tipos de composição de software, incluindo os comportamentos de interface de usuário em aplicativos da Web e móveis e, no futuro, também podem nos ajudar a gerenciar a complexidade de realidade, dispositivos e veículos autônomos, etc.

Para o propósito desta discussão, quando digo “transdutor”, não estou me referindo aos transdutores SICP, embora possa parecer que estou descrevendo-os se você já estiver familiarizado com os transdutores do SICP. Também não estou me referindo especificamente aos transdutores do Clojure, ou ao protocolo do transdutor que se tornou um padrão de fato em JavaScript (suportado por Ramda, Transducers-JS, RxJS, etc…). Estou me referindo ao conceito geral de um redutor de ordem superior – uma transformação de uma transformação.

Na minha opinião, os detalhes particulares dos protocolos do transdutor importam muito menos do que os princípios gerais e as propriedades matemáticas subjacentes dos transdutores, no entanto, se você quiser usar transdutores em produção, minha recomendação atual é usar uma biblioteca existente que implemente o protocolo de transdutores por razões de interoperabilidade.

Os transdutores que descreverei aqui devem ser considerados pseudocódigo para expressar os conceitos. Eles não são compatíveis com o protocolo do transdutor e não devem ser usados na produção. Se você quiser aprender a usar os transdutores de uma biblioteca específica, consulte a documentação da biblioteca. Eu estou escrevendo-os desta maneira para levantar o capô e deixar você ver como eles funcionam sem forçá-lo a aprender o protocolo ao mesmo tempo.

Quando terminarmos, você deverá ter um melhor entendimento dos transdutores em geral e como aplicá-los em qualquer contexto, com qualquer biblioteca, em qualquer idioma que ofereça suporte a fechamentos e funções de ordem superior.

Uma Analogia Musical para Transdutores

Se você está entre o grande número de desenvolvedores de software que também são músicos, uma analogia de música pode ser útil: você pode pensar em transdutores como equipamentos de processamento de sinais (por exemplo, pedais de distorção de guitarra, EQ, botões de volume, eco, reverb e áudio misturadores).

Para gravar uma música usando instrumentos musicais, precisamos de algum tipo de transdutor físico (ou seja, um microfone) para converter as ondas sonoras no ar em eletricidade no fio. Então, precisamos rotear esse fio para qualquer unidade de processamento de sinal que gostaríamos de usar. Por exemplo, adicionar distorção a uma guitarra elétrica ou reverberar a uma faixa de voz. Eventualmente, essa coleção de sons diferentes deve ser agregada e misturada para formar um único sinal (ou coleção de canais) representando a gravação final.

Em outras palavras, o fluxo de sinal pode ser algo assim. Imagine as setas são fios entre transdutores:

 [ Source ] -> [ Mic ] -> [ Filter ] -> [ Mixer ] -> [ Recording ] 

Em termos mais gerais, você poderia expressá-lo assim:

 [ Enumerator ]->[ Transducer ]->[ Transducer ]->[ Accumulator ] 

Se você já usou um software de produção musical, isso pode lembrar uma cadeia de efeitos de áudio. É uma boa intuição ter quando se está pensando em transdutores, mas eles podem ser aplicados de maneira muito mais geral a números, objetos, quadros de animação, modelos 3D ou qualquer outra coisa que você possa representar no software.

Screenshot: canal de efeitos de áudio Renoise

Você pode ter experiência com algo que se comporta um pouco como um transdutor, se você já usou o método do mapa em matrizes. Por exemplo, para duplicar uma série de números:

 const double = x => x * 2; 
const arr = [1, 2, 3];
 resultado const = arr.map (double); 

Neste exemplo, a matriz é um objeto enumerável. O método map enumera a matriz original e passa seus elementos pelo estágio de processamento, double , que multiplica cada elemento por 2 e, em seguida, acumula os resultados em uma nova matriz.

Você pode até compor efeitos como este:

 const double = x => x * 2; 
const isEven = x => x% 2 === 0;
 const arr = [1, 2, 3, 4, 5, 6]; 
 resultado const = arr 
.filter (isEven)
.map (duplo)
;
 console.log (resultado); 
// [4, 8, 12]

Mas e se você quiser filtrar e duplicar um fluxo potencialmente infinito de números, como os dados de telemetria de um drone?

As matrizes não podem ser infinitas e cada estágio no processamento da matriz exige que você processe a matriz inteira antes que um único valor possa fluir pelo próximo estágio no pipeline. Essa mesma limitação significa que a composição usando métodos de matriz terá um desempenho degradado, pois será necessário criar uma nova matriz e repetir uma nova coleção para cada estágio da composição.

Imagine que você tenha duas seções de tubulação, cada uma representando uma transformação a ser aplicada ao fluxo de dados e uma string representando o fluxo. A primeira transformação representa o filtro isEven e a próxima representa o mapa double . Para produzir um único valor totalmente transformado a partir de um array, você teria que percorrer toda a cadeia através do primeiro tubo, resultando em um array completamente novo e filtrado antes de processar um único valor através do tubo double . Quando você finalmente consegue double seu primeiro valor, você tem que esperar que o array inteiro seja duplicado antes que você possa ler um único resultado.

Então, o código acima é equivalente a isso:

 const double = x => x * 2; 
const isEven = x => x% 2 === 0;
 const arr = [1, 2, 3, 4, 5, 6]; 
 const tempResult = arr.filter (isEven); 
const result = tempResult.map (double);
 console.log (resultado); 
// [4, 8, 12]

A alternativa é fornecer um valor diretamente da saída filtrada para a transformação de mapeamento sem criar e iterar em um novo array temporário entre eles. Fluir os valores através de um de cada vez elimina a necessidade de iterar sobre a mesma coleção para cada estágio no processo de transdução, e os transdutores podem sinalizar uma parada a qualquer momento, o que significa que você não precisa enumerar cada estágio mais profundamente sobre a coleção necessário para produzir os valores desejados.

Existem duas maneiras de fazer isso:

  • Pull: avaliação preguiçosa, ou
  • Empurre: avaliação ansiosa

Uma API de recebimento aguarda até que um consumidor solicite o próximo valor. Um bom exemplo em JavaScript é um Iterable , como o objeto produzido por uma função geradora. Nada acontece na função de gerador até que você peça o próximo valor chamando .next() no objeto iterador que ele retorna.

Uma API de envio enumera os valores de origem e os empurra pelos tubos o mais rápido possível. Uma chamada para array.reduce() é um bom exemplo de uma API push. array.reduce() pega um valor de cada vez do array e o empurra através do redutor, resultando em um novo valor no outro extremo. Para processos ávidos, como redução de matriz, o processo é imediatamente repetido para cada elemento na matriz até que toda a matriz tenha sido processada, bloqueando a execução de programas adicionais nesse meio tempo.

Transdutores não se importam se você puxa ou empurra. Os transdutores não têm consciência da estrutura de dados em que estão atuando. Eles simplesmente chamam o redutor que você passa para acumular novos valores.

Transdutores são redutores de maior ordem: funções redutoras que tomam um redutor e retornam um novo redutor. Rich Hickey descreve os transdutores como transformações de processo, o que significa que, ao contrário de simplesmente alterar os valores que passam pelos transdutores, os transdutores alteram os processos que atuam nesses valores.

As assinaturas são assim:

 redutor = (acumulador, corrente) => acumulador 
 transdutor = redutor => redutor 

Ou, para soletrar:

 transdutor = ((acumulador, corrente) => acumulador) => ((acumulador, corrente) => acumulador) 

De um modo geral, porém, a maioria dos transdutores precisará ser parcialmente aplicada a alguns argumentos para especializá-los. Por exemplo, um transdutor de mapa pode ter esta aparência:

 map = transform => reducer => redutor 

Ou mais especificamente:

 map = (a => b) => passo => redutor 

Em outras palavras, um transdutor de mapa usa uma função de mapeamento (chamada de transformação) e um redutor (chamada de função de step ) e retorna um novo redutor. A função step é um redutor para chamar quando produzimos um novo valor para adicionar ao acumulador na próxima etapa.

Vamos ver alguns exemplos ingênuos:

 const compose = (... fns) => x => fns.reduceRight ((y, f) => f (y), x); 
 mapa const = f => passo => 
(a, c) => passo (a, f (c));
 filtro const = predicado => passo => 
(a, c) => predicado (c)? passo (a, c): a;
 const isEven = n => n% 2 === 0; 
const double = n => n * 2;
 const doubleEvens = compor ( 
filtro (isEven),
mapa (duplo)
);
 const arrayConcat = (a, c) => a.concat ([c]); 
 const xform = doubleEvens (arrayConcat); 
 resultado const = [1,2,3,4,5,6] .reduce (xform, []); // [4, 8, 12] 
 console.log (resultado); 

Isso é muito para absorver. Vamos acabar com isso. map aplica uma função aos valores dentro de algum contexto. Nesse caso, o contexto é o pipeline do transdutor. Parece mais ou menos assim:

 mapa const = f => passo => 
(a, c) => passo (a, f (c));

Você pode usá-lo assim:

 const double = x => x * 2; 
 const doubleMap = map(double); 
 const step = (a, c) => console.log(c); 
 doubleMap(step)(0, 4); // 8 
doubleMap(step)(0, 21); // 42

Os zeros nas chamadas de função no final representam os valores iniciais dos redutores. Observe que a função de etapa deve ser um redutor, mas para fins de demonstração, podemos seqüestrá-lo e fazer o login no console. Você pode usar o mesmo truque em seus testes de unidade se precisar fazer afirmações sobre como a função de etapa é usada.

Transdutores ficam interessantes quando os compondo juntos. Vamos implementar um transdutor de filtro simplificado:

 filtro const = predicado => passo => 
(a, c) => predicado (c)? passo (a, c): a;

O filtro usa uma função de predicado e passa apenas pelos valores que correspondem ao predicado. Caso contrário, o redutor retornado retorna o acumulador, inalterado.

Como essas duas funções tomam um redutor e retornam um redutor, podemos compor com uma composição de função simples:

 const compose = (... fns) => x => fns.reduceRight ((y, f) => f (y), x); 
 const isEven = n => n% 2 === 0; 
const double = n => n * 2;
 const doubleEvens = compor ( 
filtro (isEven),
mapa (duplo)
);

Isso também retornará um transdutor, o que significa que devemos fornecer uma função de etapa final para informar ao transdutor como acumular o resultado:

 const arrayConcat = (a, c) => a.concat ([c]); 
 const xform = doubleEvens (arrayConcat); 

O resultado dessa chamada é um redutor padrão que podemos passar diretamente para qualquer API de redução compatível. O segundo argumento representa o valor inicial da redução. Nesse caso, uma matriz vazia:

 resultado const = [1,2,3,4,5,6] .reduce (xform, []); // [4, 8, 12] 

Se isso parece um monte de trabalho, tenha em mente já existem bibliotecas de programação funcional que fornecem transdutores comuns, juntamente com utilitários como compose , que lida com composição de função, e into que transduz um valor para o valor dado vazio, por exemplo:

 const xform = compor ( 
mapa (inc),
filtro (isEven)
);
 em ([], xform, [1, 2, 3, 4]); // [2, 4] 

Com a maioria das ferramentas necessárias já no cinto de ferramentas, a programação com transdutores é realmente intuitiva.

Algumas bibliotecas populares que suportam transdutores incluem Ramda, RxJS e Mori.

Transdutores compõem de cima para baixo

Transdutores sob composição de função padrão ( f(g(x)) ) aplicam-se de cima para baixo / esquerda para a direita, em vez de de baixo para cima / da direita para a esquerda. Em outras palavras, usando composição de função normal, compose(f, g) significa "compor f após g ". Transdutores envolvem outros transdutores na composição. Em outras palavras, um transdutor diz "Eu vou fazer o meu trabalho, e depois chamar o próximo transdutor no pipeline", que tem o efeito de virar a pilha de execução de dentro para fora.

Imagine que você tenha uma pilha de papéis, o topo rotulado, f , o próximo, g e o próximo h . Para cada folha, retire a folha do topo da pilha e coloque-a no topo de uma nova pilha adjacente. Quando estiver pronto, você terá uma pilha cujas folhas são rotuladas h , então g , então f .

Regras do Transdutor

Os exemplos acima são ingênuos porque ignoram as regras que os transdutores devem seguir para interoperabilidade.

Como a maioria das coisas no software, os transdutores e os processos de transdução precisam obedecer a algumas regras:

  1. Inicialização: Dado nenhum valor acumulador inicial, um transdutor deve chamar a função de passo para produzir um valor inicial válido para agir. O valor deve representar o estado vazio. Por exemplo, um acumulador que acumula uma matriz deve fornecer uma matriz vazia quando sua função de etapa for chamada sem argumentos.
  2. Finalização antecipada: Um processo que usa transdutores deve verificar e parar quando receber um valor reduzido de acumulador. Além disso, uma função de etapa do transdutor que usa uma redução aninhada deve verificar e transmitir valores reduzidos quando eles são encontrados.
  3. Conclusão (opcional): Alguns processos de transdução nunca são concluídos, mas aqueles que devem chamar a função de conclusão para produzir um valor final e / ou estado de descarga, e transdutores com estado devem fornecer uma operação de conclusão que limpa todos os recursos acumulados e potencialmente produz uma final. valor.

Inicialização

Vamos voltar para a operação do map e garantir que ela obedeça à lei de inicialização (vazia). É claro que não precisamos fazer nada de especial, basta passar o pedido pelo pipeline usando a função step para criar um valor padrão:

 mapa const = f => passo => (a = passo (), c) => ( 
etapa (a, f (c))
);

A parte que nos interessa é a = step() na assinatura da função. Se não houver valor para a (o acumulador), criaremos um pedindo o próximo redutor na cadeia para produzi-lo. Eventualmente, chegará ao final do pipeline e (esperançosamente) criará um valor inicial válido para nós.

Lembre-se desta regra: Quando chamado sem argumentos, um redutor deve sempre retornar um valor inicial (vazio) válido para a redução. Geralmente é uma boa idéia obedecer a essa regra para qualquer função de redutor, incluindo redutores para React ou Redux.

Rescisão antecipada

É possível sinalizar para outros transdutores no pipeline que acabamos de reduzir, e eles não devem esperar processar mais nenhum valor. Ao ver um valor reduced , outros transdutores podem decidir parar de adicionar à coleção e o processo de transdução (conforme controlado pela função de step() final) pode decidir parar de enumerar os valores excedentes. O processo de transdução pode fazer mais uma chamada como resultado de receber um valor reduced : A chamada de conclusão mencionada acima. Podemos sinalizar essa intenção com um valor especial de acumulador reduzido.

O que é um valor reduzido? Pode ser tão simples quanto envolver o valor do acumulador em um tipo especial chamado reduced . Pense nisso como embrulhar um pacote em uma caixa e rotular a caixa com mensagens como "Express" ou "Fragile". Os wrappers de metadados como esse são comuns na computação. Por exemplo: mensagens http são agrupadas em contêineres chamados "request" ou "response", e esses tipos de contêiner possuem cabeçalhos que fornecem informações como códigos de status, comprimento de mensagem esperado, parâmetros de autorização, etc …

Basicamente, é uma maneira de enviar várias mensagens onde apenas um único valor é esperado. Um exemplo mínimo (não padrão) de um elevador de tipo reduced() pode ser assim:

 const reduzida = v => ({ 
obter isReduced () {
retorno verdadeiro;
}
valueOf: () => v,
toString: () => `Reduzido ($ {JSON.stringify (v)})`
});

As únicas partes estritamente necessárias são:

  • O tipo de elevador: Uma maneira de obter o valor dentro do tipo (por exemplo, a função reduced , neste caso)
  • Identificação de tipo: uma maneira de testar o valor para ver se é um valor reduced (por exemplo, o getter isReduced )
  • Extração de valor: uma maneira de recuperar o valor do tipo (por exemplo, valueOf() )

toString() está incluído aqui estritamente para facilitar a depuração. Ele permite que você examine o tipo e o valor ao mesmo tempo no console.

Conclusão

“Na etapa de conclusão, um transdutor com estado de redução deve liberar o estado antes de chamar a função de conclusão do transformador aninhado, a menos que tenha visto anteriormente um valor reduzido da etapa aninhada, caso em que o estado pendente deve ser descartado.” ~ Documentação dos transdutores Clojure

Em outras palavras, se você tiver mais estado para liberar depois que a função anterior tiver sinalizado que ela acabou de ser reduzida, a etapa de conclusão é a hora de lidar com isso. Nesta fase, você pode opcionalmente:

  • Enviar mais um valor (liberar seu estado pendente)
  • Descarte seu estado pendente
  • Execute qualquer limpeza de estado necessária

Transdução

É possível fazer a transdução de vários tipos diferentes de dados, mas o processo pode ser generalizado:

 // importar um caril padrão ou usar essa mágica: 
const curry = (
f, arr = []
) => (... args) => (
a => a.length === f.length?
f (... a):
caril (f, a)
) ([... arr, ... args]);
 const transduce = curry ((passo, inicial, xform, dobrável) => 
foldable.reduce (xform (step), inicial)
);

A função de transduce() executa uma função de etapa (a etapa final no pipeline do transdutor), um valor inicial para o acumulador, um transdutor e um dobrável. Um dobrável é qualquer objeto que fornece um método .reduce() .

Com o transduce() definido, podemos facilmente criar uma função que transpõe para um array. Primeiro, precisamos de um redutor que reduza a um array:

 const concatArray = (a, c) => a.concat ([c]); 

Agora podemos usar o transduce() curry transduce() para criar um aplicativo parcial que transpõe para matrizes:

 const toArray = transduce (concatArray, []); 

Com toArray() podemos substituir duas linhas de código por uma, e reutilizá-la em muitas outras situações, além de:

 // Manual transduce: 
const xform = doubleEvens (arrayConcat);
resultado const = [1,2,3,4,5,6] .reduce (xform, []);
// => [4, 8, 12]
 // Automatic transduce: 
const result2 = toArray (doubleEvens, [1,2,3,4,5,6]);
console.log (resultado2); // [4, 8, 12]

O Protocolo Transdutor

Até este ponto, tenho escondido alguns detalhes atrás de uma cortina, mas é hora de dar uma olhada neles agora. Transdutores não são realmente uma única função. Eles são feitos de 3 funções diferentes. Clojure alterna entre eles usando correspondência de padrões na aridade da função.

Na ciência da computação, a aridade de uma função é o número de argumentos que uma função assume. No caso dos transdutores, existem dois argumentos para a função redutor, o acumulador e o valor atual. No Clojure, Both são opcionais e o comportamento é alterado com base no fato de os argumentos serem ou não passados. Se um parâmetro não é passado, o tipo desse parâmetro dentro da função é undefined .

O protocolo do transdutor JavaScript lida com as coisas de maneira um pouco diferente. Em vez de usar arity de função, os transdutores de JavaScript são uma função que pega um transdutor e retorna um transdutor. O transdutor é um objeto com três métodos:

  • init Retorna um valor inicial válido para o acumulador (geralmente, basta chamar o próximo step() ).
  • step Aplique a transformação, por exemplo, para map(f) : step(accumulator, f(current)) .
  • result Se um transdutor for chamado sem um novo valor, ele deve manipular sua etapa de conclusão (geralmente a step(a) , a menos que o transdutor seja stateful).

Nota: O protocolo do transdutor em JavaScript usa @@transducer/init , @@transducer/step e @@transducer/result , respectivamente.

Algumas bibliotecas fornecem um utilitário transducer() que envolve automaticamente seu transdutor para você.

Aqui está uma implementação menos ingênua do transdutor de mapa:

 mapa const = f => next => transdutor ({ 
init: () => next.init (),
resultado: a => próximo.resultado (a),
passo: (a, c) => next.step (a, f (c))
});

Por padrão, a maioria dos transdutores deve passar a chamada init() para o próximo transdutor no pipeline, porque não sabemos o tipo de dados de transporte, portanto não podemos produzir um valor inicial válido para ele.

Além disso, o objeto reduced especial usa essas propriedades (também namespaced @@transducer/<name> no protocolo do transdutor:

  • reduced Um valor booleano que é sempre true para valores reduzidos.
  • value O value reduzido.

Conclusão

Os transdutores são redutores de ordem superior compostos que podem reduzir qualquer tipo de dados subjacente.

Os transdutores produzem código que pode ser de ordem de grandeza mais eficiente do que o encadeamento de pontos com arrays e manipula conjuntos de dados potencialmente infinitos sem criar agregações intermediárias.

Nota: Os transdutores nem sempre são mais rápidos do que os métodos de matriz incorporados. Os benefícios de desempenho tendem a se manifestar quando o conjunto de dados é muito grande (centenas de milhares de itens) ou os pipelines são muito grandes (aumentando significativamente o número de iterações necessárias usando cadeias de métodos). Se você está após os benefícios de desempenho, lembre-se de perfil.

Dê uma outra olhada no exemplo da introdução. Você deve ser capaz de construir filter() , map() e toArray() usando o código de exemplo como referência e fazer este código funcionar:

 amigos constantes = [ 
{id: 1, nome: "Sting", nearMe: true},
{id: 2, nome: "Radiohead", nearMe: true},
{id: 3, nome: 'NIN', nearMe: false},
{id: 4, nome: "Echo", nearMe: true},
{id: 5, nome: "Zeppelin", nearMe: false}
];
 const isNearMe = ({nearMe}) => nearMe; 
 const getName = ({name}) => nome; 
 const getFriendsNearMe = compor ( 
filtro (isNearMe),
map (getName)
);
 const results2 = toArray (getFriendsNearMe, amigos); 

Na produção, você pode usar transdutores de Ramda , RxJS , transducers-js ou Mori .

Todos esses trabalhos funcionam de forma um pouco diferente do código de exemplo aqui, mas seguem todos os mesmos princípios fundamentais.

Aqui está um exemplo da Ramda:

 importar { 
compor,
filtro,
mapa,
para dentro
} de 'ramda';
 const isEven = n => n% 2 === 0; 
const double = n => n * 2;
 const doubleEvens = compor ( 
filtro (isEven),
mapa (duplo)
);
 const arr = [1, 2, 3, 4, 5, 6]; 
 // em = (estrutura, transdutor, dados) => resultado 
// para converter os dados usando o fornecido
// transdutor na estrutura passada como o
// primeiro argumento.
resultado const = em ([], doubleEvens, arr);
 console.log (resultado); // [4, 8, 12] 

Sempre que preciso combinar várias operações, como map , filter , chunk , take e assim por diante, eu procuro transdutores para otimizar o processo e manter o código legível e limpo. Dê-lhes uma chance.

Saiba mais em EricElliottJS.com

Aulas de vídeo sobre programação funcional estão disponíveis para os membros do EricElliottJS.com. Se você não é um membro, inscreva-se hoje .