Камень-Ножницы-Бумага на Python: от наивных if-ов до изящных решений
Когда ты только начинаешь свой путь в программировании, каждый новый проект – это маленькая победа. Написать калькулятор, угадайку чисел, ну и, конечно же, "Камень-ножницы-бумага" – это своего рода обряд посвящения. Ты закрепляешь базовые концепции, радуешься, когда программа наконец-то запускается без ошибок, и чувствуешь себя почти всемогущим. Но вот проходит время, ты набиваешь руку, осваиваешь новые трюки, фреймворки, паттерны... И как часто ты возвращаешься к тем самым первым, "детским" проектам, чтобы посмотреть на них свежим, уже более опытным взглядом?
А зря, если не возвращаешься! Потому что именно на таких простых задачках, как огранка алмаза, оттачивается настоящее мастерство. Можно ведь не просто "заставить работать", а сделать это красиво, элегантно, гибко и даже, черт возьми, эффективно!
В этой статье мы займемся именно таким, на первый взгляд, неблагодарным делом. Мы возьмем донельзя избитую игру "Камень-ножницы-бумага" и препарируем ее вдоль и поперек. Нет, мы не будем добавлять онлайн-мультиплеер или блокчейн. Наша цель – посмотреть, как одну и ту же, казалось бы, элементарную логику можно реализовать совершенно разными способами, от самого наивного и прямолинейного до более продвинутых и "хитрых" вариантов.
Зачем? А затем, что это отличный способ:
- Увидеть эволюцию мысли разработчика: Как от громоздких конструкций мы приходим к более лаконичным и гибким решениям.
- Понять силу правильных структур данных: Как выбор между списком, словарем или множеством может кардинально изменить код.
- Задуматься о компромиссах: Краткость vs. читаемость, гибкость vs. простота, производительность vs. элегантность. В реальной разработке мы постоянно делаем такой выбор.
- Просто получить удовольствие: Иногда приятно вернуться к основам и "поиграть" с кодом без давления дедлайнов и сложных бизнес-требований.
Почему именно "Камень-ножницы-бумага"? Да потому что правила этой игры знают все, они просты, но уже содержат достаточно логических ветвлений, чтобы было где развернуться. Это как "Hello, World!" для демонстрации различных подходов к решению одной задачи.
Так что, если вы новичок – эта статья поможет вам увидеть, куда можно расти. Если вы уже опытный боец – возможно, вы найдете здесь пару интересных идей или просто улыбнетесь, вспоминая свои первые шаги. А может, даже предложите свой, еще более крутой вариант в комментариях! Поехали разбираться, как можно "улучшить" то, что, казалось бы, и так работает😉
Правила игры: вспоминаем классику (если вдруг забыли)
Прежде чем мы сломя голову бросимся писать код, давайте на секунду остановимся и освежим в памяти правила этой великой игры, которой решались судьбы. Уверен, вы их знаете, но для полноты картины – и чтобы потом не было мучительно больно за бесцельно написанные if
ы – повторим.
Итак, на арене два игрока. Каждый тайно от другого выбирает один из трех сакральных артефактов:
В решающий момент игроки одновременно "вскрывают карты", то есть показывают свой выбор. Победитель определяется по незыблемым законам мироздания (или просто по общепринятой договоренности):
- Камень безжалостно тупит (бьет) ножницы. (Логично, да?)
- Ножницы с легкостью режут (бьют) бумагу. (Тоже не поспоришь.)
- Бумага коварно накрывает (бьет) камень. (А вот тут уже не так очевидно, но правила есть правила!)
Если оба игрока выбрали один и тот же предмет – объявляется ничья, и раунд, возможно, переигрывается до выявления абсолютного чемпиона (или пока не надоест).
С помощью этой, без преувеличения, гениальной игры можно решать множество "крайне важных" жизненных вопросов и споров. От того, кто сегодня моет посуду, до выбора архитектуры следующего highload-проекта (шутка... или нет?).
Теперь, когда правила у нас перед глазами, можно сформулировать требования к нашей будущей Python-реализации.
Техническое задание
Чтобы наше исследование различных подходов было предметным, давайте четко определим, что именно мы будем реализовывать. Вместо того чтобы создавать полноценную игру с вводом от пользователя, анимацией и подключением к нейросети для предсказания выбора соперника (хотя это было бы забавно!), мы сосредоточимся на ядре – функции, определяющей исход одного раунда.
Входные данные и ожидаемый результат
- Функция
play()
должна принимать два аргумента: player1_choice
: строка, представляющая выбор первого игрока.player2_choice
: строка, представляющая выбор второго игрока.- Допустимые значения для выбора: "камень", "бумага", "ножницы". Регистр букв для простоты пока учитывать не будем, но в идеале стоит привести все к одному регистру внутри функции (для наших примеров будем считать, что строки приходят в нижнем регистре).
- Функция
play()
должна возвращать строку, описывающую результат:
Вот несколько примеров того, как должна работать наша функция:
play("камень", "бумага") # должен вернуть "побеждает бумага" play("ножницы", "бумага") # должен вернуть "побеждают ножницы" play("бумага", "бумага") # должен вернуть "ничья"
Обработка ошибок
Что если на вход функции придет что-то, не соответствующее правилам? Например, play("камень", "динамит")
или play(1, None)
?
- Если один или оба аргумента являются некорректными (то есть, не являются одной из строк "камень", "бумага" или "ножницы"), функция
play()
должна возбуждать исключение. Тип исключения на данном этапе не так критичен, ноValueError
был бы вполне уместен, сигнализируя о том, что передано некорректное значение.
В "боевой" системе мы бы, конечно, добавили валидацию входных данных еще до вызова основной логики. Но для нашей учебной задачи требование возбуждать исключение внутри play()
поможет нам рассмотреть разные способы обработки ошибок в разных реализациях.
Коммутативность: play("камень", "бумага") == play("бумага", "камень")
Результат игры не должен зависеть от того, какой игрок указан первым, а какой – вторым. То есть, вызов play("камень", "бумага")
должен давать тот же результат, что и play("бумага", "камень")
. Наша функция должна быть коммутативной по отношению к аргументам в части определения победителя (сама строка результата, конечно, будет содержать имя победившего предмета, но логика победы должна быть симметрична).
Например, если play("камень", "бумага")
возвращает "побеждает бумага", то и play("бумага", "камень")
тоже должен вернуть "побеждает бумага".
С требованиями разобрались. Они достаточно просты, чтобы не усложнять задачу сверх меры, но в то же время дают пространство для различных подходов к реализации. Приступим к первому, самому очевидному варианту!
Подход №1: вариант джунов – прямолинейно и многословно
Итак, представьте: вы – начинающий Python-разработчик. Вам дали задачу написать функцию play()
. Вы знаете про if
/elif
/else
, умеете сравнивать строки и возвращать значения. Какой будет ваша первая, самая инстинктивная реакция? Скорее всего, вы начнете методично перебирать все возможные комбинации.
Реализация "в лоб": каскад из if
/elif
/else
Давайте попробуем смоделировать ход мыслей.
"Так, если первый игрок выбрал 'камень'..."
"...а второй игрок тоже 'камень', то это ничья."
"...а если второй 'бумага', то бумага побеждает."
"...а если второй 'ножницы', то камень побеждает."
"Хм, а если второй выбрал что-то не то? Надо ошибку выдать."
И так далее для каждого выбора первого игрока. В результате может получиться что-то в духе кода, который часто можно встретить в сети по запросу "rock paper scissors python" от начинающих авторов:
def play_v1(player1_choice: str, player2_choice: str) -> str: """ Определяет результат игры "Камень-ножницы-бумага". Наивная реализация с большим количеством if/elif/else. """ if player1_choice == "камень": if player2_choice == "камень": return "ничья" elif player2_choice == "бумага": return "побеждает бумага" elif player2_choice == "ножницы": return "побеждает камень" else: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") elif player1_choice == "бумага": if player2_choice == "камень": return "побеждает бумага" elif player2_choice == "бумага": return "ничья" elif player2_о: ножницы бьют бумагу. return "побеждают ножницы" else: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") elif player1_choice == "ножницы": if player2_choice == "камень": return "побеждает камень" elif player2_choice == "бумага": return "побеждают ножницы" elif player2_choice == "ножницы": return "ничья" else: raise ValueError(f"Некорректный выбор для Игрокаекорректный ввод для player1_choice raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}")
print(f"'камень' vs 'бумага': {play_v1('камень', 'бумага')}") # Ожидаем: побеждает бумага print(f"'ножницы' vs 'бумага': {play_v1('ножницы', 'бумага')}") # Ожидаем: побеждают ножницы print(f"'бумага' vs 'бумага': {play_v1('бумага', 'бумага')}") # Ожидаем: ничья print(f"'камень' vs 'ножницы': {play_v1('камень', 'ножницы')}") # Ожидаем: побеждает камень # Проверка обработки ошибок try: play_v1("камень", "ящерица") except ValueError as e: print(f"Ошибка при ('камень', 'ящерица'): {e}") # Ожидаем: Некорректный выбор для Игрока 2: ящерица try: play_v1("Спок", "бумага") except ValueError as e: print(f"Ошибка при ('Спок', 'бумага'): {e}") # Ожидаем: Некорректный выбор для Игрока 1: Спок
Плюсы и минусы: работает, но можно лучше (и короче!)
- Понятность (для начинающих): Логика очень прямолинейна. Каждое условие явно прописано. Новичку легко проследить ход выполнения для конкретного случая.
- Соответствие требованиям: Функция выполняет все, что от нее требовалось: определяет победителя, обрабатывает ничью и выбрасывает исключения при некорректном вводе.
- Работает без ошибок (при правильной реализации): Если все условия прописаны корректно, функция будет давать правильные результаты.
- Многословность и избыточность: Код получается довольно длинным для такой простой задачи. Много повторяющихся блоков (например, проверка
player2_choice
и генерацияValueError
). - Трудно поддерживать и расширять: Представьте, что мы захотим добавить "ящерицу" и "Спока" из расширенной версии игры. Количество
if
-ов вырастет катастрофически! Каждое новое правило потребует добавления и изменения множества условий. - Высокая цикломатическая сложность: Большое количество ветвлений делает код сложнее для тестирования (нужно покрыть все пути).
- Меньшая "элегантность": Опытный разработчик сразу почувствует, что здесь "что-то не так" и это можно сделать гораздо изящнее.
Почему опытный разработчик здесь насторожится?
Этот стиль реализации часто называют "кодом с запашком" (code smell). Основные "ароматы", которые здесь витают:
- Дублирование кода (DRY - Don't Repeat Yourself - нарушен): Проверки на корректность
player2_choice
повторяются в каждой основной веткеif
/elif
дляplayer1_choice
. Возвращаемые строки типа"побеждает ..."
,"ничья"
также могут дублироваться. - Глубокая вложенность
if
-ов: Хотя в нашем примере глубина всего два уровня, при усложнении правил она могла бы расти, делая код похожим на "лапшу" или "пирамиду судьбы" (pyramid of doom). - Слишком много явных сравнений со строковыми литералами: Когда вы видите множество
choice == "камень"
,choice == "бумага"
и т.д., это часто намекает на то, что можно использовать более подходящие структуры данных (например, словари или множества) для хранения правил или состояний, что сделает код более управляемым и менее подверженным опечаткам.
Строго говоря, в этом коде нет ничего критически неверного, особенно для того, кто только начинает свой путь в программировании. Он решает поставленную задачу. Однако он неэффективен с точки зрения разработки, поддержки и масштабируемости. Этот вариант – отличная отправная точка, чтобы продемонстрировать, как можно сделать код значительно лучше. И первым шагом на пути к улучшению может стать использование более современных синтаксических конструкций самого Python.
Именно этим мы и займемся в следующих разделах, рассмотрев более продвинутые и элегантные подходы. Готовы к первому этапу рефакторинга и улучшения.
Подход №2: посовременнее – структурное сопоставление с match
/case
После того как мы увидели, во что может превратиться простая задача при "лобовой" атаке с помощью if
/elif
/else
, хочется чего-то более... элегантного, что ли? И Python, начиная с версии 3.10, предлагает нам такой инструмент – структурное сопоставление с образцом, или, проще говоря, конструкцию match
/case
.
Python 3.10+: кратко о match
/case
Если вы еще не сталкивались с match
/case
, то вкратце: это мощная конструкция, похожая на switch
/case
из других языков, но значительно более гибкая. Она позволяет сопоставлять значение переменной (или нескольких переменных) с различными образцами (паттернами). Эти образцы могут быть не только литералами, но и более сложными структурами, включая типы данных, атрибуты объектов, последовательности и словари.
Для нашей задачи "Камень-ножницы-бумага" match
/case
может помочь сделать код более читаемым, особенно когда количество возможных комбинаций выбора игроков становится значительным.
Реализация play()
с использованием match
/case
Давайте посмотрим, как наша функция play()
может выглядеть с использованием match
/case
. Мы будем сопоставлять кортеж из выборов двух игроков (player1_choice
, player2_choice
).
def play_v2_match_case(player1_choice: str, player2_choice: str) -> str: """ Определяет результат игры "Камень-ножницы-бумага" с использованием конструкции match/case (Python 3.10+). """ valid_choices = {"камень", "бумага", "ножницы"} match (player1_choice, player2_choice): # Случаи ничьей case ("камень", "камень") | ("бумага", "бумага") | ("ножницы", "ножницы"): return "ничья" # Случаи победы Игрока 1 (или Игрока 2, если смотреть с другой стороны) # Мы перечисляем все выигрышные комбинации для одного из предметов, # а потом для другого, чтобы обеспечить коммутативность явно в case. case ("камень", "ножницы"): # Камень бьет ножницы return "побеждает камень" case ("бумага", "камень"): # Бумага бьет камень return "побеждает бумага" case ("ножницы", "бумага"): # Ножницы бьют бумагу return "побеждают ножницы" # Коммутативные случаи (если первый проиграл второму) # Это же, что и выше, но с перестановкой игроков case ("ножницы", "камень"): # Камень бьет ножницы (победа второго) return "побеждает камень" case ("камень", "бумага"): # Бумага бьет камень (победа второго) return "побеждает бумага" case ("бумага", "ножницы"): # Ножницы бьют бумагу (победа второго) return "побеждают ножницы" # Обработка некорректного ввода # Захватываем значения в p1, p2 и проверяем их case (p1, _) if p1 not in valid_choices: # Проверка первого игрока raise ValueError(f"Некорректный выбор для Игрока 1: {p1}") case (_, p2) if p2 not in valid_choices: # Проверка второго игрока (первый уже валиден) raise ValueError(f"Некорректный выбор для Игрока 2: {p2}")
В этой версии мы явно перечисляем все выигрышные комбинации и ничьи. Обработка ошибок встроена с помощью case с условиями (if
).
Давайте протестируем play_v2_match_case
:
print(f"'камень' vs 'бумага': {play_v2_match_case('камень', 'бумага')}") # Ожидаем: побеждает бумага print(f"'ножницы' vs 'бумага': {play_v2_match_case('ножницы', 'бумага')}") # Ожидаем: побеждают ножницы print(f"'бумага' vs 'бумага': {play_v2_match_case('бумага', 'бумага')}") # Ожидаем: ничья # Проверка коммутативности print(f"'бумага' vs 'камень' (коммутативность): {play_v2_match_case('бумага', 'камень')}") # Ожидаем: побеждает бумага # Проверка обработки ошибок try: play_v2_match_case("камень", "ящерица") except ValueError as e: print(f"Ошибка при ('камень', 'ящерица'): {e}") # Ожидаем: Некорректный выбор для Игрока 2: ящерица try: play_v2_match_case("Спок", "бумага") except ValueError as e: print(f"Ошибка при ('Спок', 'бумага'): {e}") # Ожидаем: Некорректный выбор для Игрока 1: Спок
Обработка "ничьей" и некорректного ввода в match
/case
В приведенном выше примере мы использовали:
- Объединение образцов с помощью
|
(ИЛИ): Для случаев ничьейcase
("камень", "камень") | ("бумага", "бумага") | ("ножницы", "ножницы"): - Защитные условия (guard clauses) if в case: Для проверки валидности ввода
case (p1, _) if p1 not in valid_choices:
. Переменныеp1
иp2
здесь "захватывают" значения из сопоставляемого кортежа (символ _ используется как "неважно какое значение" для второго элемента, когда проверяем первого, и наоборот).
Альтернативный подход к обработке ошибок и ничьей:
Можно было бы сначала проверить на корректность ввода и ничью с помощью обычных if
, а затем использовать match
/case
только для определения победителя в валидных и не ничейных ситуациях. Это могло бы сделать сам блок match/case чище, так как он бы фокусировался только на игровой логике.
def play_v2_alt_error_handling(player1_choice: str, player2_choice: str) -> str: valid_choices = {"камень", "бумага", "ножницы"} if player1_choice not in valid_choices: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in valid_choices: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") if player1_choice == player2_choice: return "ничья" # Теперь match/case обрабатывает только валидные, не ничейные ситуации match (player1_choice, player2_choice): # Случаи победы одного из игроков # Коммутативность здесь достигается тем, что мы перечисляем все 6 исходов (3 победы одного, 3 другого) case ("камень", "ножницы") | ("ножницы", "камень"): return "побеждает камень" case ("бумага", "камень") | ("камень", "бумага"): return "побеждает бумага" case ("ножницы", "бумага") | ("бумага", "ножницы"): return "побеждают ножницы" # Другие case здесь не нужны, так как все валидные и не-ничейные наборе valid_choices.
Этот вариант (play_v2_alt_error_handling
) выглядит чище в части match
/case
, но переносит часть логики обратно в if. Выбор между ними — это часто вопрос стиля и предпочтений. Первый вариант (play_v2_match_case
) более полно использует возможности match для всех аспектов, но может показаться чуть более громоздким из-за дублирования коммутативных пар и проверок на валидность внутри case
.
Плюсы: читаемость для сложных ветвлений, структурное сопоставление
- Улучшенная читаемость (потенциально): Для задач с большим количеством дискретных состояний и комбинаций,
match
/case
может быть более читаемым, чем длинные цепочкиif
/elif
/else
. Структура "образец -> действие" часто интуитивно понятна. - Декларативность: Вы описываете, "каким должен быть образец", а не последовательность проверок.
- Мощные возможности сопоставления:
match
/case
позволяет сопоставлять не только значения, но и структуры, типы, использовать "захват" переменных (какp1
,p2
в нашем примере) и защитные условия. Для нашей простой игры это не так критично, но для более сложных сценариев это большое преимущество.
Минусы и когда это уместно: сравнение с if
/elif
/else
- Многословность для простых случаев: Если у вас всего 2-3 ветки, if/elif/else может быть короче и проще. match/case раскрывает свою силу при большем количестве вариантов.
- Порог вхождения: Для тех, кто не знаком с этой конструкцией, код может потребовать некоторого времени на освоение.
- Не всегда панацея от дублирования: В нашем примере с
play_v2_match_case
мы все еще дублируем логику для коммутативных пар (например, ("камень", "ножницы") и ("ножницы", "камень") обрабатываются как отдельныеcase
, хотя результат для "камня" один). Вариантplay_v2_alt_error_handling
решает это для игровых исходов, но за счет вынесения части логики вif
.
Итог по match
/case
для "Камень-ножницы-бумага":
Для нашей простой игры match/case предлагает некоторую альтернативу громоздким if
/elif
/else
, делая код потенциально более структурированным. Однако, как мы увидим дальше, использование специализированных структур данных (словарей) может дать еще более компактные и гибкие решения, особенно когда речь идет об определении правил игры. match
/case
здесь – это скорее демонстрация синтаксической возможности, чем серебряная пуля для данной конкретной задачи.
Теперь давайте посмотрим, как можно подойти к задаче с точки зрения структур данных!
Подход №3: рефакторинг – используем словари для элегантности
После знакомства с "джуниорским" каскадом if
-ов и более современной конструкцией match
/case
, опытный разработчик неизбежно задастся вопросом: "А нельзя ли всю эту логику правил вынести в какую-нибудь удобную структуру данных?" И действительно, для задач, где нужно сопоставлять одни сущности с другими (как в нашем случае – какой предмет какой бьет), словари (dict
) в Python подходят как нельзя лучше.
Структура данных: кто кого бьет?
Давайте создадим словарь, который будет компактно и наглядно хранить правила нашей игры. Ключом будет предмет, а значением – тот предмет, который он побеждает. Назовем этот словарь BEATS_RULES
.
# Словарь, описывающий, какой предмет какой побеждает # Ключ: предмет, Значение: предмет, который ключ побеждает BEATS_RULES = { "камень": "ножницы", "ножницы": "бумага", "бумага": "камень", } # Также определим множество допустимых выборов для удобства валидации VALID_CHOICES = set(BEATS_RULES.keys())
С таким словарем правила игры становятся очевидны:
BEATS_RULES["камень"]
вернет "ножницы" (камень бьет ножницы).BEATS_RULES["ножницы"]
вернет "бумага" (ножницы бьют бумагу).
Уменьшаем количество if
-ов: Логика становится компактнее
Вооружившись словарем правил, мы можем значительно упростить нашу функцию play()
. Основная логика будет следующей:
- Сначала убедимся, что оба игрока сделали допустимый выбор.
- Проверим, не выбрали ли игроки одно и то же (ничья).
- Если не ничья, используем наш словарь
BEATS_RULES
, чтобы определить, победил ли первый игрок. - Если первый игрок не победил, значит, по логике игры "Камень-ножницы-бумага", должен победить второй игрок (при условии, что его выбор также валиден и это не ничья).
Вот как это может выглядеть в коде:
def play_v3(player1_choice: str, player2_choice: str) -> str: """ Определяет результат игры "Камень-ножницы-бумага" с использованием словаря для правил. """ if player1_choice not in VALID_CHOICES: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in VALID_CHOICES: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") if player1_choice == player2_choice: return "ничья" # Проверяем, побеждает ли Игрок 1 if BEATS_RULES[player1_choice] == player2_choice: return f"побеждает {player1_choice}" # Если дошли сюда, значит, не ничья и Игрок 1 не победил. # это означает, что Игрок 2 победил. return f"побеждает {player2_choice}"
Давайте протестируем эту версию:
print(f"'камень' vs 'бумага': {play_v3('камень', 'бумага')}") # 'камень' vs 'бумага': побеждает бумага print(f"'ножницы' vs 'бумага': {play_v3('ножницы', 'бумага')}") # 'ножницы' vs 'бумага': побеждает ножницы print(f"'бумага' vs 'бумага': {play_v3('бумага', 'бумага')}") # 'бумага' vs 'бумага': ничья print(f"'бумага' vs 'камень' (коммутативность): {play_v3('бумага', 'камень')}") # 'бумага' vs 'камень' (коммутативность): побеждает бумага print(f"'камень' vs 'ножницы': {play_v3('камень', 'ножницы')}") # 'камень' vs 'ножницы': побеждает камень try: result = play_v3("камень", "ящерица") print(result) except ValueError as e: print(f"Ошибка при ('камень', 'ящерица'): {e}") # Ошибка при ('камень', 'ящерица'): Некорректный выбор для Игрока 2: ящерица try: result = play_v3("Спок", "бумага") print(result) except ValueError as e: print(f"Ошибка при ('Спок', 'бумага'): {e}") # Ошибка при ('Спок', 'бумага'): Некорректный выбор для Игрока 1: Спок
Этот код уже выглядит значительно привлекательнее, чем первоначальный каскад if
-ов.
Баланс найден? Краткость и понятность
Версия play_v3
предлагает хороший баланс:
- Краткость: Код значительно короче по сравнению с первоначальным подходом.
- Читаемость: Логика ясна, правила игры вынесены в отдельную структуру данных.
- Централизация правил: Словарь
BEATS_RULES
является единым источником правды об игровых взаимодействиях. - Простота поддержки (для текущих правил): Если бы одно из правил КНБ изменилось (например, камень вдруг начал бить бумагу), нам бы потребовалось изменить всего одну строку в словаре
BEATS_RULES
.
Для классической игры "Камень-ножницы-бумага" этот подход уже очень хорош. Он устраняет многие недостатки "джуниорского" варианта и выглядит вполне профессионально.
Однако, если мы задумаемся о расширении игры (например, добавлении "ящерицы" и "Спока", где один предмет может бить несколько других), наш текущий словарь BEATS_RULES
столкнется с ограничениями: его значение – это всего одна строка. Как быть, если "камень" должен бить и "ножницы", и "ящерицу"? Об этом – в следующем разделе, где мы сделаем нашу структуру правил еще более гибкой.
Подход №4: добавляем гибкость – готовимся к "Ящерице и Споку"
Что, если мы захотим реализовать знаменитое расширение "Камень-ножницы-бумага-ящерица-Спок", придуманное Сэмом Кассом и популяризованное сериалом "Теория Большого Взрыва"? Один предмет уже может побеждать несколько других:
- Камень бьет ножницы и ящерицу.
- Бумага бьет камень и Спока.
- Ножницы бьют бумагу и ящерицу.
- Ящерица бьет Спока и бумагу.
- Спок бьет ножницы и камень.
Наш предыдущий словарь BEATS_RULES
, где значением была одна строка, уже не справится с такой задачей. Нам нужна структура данных, которая позволит сопоставить один предмет (ключ) с коллекцией предметов, которые он побеждает.
Модернизируем BEATS_RULES
: строки меняем на множества
Идеальной структурой для хранения коллекции уникальных предметов, которые побеждает наш ключ, будет множество (set
). Множества хороши тем, что:
- Хранят только уникальные элементы (нам не нужно, чтобы "камень" дважды бил "ножницы").
- Позволяют очень быстро проверять принадлежность элемента (оператор
in
). - Порядок элементов в множестве не важен, что соответствует логике "бьет этих".
Давайте обновим наш словарь BEATS_RULES
(назовем его EXTENDED_BEATS_RULES
для ясности), используя множества для значений. Сначала для классической игры, чтобы увидеть разницу:
# Правила для классической игры, значения теперь - множества CLASSIC_BEATS_RULES_SET = { "камень": {"ножницы"}, "бумага": {"камень"}, "ножницы": {"бумага"}, } # Допустимые выборы теперь можно получить так же, но они будут более явно связаны с правилами VALID_CHOICES_CLASSIC_SET = set(CLASSIC_BEATS_RULES_SET.keys())
Адаптируем play()
: Оператор in
вместо ==
для проверки правил
Теперь, когда значения в нашем словаре правил – это множества, для проверки, побеждает ли player1_choice
предмет player2_choice
, мы должны использовать оператор in
вместо ==
:
def play_v4_set_rules(player1_choice: str, player2_choice: str) -> str: """ Версия игры "Камень-ножницы-бумага" с правилами, хранящимися в словаре, где значения - множества. """ if player1_choice not in VALID_CHOICES_CLASSIC_SET: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in VALID_CHOICES_CLASSIC_SET: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") if player1_choice == player2_choice: return "ничья" # Теперь проверяем, находится ли выбор второго игрока # во множестве тех, кого бьет выбор первого игрока if player2_choice in CLASSIC_BEATS_RULES_SET[player1_choice]: return f"побеждает {player1_choice}" else: # Если Игрок 1 не победил (и не ничья, и ввод валиден), # то победил Игрок 2. return f"побеждает {player2_choice}"
Эта функция play_v4_set_rules
будет работать точно так же, как play_v3
для классической игры. Но теперь мы готовы к большему!
Расширяем игру: "Камень-ножницы-бумага-ящерица-Спок"
Самое замечательное в новом подходе то, что для расширения игры до "Камень-ножницы-бумага-ящерица-Спок" нам нужно изменить только структуру данных EXTENDED_BEATS_RULES
. Сама функция play_v4_set_rules
(если мы переименуем используемые в ней константы или передадим правила как аргумент) останется без изменений!
Вот как будут выглядеть расширенные правила:
EXTENDED_BEATS_RULES = { "камень": {"ножницы", "ящерица"}, "бумага": {"камень", "Спок"}, "ножницы": {"бумага", "ящерица"}, "ящерица": {"Спок", "бумага"}, "Спок": {"ножницы", "камень"}, } VALID_CHOICES_EXTENDED = set(EXTENDED_BEATS_RULES.keys()) # Обновляем допустимые выборы
Теперь, если наша функция play_v4_set_rules
будет использовать EXTENDED_BEATS_RULES
и VALID_CHOICES_EXTENDED
, она сможет корректно обрабатывать новую игру:
# Для демонстрации сделаем функцию, принимающую правила как аргумент def play_v4_flexible(player1_choice: str, player2_choice: str, rules: dict, valid_options: set) -> str: """ Гибкая версия игры, принимающая правила и допустимые опции как аргументы. """ if player1_choice not in valid_options: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in valid_options: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") if player1_choice == player2_choice: return "ничья" if player2_choice in rules[player1_choice]: # Ключевое изменение: player2_choice IN set return f"побеждает {player1_choice}" else: return f"побеждает {player2_choice}"
Давайте протестируем play_v4_flexible
с расширенными правилами:
# Используем EXTENDED_BEATS_RULES и VALID_CHOICES_EXTENDED print(f"'камень' vs 'бумага': {play_v4_flexible('камень', 'бумага', EXTENDED_BEATS_RULES, VALID_CHOICES_EXTENDED)}") # 'камень' vs 'бумага': побеждает бумага print(f"'Спок' vs 'ящерица': {play_v4_flexible('Спок', 'ящерица', EXTENDED_BEATS_RULES, VALID_CHOICES_EXTENDED)}") # 'Спок' vs 'ящерица': побеждает ящерица print(f"'ящерица' vs 'ножницы': {play_v4_flexible('ящерица', 'ножницы', EXTENDED_BEATS_RULES, VALID_CHOICES_EXTENDED)}") # 'ящерица' vs 'ножницы': побеждает ножницы print(f"'Спок' vs 'Спок': {play_v4_flexible('Спок', 'Спок', EXTENDED_BEATS_RULES, VALID_CHOICES_EXTENDED)}") # 'Спок' vs 'Спок': ничья # Тест на ошибку с невалидным вводом try: play_v4_flexible("камень", "лазер", EXTENDED_BEATS_RULES, VALID_CHOICES_EXTENDED) except ValueError as e: print(e) # Некорректный выбор для Игрока 2: лазер
Сила правильных структур данных: меньше кода, больше возможностей
Этот пример наглядно демонстрирует один из ключевых принципов хорошего программирования: выбирайте правильные структуры данных для вашей задачи.
- Переход от простого значения в словаре к множеству позволил нам естественным образом представить ситуацию, когда один предмет побеждает несколько других.
- Это сделало нашу функцию
play()
гораздо более гибкой и легко расширяемой. Для добавления новых правил или даже совершенно новой игры с похожей логикой нам достаточно изменить только структуру данных rules, не трогая код самой функции (если она написана достаточно обобщенно, какplay_v4_flexible
).
- Максимальная гибкость: Легко добавлять новые предметы и правила.
- Читаемость правил: Словарь
EXTENDED_BEATS_RULES
сам по себе является хорошей документацией правил игры. - Эффективность: Проверка
in
для множеств в Python очень быстрая.
Минусы (незначительные для данной задачи):
- Слегка усложняется структура данных
rules
по сравнению с предыдущим вариантом, но это усложнение оправдано гибкостью.
Этот подход демонстрирует понимание того, как отделять данные (правила игры) от логики их обработки, что является основой для написания чистого и поддерживаемого кода.
Но можно ли пойти еще дальше? Что если мы захотим максимальной производительности при определении результата, пожертвовав частью гибкости во время выполнения? Об этом – в следующем, финальном подходе.
Подход №5: предрасчет результатов (Look-up Table)
Идея этого подхода заключается в том, чтобы заранее вычислить результаты для всех возможных комбинаций выбора игроков и сохранить их в таблице (чаще всего это будет словарь). Затем, во время игры, функция play()
просто будет искать нужную комбинацию в этой таблице и возвращать уже готовый результат. Такой шаблон называется таблицей поиска (look-up table).
Таблица всех возможных исходов
Вместо того чтобы каждый раз при вызове play() применять логику правил, мы можем один раз, при запуске программы или инициализации модуля, сгенерировать все исходы. Используем наш гибкий словарь со множествами из предыдущего подхода как основу для правил.
# Правила для Камень-Ножницы-Бумага-Ящерица-Спок RULES_FOR_TABLE_V5 = { "камень": {"ножницы", "ящерица"}, "бумага": {"камень", "Спок"}, "ножницы": {"бумага", "ящерица"}, "ящерица": {"Спок", "бумага"}, "Спок": {"ножницы", "камень"}, } ALL_VALID_ITEMS_V5 = list(RULES_FOR_TABLE_V5.keys()) # Список всех допустимых предметов VALID_CHOICES_V5_SET = set(ALL_VALID_ITEMS_V5) # Множество для быстрой проверки валидности
Функция build_results_table()
: генерируем "шпаргалку" для игры
Напишем функцию, которая на основе этих правил создаст нашу таблицу результатов. Ключом в этой таблице будет frozenset
из пары выборов игроков (или одного элемента для ничьей), а значением – строка с результатом.
def build_results_table_v5(rules_dict: dict, all_items: list) -> dict: """ Создает таблицу (словарь) всех возможных исходов игры на основе предоставленных правил. """ results_table = {} for item1 in all_items: for item2 in all_items: state_key = frozenset((item1, item2)) if state_key in results_table: continue # Уже обработали из-за коммутативности if item1 == item2: results_table[state_key] = "ничья" elif item2 in rules_dict.get(item1, set()): # item1 побеждает item2 results_table[state_key] = f"побеждает {item1}" elif item1 in rules_dict.get(item2, set()): # item2 побеждает item1 results_table[state_key] = f"побеждает {item2}" return results_table # Генерируем таблицу результатов один раз PRECALCULATED_OUTCOMES_V5 = build_results_table_v5(RULES_FOR_TABLE_V5, ALL_VALID_ITEMS_V5)
В нашем случае мы получим такую таблицу результатов:
{frozenset({'камень'}): 'ничья', frozenset({'бумага', 'камень'}): 'побеждает бумага', frozenset({'камень', 'ножницы'}): 'побеждает камень', frozenset({'камень', 'ящерица'}): 'побеждает камень', frozenset({'Спок', 'камень'}): 'побеждает Спок', frozenset({'бумага'}): 'ничья', frozenset({'бумага', 'ножницы'}): 'побеждает ножницы', frozenset({'бумага', 'ящерица'}): 'побеждает ящерица', frozenset({'Спок', 'бумага'}): 'побеждает бумага', frozenset({'ножницы'}): 'ничья', frozenset({'ножницы', 'ящерица'}): 'побеждает ножницы', frozenset({'Спок', 'ножницы'}): 'побеждает Спок', frozenset({'ящерица'}): 'ничья', frozenset({'Спок', 'ящерица'}): 'побеждает ящерица', frozenset({'Спок'}): 'ничья'}
play()
в две строки: максимальная простота вызова
Теперь, когда у нас есть предварительно рассчитанная таблица PRECALCULATED_OUTCOMES_V5
, функция play()
становится невероятно простой:
def play_v5_lookup_table(player1_choice: str, player2_choice: str) -> str: """ Определяет результат игры, используя предрасчитанную таблицу исходов. """ # VALID_CHOICES_V5_SET определен глобально if player1_choice not in VALID_CHOICES_V5_SET: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in VALID_CHOICES_V5_SET: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") state_key = frozenset((player1_choice, player2_choice)) return PRECALCULATED_OUTCOMES_V5[state_key]
print(f"'камень' vs 'ящерица': {play_v5_lookup_table('камень', 'ящерица')}") # 'камень' vs 'ящерица': побеждает камень print(f"'Спок' vs 'ящерица': {play_v5_lookup_table('Спок', 'ящерица')}") # 'Спок' vs 'ящерица': побеждает ящерица print(f"'Спок' vs 'Спок': {play_v5_lookup_table('Спок', 'Спок')}") # 'Спок' vs 'Спок': ничья try: play_v5_lookup_table("камень", "лазер") except ValueError as e: print(e) # Некорректный выбор для Игрока 2: лазер
Анализ производительности: предрасчет vs. логика "на лету"
Теперь самое интересное – реальный замер производительности. Сравним наш play_v4_flexible
(из предыдущего раздела, где логика применяется при каждом вызове) и play_v5_lookup_table
. Для этого воспользуемся модулем timeit.
import timeit # Воспроизведем play_v4_flexible для полноты теста def play_v4_flexible(player1_choice: str, player2_choice: str, rules: dict, valid_options: set) -> str: if player1_choice not in valid_options: raise ValueError(f"Некорректный выбор для Игрока 1: {player1_choice}") if player2_choice not in valid_options: raise ValueError(f"Некорректный выбор для Игрока 2: {player2_choice}") if player1_choice == player2_choice: return "ничья" if player2_choice in rules[player1_choice]: return f"побеждает {player1_choice}" else: return f"побеждает {player2_choice}" # Параметры для timeit number_of_executions = 1_000_000 # Количество вызовов функции в одном замере repeat_count = 5 # Количество повторений замера для усреднения # Замер времени для play_v4_flexible (логика "на лету") time_v4 = timeit.timeit( stmt="play_v4_flexible('камень', 'ящерица', RULES_FOR_TABLE_V5, VALID_CHOICES_V5_SET)", globals=globals(), # Передаем глобальные переменные (включая функцию и константы) number=number_of_executions ) / number_of_executions * repeat_count # Среднее время одного выполнения # Замер времени для play_v5_lookup_table (таблица поиска) time_v5 = timeit.timeit( stmt="play_v5_lookup_table('камень', 'ящерица')", globals=globals(), number=number_of_executions ) / number_of_executions * repeat_count print(f"\n--- Замеры производительности (среднее время на {number_of_executions} вызовов, усредненное по {repeat_count} замерам) ---") print(f"Среднее время выполнения play_v4_flexible (логика 'на лету'): {time_v4 * 1e9 / repeat_count:.2f} нс") # в наносекундах на один вызов print(f"Среднее время выполнения play_v5_lookup_table (таблица): {time_v5 * 1e9 / repeat_count:.2f} нс") if time_v5 < time_v4: print(f"Подход с таблицей поиска (v5) быстрее на {((time_v4 - time_v5) / time_v4) * 100:.2f}%") else: print(f"Подход с логикой 'на лету' (v4) быстрее или равен по скорости таблице (v5) на {((time_v5 - time_v4) / time_v5) * 100:.2f}%") # Стоимость генерации таблицы (выполняется один раз) time_build_table = timeit.timeit( stmt="build_results_table_v5(RULES_FOR_TABLE_V5, ALL_VALID_ITEMS_V5)", globals=globals(), number=1000 # Построим таблицу 1000 раз, чтобы получить измеримое время ) / 1000 print(f"\nВремя на однократную генерацию таблицы результатов: {time_build_table * 1e6:.2f} мкс") # в микросекундах
--- Замеры производительности (среднее время на 1000000 вызовов, усредненное по 5 замерам) --- Среднее время выполнения play_v4_flexible (логика 'на лету'): 256.08 нс Среднее время выполнения play_v5_lookup_table (таблица): 352.26 нс Подход с логикой 'на лету' (v4) быстрее или равен по скорости таблице (v5) на 27.30% Время на однократную генерацию таблицы результатов: 8.97 мкс
Как и предполагалось, для такой простой игры, как "Камень-ножницы-бумага-ящерица-Спок", подход с логикой "на лету" (play_v4_flexible
) оказывается немного быстрее, чем подход с таблицей поиска (play_v5_lookup_table
).
Причина в том, что накладные расходы на:
- Создание
frozenset((player1_choice, player2_choice))
на каждом вызовеplay_v5_lookup_table
. - Сам по себе поиск ключа в словаре (хоть и O(1) в среднем, но не нулевой).
...оказываются сопоставимы или даже чуть больше, чем выполнение нескольких простых if
и одной операции in
над небольшим множеством в play_v4_flexible
.
Время на генерацию самой таблицы (build_results_table_v5
) составляет несколько микросекунд. Это однократные затраты, которые были бы незначительны, если бы функция play
вызывалась миллионы раз, и если бы каждый вызов play_v5
был бы значительно быстрее play_v4
. Но в данном случае это не так.
Когда такой подход оправдан: Q-learning и другие сценарии
Так зачем же мы рассматривали этот подход?
- Для демонстрации паттерна: Шаблон с предварительным расчетом таблицы результатов (look-up table) очень важен и широко используется в программировании.
- Для более сложных систем: Этот подход становится действительно эффективным, когда:
- Состояний очень много: Сотни тысяч, миллионы или даже больше.
- Вычисление результата для одного состояния – дорогая операция: Если бы определение победителя требовало сложных вычислений, а не простого сравнения.
- Функция
play()
вызывается чрезвычайно часто: В этом случае даже небольшая экономия на каждом вызове может дать существенный общий прирост.
Реальные примеры использования look-up tables:
- Алгоритмы обучения с подкреплением (Reinforcement Learning): Например, в Q-learning используется Q-таблица.
- Кэширование результатов тяжелых функций (мемоизация).
- Таблицы трансляции, конечные автоматы.
- Игровой ИИ для сложных игр.
В нашем случае "Камень-ножницы-бумага" – это слишком простая игра, чтобы предрасчет дал заметный выигрыш в скорости выполнения самой функции play()
. Однако, если бы правил было на порядок больше, или если бы предметы были не строками, а сложными объектами, сравнение которых затратно, картина могла бы измениться.
Этот подход подчеркивает важность анализа компромиссов и реальных замеров производительности перед тем, как внедрять оптимизации, которые могут усложнить код без ощутимой выгоды для конкретной задачи.
Мы рассмотрели пять различных способов реализации одной и той же, казалось бы, простой игры. Каждый из них имеет свои сильные и слабые стороны и иллюстрирует различные концепции и подходы в программировании. Пора подводить итоги!
Заключение
Ну что ж, наше небольшое путешествие по различным реализациям "Камня-ножниц-бумаги" подошло к концу. Мы начали с самого, казалось бы, очевидного "джуниорского" решения с кучей if-ов и постепенно, шаг за шагом, двигались к более элегантным, гибким и (иногда) производительным вариантам.
- Сила абстракции и структур данных: Переход от жестко закодированной логики в if/elif/else к использованию словарей (сначала со строками, потом с множествами) для определения правил игры кардинально изменил наш код. Он стал короче, чище, а главное – гораздо более гибким и расширяемым. Правила игры отделились от логики их применения.
- Современные возможности Python: Конструкция
match
/case
показала себя как интересная альтернатива для структурирования сложных условных ветвлений, хотя для нашей конкретной задачи ее преимущества не были подавляющими по сравнению с хорошо продуманным словарем. - Компромиссы в разработке: Мы постоянно сталкивались с выбором:
- Краткость vs. Явность: Иногда очень короткий код требует больше умственных усилий для понимания, чем чуть более многословный, но прямолинейный.
- Гибкость vs. Простота: Более гибкие решения часто требуют более сложных структур данных или дополнительного уровня абстракции.
- Производительность vs. сложность реализации: Подход с предрасчетом таблицы результатов (look-up table) мог бы дать выигрыш в производительности для очень сложных или часто вызываемых систем, но для нашей простой игры накладные расходы на подготовку ключа (
frozenset
) свели на нет потенциальную выгоду. - Важность контекста: Нет "единственно правильного" решения. Лучший подход всегда зависит от конкретных требований задачи, ожидаемой нагрузки, необходимости дальнейшего расширения, уровня команды и даже версии Python. То, что идеально для одного сценария, может быть избыточным или неэффективным для другого.
Чему нас учит "Камень-ножницы-бумага"?
Эта, казалось бы, игрушечная задача на самом деле является отличным микрокосмом реальной разработки:
- Думай о будущем: Даже для простого проекта стоит задуматься: а что если правила изменятся? А что если добавятся новые сущности? Гибкость часто окупается.
- Выбирай правильные инструменты (структуры данных): Python предлагает богатый набор встроенных типов. Умение выбрать наиболее подходящий для задачи – признак мастерства.
- Не бойся рефакторинга: Первый работающий вариант – это только начало. Почти всегда можно что-то улучшить, сделать чище, понятнее или эффективнее.
- Измеряй, прежде чем оптимизировать: Интуиция о производительности может подвести. Если скорость критична, всегда проводи замеры.
- Читай код (свой и чужой): Анализ различных решений одной и той же задачи – один из лучших способов учиться и расширять свой программистский кругозор.
Пересматривайте старый код: Это лучший способ увидеть свой рост
Надеюсь, это небольшое упражнение было для вас полезным, независимо от вашего текущего уровня. Если вы новичок, вы увидели, какие существуют альтернативы простым if
-ам и как структуры данных могут упростить жизнь. Если вы уже опытный разработчик, возможно, это напомнило вам о важности базовых принципов или просто доставило несколько минут удовольствия от "игры в код".
Попробуйте это сами! Возьмите один из своих самых первых учебных проектов. Тот, который вы писали, когда только-только знакомились с циклами и условиями. Посмотрите на него сейчас, с высоты вашего текущего опыта. Вы удивитесь, насколько по-другому вы бы подошли к его решению сегодня. Какие структуры данных вы бы использовали? Как бы вы организовали функции? О чем бы вы подумали в первую очередь?
Такой ретроспективный анализ – один из лучших способов оценить свой собственный прогресс и закрепить новые знания.