Princípios de programação funcional em JavaScript

O autor do material, cuja tradução publicamos hoje, diz que, depois de muito tempo envolvido em programação orientada a objetos, pensou na complexidade dos sistemas. Segundo John Ousterhout , complexidade é tudo o que dificulta a compreensão ou modificação de software. O autor deste artigo, após concluir algumas pesquisas, descobriu os conceitos de programação funcional como imunidade e funções puras. O uso de tais conceitos permite criar funções que não têm efeitos colaterais. O uso dessas funções simplifica o suporte ao sistema e oferece ao programador outros benefícios .

imagem

Aqui falamos sobre programação funcional e alguns de seus princípios importantes. Tudo isso será ilustrado por muitos exemplos de código JavaScript.

O que é programação funcional?


Você pode ler sobre o que é programação funcional na Wikipedia . Nomeadamente, estamos falando do fato de que a programação funcional é um paradigma de programação no qual o processo de cálculo é tratado como o cálculo dos valores das funções no entendimento matemático deste último. A programação funcional envolve o cálculo dos resultados das funções a partir dos dados de origem e os resultados de outras funções, e não implica armazenamento explícito do estado do programa. Consequentemente, isso não implica a variabilidade desse estado.

Agora, a título de exemplo, analisaremos algumas idéias de programação funcional.

Funções puras


Funções puras são o primeiro conceito fundamental que precisa ser estudado para entender a essência da programação funcional.

O que é uma "função pura"? O que torna uma função "limpa"? Uma função pura deve atender aos seguintes requisitos:

  • Sempre retorna, ao passar os mesmos argumentos, o mesmo resultado (essas funções também são chamadas determinísticas).
  • Essa função não tem efeitos colaterais.

Considere a primeira propriedade das funções puras, a saber, o fato de que, ao passarem os mesmos argumentos, sempre retornam o mesmo resultado.

Arguments Argumentos de função e valores de retorno


Imagine que precisamos criar uma função que calcule a área de um círculo. Uma função que não está limpa deve tomar como parâmetro o raio do círculo ( radius ), após o qual retornaria o valor do cálculo da expressão radius * radius * PI :

 const PI = 3.14; function calculateArea(radius) { return radius * radius * PI; } calculateArea(10); //  314 

Por que essa função não pode ser chamada pura? O fato é que ele usa uma constante global, que não é passada a ela como argumento.

Agora imagine que alguns matemáticos chegaram à conclusão de que o valor da PI constante deveria ser o número 42 , pelo qual o valor dessa constante foi alterado.

Agora, uma função que não é pura, quando é passado o mesmo valor de entrada, o número 10 , retornará o valor 10 * 10 * 42 = 4200 . Acontece que, usando aqui o mesmo que no exemplo anterior, o valor do parâmetro radius , a função retorna um resultado diferente. Vamos consertar isso:

 const PI = 3.14; function calculateArea(radius, pi) { return radius * radius * pi; } calculateArea(10, PI); //  314 

Agora, ao chamar essa função, sempre passaremos o argumento pi . Como resultado, a função funcionará apenas com o que é passado quando chamada, sem recorrer a entidades globais. Se analisarmos o comportamento dessa função, podemos chegar às seguintes conclusões:

  • Se as funções passarem um argumento de radius de 10 e pi 3.14 , sempre retornará o mesmo resultado - 314 .
  • Quando chamado com um argumento de radius de 10 e pi de 42 , ele sempre retornará 4200 .

Lendo arquivos


Se nossa função ler arquivos, ela não ficará limpa. O fato é que o conteúdo dos arquivos pode mudar.

 function charactersCounter(text) { return `Character count: ${text.length}`; } function analyzeFile(filename) { let fileContent = open(filename); return charactersCounter(fileContent); } 

Geração de números aleatórios


Qualquer função que depende de um gerador de números aleatórios não pode ser pura.

 function yearEndEvaluation() { if (Math.random() > 0.5) {   return "You get a raise!"; } else {   return "Better luck next year!"; } } 

