No décimo dia do Advent of Code de 2022 tem um problema interessante para discutir sobre divisão de responsabilidades de funções geradoras.
O problema do dia 10
O problema do dia 10, "tubo de raios catódicos", consiste em implementar um emulador simples. Na parte 1 deve ser feito a decodificação e execução de instruções de um processador, enquanto na parte 2 deve-se implementar uma saída de vídeo. Recomendo tentar resolvê-lo primeiro.
Resolução da parte 1
A parte 1 pede para ler as instruções da entrada, executá-las considerando a quantidade de ciclos que cada tipo de instrução leva, e em determinados ciclos fazer um calculo para obter a resposta, guardando o resultado em um acumulador. Nessa descrição podem ser identificado três responsabilidades diferentes: ler e tratar as instruções da entrada; controlar a execução das instruções no processador e seus ciclos necessários; e controlar o ciclo atual, fazendo o cálculo do valor desejado. Uma forma de escrever funções separando essas responsabilidades é utilizando geradores do Python, que como eles permitem executar um trecho de código, parar e voltar depois, isso permite com que cada função foque apenas na sua responsabilidade, sem precisar misturá-las.
A função para tratar a entrada pode ser feita como um gerador que lê um arquivo e retorna cada instrução com seus valores já convertidos (quando a instrução possui valores extras). No caso da instrução noop
, seria apenas a informação que é uma instrução NOOP. Enquanto a instrução addx
tem também um valor inteiro a ser somado no registrador x
. Segue um exemplo onde cada valor retornado pelo gerador é uma lista contendo a instrução na primeira posição e o inteiro associado a ela, quando presente, na segunda posição:
def ler_instrucoes(entrada):
for linha in entrada:
instrucao = linha.strip().split(' ')
if len(instrucao) >= 2:
instrucao[1] = int(instrucao[1])
yield instrucao
Esse gerador permite com que a entrada possa ser carregada e tradada conforme as instruções forem sendo executadas, não sendo necessário carregar tudo para a memória, nem tratar toda a entrada primeiro, antes de seguir para a próxima parte do problema. Além de simplificar o processo para quem for executar as operações, onde não será necessário se preocupar com conversão de tipos, por exemplo.
A execução das instruções também pode ser feita através de um gerador. Como um gerador permite executar um trecho de código e parar, isso pode ser utilizado para simular os ciclos do processador, onde cada valor retornado pelo gerador (yield
) representar o estado no processador naquele ciclo, que nesse problema se resume ao valor do registrador x
, que pode ser implementado como uma variável local da função geradora. Segue um exemplo de código:
def processador(instrucoes):
x = 1
for instrucao in instrucoes:
if instrucao[0] == 'noop':
yield x
elif instrucao[0] == 'addx':
yield x
yield x
x += instrucao[1]
Essa função itera sobre um conjunto de instruções recebida como argumento (gerador apresentado anteriormente) e executa essas instruções. No caso de instruções noop
, nada é feito, porém o yield x
faz com que o gerador pare nesse ponto, sendo necessário a leitura do próximo valor do gerador do processador para a continuação da execução desse código, simulando o ciclo do processador. Já as instruções addx
, como possuem dois yield x
, faz com que seja necessário mais leitura de valores para que finalmente o valor da instrução seja adicionado ao registrador x
, simulando o processador parado dois ciclos, para só no final deles a instrução ser completada.
Com a decodificação das instruções e o processador feitos, basta instanciar um gerador da função ler_instrucoes
e um da função processador
, passando o primeiro como argumento para o segundo. Como o segundo gerador é o responsável por iterar sobre o primeiro (ele faz um for
nesse iterador), basta iterar sobre o segundo para simular os ciclos e execução das instruções, e ao utilizar um enumerate
é possível saber em que ciclo a execução se encontra e realizar as operações desejadas para se calcular o valor pedido pelo problema. Segue um exemplo de código:
p = processador(ler_instrucoes(open('entrada.txt')))
total = 0
for i, x in enumerate(p, start=1):
if (i - 20) % 40 == 0:
total += i * x
if i == 220:
break
print(total)
Resolução da parte 2
Entendido como decodificar as instruções e controlar os ciclos para a execução das mesmas, a parte 2 pede para simular uma tela de tubo de raios catódicos. Essa tela possui algumas regras, como mostrar um pixel por vez, e a cada 40 pixeis, ir para a linha a baixo, reiniciando o processo. Isso também pode ser implementado como um gerador, encapsulando essas regras, porém em vez de retornar valores, ele deve receber valores, o que também é chamado de consumidor, mas tem a mesma sintaxe, com a diferença que em vez de usar yield x
para retornar um valor x
, é usado x = yield
para guardar numa variável o valor recebido, e ainda continua possuindo a função de parar a execução do código, nesse caso aguardando o próximo valor. Segue um exemplo de implementação:
def crt():
while True:
for i in range(40):
x = yield
if x - 1 <= i <= x + 1:
print('\u2588', end='')
else:
print(' ', end='')
print()
Essa função é um loop infinito, que percorre 40 valores imprimindo na tela um bloco preenchido ou um espaço (fiz essa mudança uma vez que facilitou a visualização do resultado), conforme o valor do registrador x
recebido, e após isso faz uma quebra de linha.
Como as funções ler_instrucoes
e processador
já estão implementadas, e não é necessário nenhuma alteração em suas lógicas, basta acoplar o consumidor crt
com elas para ter o resultado desejado. Porém como ao criar um gerador, nenhum código do mesmo é executado até que seu primeiro valor seja lido, é necessário instanciar o consumidor crt
e fazer uma primeira execução para que o mesmo pare no yield
e esteja pronto para receber o primeiro valor, o que pode ser feito utilizando a função next
, assim como é possível fazer para recuperar um valor do gerador. Segue um exemplo dessa implementação:
tela = crt()
next(tela)
for x in processador(ler_instrucoes(open('entrada.txt'))):
tela.send(x)
Considerações
A utilização de geradores permitiu a implementação do código separando responsabilidades em diferentes funções, além de encapsular regras de controle do processador numa função e da tela em outra, sem misturar as duas coisas, deixando-as desacopladas e que poderiam ser utilizadas separadamente. E embora as lógicas estejam separadas, a execução intercala elas, como se o código fosse um loop que lê uma instrução, executado ela e mostrado na tela o caractere resultante, para então repetir essas ações, o que permite ser eficiente em relação ao uso de memória, como já comentado anteriormente nessa série.
Geradores também são a forma como corrotinas ou funções assíncronas eram feitas em Python antes deles entrarem como um recurso da linguagem, recomendo a série "Geradores e uma Introdução histórica à corrotinas com Python" do Dunossauro sobre o assunto.
Top comments (0)