LIFE en Python 3
J'ai commencé à apprendre Python et j'ai choisi le jeu de la vie de Conway comme premier programme. Je serais intéressé à lire comment écrire un Python plus idiomatique. De plus, ce qui m'a déconcerté pendant un certain temps, c'est que tout est passé par référence et que l'attribution d'une liste ne copie pas ses valeurs mais copie la référence. Par conséquent, j'ai utilisé la fonction deepcopy, mais je pense que les listes pourraient être le mauvais choix dans ce cas. Quel serait le meilleur choix 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("")
Réponses
Je pense que votre get_neighborfonction peut être nettoyée en utilisant minet max, et en utilisant 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)]
C'est encore assez long, mais cela supprime tous les ifenvois désordonnés vers des listes d'index codées en dur. J'ai également divisé la compréhension de la liste en quelques lignes. Chaque fois que mes compréhensions commencent à devenir un peu longues, je les brise comme ça. Je trouve que cela aide considérablement la lisibilité.
Pour
"\n".join(["".join(x) for x in field_copy])
Vous n'avez pas besoin du []:
"\n".join("".join(x) for x in field_copy)
Sans les crochets, c'est une expression de générateur au lieu d'une compréhension de liste. Ils sont paresseux, ce qui vous évite de créer une liste juste pour pouvoir y être introduite join. La différence ici n'est pas énorme, mais pour les longues listes qui peuvent économiser de la mémoire.
Je ne représenterais pas la carte comme une liste 2D de chaînes. Cela utilise probablement plus de mémoire que nécessaire, et surtout avec la façon dont vous l'avez maintenant, vous êtes obligé de vous rappeler quel symbole de chaîne représente quoi. En plus de cela, vous avez deux ensembles de symboles de chaîne: l'un utilisé en interne pour la logique ( 'o'et '.') et l'autre pour l'impression ( ' 'et 'x'). C'est plus déroutant qu'il ne devrait l'être.
Si vous vouliez vraiment utiliser des chaînes, vous devriez avoir une constante globale en haut qui définit clairement quelle chaîne est quoi:
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
Les chaînes comme celles qui '.'flottent entrent dans la catégorie des «nombres magiques»: des valeurs qui sont utilisées librement dans un programme et qui n'ont pas de signification explicite. Si le but d'une valeur n'est pas évident, stockez-la dans une variable avec un nom descriptif afin que vous et vos lecteurs sachiez exactement ce qui se passe dans le code.
Personnellement cependant, lorsque j'écris des implémentations GoL, j'utilise une liste 1D ou 2D de valeurs booléennes, ou un ensemble de tuples représentant des cellules vivantes. Pour les versions de liste booléenne, si une cellule est vivante, c'est vrai, et si elle est morte, c'est faux. Pour la version set, une cellule est vivante si elle est dans l'ensemble, sinon elle est morte.
Je mettrais toutes les choses en bas dans une mainfonction. Vous ne voulez pas nécessairement toujours que tout cela fonctionne simplement parce que vous avez chargé le fichier.
Par souci d'efficacité, au lieu de créer constamment de nouvelles copies de terrain à chaque génération, une astuce courante consiste à en créer deux dès le début, puis à les échanger à chaque génération.
La façon dont je le fais est qu'un champ est le write_fieldet un est le read_field. Comme les noms le suggèrent, toutes les écritures arrivent sur le write_field, et toutes les lectures depuis read_field. Après chaque "tick", vous les échangez simplement; read_fielddevient le nouveau write_fieldet write_fielddevient read_field. Cela vous évite un deepcopyappel coûteux une fois par tick.
Vous pouvez faire ce swap tout simplement en Python :
write_field, read_field = read_field, write_field
Commentaire 1
Il n'est pas nécessaire d'avoir un cas particulier pour l'impression de la génération 0.
Laissez simplement votre plage commencer à partir de 0 et imprimez avant de mettre à jour.
for generation in range(N_GENERATIONS+1):
print(f"Generation {generation}")
print("")
print_field(field)
print("")
field = update_field(field)
Commentaire 2
De plus, il semble que vous ajustez un peu votre code à la manière dont vous le définissez en INITIAL_FIELDtant que chaîne multiligne, simplement parce que cela a l'air bien dans la fenêtre de code. C'est à l'envers.
Vous devriez plutôt le définir comme une liste de chaînes afin de ne pas avoir à faire de lignes de fractionnement et autres choses dessus avant de démarrer le programme. Si vous voulez toujours le rendre lisible par l'homme, vous pouvez utiliser des sauts de ligne \ (si nécessaire), mais je pense que la syntaxe sera correcte même sans cela.
INITIAL_FIELD = [
"...................",
"...................",
etc
]
Commentaire 3
def print_field(field_copy, dead_cells=' ', living_cells='x'):
Cette fonction accepte deux paramètres mais aucun appel à celle-ci ne les transmet. Donc, ce ne sont en fait que des variables internes et ne devraient pas être dans la définition de la fonction.
Commentaire 4
field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)
C'est une répétition inutile et difficile à lire. Je préfère enchaîner ces 3 lignes en une seule
print(field_string.replace('.', dead_cells).replace('o', living_cells))
Commentaire 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
C'est également à l'envers, en raison de la façon dont vous représentez vos cellules sous forme de caractères et de chaînes.
Il serait plus judicieux de donner la priorité à la logique de programme simple et de laisser les fonctions d'impression s'ajuster au besoin. Si vous représentez des cellules vivantes comme le numéro 1 et les cellules mortes comme le nombre 0, alors une liste de cellules ressemblerait [0,1,1,0,0,1,0]et cette fonction pourrait être écrite comme
return sum(cell_list)
En fait, vous n'auriez même plus besoin d'une fonction, car c'est si court.
Dans votre fonction d'impression, vous pouvez alors remplacer 1 par un autre caractère et 0 par un autre caractère avant d'imprimer.
Le code que vous avez publié offre un bon exemple des avantages qui peuvent découler d'un investissement initial plus important dans la cohérence des concepts et des noms. Tel qu'il est écrit, le code a deux façons différentes de représenter les cellules vivantes ou mortes, il bascule entre la langue des lignes / colonnes et la langue des coordonnées x / y, et il bascule entre fieldet field_copy.
Lorsque vous atteignez ce point dans le développement d'un programme, il est utile de prendre du recul et de vous engager à une certaine cohérence. Par exemple:
field : list of rows
row : list of cells
cell : either 'x' (alive) or space (dead)
r : row index
c : column index
Et commençons également sur une base solide en mettant tout le code dans des fonctions, ajoutant un tout petit peu de flexibilité à l'utilisation afin que nous puissions faire varier le N de générations sur la ligne de commande (pratique pour le débogage et les tests). De plus, nous voulons maintenir une séparation stricte entre les parties algorithmiques du programme et les parties du programme qui traitent de l'impression et de la présentation. Voici une façon de commencer sur cette voie:
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:])
Partant de cette base, l'étape suivante consiste à faire conway()quelque chose d'intéressant - à savoir, calculer le champ pour la prochaine génération. La new_field()mise en œuvre est facile si nous définissons quelques constantes de plage.
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.
Et puis la prochaine étape est de mettre en œuvre un réel new_cell_value(), qui, nous le savons, nous amènera à penser aux cellules voisines. Dans ces situations de grille 2D, la logique des voisins peut souvent être simplifiée en exprimant les voisins en (R, C)termes relatifs dans une structure de données 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
]
Une dernière remarque: en adoptant une convention de dénomination cohérente et en décomposant le problème en fonctions assez petites, nous pouvons nous en sortir avec de nombreux noms de variables courts, ce qui allège le poids visuel du code et contribue à la lisibilité. Dans de petites étendues et dans un contexte clair (les deux sont cruciaux), les noms de variables courts ont tendance à augmenter la lisibilité. Considérez neighbor_cells(): ret ctravaillez parce que notre convention est suivie partout; RNG_Ret RNG_Cfonctionnent parce qu'ils s'appuient sur cette convention; dret dctravailler en partie pour la même raison et en partie parce qu'ils ont le contexte d'un conteneur explicitement nommé, NEIGHBOR_SHIFTS.