В этой небольшой заметке я хотел бы поделиться соображениями, которые возникли в процессе разбора одного нехитрого упражнения. У меня нет хороших выводов, но общее направление мысли состоит в том, что эволюционный рост системы приводит к своего рода локальному оптимуму архитектуры, выбираться из которого приходится ценой существенной перестройки устройства проекта.
Начало
Мне хотелось придумать очень простую задачу, которая бы иллюстрировала процесс перевода понятий реального мира в компьютерную программу. Думаю, используемый далее объектно-ориентированный стиль всего лишь отражает тот факт, что мир данной конкретной задачи действительно состоит из "объектов" в обыденном смысле этого слова, и тут я просто иду по пути наименьшего сопротивления.
Итак, задача звучит следующим образом.
Требуется разработать простейшую текстовую игру-приключение. Мир игры состоит из комнат. У каждой комнаты есть название и список возможных выходов. Выходы помечаются сторонами света, такими как 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. Не буду, однако, делать далеко идущих выводов. Достаточно заметить, что в короткой программе нетрудно применить кучу самых разных трюков и воспользоваться теми или иными особенностями библиотеки. "Выращивание" же более крупной системы — это деятельность совершенно иного характера с массой сюрпризов по дороге.
Комментарии (15)
SadOcean
13.10.2021 19:16+1Я для себя пришел к похожему выводу, что хорошая архитектура хороша для конкретной задачи.
И при изменении задачи, вводных, оптимальная архитектура тоже может измениться.
Предсказывать будущие изменение сложно и люди склонны подкладывать травку там, где было твердо, а не там, куда планируют упасть, поэтому закладывание архитектуры очень часто приводит к оверинженерингу.
Поэтому главное - не пропустить момент, когда архитектуру нужно изменить, что тоже сложно (потому что на готовом проекте дорого)
Получается некий вечный компромисс и большое количество плохого кода.Но, несмотря на это, Я не считаю планирование расширения и планирование архитектуры с учетом развития бесполезным делом.
nick1612
Здравствуйте, спасибо за статью. Хочу сказать, что я тоже много размышлял над подобной темой на основании разных проектов, над которыми я работал. Вывод, к которому я на данный момент пришел неутешительный, и заключается в том, что в конечном успехе того или иного решения, значительную роль может играть случайность. Это чем-то напоминает мне ошибку выжившего или всякие книги/cтатьи об успешных людях, где приводятся их качества/привычки и выдвигается тезис о том, что только это сделало их богатыми и успешными, а стечение обстоятельст не принимается в расчет. В анализе успешных архитектур и решений, я наблюдал подобные ситуации, когда определенное архитектурное решение было очень удачным, в виду того, что развитие проекта шло именно тем путем, который максимально ему соответсвует. И задним числом рассказывалось о том, как автор все четко продумал.
Но я так же был свидетелем ситуаций, когда требования бизнеса менялись таким образом, что выбранная архитектура наоборот, очень сильно все усложняла, хотя изначально она казалась очень удачной. Это приводит к банальному выводу, что универсальных архитектур нет, а прогнозировать будущее очень трудно. Например, ваше первое решение, где вы используете наиболее простой подход и как выражаетесь пользуетесь случайностью выбранных структур данных, может быть отличным и до определенной степени расширяемым решением, если последующие требования не будут упираться в его естественные ограничения (локальный оптимум). Но кардинальное переписывание может быть неизбежным, в случае сильного отклонения требований от изначальных. Пытаться с самого начала идти вторым путем, мне кажется еще хуже, так как правильное моделирование концепций реального мира с возможностью расширения по разным фронтам, задача намного более сложная и скорее всего получится Франкенштейн. Для себя на данный момент я избрал такой метод:
Сперва написать самую простую реализацию, без попытки моделирования предметной области.
Посмотреть на результат и сделать рефакторинг на основе реализации. То есть конечное выделение структур данных и объектов происходит на основе структуры программы, а не концепций реального мира.
Это конечно же не панацея и есть ситуации, в которых это не лучшее решение, но по моему опыту намного чаще дает лучший результат, чем альтернативный подход.
rg_software Автор
Спасибо. Да, я в целом согласен с этим подходом. Наверно, надо отделять реальный мир от того, который мы создаём в коде. В данном случае, однако, эксперимент получается довольно чистым, т.к. эти концепции очень хорошо друг на друга ложатся.
В целом перспектива выбросить/провести рефакторинг/переписать меня не смущает. Смущает то, что вот когда задача маленькая, мы можем прикинуть себе хорошую архитектуру (т.к. всё в голове помещается). А вот если в системе много элементов, не исключено, что гипотетическое оптимальное решение уже и не увидеть.
Может, это чисто умозрительная проблема. А может, и нет, если взять массу классических алгоритмов "из книжки", там нередко самая разная магия происходит, которую трудно концептуально свести к сущностям, связям и протоколам общения.
nick1612
Добавлю несколько мыслей (так как тема мне мне близка):
Мне кажется, что здесь мы попадаем в некую ловушку, создающую иллюзию понятности. Например я не уверен, что понимаю, как мой мозг строит модель реальности - как мы представляем себе объекты и концепции реального мира, чтобы можно было легко переложить их на программу. То есть, мне кажется, что я понимаю, но это ровно до тех пор, пока я об этом хорошо не задумываюсь.
Приведу аналогию со зрением - мы просто открываем глаза и видим. Это происходит автоматически, не прилагая никаких усилий. Это создает иллюзию понятности данного процесса. Даже сейчас, если спросить неподготовленного программиста "как работает зрение?" или распознавание объектов, то он может придумать разные на первый взгляд правдоподобные модели для этого.
Но люди, которые реально работали над проблемой компьютерного зрения понимают, насколько это нетривиальная задача и насколько мы ее изначально недооценивали.
Так что для меня вопрос представления концепций реального мира в программе остается открытым, ну или как минимум имеющим множество разных решений.
Я не уверен, что на 100% уловил вашу мысль, но мне кажется, что это имеет вполне разумное объяснение, так как мы можем оперировать лишь ограниченным числом элементов, а тем более не можем представить их все в деталях. Из этого следует, что прийти к оптимальному решению очень сложно. Я подозреваю, что в общем случае, это вообще относится к NP-hard или даже более сложному классу задач (да простят меня CS специалисты, если я ошибаюсь).
Было бы интересно услышать мнения большего количества людей.
rg_software Автор
Ну я на это не претендую. Мы говорим о модели физического мира (комнатах и коридорах), а как оно у нас в голове -- это отдельная история.
Да, именно так. Если представить себе, что гипотетическая оптимальная архитектура -- это клубок из 25 компонентов и 100 связей между ними, то вряд ли мы сможем её придумать, наша голова на такое не рассчитана.
Мне тоже :) Ну, ещё не вечер.
nick1612
По моему мнению, ваша проблема с ООП подходом имеет прямое отношение к этому.
Здесь, я вижу то, что вы пытаетесь моделировать не реальные объекты, а именно ваше представление о реальных объектах.
Например, почему изначально вы решили, что в реальности "выход", это отдельная сущность, которая должна принадлежать комнате? Во первых в реальности "выход/проход" находится между комнатами и соответственно принадлежит им обоим. Но так же можно посмотреть на задачу с другой стороны и во главу поставить не комнаты, а "выходы". Тогда у каждого выхода будут несколько комнат. Какой из этих подходов верен с точки зрения физического мира?
Надеюсь, я понятно сформулировал свою мысль.
qw1
Интересно, но попробуйте развить эту мысль. Может быть, она окажется тупиковой.
В игре есть не только комнаты/корридоры, но и состояние, игрок, команды, которые он отдаёт, игровой процесс.
Дальше могут появляться другие объекты и существа, взаимодействовать с которыми, согласно игровым условностям, можно лишь, если находишься с ними в одной комнате.
Можно ли развивать игру, применив подход «корридоры первичны, комнаты вторичны?»
nick1612
Я не говорил о том, что какое-то из решений не приведет к проблемам. В данном случае, я хотел обратить внимание на описанную автором проблему возможности соответствия реальным физическим объектам. Мне кажется, что наша модель физического объекта сильно зависит от восприятия. И это уже начинает переходить в "философскую" плоскость.
Вот например - у нас есть физическая комната с двумя дверьми на противоположных сторонах. Мы берем и строим по середине комнаты перегородку. Теперь вопрос - у нас стало две комнаты или одна комната с перегородкой? То есть физически это одно и тоже, но когда мы собираемся моделировать это в виде объектов, то в зависимости от точки зрения, у нас получатся разные модели и код. То есть по моему мнению, никакого однозначного соответствия между физическими объектами и программными объектами быть не может. Но возможно я что-то упускаю.
rg_software Автор
Я думаю, важный момент состоит в том, что эти соображения в идеале не должны играть роли. Ну то есть у меня в голове комнаты и выходы, у вас -- коридоры в центре, а третья опция -- перегородки. В принципе, любое из этих представлений соответствует исходному описанию задачи, поэтому универсальная методология моделирования должна сработать для любой картины в голове, при условии, что она непротиворечива и точно сформулирована. Понятно, что можно попробовать придумать десять способом представления задачи, написать десять программ и убедиться, что не все они одинаково хороши. Но нет же способа узнать заранее, какое представление приведёт к более лаконичному коду.
nick1612
Поправьте меня, если я что-то упускаю, но в вашем примере, вы пришли к рекурсивной зависимости потому, что пытались запрограммировать иерархическую структуру, хотя на словах (и в мыслях) представляли плоскую.
То есть в соответствии с данным кодом, получается что bedroom, который в Exit("S"), это не тот bedroom, который вы пытаетесь инициализировать. Как будто bedroom содержит сам себя, а не ссылку на себя. Что и приводит к противоречию.
nick1612
В дополнение - на мой взгляд, здесь проблема именно в реализации вашей модели. То есть необходимости в введении абстракции в виде класса _RoomConcept на самом деле никакой нет. Если бы вы, как и хотели, в начале создали все экземпляры комнат, а потом связали их, то все бы получилось и без лишней прослойки абстракции.
rg_software Автор
Да, в целом согласен.
1) По поводу разделения "комнат" и "абстрактных комнат" -- верно, тут обманка бытового языка. "Выход ведёт из комнаты А в комнату Б" -- это неточная формулировка, потому что если её реализовывать ровно как сказано, то получится рекурсивная зависимость. Как разработчик я это понимаю, но в рамках упражнения мне было интересно аккуратно перевести русский на Python и посмотреть, что получится. А когда не получилось, стало интересно, почему не получилось.
2) По поводу "создать экземпляры, а потом соединить" -- это ровно та же история. Конечно, можно сделать комнату, а потом соединить комнаты коридорами. Но в описании на русском было не так.
Собственно, это и есть круг интересных вопросов. Мы понимаем, как написать программу, и мы понимаем, как её сформулировать на русском так, чтобы результат "склеился" хорошо ("создаём комнату А, создаём комнату Б, создаём коридор"). Интереснее обсудить, почему альтернативное описание (на первый взгляд ничем не уступающее) оказывается негодным.
nick1612
Да, это интересные вопросы и мне эта тема близка, так как сам много думал о том, как правильно переносить и моделировать объекты и концепции реального мира. Выскажу буквально последнюю мысль, по поводу представленной проблемы - у вас при описании объекта комнаты, помимо описания ее выходов, требовалось указание того, куда эти выходы ведут. Если мы посмотрим на это с "точки зрения" реальных объектов, то комната не должна "знать", какая комната находится на противоположной стороне "выхода/прохода", она "знает" только о своих "выходах". Из-за этого возникает рекурсивная зависимость при инициализации объектов. По моему мнению, здесь как раз и заключается ранее указанная мною проблема - что мы пытаемся моделировать не физические объекты, а наши представления о физических объектах. Я не думаю, что мы можем одномоментно держать в голове полную модель даже для такой маленькой задачи, а каждый раз рассматриваем лишь ее часть (отделые комнаты/выходы). Из-за этого и могут случаться такие вещи.
Ладно, заканчиваю тут свои трактаты, а то я слишком много пишу.
Спасибо за поднятые вопросы.
qw1
Можно так же написать «ведёт в комнату Икс», и нигде далее про эту комнату не упоминать, и это будет с точки зрения человеческого языка корректным описанием. Человек извлечёт из этого информацию, что Икс — это комната.
Поэтому RoomConcept на самом деле приближает нас к пониманию человеческого восприятия задачи.
nick1612
Да, я могу согласиться с этим описанием, но тут, как мне кажется, мы уже пытаемся смоделировать "то как мы думаем, о том как мы думаем". То есть, мы не знаем как на самом деле мозг представляет объекты и концепции, и как на самом деле происходит наше мышление, а введение понятия "концепции" и абстракции, тут уже играет роль нашего объяснения нашего же процесса мышления. Я не против такого подхода, но я не уверен, что он дает преимущества.