LIFE em Python 3
Comecei a aprender Python e escolhi o jogo da vida de Conway como meu primeiro programa. Eu estaria interessado em ler como escrever Python mais idiomática. Além disso, o que me confundiu por algum tempo foi que tudo é passado por referência e a atribuição de uma lista não copia seus valores, mas copia a referência. Portanto, usei a função deepcopy, mas estou pensando que as listas podem ser a escolha errada neste caso. Qual seria a melhor escolha em Python?
""" Implementation of LIFE """
import copy
# PARAMETERS
# Number of generations to simulate
N_GENERATIONS = 10
# Define the field. Dots (.) are dead cells, the letter "o" represents living cells
INITIAL_FIELD = \
"""
...................
...................
...................
...................
.ooooo.ooooo.ooooo.
...................
...................
...................
...................
"""
# FUNCTIONS
def print_field(field_copy, dead_cells=' ', living_cells='x'):
"""Pretty-print the current field."""
field_string = "\n".join(["".join(x) for x in field_copy])
field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)
def get_neighbours(field_copy, x, y):
"""Get all neighbours around a cell with position x and y
and return them in a list."""
n_rows = len(field_copy)
n_cols = len(field_copy[0])
if y == 0:
y_idx = [y, y+1]
elif y == n_rows - 1:
y_idx = [y-1, y]
else:
y_idx = [y-1, y, y+1]
if x == 0:
x_idx = [x, x+1]
elif x == n_cols - 1:
x_idx = [x-1, x]
else:
x_idx = [x-1, x, x+1]
neigbours = [field_copy[row][col] for row in y_idx for col in x_idx if (row, col) != (y, x)]
return neigbours
def count_living_cells(cell_list):
"""Count the living cells."""
accu = 0
for cell in cell_list:
if cell == 'o':
accu = accu + 1
return accu
def update_field(field_copy):
"""Update the field to the next generation."""
new_field = copy.deepcopy(field_copy)
for row in range(len(field_copy)):
for col in range(len(field_copy[0])):
living_neighbours = count_living_cells(get_neighbours(field_copy, col, row))
if living_neighbours < 2 or living_neighbours > 3:
new_field[row][col] = '.'
elif living_neighbours == 3:
new_field[row][col] = 'o'
return new_field
# MAIN
# Convert the initial playfield to an array
field = str.splitlines(INITIAL_FIELD)
field = field[1:] # Getting rid of the empty first element due to the multiline string
field = [list(x) for x in field]
print("Generation 0")
print_field(field)
for generation in range(1, N_GENERATIONS+1):
field = update_field(field)
print(f"Generation {generation}")
print("")
print_field(field)
print("")
Respostas
Acho que sua get_neighborfunção pode ser limpa usando mine max, e fazendo uso de ranges:
def get_neighbours(field_copy, x, y):
"""Get all neighbours around a cell with position x and y
and return them in a list."""
n_rows = len(field_copy)
n_cols = len(field_copy[0])
min_x = max(0, x - 1)
max_x = min(x + 1, n_cols - 1)
min_y = max(0, y - 1)
max_y = min(y + 1, n_rows - 1)
return [field_copy[row][col]
for row in range(min_y, max_y + 1)
for col in range(min_x, max_x + 1)
if (row, col) != (y, x)]
Ainda é bastante longo, mas acaba com todo o ifdespacho confuso para listas de índices embutidas em código. Também desfiz a compreensão da lista em algumas linhas. Sempre que minhas compreensões começam a ficar um pouco longas, eu as divido assim. Acho que ajuda significativamente a legibilidade.
Para
"\n".join(["".join(x) for x in field_copy])
Você não precisa de []:
"\n".join("".join(x) for x in field_copy)
Sem os colchetes, é uma expressão geradora em vez de uma compreensão de lista. Eles são preguiçosos, o que evita que você crie uma lista apenas para alimentá-la join. A diferença aqui não é grande, mas para listas longas que podem economizar memória.
Eu não representaria o tabuleiro como uma lista 2D de cordas. Isso provavelmente usa mais memória do que o necessário e, especialmente com a forma como você a tem agora, você é forçado a lembrar qual string representa o quê. Além disso, você tem dois conjuntos de símbolos de string: um usado internamente para lógica ( 'o'e '.') e o outro para quando você imprime ( ' 'e 'x'). Isso é mais confuso do que precisa ser.
Se você realmente deseja usar strings, deve ter uma constante global no topo que define claramente o que string é:
DEAD_CELL = '.' # At the very top somewhere
ALIVE_CELL = 'o'
. . .
if living_neighbours < 2 or living_neighbours > 3: # Later on in a function
new_field[row][col] = DEAD_CELL
elif living_neighbours == 3:
new_field[row][col] = ALIVE_CELL
Strings como '.'flutuar caem na categoria de "números mágicos": valores que são usados livremente em um programa que não tem um significado autoexplicativo. Se o propósito de um valor não for evidente, armazene-o em uma variável com um nome descritivo para que você e seus leitores saibam exatamente o que está acontecendo no código.
No entanto, pessoalmente, quando escrevo implementações GoL, uso uma lista 1D ou 2D de valores booleanos, ou um conjunto de tuplas representando células vivas. Para as versões de lista booleana, se uma célula está viva, é verdadeira e se está morta é falsa. Para a versão do conjunto, uma célula está viva se estiver no conjunto, caso contrário, está morta.
Eu colocaria todas as coisas no fundo em uma mainfunção. Você nem sempre deseja que tudo isso funcione simplesmente porque carregou o arquivo.
Por uma questão de eficiência, em vez de criar constantemente novas cópias de campo a cada geração, um truque comum é criar duas logo no início e trocá-las a cada geração.
A forma como eu faço isso é que um campo é o write_fielde outro é o read_field. Como os nomes sugerem, todas as gravações acontecem no write_fielde todas as leituras de read_field. Após cada "tique", você simplesmente os troca; read_fieldtorna-se o novo write_fielde write_fieldtorna - se read_field. Isso o livra da deepcopyligação cara uma vez por tick.
Você pode fazer essa troca simplesmente em Python :
write_field, read_field = read_field, write_field
Comentário 1
Não há necessidade de um caso especial para imprimir a Geração 0.
Deixe seu intervalo começar em 0 e imprima antes de atualizar.
for generation in range(N_GENERATIONS+1):
print(f"Generation {generation}")
print("")
print_field(field)
print("")
field = update_field(field)
Comentário 2
Além disso, parece que você está ajustando seu código bastante à maneira INITIAL_FIELDcomo define como uma string de várias linhas, apenas porque fica bem assim na janela de código. Isso está ao contrário.
Em vez disso, você deve defini-lo como uma lista de strings para que não precise fazer linhas de divisão e outras coisas antes de iniciar o programa. Se você ainda quiser torná-lo legível por humanos, pode usar algumas quebras de linha \ (se necessário), mas acho que a sintaxe ficará bem mesmo sem isso.
INITIAL_FIELD = [
"...................",
"...................",
etc
]
Comentário 3
def print_field(field_copy, dead_cells=' ', living_cells='x'):
Essa função aceita dois parâmetros, mas nenhuma chamada para ela os transmite. Portanto, eles são, na verdade, apenas variáveis internas e não devem estar na definição da função.
Comentário 4
field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)
Esta é uma repetição desnecessária e difícil de ler. Eu prefiro encadear essas 3 linhas em uma
print(field_string.replace('.', dead_cells).replace('o', living_cells))
Comentário 5
def count_living_cells(cell_list):
"""Count the living cells."""
accu = 0
for cell in cell_list:
if cell == 'o':
accu = accu + 1
return accu
Isso também ocorre ao contrário, devido à forma como você representa suas células como caracteres e strings.
Acho que seria mais sensato priorizar a lógica de programa simples e deixar as funções de impressão se ajustarem conforme necessário. Se você representar células vivas como o número 1 e células mortas como o número 0, então uma lista de células seria semelhante [0,1,1,0,0,1,0]e esta função poderia ser escrita como
return sum(cell_list)
Na verdade, você nem precisaria mais de uma função, já que é tão curta.
Em sua função de impressão, você pode substituir 1 por algum outro caractere e 0 por algum outro caractere antes de imprimir.
O código que você postou oferece um bom exemplo dos benefícios que podem fluir na sequência de um maior investimento inicial em consistência conceitual e de nomenclatura. Conforme escrito, o código tem duas maneiras diferentes de representar células vivas ou mortas, ele alterna entre a linguagem de linhas / colunas e a linguagem de coordenadas x / y, e alterna entre fielde field_copy.
Quando você atinge esse ponto no desenvolvimento de um programa, é útil dar um passo atrás e se comprometer com alguma consistência. Por exemplo:
field : list of rows
row : list of cells
cell : either 'x' (alive) or space (dead)
r : row index
c : column index
E também vamos começar em uma base sólida, colocando todo o código em funções, adicionando um pouco de flexibilidade ao uso para que possamos variar o N de gerações na linha de comando (útil para depuração e teste). Além disso, queremos manter uma separação estrita entre as partes algorítmicas do programa e as partes do programa que lidam com impressão e apresentação. Esta é uma maneira de começar nesse caminho:
import sys
ALIVE = 'x'
DEAD = ' '
INITIAL_FIELD_TEMPLATE = [
' ',
' ',
' ',
' ',
' xxxxx xxxxx xxxxx ',
' ',
' ',
' ',
' ',
]
DEFAULT_GENERATIONS = 10
def main(args):
# Setup: initial field and N of generations.
init = [list(row) for row in INITIAL_FIELD_TEMPLATE]
args.append(DEFAULT_GENERATIONS)
n_generations = int(args[0])
# Run Conway: we now have the fields for all generations.
fields = list(conway(n_generations, init))
# Analyze, report, whatever.
for i, f in enumerate(fields):
s = field_as_str(f)
print(f'\nGeneration {i}:\n{s}')
def conway(n, field):
for _ in range(n + 1):
yield field # Temporary implementation.
def field_as_str(field):
return '\n'.join(''.join(row) for row in field)
if __name__ == '__main__':
main(sys.argv[1:])
Partindo dessa base, a próxima etapa é conway()fazer algo interessante - ou seja, calcular o campo para a próxima geração. A new_field()implementação é fácil se definirmos algumas constantes de intervalo.
RNG_R = range(len(INITIAL_FIELD_TEMPLATE))
RNG_C = range(len(INITIAL_FIELD_TEMPLATE[0]))
def new_field(field):
return [
[new_cell_value(field, r, c) for c in RNG_C]
for r in RNG_R
]
def new_cell_value(field, r, c):
return field[r][c] # Temporary implementation.
E então o próximo passo é implementar um real new_cell_value(), que sabemos que nos levará a pensar em células vizinhas. Nessas situações de grade 2D, a lógica de vizinho pode muitas vezes ser simplificada, expressando os vizinhos em (R, C)termos relativos em uma estrutura de dados simples:
NEIGHBOR_SHIFTS = [
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
]
def new_cell_value(field, r, c):
n_living = sum(
cell == ALIVE
for cell in neighbor_cells(field, r, c)
)
return (
field[r][c] if n_living == 2 else
ALIVE if n_living == 3 else
DEAD
)
def neighbor_cells(field, r, c):
return [
field[r + dr][c + dc]
for dr, dc in NEIGHBOR_SHIFTS
if (r + dr) in RNG_R and (c + dc) in RNG_C
]
Uma observação final: ao adotar uma convenção de nomenclatura consistente e decompor o problema em funções relativamente pequenas, podemos nos safar com muitos nomes de variáveis curtos, o que torna mais leve o peso visual do código e ajuda na legibilidade. Em escopos pequenos e em um contexto claro (ambos são cruciais), nomes curtos de variáveis tendem a aumentar a legibilidade. Considere neighbor_cells(): re ctrabalhe porque nossa convenção é seguida em todos os lugares; RNG_Re RNG_Ctrabalham porque se baseiam nessa convenção; dre dcfuncionam em parte pelo mesmo motivo e em parte porque têm o contexto de um contêiner explicitamente nomeado NEIGHBOR_SHIFTS,.