VIDA en Python 3
Empecé a aprender Python y elegí el juego de la vida de Conway como mi primer programa. Me interesaría leer cómo escribir Python más idiomático. Además, lo que me desconcertó durante un tiempo fue que todo se pasa por referencia y la asignación de una lista no copia sus valores sino que copia la referencia. Por lo tanto, he usado la función deepcopy, pero creo que las listas podrían ser la elección incorrecta en este caso. ¿Cuál sería una mejor opción en 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("")
Respuestas
Creo que su get_neighborfunción se puede limpiar usando miny max, y haciendo 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)]
Todavía es bastante largo, pero elimina todo el desordenado ifenvío a listas de índices codificadas de forma rígida. También dividí la comprensión de la lista en unas pocas líneas. Siempre que mis comprensiones empiezan a alargarse un poco, las rompo así. Encuentro que ayuda significativamente a la legibilidad.
por
"\n".join(["".join(x) for x in field_copy])
No necesitas []:
"\n".join("".join(x) for x in field_copy)
Sin los corchetes, es una expresión generadora en lugar de una lista de comprensión. Son perezosos, lo que le evita tener que crear una lista solo para que pueda introducirla join. La diferencia aquí no es enorme, pero para listas largas que pueden ahorrar memoria.
No representaría el tablero como una lista 2D de cadenas. Es probable que esto use más memoria de la necesaria, y especialmente con la forma en que la tiene ahora, se ve obligado a recordar qué símbolo de cadena representa qué. Además de eso, tiene dos conjuntos de símbolos de cadena: uno que se usa internamente para la lógica ( 'o'y '.') y el otro para cuando imprime ( ' 'y 'x'). Esto es más confuso de lo que debería ser.
Si realmente desea utilizar cadenas, debe tener una constante global en la parte superior que defina claramente qué cadena es qué:
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
Las cadenas como '.'flotar caen en la categoría de "números mágicos": valores que se usan sueltos en un programa que no tienen un significado autoexplicativo. Si el propósito de un valor no es evidente, guárdelo en una variable con un nombre descriptivo para que usted y sus lectores sepan exactamente lo que está sucediendo en el código.
Sin embargo, personalmente, cuando escribo implementaciones de GoL, uso una lista 1D o 2D de valores booleanos, o un conjunto de tuplas que representan celdas vivas. Para las versiones de la lista booleana, si una celda está viva, es verdadera y si está muerta, es falsa. Para la versión de conjunto, una celda está viva si está en el conjunto, de lo contrario, está muerta.
Mete todas las cosas en la parte inferior en una mainfunción. No siempre quiere que todo eso se ejecute simplemente porque cargó el archivo.
En aras de la eficiencia, en lugar de crear constantemente nuevas copias de campo en cada generación, un truco común es crear dos desde el principio y luego intercambiarlas en cada generación.
La forma en que lo hago es un campo es el write_fieldy uno es el read_field. Como sugieren los nombres, todas las escrituras suceden en el write_fieldy todas las lecturas read_field. Después de cada "tick", simplemente los intercambia; read_fieldse convierte en lo nuevo write_fieldy se write_fieldvuelve read_field. Esto le ahorra la costosa deepcopyllamada una vez por tic.
Puede hacer este intercambio de manera bastante simple en Python :
write_field, read_field = read_field, write_field
Comentario 1
No es necesario tener un estuche especial para imprimir la Generación 0.
Simplemente deje que su rango comience desde 0 e imprima antes de actualizar.
for generation in range(N_GENERATIONS+1):
print(f"Generation {generation}")
print("")
print_field(field)
print("")
field = update_field(field)
Comentario 2
Además, parece que está ajustando bastante su código a la forma en que define INITIAL_FIELDcomo una cadena de varias líneas, solo porque se ve bien de esa manera en la ventana de código. Esto es al revés.
Más bien debería definirlo como una lista de cadenas para que no tenga que hacer líneas de división y esas otras cosas antes de iniciar el programa. Si aún desea que sea legible por humanos, puede usar algunos saltos de línea \ (si es necesario), pero creo que la sintaxis estará bien incluso sin eso.
INITIAL_FIELD = [
"...................",
"...................",
etc
]
Comentario 3
def print_field(field_copy, dead_cells=' ', living_cells='x'):
Esta función acepta dos parámetros pero ninguna llamada a ella los pasa nunca. Por lo tanto, de hecho, son solo variables internas y no deberían estar en la definición de la función.
Comentario 4
field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)
Esta es una repetición innecesaria y difícil de leer. Prefiero encadenar esas 3 líneas en una
print(field_string.replace('.', dead_cells).replace('o', living_cells))
Comentario 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
Esto también es al revés, debido a cómo representa sus celdas como caracteres y cadenas.
Creo que sería más sensato priorizar la lógica del programa simple y dejar que las funciones de impresión se ajusten según sea necesario. Si representa las células vivas como el número 1 y las células muertas como el número 0, entonces se vería una lista de células [0,1,1,0,0,1,0]y esta función se podría escribir como
return sum(cell_list)
En realidad, ya ni siquiera necesitarías una función, ya que es muy corta.
En su función de impresión, podría reemplazar 1 por algún otro carácter y 0 por algún otro carácter antes de imprimir.
El código que publicó ofrece un buen ejemplo de los beneficios que pueden derivarse de una mayor inversión inicial en consistencia conceptual y de nombres. Tal como está escrito, el código tiene dos formas diferentes de representar células vivas o muertas, alterna entre el idioma de filas / columnas y el idioma de las coordenadas x / y, y cambia entre fieldy field_copy.
Cuando llega a ese punto en el desarrollo de un programa, es útil dar un paso atrás y comprometerse con cierta coherencia. Por ejemplo:
field : list of rows
row : list of cells
cell : either 'x' (alive) or space (dead)
r : row index
c : column index
Y también comencemos sobre una base sólida colocando todo el código en funciones, agregando un poco de flexibilidad al uso para que podamos variar la N de generaciones en la línea de comando (útil para depurar y probar). Además, queremos mantener una separación estricta entre las partes algorítmicas del programa y las partes del programa que se ocupan de la impresión y la presentación. Aquí hay una forma de comenzar en ese camino:
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:])
Partiendo de esa base, el siguiente paso es hacer conway()algo interesante, es decir, calcular el campo para la próxima generación. La new_field()implementación es fácil si definimos un par de constantes de rango.
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.
Y luego el siguiente paso es implementar un real new_cell_value(), que sabemos que nos llevará a pensar en las células vecinas. En estas situaciones de cuadrícula 2D, la lógica de vecinos a menudo se puede simplificar expresando los vecinos en (R, C)términos relativos en una estructura de datos simple:
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
]
Una nota final: al adoptar una convención de nomenclatura coherente y al descomponer el problema en funciones bastante pequeñas, podemos salirse con la suya con muchos nombres cortos de variables, lo que aligera el peso visual del código y ayuda con la legibilidad. Dentro de ámbitos pequeños y dentro de un contexto claro (ambos son cruciales), los nombres cortos de variables tienden a aumentar la legibilidad. Considere neighbor_cells(): ry ctrabaje porque nuestra convención se sigue en todas partes; RNG_Ry RNG_Ctrabajan porque se basan en esa convención; dry dctrabajar en parte, por la misma razón y en parte porque tienen el contexto de un contenedor denominado explícitamente NEIGHBOR_SHIFTS.