Agora vamos falar sobre efeitos colaterais.

Effects efeitos colaterais


Um exemplo de efeito colateral que pode ocorrer quando uma função é chamada é a modificação de variáveis ​​globais ou argumentos passados ​​para funções por referência.

Suponha que precisamos criar uma função que use um número inteiro e aumente esse número em 1. Veja como pode ser a implementação de uma idéia semelhante:

 let counter = 1; function increaseCounter(value) { counter = value + 1; } increaseCounter(counter); console.log(counter); // 2 

Existe um counter variável global. Nossa função, que não é pura, recebe esse valor como argumento e o substitui, adicionando um ao seu valor anterior.

A variável global está mudando; similar em programação funcional não é bem-vinda.

No nosso caso, o valor da variável global é modificado. Como tornar a função raiseCounter increaseCounter() limpa nessas condições? De fato, é muito simples:

 let counter = 1; function increaseCounter(value) { return value + 1; } increaseCounter(counter); // 2 console.log(counter); // 1 

Como você pode ver, a função retorna 2 , mas o valor do counter variáveis ​​globais não muda. Aqui podemos concluir que a função retorna o valor passado a ela, aumentado em 1 , sem alterar nada.

Se você seguir as duas regras acima para escrever funções puras, isso facilitará a navegação nos programas criados usando essas funções. Acontece que cada função será isolada e não afetará as partes do programa externas a ela.

As funções puras são estáveis, consistentes e previsíveis. Recebendo os mesmos dados de entrada, essas funções sempre retornam o mesmo resultado. Isso evita que o programador tente levar em consideração a possibilidade de situações nas quais a transferência de funções dos mesmos parâmetros leva a resultados diferentes, pois isso é simplesmente impossível com funções puras.

▍ Pontos fortes de funções puras


Entre os pontos fortes das funções puras está o fato de o código escrito usá-las ser mais fácil de testar. Em particular, você não precisa criar nenhum objeto stub. Isso permite o teste unitário de funções puras em vários contextos:

  • Se o parâmetro A for passado para a função, o valor de retorno de B é esperado.
  • Se o parâmetro C for passado para a função, o valor de retorno de D é esperado.

Como um exemplo simples dessa idéia, podemos fornecer uma função que recebe uma matriz de números, e espera-se que ela aumente em um a cada número dessa matriz, retornando uma nova matriz com os resultados:

 let list = [1, 2, 3, 4, 5]; function incrementNumbers(list) { return list.map(number => number + 1); } 

Aqui passamos uma matriz de números para a função, após a qual usamos o método da matriz map() , que nos permite modificar cada elemento da matriz e formar uma nova matriz retornada pela função. Chamamos a função passando uma matriz de list :

 incrementNumbers(list); //  [2, 3, 4, 5, 6] 

A partir dessa função, espera-se que, tendo aceito uma matriz do formato [1, 2, 3, 4, 5] , ela retorne uma nova matriz [2, 3, 4, 5, 6] . É assim que funciona.

Imunidade


A imunidade de uma determinada entidade pode ser descrita como o fato de não mudar ao longo do tempo ou como a impossibilidade de alterar essa entidade.

Se eles tentarem alterar um objeto imutável, isso não terá êxito. Em vez disso, você precisará criar um novo objeto contendo os novos valores.

Por exemplo, o JavaScript geralmente usa o loop for . No decorrer de seu trabalho, como mostrado abaixo, variáveis ​​mutáveis ​​são usadas:

 var values = [1, 2, 3, 4, 5]; var sumOfValues = 0; for (var i = 0; i < values.length; i++) { sumOfValues += values[i]; } sumOfValues // 15 

