VITA in Python 3

Aug 27 2020

Ho iniziato a imparare Python e ho scelto il gioco della vita di Conway come primo programma. Sarei interessato a leggere come scrivere Python più idiomatico. Inoltre, ciò che mi ha spiazzato per un po 'di tempo è stato che tutto è passato per riferimento e l'assegnazione di una lista non ne copia i valori ma copia il riferimento. Pertanto, ho utilizzato la funzione deepcopy, ma penso che gli elenchi potrebbero essere la scelta sbagliata in questo caso. Quale sarebbe una scelta migliore in 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("")

Risposte

5 Carcigenicate Aug 27 2020 at 22:05

Penso che la tua get_neighborfunzione possa essere ripulita usando mine max, e facendo uso di 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)]

È ancora piuttosto lungo, ma elimina tutto il disordine di ifinvio a elenchi di indici hard-coded. Ho anche interrotto la comprensione della lista in poche righe. Ogni volta che le mie comprensioni iniziano a diventare un po 'lunghe, le spezzo in quel modo. Trovo che aiuti notevolmente la leggibilità.


Per

"\n".join(["".join(x) for x in field_copy])

Non hai bisogno di []:

"\n".join("".join(x) for x in field_copy)

Senza le parentesi quadre, è un'espressione generatrice invece di una comprensione di elenchi. Sono pigri, il che ti evita di creare un elenco solo per poterlo inserire join. La differenza qui non è enorme, ma per elenchi lunghi che possono far risparmiare memoria.


Non rappresenterei il tabellone come un elenco 2D di stringhe. Questo probabilmente consuma più memoria del necessario, e soprattutto con come lo hai ora, sei costretto a ricordare quale simbolo di stringa rappresenta cosa. Inoltre, hai due serie di simboli stringa: uno utilizzato internamente per la logica ( 'o'e '.') e l'altro per quando stampi ( ' 'e 'x'). Questo è più confuso di quanto dovrebbe essere.

Se vuoi davvero usare le stringhe, dovresti avere una costante globale in alto che definisce chiaramente quale stringa è cosa:

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

Le stringhe come '.'fluttuare in giro rientrano nella categoria dei "numeri magici": valori che vengono utilizzati liberamente in un programma che non hanno un significato autoesplicativo. Se lo scopo di un valore non è evidente, memorizzalo in una variabile con un nome descrittivo in modo che tu ei tuoi lettori sappiate esattamente cosa sta succedendo nel codice.

Personalmente, però, quando scrivo implementazioni GoL, utilizzo un elenco 1D o 2D di valori booleani o un insieme di tuple che rappresentano celle vive. Per le versioni dell'elenco booleano, se una cella è viva, è vera, e se è morta è falsa. Per la versione set, una cella è viva se è nel set, altrimenti è morta.


Metterei tutte le cose in fondo in una mainfunzione. Non necessariamente vuoi sempre che tutto funzioni semplicemente perché hai caricato il file.


Per motivi di efficienza, invece di creare costantemente nuove copie sul campo ogni generazione, un trucco comune è crearne due all'inizio, quindi scambiarle ogni generazione.

Il modo in cui lo faccio è un campo è il write_fielde uno è il read_field. Come suggeriscono i nomi, tutte le operazioni di scrittura avvengono su write_fielde tutte le letture da read_field. Dopo ogni "segno di spunta", li scambi semplicemente; read_fielddiventa il nuovo write_fielde write_fielddiventa read_field. Questo ti evita la costosa deepcopychiamata una volta per tick.

Puoi fare questo scambio abbastanza semplicemente in Python :

write_field, read_field = read_field, write_field
4 user985366 Aug 27 2020 at 21:10

Commento 1

Non è necessario disporre di un caso speciale per la stampa di generazione 0.

Lascia che il tuo intervallo inizi da 0 e stampa prima di aggiornare.

for generation in range(N_GENERATIONS+1):
    print(f"Generation {generation}")
    print("")
    print_field(field)
    print("")
    field = update_field(field)

Commento 2

