Как стать автором
Обновить

Представление объектами: трудности роста

Время на прочтение8 мин
Количество просмотров1.7K
Автор оригинала: rg_software

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

Начало

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

Итак, задача звучит следующим образом.

Требуется разработать простейшую текстовую игру-приключение. Мир игры состоит из комнат. У каждой комнаты есть название и список возможных выходов. Выходы помечаются сторонами света, такими как N, S, NE и т.д. Когда игрок попадает в комнату, игра выводит её название и список возможных выходов:

You are in: Kitchen
Exits: N, E, S

Простое решение

Учитывая, что это всего лишь упражнение, имеет смысл начать с простого решения, чтобы иметь хоть какой-то ориентир на будущее. Предположим, что имена комнат уникальны, а выходы не обязательно симметричны (т.е. если мы можем переместиться на север, это ещё не означает, что можно вернуться обратно через южный выход). Второе предположение имеет смысл, например, в случае прыжка с высокого забора: спрыгнуть легко, залезть обратно сложно. Также давайте зафиксируем карту:

Моё решение задачи выглядит так:

game_map = {
    "Kitchen": {"N": "Dining room", "E": "Bathroom", "W": "Street"},
    "Bathroom": {"W": "Kitchen", "NW": "Dining room"},
    "Dining room": {"S": "Kitchen", "SE": "Bathroom", "U": "Playroom"},
    "Playroom": {"D": "Dining room", "S": "Bedroom"},
    "Bedroom": {"N": "Playroom"},
    "Street": {},
}

now = "Bedroom"
goal = "Street"

while now != goal:
    print(f"You are in: {now}")
    print(f"Exits: {', '.join(list(game_map[now].keys()))}")

    dir = input("Where to go? ").upper()
    if dir in game_map[now]:
        now = game_map[now][dir]
    else:
        print("You can't go there.")

print("Well done!")

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

Например, названия комнат — это строки, а базовой структурой данных для хранения чего угодного являются словари. Таким образом, мы можем легко сопоставить комнатам выходы, состоящие, в свою очередь, из названий и целевых комнат. Строковые имена комнат позволяют легко проверять критерий победы и передвигаться между комнатами. Даже вывод названий выходов осуществляется простым вызовом join() для списка ключей словаря.

Переход к ООП

Программирование во многих случаях можно рассматривать как процесс моделирования предметной области на компьютере. Если мы работаем с клиентами и заказами, в программе так или иначе будут отражены эти сущности. Это некое общее замечание, выходящее за рамки конкретной парадигмы программирования. Думаю, что вполне конструктивно следующее соображение. Допустим, мы собираемся реализовать некоторую функциональность, например, "клиент при желании должен иметь возможность выбрать подарочную упаковку для товара из списка предлагаемых нами вариантов". Хорошо, если разработчик этой функциональности сумеет быстро найти и модифицировать относящиеся к делу части системы. Исходная задача заказчика, по всей вероятности, будет сформулирована на нашем обыденном языке. Таким образом, если код отходит от языка задачи слишком далеко, его будет сложнее поддерживать. Иначе говоря, соотнесение понятий кода и реального мира — это работа, требующая усилий и времени, и в наших интересах её минимизировать.

Приведённое выше решение по сути не выполняет никакого разумного соотнесения понятий. Вместо этого оно полагается на случайные свойства понятий, взятых из исходной постановки задачи, и пользуется их случайной схожестью со встроенными типами Python, извлекая выгоду из возможностей последних.

Давайте попробуем подойти к задаче более системно. ООП в данном случае представляется естественным выбором, поскольку мы имеем дело с действительными сущностями реального мира: комнатами и выходами (ну или "коридорами", если угодно). Мы создаём виртуальный мир, очень похожий на настоящий, так что соотнесение виртуальных и реальных сущностей не должно представлять проблемы.

Итак, имеются "комнаты". У каждой комнаты есть название и список выходов. Также имеются "выходы". Каждый выход связан с некоторой стороной света и ведёт в некоторую комнату. Эти наблюдения можно легко изложить в коде:

class Room:
    def __init__(self, name, exits):
        self._name = name
        self._exits = exits


class Exit:
    def init(self, direction, target):
        self._direction = direction
        self._target = target

Теперь попробуем описать часть игрового мира:

street = Room("Street", [])  # ok!
bedroom = Room("Bedroom", 
               [Exit("N", Room("Playroom", [Exit("S", bedroom)]))])  # ouch!

