ЖИЗНЬ в Python 3

Aug 27 2020

Я начал изучать Python и выбрал игру жизни Конвея в качестве своей первой программы. Мне было бы интересно прочитать, как написать более идиоматический Python. Кроме того, какое-то время меня сбивало с толку то, что все передается по ссылке, и назначение списка не копирует его значения, а копирует ссылку. Поэтому я использовал функцию deepcopy, но думаю, что списки могут быть неправильным выбором в этом случае. Что было бы лучшим выбором в 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("")

Ответы

5 Carcigenicate Aug 27 2020 at 22:05

Я думаю, что вашу get_neighborфункцию можно очистить с помощью minи max, а также с помощью 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)]

Он все еще довольно длинный, но он устраняет всю беспорядочную ifотправку в жестко запрограммированные списки индексов. Я также разбил понимание списка на несколько строк. Когда мои представления становятся немного затяжными, я так их разбиваю. Я считаю, что это значительно улучшает читаемость.


За

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

Вам не нужны []:

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

Без квадратных скобок это выражение генератора, а не понимание списка. Они ленивы, что избавляет вас от необходимости создавать список только для того, чтобы его можно было вводить join. Разница здесь небольшая, но для длинных списков, которые могут сэкономить память.


Я бы не стал представлять доску в виде двухмерного списка строк. Это, вероятно, использует больше памяти, чем необходимо, и особенно с учетом того, как она у вас сейчас, вы вынуждены помнить, какой символ строки что представляет. Кроме того, у вас есть два набора строковых символов: один используется внутри для логики ( 'o'и '.'), а другой - для вывода на печать ( ' 'и 'x'). Это сбивает с толку больше, чем должно быть.

Если вы действительно хотите использовать строки, у вас должна быть глобальная константа вверху, которая четко определяет, что такое строка:

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

Такие строки, как « '.'плавающие», попадают в категорию «магических чисел»: значения, которые используются в программе без всяких пояснений. Если назначение значения неочевидно, сохраните его в переменной с описательным именем, чтобы вы и ваши читатели точно знали, что происходит в коде.

Лично же, когда я пишу реализации GoL, я использую одномерный или двухмерный список логических значений или набор кортежей, представляющих живые ячейки. Для версий логического списка, если ячейка жива, это правда, а если она мертва - ложь. Для установленной версии ячейка живая, если она есть в наборе, в противном случае - мертвая.


Я бы поместил все, что находится внизу, в mainфункцию. Необязательно, чтобы все это выполнялось просто потому, что вы загрузили файл.


Ради эффективности, вместо того, чтобы постоянно создавать новые копии полей при каждом поколении, распространенный трюк состоит в том, чтобы создать две в самом начале, а затем менять их местами каждое поколение.

Я делаю это так: одно поле - это, write_fieldа другое - это read_field. Как следует из названий, все записи происходят в write_field, а все считываются из read_field. После каждой «галочки» вы просто меняете их местами; read_fieldстановится новым write_fieldи write_fieldстановится read_field. Это избавляет вас от дорогостоящего deepcopyзвонка один раз за тик.

Вы можете сделать это довольно просто в Python :

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

Комментарий 1

Нет необходимости иметь особый случай для печати поколения 0.

Просто позвольте вашему диапазону начинаться с 0 и распечатайте перед обновлением.

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

Комментарий 2

Кроме того, похоже, что вы немного настраиваете свой код в соответствии с тем, как вы определяете его INITIAL_FIELDкак многострочную строку, просто потому, что это хорошо выглядит в окне кода. Это наоборот.

Вам лучше определить его как список строк, чтобы вам не приходилось делать с ним разделительные линии и другие вещи перед запуском программы. Если вы по-прежнему хотите сделать его удобочитаемым, вы можете использовать некоторые разрывы строк \ (при необходимости), но я думаю, что синтаксис будет в порядке и без этого.

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

Комментарий 3

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

Эта функция принимает два параметра, но их вызов не передается. На самом деле это просто внутренние переменные, и их не должно быть в определении функции.

Комментарий 4

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

Это ненужное повторение и его трудно читать. Я бы предпочел связать эти 3 строки в одну

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

Комментарий 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

Это также происходит в обратном порядке из-за того, как вы представляете свои ячейки как символы и строки.

Я думаю, было бы разумнее отдать приоритет простой программной логике и позволить функциям печати настраиваться по мере необходимости. Если вы представите живые клетки как число 1, а мертвые клетки как число 0, тогда будет выглядеть список ячеек, [0,1,1,0,0,1,0]и эту функцию можно записать как

return sum(cell_list)

На самом деле, вам даже не нужна функция, так как она такая короткая.

Затем в функции печати перед печатью вы можете заменить 1 другим символом и 0 другим символом.

3 FMc Aug 29 2020 at 01:12

Опубликованный вами код представляет собой хороший пример преимуществ, которые могут возникнуть в результате больших предварительных вложений в концептуальную согласованность и согласованность имен. В написанном виде код имеет два разных способа представления живых или мертвых ячеек, он переключается между языком строк / столбцов и языком координат x / y, а также переключается между fieldи field_copy.

Когда вы достигли этого момента в разработке программы, полезно сделать шаг назад и взять на себя обязательство соблюдать некоторую последовательность. Например:

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

r     : row index
c     : column index

И давайте также начнем с прочной основы, включив весь код в функции, добавив немного гибкости в использование, чтобы мы могли изменять N поколений в командной строке (удобно для отладки и тестирования). Кроме того, мы хотим поддерживать строгое разделение между алгоритмическими частями программы и частями программы, которые имеют дело с печатью и презентацией. Вот один из способов начать этот путь:

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

Исходя из этого, следующий шаг - сделать conway()что-то интересное, а именно вычислить поле для следующего поколения. new_field()Реализация проста , если мы определим несколько констант диапазона.

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.

А следующий шаг - реализовать реал new_cell_value(), который, как мы знаем, приведет нас к размышлениям о соседних ячейках. В этих двухмерных сетках логику соседей часто можно упростить, выразив соседей в относительных (R, C)терминах в простой структуре данных:

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
    ]

И последнее замечание: приняв согласованное соглашение об именах и разложив проблему на довольно небольшие функции, мы можем избежать многих коротких имен переменных, что облегчает визуальный вес кода и помогает улучшить читаемость. В небольших пределах и в четком контексте (оба имеют решающее значение) короткие имена переменных, как правило, повышают удобочитаемость. Подумайте neighbor_cells(): rи cработайте, потому что наша конвенция соблюдается везде; RNG_Rи RNG_Cработают, потому что строят на этом соглашении; drи dcработают частично по той же причине, а частично потому, что они имеют контекст явно названного контейнера NEIGHBOR_SHIFTS.