A cada iteração do loop, o valor da variável i o valor da variável global (pode ser considerado o estado do programa) sumOfValues . Como em tal situação para manter a imutabilidade das entidades? A resposta está no uso da recursão.

 let list = [1, 2, 3, 4, 5]; let accumulator = 0; function sum(list, accumulator) { if (list.length == 0) {   return accumulator; } return sum(list.slice(1), accumulator + list[0]); } sum(list, accumulator); // 15 list; // [1, 2, 3, 4, 5] accumulator; // 0 

Existe uma função sum() , que recebe uma matriz de números. Essa função se autodenomina até que a matriz esteja vazia (este é o caso básico do nosso algoritmo recursivo ). Em cada "iteração", adicionamos o valor de um dos elementos da matriz ao parâmetro da função accumulator , sem afetar o accumulator variável global. Nesse caso, a list variáveis ​​globais e o accumulator permanecem inalterados; os mesmos valores são armazenados neles antes e após a chamada da função.

Note-se que, para implementar esse algoritmo, você pode usar o método de matriz de reduce . Falaremos sobre isso abaixo.

Na programação, a tarefa é generalizada quando é necessário, com base em um determinado modelo de um objeto, criar sua representação final. Imagine que temos uma sequência que precisa ser convertida em uma exibição adequada para uso como parte da URL que leva a um determinado recurso.

Se resolvermos esse problema usando Ruby e usando os princípios do OOP, primeiro criaremos uma classe, digamos, chamando-a de UrlSlugify , e depois criaremos um método para essa classe slugify! que é usado para converter a string.

 class UrlSlugify attr_reader :text def initialize(text)   @text = text end def slugify!   text.downcase!   text.strip!   text.gsub!(' ', '-') end end UrlSlugify.new(' I will be a url slug   ').slugify! # "i-will-be-a-url-slug" 

Nós implementamos o algoritmo, e isso é maravilhoso. Aqui vemos uma abordagem imperativa da programação, quando nós, processando a linha, pintamos cada etapa de sua transformação. Ou seja, primeiro reduzimos seus caracteres para minúsculas, depois removemos espaços desnecessários e, finalmente, alteramos os espaços restantes no traço.

No entanto, durante essa transformação, ocorre uma mutação no estado do programa.

Você pode lidar com o problema da mutação compondo funções ou encadeando chamadas de funções. Em outras palavras, o resultado retornado pela função será usado como entrada para a próxima função e, portanto, para todas as funções em uma cadeia. Nesse caso, a cadeia original não será alterada.

 let string = " I will be a url slug   "; function slugify(string) { return string.toLowerCase()   .trim()   .split(" ")   .join("-"); } slugify(string); // i-will-be-a-url-slug 

Aqui, usamos as seguintes funções, representadas em JavaScript pelos métodos padrão de string e array:

  • toLowerCase : Converte caracteres de seqüência de caracteres em toLowerCase .
  • trim : remove os espaços em branco do início e do fim de uma linha.
  • split : divide uma string em partes, colocando palavras separadas por espaços em uma matriz.
  • join : forma uma string com palavras separadas por um traço com base em uma matriz com palavras.

Essas quatro funções permitem criar uma função para converter uma sequência que não altera essa sequência em si.

Transparência do link


Crie uma função square() que retorne o resultado da multiplicação de um número pelo mesmo número:

 function square(n) { return n * n; } 

Essa é uma função pura que sempre, pelo mesmo valor de entrada, retornará o mesmo valor de saída.

 square(2); // 4 square(2); // 4 square(2); // 4 // ... 

Por exemplo, não importa quantos números 2 sejam passados, essa função sempre retornará o número 4 . Como resultado, verifica-se que uma chamada do square(2) do formulário square(2) pode ser substituída pelo número 4 . Isso significa que nossa função tem a propriedade de transparência referencial.

Em geral, podemos dizer que, se uma função sempre retorna o mesmo resultado para os mesmos valores de entrada passados, ela possui transparência referencial.

Functions Funções puras + dados imutáveis ​​= transparência referencial


Usando a ideia apresentada no título desta seção, você pode memorizar funções. Suponha que tenhamos uma função como esta:

 function sum(a, b) { return a + b; } 