Только не это, рекурсивная зависимость! Для определения выходов комнаты "Bedroom" мне нужна "Playroom", но по той же причине для определения "Playroom" мне требуется "Bedroom"! Так что же делать?

Разумеется, можно предложить массу очевидных решений (и вообще, полное решение уже приведено). Вероятно, самым простым будет разделить создание комнат и определение выходов следующим образом:

bedroom = Room("Bedroom")
playroom = Room("Playroom")

bedroom.add_exit("N", playroom)
playroom.add_exit("S", bedroom)

Можно также снова воспользоваться тем фактом, что названия комнат по сути являются их уникальными идентификаторами, и передавать названия вместо комнат при создании выходов. Однако вопрос же не в этом: мы все понимаем, как тут всё быстро починить. Вопрос в том, что в процессе моделирования пошло не так, и почему мы в принципе столкнулись с такой неприятностью.

Имеются комнаты. Комнате соответствуют выходы. Выходы ведут в другие комнаты. Что не так с этим описанием? Почему на естественном языке всё в порядке, а в коде уже нет?

Снова о мире идей и мире вещей

Моя текущая теория проста: фразы вроде "есть комната Bedroom с North-выходом, ведущая в комнату Playroom" обманчивы. Они звучат так, словно мы обсуждаем две комнаты, хотя на самом деле слово "Playroom" в данном контексте является ссылкой на "абстрактную комнату", а не на реальную. Например, к текущему моменту я могу ничего не знать о комнате Playroom, кроме самого факта её существования. Скажем, я могу не знать списка выходов из неё, и это не проблема.

Когда мы приводим полноценное описание комнаты (со списком выходов), абстрактное понятие становится реальностью. Комнаты и "понятия о комнатах" связаны именами. Говоря "Playroom (понятие)", я подразумеваю, что где-то должна существовать реальная комната под названием "Playroom". Понятия могут существовать без полноценных определений соответствующих комнат (так что их можно воспринимать как предварительные объявления своего рода), но каждой комнате соответствует абстрактное понятие о комнате, которое возникает в нашей мысленной картине мира при первом упоминании.

Давайте попробуем отразить это соображение в коде. Идея состоит в возможности обращаться к абстрактным комнатам используя вызовы Room.Concept(name):

class Room:
    _game_map = {}  # map of concepts

    class _RoomConcept:
        def __init__(self, name):
            self._name = name
            self._room = None

        def connect_room(self, room):
            self._room = room

        @property
        def room(self):
            return self._room

    @staticmethod
    def Concept(name):
        # create a concept if it is not yet in the map
        if name not in Room._game_map:
            Room._game_map[name] = Room._RoomConcept(name)
        return Room._game_map[name]

    def __init__(self, name, exits):
        Room.Concept(name).connect_room(self)
        self._name = name
        self._exits = exits

    @property
    def name(self):
        return self._name

    @property
    def exit_directions(self):
        return [e.direction for e in self._exits]

    def room_at(self, exit_direction):
        for e in self._exits:
            if e.direction == exit_direction:
                return e.target
        assert False, "Exit does not exist"

Не могу сказать, что я в восторге от этого кода, но это первое, что приходит в голову. Центральная его идея состоит в организации скрытой "карты понятий", своего рода платонического мира идей, в котором они существуют. При каждом создании комнаты создаётся и соответствующее "понятие" ("абстрактная комната", "идея комнаты"). В остальном здесь предлагается вполне обыкновенный класс Room с ожидаемым интерфейсом: вернуть список выходов, вернуть комнату на другом конце выхода, и тому подобное.

Теперь нам потребуется простой класс Exit:

class Exit:
    def __init__(self, direction, target):
        self._direction = direction
        self._target = target

    @property
    def direction(self):
        return self._direction
    @property
    def target(self):
        return self._target.room

Имея комнаты и выходы, можно описать весь уровень. Теперь уже нет необходимости присваивать объекты Room именованным переменным: реальные комнаты соответствуют абстрактным комнатам из "мира идей", который обеспечивает доступ к ним и защищает от сборщика мусора.

Room(
    "Kitchen",
    [
        Exit("N", Room.Concept("Dining room")),
        Exit("E", Room.Concept("Bathroom")),
        Exit("W", Room.Concept("Street")),
    ],
)

