Programação eficaz. Parte 1: iteradores e geradores

Javascript é atualmente a linguagem de programação mais popular de acordo com as versões de muitos sites (por exemplo, Github). Ao mesmo tempo, é a linguagem mais avançada ou favorita? Ele carece de construções que são partes integrantes de outras linguagens: uma extensa biblioteca padrão, imutabilidade, macros. Mas há um detalhe que, na minha opinião, não recebe atenção suficiente - os geradores.



Além disso, é oferecido ao leitor um artigo que, no caso de uma resposta positiva, pode evoluir para um ciclo. Se eu escrever este ciclo com sucesso e o Reader o tiver dominado com sucesso, ficará claro sobre o código a seguir, não apenas o que ele faz, mas também como funciona nos bastidores:



while (true) {
    const data = yield getNextChunk(); //   
    const processed = processData(data);
    try {
        yield sendProcessedData(processed);
        showOkResult();
    } catch (err) {
        showError();
    }
}


Esta é a primeira parte do piloto: Iteradores e Geradores.



Iteradores



Portanto, um iterador é uma interface que fornece acesso sequencial aos dados.



Como você pode ver, a definição não diz nada sobre dados ou estruturas de memória. Na verdade, uma sequência de s indefinidos pode ser representada como um iterador sem ocupar nenhum espaço de memória.



Sugiro ao leitor que responda à pergunta: um array é um iterador?



Responda
. shift pop .



Por que, então, os iteradores são necessários se um array, uma das estruturas básicas da linguagem, permite que você trabalhe com dados sequencialmente e em ordem arbitrária?



Vamos imaginar que precisamos de um iterador que implemente uma sequência de números naturais. Ou números de Fibonacci. Ou qualquer outra sequência infinita . É difícil colocar uma sequência infinita em um array, você precisa de um mecanismo para preencher gradativamente o array com dados, bem como remover dados antigos para não preencher toda a memória do processo. Essa é uma complicação desnecessária, que traz consigo uma complexidade adicional de implementação e suporte, apesar do fato de que uma solução sem uma matriz pode caber em várias linhas:



const getNaturalRow = () => {
    let current = 0;
    return () => ++current;
};


Além disso, um iterador pode representar o recebimento de dados de um canal externo, por exemplo, um websocket.



Em javascript, um iterador é qualquer objeto que possui um método next () que retorna uma estrutura com o valor dos campos - o valor atual do iterador e concluído - um sinalizador que indica o fim da sequência (esta convenção é descrita no padrão de linguagem ECMAScript ). Esse objeto implementa a interface Iterator. Vamos reescrever o exemplo anterior neste formato:



const getNaturalRow = () => ({
    _current: 0,
    next() { return {
        value: ++this._current,
        done: false,
    }},
});


Javascript também possui uma interface Iterable, que é um objeto que possui um método @@ iterator (esta constante está disponível como Symbol.iterator) que retorna um iterador. Para objetos que implementam tal interface, a travessia do operador está disponível for..of. Vamos reescrever nosso exemplo mais uma vez, só que desta vez como uma implementação Iterable:



const naturalRowIterator = {
    [Symbol.iterator]: () => ({
        _current: 0,
        next() { return {
            value: ++this._current,
            done: this._current > 3,
       }},
   }),
}

for (num of naturalRowIterator) {
    console.log(num);
}
// : 1, 2, 3


Como você pode ver, tivemos que fazer com que o sinalizador concluído em algum ponto se tornasse positivo, caso contrário, o loop seria infinito.



Geradores



Os geradores se tornaram o próximo estágio na evolução dos iteradores. Eles fornecem açúcar sintático para retornar valores de iterador como um valor de função. Um gerador é uma função (declarada com um asterisco: função * ) que retorna um iterador. Nesse caso, o iterador não é retornado explicitamente, as funções retornam apenas os valores do iterador usando a instrução yield . Quando a função termina sua execução, o iterador é considerado completo (os resultados das chamadas subsequentes para o próximo método terão o sinalizador feito igual a verdadeiro)



function* naturalRowGenerator() {
    let current = 1;
    while (current <= 3) {
        yield current;
        current++;
    }
}

for (num of naturalRowGenerator()) {
    console.log(num);
}
// : 1, 2, 3


Já neste exemplo simples, a principal nuance dos geradores é visível a olho nu: o código dentro da função do gerador não é executado de forma síncrona . O código do gerador é executado em etapas, como resultado de chamadas para next () no iterador correspondente. Vamos ver como o código do gerador é executado no exemplo anterior. Usaremos um cursor especial para marcar onde o gerador parou.



Quando naturalRowGenerator é chamado, um iterador é criado.



function* naturalRowGenerator() {let current = 1;
    while (current <= 3) {
        yield current;
        current++;
    }
}


Além disso, quando chamamos o próximo método pelas três primeiras vezes, ou, em nosso caso, iteramos através do loop, o cursor é posicionado após a instrução de rendimento.



function* naturalRowGenerator() {
    let current = 1;
    while (current <= 3) {
        yield current; ▷
        current++;
    }
}


E para todas as chamadas subsequentes para next e depois de sair do loop, o gerador termina sua execução e, os resultados da chamada de next serão { value: undefined, done: true }



Passando parâmetros para um iterador



Vamos imaginar que precisamos adicionar a capacidade de zerar o contador atual e começar a contar do início ao nosso iterador de números naturais.



naturalRowIterator.next() // 1
naturalRowIterator.next() // 2
naturalRowIterator.next(true) // 1
naturalRowIterator.next() // 2


É claro como lidar com esse parâmetro em um iterador autoescrito, mas e os geradores?

Acontece que os geradores suportam a passagem de parâmetros!



function* naturalRowGenerator() {
    let current = 1;
    while (true) {
        const reset = yield current;
        if (reset) {
          current = 1;
        } else {
          current++;
        }
    }
}


O parâmetro passado é disponibilizado como resultado da declaração de rendimento. Vamos tentar adicionar clareza com uma abordagem de cursor. Quando o iterador foi criado, nada mudou. Isso é seguido pela primeira chamada para o método next ():



function* naturalRowGenerator() {
    let current = 1;
    while (true) {
        const reset = ▷yield current;
        if (reset) {
          current = 1;
        } else {
          current++;
        }
    }
}


O cursor congelou no momento em que retornou da declaração de rendimento. Na próxima chamada para next, o valor passado para a função definirá o valor da variável de reset. Para onde vai o valor passado na primeira chamada para a próxima, uma vez que ainda não houve uma chamada para render? Lugar algum! Ele vai se dissolver na vastidão do coletor de lixo. Se você precisar passar algum valor inicial para o gerador, isso pode ser feito usando os argumentos do próprio gerador. Exemplo:



function* naturalRowGenerator(start = 1) {
    let current = start;
    while (true) {
        const reset = yield current;
        if (reset) {
          current = start;
        } else {
          current++;
        }
    }
}

const iterator = naturalRowGenerator(10);
iterator.next() // 10
iterator.next() // 11
iterator.next(true) // 10


Conclusão



Discutimos o conceito de iteradores e sua implementação na linguagem javascript. Também estudamos geradores - uma construção sintática para implementar iteradores de maneira conveniente.



Embora eu tenha dado exemplos com sequências numéricas neste artigo, os iteradores de javascript podem fazer muito mais. Eles podem representar qualquer sequência de dados e até mesmo muitas máquinas de estados finitos. No próximo artigo, gostaria de falar sobre como você pode usar geradores para construir processos assíncronos (co-rotinas, goroutines, csp, etc.).



All Articles