Chamamos assim:

 sum(3, sum(5, 8)); 

A sum(5, 8) chamada sum(5, 8) sempre dá 13 . Portanto, a chamada acima pode ser reescrita da seguinte maneira:

 sum(3, 13); 

Essa expressão, por sua vez, sempre dá 16 . Como resultado, ele pode ser substituído por uma constante numérica e memorizado .

Funções como Objetos de Primeira Classe


A idéia de perceber funções como objetos da primeira classe é que essas funções podem ser consideradas como valores e trabalhar com elas como dados. Os seguintes recursos das funções podem ser distinguidos:

  • As referências a funções podem ser armazenadas em constantes e variáveis ​​e, através delas, o acesso a funções.
  • As funções podem ser passadas para outras funções como parâmetros.
  • As funções podem ser retornadas de outras funções.

Ou seja, trata-se de considerar funções como valores e tratá-las como dados. Com essa abordagem, você pode combinar várias funções no processo de criação de novas funções que implementam novos recursos.

Imagine que temos uma função que adiciona dois valores numéricos passados ​​a ela, depois os multiplica por 2 e retorna o que acabou:

 function doubleSum(a, b) { return (a + b) * 2; } 

Agora, escrevemos uma função que subtrai o segundo do primeiro valor numérico passado para ele, multiplica o que aconteceu por 2 e retorna o valor calculado:

 function doubleSubtraction(a, b) { return (a - b) * 2; } 

Essas funções têm lógica semelhante, diferem apenas em que tipo de operações são executadas com os números passados ​​para elas. Se pudermos considerar funções como valores e passá-las como argumentos para outras funções, isso significa que podemos criar uma função que aceite e use outra função que descreva os recursos dos cálculos. Essas considerações nos permitem alcançar as seguintes construções:

 function sum(a, b) { return a + b; } function subtraction(a, b) { return a - b; } function doubleOperator(f, a, b) { return f(a, b) * 2; } doubleOperator(sum, 3, 1); // 8 doubleOperator(subtraction, 3, 1); // 4 

Como você pode ver, agora a função doubleOperator() possui um parâmetro f , e a função que ele representa é usada para processar os parâmetros a e b . As funções sum() e substraction() passadas para a função doubleOperator() , na verdade, permitem controlar o comportamento da função doubleOperator() , alterando-a de acordo com a lógica implementada nelas.

Funções de ordem superior


Falando em funções de ordem superior, entendemos funções caracterizadas por pelo menos um dos seguintes recursos:

  • Uma função assume outra função como argumento (pode haver várias dessas funções).
  • A função retorna outra função como resultado de seu trabalho.

Você já deve estar familiarizado com os métodos padrão de matriz JS filter() , map() e reduce() . Vamos conversar sobre eles.

▍ Filtrando matrizes e o método filter ()


Suponha que tenhamos uma certa coleção de elementos que queremos filtrar por algum atributo dos elementos desta coleção e forme uma nova coleção. A função filter() espera receber algum critério para avaliar os elementos, com base no qual determina se deve ou não incluir um elemento na coleção resultante. Esse critério é definido pela função transmitida a ele, que retorna true se a função filter() incluir um elemento na coleção final, caso contrário, retornará false .

Imagine que temos uma matriz de números inteiros e queremos filtrá-la obtendo uma nova matriz que contenha apenas números pares da matriz original.

Abordagem imperativa


Ao aplicar uma abordagem imperativa para resolver esse problema usando JavaScript, precisamos implementar a seguinte sequência de ações:

  • Crie uma matriz vazia para novos elementos (vamos chamá-lo evenNumbers ).
  • Iterar sobre a matriz original de números inteiros (vamos chamá-la de numbers ).
  • Coloque os números pares encontrados na matriz de numbers evenNumbers matriz de numbers evenNumbers .