Room(
    "Bathroom",
    [Exit("W", Room.Concept("Kitchen")), Exit("NW", Room.Concept("Dining room"))],
)
Room(
    "Dining room",
    [
        Exit("S", Room.Concept("Kitchen")),
        Exit("SE", Room.Concept("Bathroom")),
        Exit("U", Room.Concept("Playroom")),
    ],
)
Room(
    "Playroom",
    [Exit("D", Room.Concept("Dining room")), Exit("S", Room.Concept("Bedroom"))],
)
now = Room("Bedroom", [Exit("N", Room.Concept("Playroom"))])
goal = Room("Street", [])

Теперь всё готово, чтобы собрать воедино оставшиеся фрагменты головоломки:

while now != goal:
    print(f"You are in: {now.name}")
    print(f"Exits: {', '.join(now.exit_directions)}")

    dir = input("Where to go? ").upper()
    if dir in now.exit_directions:
        now = now.room_at(dir)
    else:
        print("You can't go there.")

print("Well done!")

Обсуждение

Сравним два приведённых здесь решения. Можно аргументировать, что первое решение лучше второго практически во всех отношениях. Оно гораздо короче (23 строки против 100), его карта хранится в легко читаемом и сериализуемом виде, оно проще для понимания и содержит гораздо меньше "движущихся деталей". Ко всему прочему, в нём используется словарь для хранения выходов, что позволяет обращаться к ним быстрее. Думаю, я могу упростить второе решение без особых потерь его "объектно-ориентированности", если принять за данность, что комнаты и понятия связаны именами. Это позволит использовать имена вместо понятий, сведя тем самым абстрактные комнаты до простых строк. Это сблизит второе решение с первым. С другой стороны, я сообразил, что всё здесь завязано на именах лишь будучи уже на полпути к решению. Таким образом, моё изначальное соображение о неважности или "случайности" имён оказалось неверным: уникальное имя является важной характеристикой комнаты, хотя это стало ясно не сразу.

ООП-решение уже чересчур сильно напоминает "Hello, World!"-версию разработчика на пятом году работы, так что хорошо бы понимать, в чём его преимущества, и каким образом мы дошли до него, казалось бы, идя обычным путём объектного анализа.

Потенциальной выгодой второго решения можно считать его (предполагаемую) расширяемость. Если свести разницу к чему-то простому, это будет подход к разработке типов. Логика первой программы состоит в попытке втиснуть наши типы в систему существующих типов Python везде, где это возможно. У комнаты есть уникальное имя и список связанных уникальных элементов (выходов). Звучит как пара "строка / словарь", так что мы просто используем строку и словарь. Этот подход позволяет сэкономить массу труда, поскольку встроенные типы прямо поддерживаются стандартной библиотекой, так что мы можем получить выгоду от существующей функциональности. Однако нам может и не повезти, и этот "бесплатный проезд" кончится. Вторая программа создаёт типы с нуля, так что нет ничего неожиданного в том, что самостоятельная разработка оказывается сложнее, многословнее и корявее. Мне кажется, всё могло быть ещё хуже: мы не создали никакой дополнительной сложности, которая возникает "сама собой" при попытке создать самостоятельные типы с хорошо продуманными интерфейсами. Создание независимых и при этом хорошо совместимых типов — это тоже работа, требующая как усилий, так и дополнительных строк кода.

Не думаю, что вина за раздутый текст решения в данном случае лежит на ООП. Мне кажется. это хороший пример 1) обманчивой простоты и неочевидной неоднозначности обыденного языка и 2) скрытой цены за разработку чего-то "своего", проявляющейся в потере функциональности и синтаксического сахара.

Кроме того, вторая программа показывает процесс эволюционного роста системы со всеми его преимуществами и недостатками. Эволюция движется туда, "где лучше", но попав в локальный оптимум, выбраться из него уже не может. Мой результат во многом схож с опытом Боба Мартина, попытавшегося написать алгоритм Дейкстры в стиле TDD. Не буду, однако, делать далеко идущих выводов. Достаточно заметить, что в короткой программе нетрудно применить кучу самых разных трюков и воспользоваться теми или иными особенностями библиотеки. "Выращивание" же более крупной системы — это деятельность совершенно иного характера с массой сюрпризов по дороге.

Теги:
Хабы:
Всего голосов 5: ↑5 и ↓0+5
Комментарии15

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань