ŻYCIE w Pythonie 3
Zacząłem się uczyć Pythona i wybrałem grę życia Conwaya jako mój pierwszy program. Byłbym zainteresowany przeczytaniem, jak pisać bardziej idiomatyczny Python. Co więcej, na jakiś czas zrzuciło mnie to, że wszystko jest przekazywane przez referencje, a przypisanie listy nie kopiuje jej wartości, ale kopiuje referencję. Dlatego użyłem funkcji deepcopy, ale myślę, że listy mogą być w tym przypadku złym wyborem. Jaki byłby lepszy wybór w Pythonie?
""" 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("")
Odpowiedzi
Myślę, że twoją get_neighborfunkcję można wyczyścić za pomocą mini max, i używając 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)]
Wciąż jest dość długi, ale eliminuje bałagan ifwysyłany na zakodowane listy indeksów. Zrozumienie listy podzieliłem również na kilka linijek. Ilekroć moje rozumienie zaczyna się wydłużać, przerywam je w ten sposób. Uważam, że znacznie poprawia czytelność.
Dla
"\n".join(["".join(x) for x in field_copy])
Nie potrzebujesz []:
"\n".join("".join(x) for x in field_copy)
Bez nawiasów kwadratowych jest to wyrażenie generatora, a nie rozumienie listy. Są leniwi, co oszczędza ci tworzenia listy tylko po to, aby można ją było wprowadzić join. Różnica tutaj nie jest ogromna, ale w przypadku długich list, które mogą zaoszczędzić pamięć.
Nie przedstawiłbym tablicy jako dwuwymiarowej listy strun. To prawdopodobnie zużywa więcej pamięci niż to konieczne, a zwłaszcza biorąc pod uwagę to, jak masz ją teraz, jesteś zmuszony pamiętać, jaki symbol ciągu reprezentuje co. Ponadto masz dwa zestawy symboli łańcuchowych: jeden używany wewnętrznie do logiki ( 'o'i '.'), a drugi do drukowania ( ' 'i 'x'). To jest bardziej zagmatwane, niż powinno.
Jeśli naprawdę chciałeś użyć łańcuchów, powinieneś mieć na górze stałą globalną, która jasno definiuje, czym jest łańcuch:
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
Łańcuchy, takie jak '.'unoszące się dookoła, należą do kategorii „liczb magicznych”: wartości, które są używane luźno w programie, które nie mają znaczenia, które nie wymaga wyjaśnienia. Jeśli cel wartości nie jest oczywisty, zapisz ją w zmiennej z opisową nazwą, abyś Ty i Twoi czytelnicy wiedzieli dokładnie, co się dzieje w kodzie.
Osobiście jednak, pisząc implementacje GoL, używam listy wartości logicznych 1D lub 2D lub zestawu krotek reprezentujących żywe komórki. W przypadku wersji z listami logicznymi, jeśli komórka żyje, to prawda, a jeśli jest martwa, to fałsz. W przypadku wersji zestawu komórka jest żywa, jeśli jest w zestawie, w przeciwnym razie jest martwa.
Wsadziłbym wszystkie rzeczy na dole do mainfunkcji. Niekoniecznie zawsze chcesz, aby wszystko działało tylko dlatego, że załadowałeś plik.
Ze względu na wydajność, zamiast ciągłego tworzenia nowych kopii terenowych co pokolenie, powszechną sztuczką jest utworzenie dwóch od razu na początku, a następnie ich zamiana co pokolenie.
Sposób, w jaki to robię, to jedno pole to, write_fielda jedno to read_field. Jak sugerują nazwy, wszystkie zapisy dotyczą write_fieldi wszystkie odczyty read_field. Po każdym „ticku” po prostu je zamieniasz; read_fieldstaje się nowym write_fieldi write_fieldstaje się read_field. Oszczędza to kosztownych deepcopypołączeń raz na takt.
Możesz to zrobić po prostu w Pythonie :
write_field, read_field = read_field, write_field
Komentarz 1
Nie ma potrzeby posiadania specjalnego etui do drukowania Generacji 0.
Po prostu pozwól, aby zakres zaczynał się od 0 i wydrukuj przed aktualizacją.
for generation in range(N_GENERATIONS+1):
print(f"Generation {generation}")
print("")
print_field(field)
print("")
field = update_field(field)
Komentarz 2
Wygląda też na to, że dostosowujesz swój kod do sposobu, w jaki definiujesz go INITIAL_FIELDjako ciąg wielowierszowy, tylko dlatego, że w ten sposób wygląda ładnie w oknie kodu. To jest wstecz.
Powinieneś raczej zdefiniować ją jako listę łańcuchów, aby nie musieć robić na niej linii podziału i innych rzeczy przed uruchomieniem programu. Jeśli nadal chcesz, aby był czytelny dla człowieka, możesz użyć kilku znaków \ końca wiersza (w razie potrzeby), ale myślę, że składnia będzie w porządku nawet bez tego.
INITIAL_FIELD = [
"...................",
"...................",
etc
]
Komentarz 3
def print_field(field_copy, dead_cells=' ', living_cells='x'):
Ta funkcja przyjmuje dwa parametry, ale żadne wywołanie jej nigdy nie przekazuje. Są to w rzeczywistości tylko zmienne wewnętrzne i nie powinny znajdować się w definicji funkcji.
Komentarz 4
field_string = field_string.replace('.', dead_cells)
field_string = field_string.replace('o', living_cells)
print(field_string)
To jest niepotrzebne powtórzenie i trudne do odczytania. Wolałbym połączyć te 3 linie w jedną
print(field_string.replace('.', dead_cells).replace('o', living_cells))
Komentarz 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
Jest to również odwrotne, ze względu na sposób, w jaki przedstawiasz swoje komórki jako znaki i ciągi.
Myślę, że rozsądniej byłoby nadać priorytet prostej logice programu i pozwolić funkcjom drukowania dostosować się do potrzeb. Jeśli przedstawisz żywe komórki jako liczbę 1, a martwe komórki jako liczbę 0, wtedy lista komórek będzie wyglądać [0,1,1,0,0,1,0]i ta funkcja może być zapisana jako
return sum(cell_list)
Właściwie nie potrzebujesz już nawet funkcji, ponieważ jest tak krótka.
W swojej funkcji drukowania możesz następnie zamienić 1 jakimś innym znakiem, a 0 jakimś innym znakiem przed drukowaniem.
Kod, który opublikowałeś, stanowi dobry przykład korzyści, które mogą wyniknąć z większej inwestycji z góry w spójność koncepcyjną i nazewniczą. Jak napisano, kod ma dwa różne sposoby przedstawiania żywych lub martwych komórek, przełącza się między językiem wierszy / kolumn a językiem współrzędnych x / y, a także przełącza się między fieldi field_copy.
Kiedy osiągniesz ten punkt w rozwoju programu, warto cofnąć się i zobowiązać się do pewnej spójności. Na przykład:
field : list of rows
row : list of cells
cell : either 'x' (alive) or space (dead)
r : row index
c : column index
Zacznijmy również od solidnych podstaw, umieszczając cały kod w funkcjach, dodając odrobinę elastyczności do użycia, abyśmy mogli zmieniać N pokoleń w wierszu poleceń (przydatne do debugowania i testowania). Ponadto chcemy zachować ścisłe oddzielenie algorytmicznych części programu od części programu, które zajmują się drukowaniem i prezentacją. Oto jeden sposób, aby rozpocząć tę ścieżkę:
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:])
Zaczynając od tego fundamentu, następnym krokiem jest conway()zrobienie czegoś interesującego - mianowicie obliczenie pola dla następnego pokolenia. new_field()Wdrożenie jest łatwe, jeśli definiujemy parę stałych zasięgu.
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.
A potem następnym krokiem jest wdrożenie rzeczywistości new_cell_value(), o której wiemy, że doprowadzi nas do myślenia o sąsiednich komórkach. W takich sytuacjach sieci 2D logikę sąsiadów można często uprościć, wyrażając sąsiadów w (R, C)kategoriach względnych w prostej strukturze danych:
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
]
Ostatnia uwaga: przyjmując spójną konwencję nazewnictwa i rozkładając problem na dość małe funkcje, możemy uciec od wielu krótkich nazw zmiennych, co zmniejsza wizualną wagę kodu i pomaga w czytelności. W małych zakresach i w jasnym kontekście (obie są kluczowe) krótkie nazwy zmiennych zwykle zwiększają czytelność. Rozważcie neighbor_cells(): ri cpracujcie, ponieważ nasza konwencja jest wszędzie przestrzegana; RNG_Ri RNG_Cpracują, ponieważ opierają się na tej konwencji; dri dcdziałają częściowo z tego samego powodu, a częściowo dlatego, że mają kontekście wyraźnie nazwie pojemnika NEIGHBOR_SHIFTS.