Aqui está a aparência da implementação desse algoritmo:

 var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; var evenNumbers = []; for (var i = 0; i < numbers.length; i++) { if (numbers[i] % 2 == 0) {   evenNumbers.push(numbers[i]); } } console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10] 

Além disso, podemos escrever uma função (vamos chamá-lo de even() ), que, se o número for par, retorna true e, se for ímpar, retorna false e, em seguida, passa-o para o método de matriz filter() , que, verificando com ela, cada elemento da matriz , formará uma nova matriz contendo apenas números pares:

 function even(number) { return number % 2 == 0; } let listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10] 

A propósito, aqui está a solução para um problema interessante sobre filtragem de matriz , que eu concluí enquanto trabalhava em tarefas de programação funcional no Hacker Rank . Pela condição do problema, era necessário filtrar uma matriz de números inteiros, exibindo apenas os elementos que são menores que um determinado valor de x .

Uma solução imperativa para esse problema no JavaScript pode ser assim:

 var filterArray = function(x, coll) { var resultArray = []; for (var i = 0; i < coll.length; i++) {   if (coll[i] < x) {     resultArray.push(coll[i]);   } } return resultArray; } console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0] 

A essência da abordagem imperativa é que descrevemos a sequência de ações executadas pela função. Ou seja, descrevemos a pesquisa da matriz, comparando o elemento atual da matriz com x e colocando esse elemento na matriz resultArray se ela passar no teste.

Abordagem declarativa


Como mudar para uma abordagem declarativa para solucionar esse problema e o uso correspondente do método filter() , que é uma função de ordem superior? Por exemplo, pode ser assim:

 function smaller(number) { return number < this; } function filterArray(x, listOfNumbers) { return listOfNumbers.filter(smaller, x); } let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0]; filterArray(3, numbers); // [2, 1, 0] 

Você pode achar incomum usar a this na função smaller() deste exemplo, mas não há nada complicado aqui. A this - this é o segundo argumento para o método filter() . No nosso exemplo, este é o número 3 representado pelo parâmetro x de filterArray() . Este número é indicado por this .

A mesma abordagem pode ser usada se a matriz contiver entidades que possuem uma estrutura bastante complexa, por exemplo, objetos. Suponha que tenhamos uma matriz que armazene objetos contendo os nomes das pessoas representadas pela propriedade name e informações sobre a idade dessas pessoas representadas pela propriedade age . Aqui está a aparência de uma matriz:

 let people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ]; 

Queremos filtrar essa matriz selecionando nela apenas os objetos que são pessoas cuja idade excedeu 21 anos. Veja como resolver esse problema:

 function olderThan21(person) { return person.age > 21; } function overAge(people) { return people.filter(olderThan21); } overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }] 

Aqui temos uma matriz com objetos representando pessoas. Verificamos os elementos dessa matriz usando a função olderThan21() . Nesse caso, ao verificar, nos referimos à propriedade age de cada elemento, verificando se o valor dessa propriedade excede 21 . Passamos essa função para o método filter() , que filtra a matriz.

▍ Processando elementos da matriz e o método map ()


O método map() é usado para converter elementos da matriz. Ele aplica a função passada a cada elemento da matriz e cria uma nova matriz que consiste nos elementos alterados.

Vamos continuar os experimentos com a matriz de people que você já conhece. Agora não vamos filtrar esse array com base na propriedade dos objetos age . Precisamos criar com base em uma lista de linhas no formato TK is 26 years old . Nessa abordagem, as linhas nas quais os elementos se transformam serão construídas de acordo com o modelo p.name is p.age years old , em que p.name e p.age são os valores das propriedades correspondentes dos elementos da matriz de people .

Uma abordagem imperativa para resolver esse problema no JavaScript é semelhante a esta:

 var people = [ { name: "TK", age: 26 }, { name: "Kaio", age: 10 }, { name: "Kazumi", age: 30 } ]; var peopleSentences = []; for (var i = 0; i < people.length; i++) { var sentence = people[i].name + " is " + people[i].age + " years old"; peopleSentences.push(sentence); } console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old'] 