Inoltre, sembra che tu stia adattando un po 'il tuo codice al modo in cui definisci INITIAL_FIELDuna stringa multilinea, solo perché sembra carino in questo modo nella finestra del codice. Questo è al contrario.

Dovresti piuttosto definirlo come un elenco di stringhe in modo da non dover eseguire linee di divisione e altre cose su di esso prima di avviare il programma. Se vuoi comunque renderlo leggibile dall'uomo, puoi usare alcune interruzioni di riga \ (se necessario), ma penso che la sintassi andrà bene anche senza di essa.

INITIAL_FIELD = [
    "...................",
    "...................",
    etc
    ]

Commento 3

def print_field(field_copy, dead_cells=' ', living_cells='x'):

Questa funzione accetta due parametri ma nessuna chiamata ad essa li passa mai. Quindi sono in realtà solo variabili interne e non dovrebbero essere nella definizione della funzione.

Commento 4

field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)

Questa è una ripetizione inutile e difficile da leggere. Preferirei concatenare quelle 3 linee in una

print(field_string.replace('.', dead_cells).replace('o', living_cells))

Commento 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

Questo è anche al contrario, a causa di come rappresenti le tue celle come caratteri e stringhe.

Sarebbe più sensato dare la priorità alla logica del programma semplice e lasciare che le funzioni di stampa si adattino secondo necessità. Se rappresenti le celle vive come numero 1 e le cellule morte come numero 0, allora un elenco di celle sarebbe simile [0,1,1,0,0,1,0]e questa funzione potrebbe essere scritta come

return sum(cell_list)

In realtà, non avresti nemmeno più bisogno di una funzione, poiché è così breve.

Nella funzione di stampa puoi quindi sostituire 1 con un altro carattere e 0 con un altro carattere prima di stampare.

3 FMc Aug 29 2020 at 01:12

Il codice che hai pubblicato offre un buon esempio dei vantaggi che possono derivare da un maggiore investimento iniziale in termini di coerenza concettuale e di denominazione. Come scritto, il codice ha due modi diversi per rappresentare le cellule vive o morte, alterna avanti e indietro tra la lingua delle righe / colonne e la lingua delle coordinate x / y e passa da fielde field_copy.

Quando si raggiunge quel punto nello sviluppo di un programma, è utile fare un passo indietro e impegnarsi per una certa coerenza. Per esempio:

field : list of rows
row   : list of cells
cell  : either 'x' (alive) or space (dead)

r     : row index
c     : column index

E iniziamo anche su una solida base inserendo tutto il codice in functions, aggiungendo un po 'di flessibilità all'utilizzo in modo da poter variare la N di generazioni sulla riga di comando (utile per il debug e il test). Inoltre, vogliamo mantenere una netta separazione tra le parti algoritmiche del programma e le parti del programma che si occupano di stampa e presentazione. Ecco un modo per iniziare su quel percorso:

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:])

Partendo da quelle basi, il passo successivo è quello di conway()fare qualcosa di interessante, ovvero calcolare il campo per la generazione successiva. L' new_field()implementazione è facile se definiamo un paio di costanti di intervallo.

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 poi il passo successivo è implementare un reale new_cell_value(), che sappiamo ci porterà a pensare alle cellule vicine. In queste situazioni di griglia 2D, la logica adiacente può spesso essere semplificata esprimendo i vicini in (R, C)termini relativi in una semplice struttura dati:

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
    ]

Un'ultima nota: adottando una convenzione di denominazione coerente e scomponendo il problema in funzioni piuttosto piccole, possiamo farla franca con molti nomi brevi di variabili, che alleggeriscono il peso visivo del codice e aiutano con la leggibilità. All'interno di piccoli ambiti e in un contesto chiaro (entrambi sono cruciali), i nomi brevi delle variabili tendono ad aumentare la leggibilità. Considera neighbor_cells(): re clavora perché la nostra convenzione è seguita ovunque; RNG_Re RNG_Clavorano perché si basano su quella convenzione; dre dclavorare in parte per lo stesso motivo e in parte perché hanno il contesto di un contenitore denominato esplicitamente, NEIGHBOR_SHIFTS.