Se você recorrer a uma abordagem declarativa, obterá o seguinte:

 function makeSentence(person) { return `${person.name} is ${person.age} years old`; } function peopleSentences(people) { return people.map(makeSentence); } peopleSentences(people); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old'] 

De fato, a idéia principal aqui é que você precisa fazer algo com cada elemento da matriz original e, em seguida, colocá-lo em uma nova matriz.

Aqui está outra tarefa com o Hacker Rank, que é dedicado à atualização da lista . Ou seja, estamos falando sobre alterar os valores dos elementos de uma matriz numérica existente para seus valores absolutos. Assim, por exemplo, ao processar uma matriz [1, 2, 3, -4, 5] ela assumirá a forma [1, 2, 3, 4, 5] uma vez que o valor absoluto de -4 é 4 .

Aqui está um exemplo de uma solução simples para esse problema, quando iteramos sobre uma matriz e alteramos os valores de seus elementos para seus valores absolutos.

 var values = [1, 2, 3, -4, 5]; for (var i = 0; i < values.length; i++) { values[i] = Math.abs(values[i]); } console.log(values); // [1, 2, 3, 4, 5] 

Aqui, para converter os valores dos elementos da matriz, o método Math.abs() é usado, os elementos alterados são gravados no mesmo local em que estavam antes da conversão.

.

, , , . . , , , .

, , map() . ?

, abs() , , .

 Math.abs(-1); // 1 Math.abs(1); // 1 Math.abs(-2); // 2 Math.abs(2); // 2 

, , .

, , Math.abs() map() . , ? map() . :

 let values = [1, 2, 3, -4, 5]; function updateListMap(values) { return values.map(Math.abs); } updateListMap(values); // [1, 2, 3, 4, 5] 

, , , , , .

▍ reduce()


reduce() .

. , -. Product 1 , Product 2 , Product 3 Product 4 . .

, . Por exemplo, pode ser assim:

 var orders = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; var totalAmount = 0; for (var i = 0; i < orders.length; i++) { totalAmount += orders[i].amount; } console.log(totalAmount); // 120 

reduce() , ( sumAmount() ), , reduce() :

 let shoppingCart = [ { productTitle: "Product 1", amount: 10 }, { productTitle: "Product 2", amount: 30 }, { productTitle: "Product 3", amount: 20 }, { productTitle: "Product 4", amount: 60 } ]; const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount; function getTotalAmount(shoppingCart) { return shoppingCart.reduce(sumAmount, 0); } getTotalAmount(shoppingCart); // 120 

shoppingCart , , sumAmount() , ( order , amount ), — currentTotalAmount .

reduce() , getTotalAmount() , sumAmount() , 0 .

map() reduce() . «»? , map() shoppingCart , amount , reduce() sumAmount() . :

 const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart   .map(getAmount)   .reduce(sumAmount, 0); } getTotalAmount(shoppingCart); // 120 

getAmount() amount . map() , , , [10, 30, 20, 60] . , reduce() , .

▍ filter(), map() reduce()


, , filter() , map() reduce() . , , .

-. , :

 let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ] 

. :

  • type , , books .
  • , .
  • .

, :

 let shoppingCart = [ { productTitle: "Functional Programming", type: "books", amount: 10 }, { productTitle: "Kindle", type: "eletronics", amount: 30 }, { productTitle: "Shoes", type: "fashion", amount: 20 }, { productTitle: "Clean Code", type: "books", amount: 60 } ] const byBooks = (order) => order.type == "books"; const getAmount = (order) => order.amount; const sumAmount = (acc, amount) => acc + amount; function getTotalAmount(shoppingCart) { return shoppingCart   .filter(byBooks)   .map(getAmount)   .reduce(sumAmount, 0); } getTotalAmount(shoppingCart); // 70 

Sumário


JavaScript-. , .

Caros leitores! ?



Source: https://habr.com/ru/post/pt434112/


All Articles