Что такое интервью по объектно-ориентированному дизайну
Интервью по объектно-ориентированному дизайну становятся всё более популярными в техническом найме. Этот сдвиг отражает растущий акцент компаний на навыках, соответствующих реальной разработке программного обеспечения. OOD-интервью важны в таких компаниях, как Amazon, Bloomberg и Uber, и служат практическим упражнением по программированию. Эти интервью проверяют вашу способность строить логичные, поддерживаемые системы и оценивают, насколько эффективно вы применяете принципы и паттерны объектно-ориентированного дизайна.
В отличие от алгоритмических интервью, требующих единственного оптимального решения, OOD-интервью оставляют место для творчества. Не существует универсального ответа, так как различные подходы могут привести к связному и работающему дизайну. Например, одни вопросы посвящены реальным системам — парковке или торговому автомату, тогда как другие носят более абстрактный характер — поиск файлов в Unix или игра «Крестики-нолики». Каждый вопрос ставит уникальные задачи для проверки ваших навыков, но все они опираются на одни и те же базовые знания и следуют схожей структуре интервью.
Как устроена эта книга?
Эта краткая глава объясняет, что такое OOD-вопросы на интервью. Затем мы представим фреймворк для подхода к OOD-интервью и проведём вас через полный сквозной пример. После этого рассмотрим общие принципы и паттерны дизайна, используемые в OOD. Остальная часть книги посвящена типичным задачам в виде кейсов, которые разбираются поочерёдно.
Зачем компании используют OOD-интервью?
Компании используют OOD-интервью для найма опытных разработчиков, способных быстро писать эффективный код. Они ищут кандидатов, которые умеют определять область задачи, прояснять требования и граничные случаи, создавать практичные низкоуровневые дизайны и строить программное обеспечение, которое легко понять, поддерживать и расширять.
OOD-интервью, наряду с вопросами по системному дизайну и поведенческими вопросами, помогают компаниям определить уровень кандидата. Хорошие результаты в OOD часто отличают инженеров среднего и старшего уровня, демонстрируя более глубокие навыки проектирования.
Вот что интервьюеры обычно ищут:
**Понимание продукта:** Переводить реальные потребности в программное обеспечение, применяя знание предметной области и принимая решения, ориентированные на пользователя.
**Системное мышление:** Разбивать сложную систему на подсистемы и компоненты. Чётко определять роль каждого и описывать их взаимодействие.
**Принятие решений:** Видеть дальше непосредственных требований, предвидеть будущие потребности и проектировать с учётом гибкости. Находить правильный баланс между избыточным усложнением и чрезмерным упрощением дизайна.
**Качество кода:** Писать чистый, логичный и поддерживаемый код для реализации дизайна. Именно поэтому современные OOD-вопросы акцентируются на написании кода, а не только на диаграммах.
**Знание OOP:** Использовать объектно-ориентированные техники, принципы SOLID и паттерны проектирования для создания простого и готового к производству программного обеспечения.
**Коммуникация:** Задавать чёткие вопросы, направлять обсуждение, хорошо объяснять свои идеи и уверенно отстаивать свои решения.
Чем OOD-интервью отличаются от алгоритмических?
OOD-интервью и алгоритмические интервью по кодированию оба предполагают написание кода, но фокусируются на разных целях. Если вы знакомы с алгоритмическими интервью, вам нужно будет изменить своё мышление для OOD. Вот в чём их отличия:
**Фокус на качестве, а не на скорости**
Алгоритмические интервью ищут наиболее быстрое решение, акцентируя внимание на временной и пространственной эффективности. OOD-интервью ценят чистое, поддерживаемое программное обеспечение. Вы будете использовать объекты и создавать хорошо структурированный код с абстракциями и декомпозицией логики — даже если это займёт немного больше времени, что вполне приемлемо на OOD-интервью. Пишите чистый, организованный код с интуитивными именами, чтобы ваши идеи выделялись без дополнительных объяснений.
**Проектирование с объектами, а не шагами**
Алгоритмические интервью часто побуждают вас решать задачи быстро, поэтому вы можете записать всю логику в одной функции. OOD-интервью, напротив, фокусируются на объектах: что они собой представляют, что делают и как взаимодействуют друг с другом. Вместо перечисления шагов думайте о роли каждого объекта и его отношениях.
**Демонстрация навыков OOP, а не просто ответов**
В алгоритмических интервью решение задачи важнее всего. OOD-интервью также проверяют ваши навыки OOP. Используйте такие концепции, как инкапсуляция, наследование и паттерны проектирования для построения решения. Демонстрация глубокого понимания принципов SOLID показывает, что вы способны проектировать чистые, поддерживаемые системы. Интервьюеры ценят ваш мыслительный процесс, а не только результат.
**Планирование расширяемых дизайнов, а не краткосрочных решений**
Алгоритмические интервью заставляют вас гнаться со временем, оставляя мало времени на планирование будущих изменений. В OOD-интервью лучшее решение часто требует меньше кода, а темп даёт вам время обсудить, как ваше решение масштабируется под будущие требования. Хороший дизайн адаптируется к изменениям с минимальной переработкой.
Как подготовиться к OOD-интервью?
Эта книга поможет вам подготовиться с наиболее актуальным взглядом на OOD-интервью. Она охватывает основы, полное пошаговое руководство и примеры типичных OOD-задач с решениями. Вы также можете воспользоваться другими ресурсами для развития своих навыков.
Вот несколько способов подготовки:
**Изучите основы OOP:** Читайте статьи, руководства или книги по OOP. Это укрепит ваши навыки на интервью и вашу производительность в работе впоследствии. Начните с простых концепций: инкапсуляция, наследование, полиморфизм и абстракция. Затем изучите принципы SOLID и паттерны проектирования с помощью онлайн-руководств.
**Практикуйте часто встречающиеся задачи:** В книге есть примеры типичных OOD-задач. Некоторые, как «Парковка», демонстрируют моделирование реальных систем. Другие, как «Система лифтов», проверяют сложную логику, или «Поиск файлов в Linux» — ваши навыки абстракции. Пишите код вместе с нами, затем попробуйте решить задачи самостоятельно и проверьте свою работу.
**Симуляция интервью и практика коммуникации:** OOD-интервью ценят чёткую коммуникацию, а не только программирование. Наша глава с разбором примеров предлагает советы по ключевым темам, но практикуйтесь объяснять свои дизайны вслух. Работайте с другом в формате симуляции интервью или записывайте себя при выполнении OOD-задач и практикуйтесь объяснять свои дизайнерские решения во время написания кода.
Запуск кода
Весь код, включённый в эту книгу, является исполняемым. Мы рекомендуем вам скачать репозиторий, запустить код и поэкспериментировать с решениями, чтобы углубить своё понимание принципов OOD. Инструкции по настройке и запуску кода приведены в файле README репозитория.
Ссылка на GitHub-репозиторий: [github.com/ByteByteGoHq/ood-interview](https://github.com/ByteByteGoHq/ood-interview)
Фреймворк для OOD-интервью
Наличие чёткого фреймворка для OOD-интервью важнее, чем многие осознают. Без структуры интервью может казаться дезорганизованным и трудным для понимания как для вас, так и для интервьюера.
Эта глава представляет четырёхшаговый фреймворк, который поможет вам уверенно ориентироваться в открытых OOD-обсуждениях. Он направляет вас в преобразовании абстрактных требований в конкретную архитектуру или код, одновременно демонстрируя вашу способность делать взвешенные компромиссы в условиях реальных ограничений.
Имейте в виду, что OOD-интервью весьма разнообразны, и этот фреймворк не является универсальным. Структура и ожидания могут варьироваться в зависимости от предпочтений интервьюера, поэтому вам нужно быть гибким и адаптироваться соответствующим образом.
Прежде чем перейти к самому фреймворку, давайте сначала рассмотрим типичные форматы OOD-интервью, с которыми вы можете столкнуться.
Различные форматы OOD-интервью
OOD-интервью обычно акцентируются на одной из трёх областей, каждая из которых предполагает предпочтительный формат результата. В начале интервью оцените ожидания интервьюера, задав вопрос: «Мы фокусируемся на высокоуровневой диаграмме классов, структуре кода или полной реализации?» Этот вопрос поможет вам адаптировать свой подход и убедиться, что вы предоставляете то, что нужно, в рамках временных ограничений (обычно 45–60 минут). Три основных формата результата:
**UML-диаграммы:** UML-диаграммы когда-то были стандартом и до сих пор широко используются для визуального представления системных дизайнов. Диаграмма классов UML помогает иллюстрировать отношения между классами, включая их атрибуты, методы и взаимодействия.
**Скелет кода:** Этот подход становится всё более популярным в современных интервью, поскольку он ближе к реальной разработке программного обеспечения. Он позволяет интервьюерам изучать детали реализации по мере необходимости. В этом стиле вы определяете структуру своего дизайна непосредственно в коде, используя соответствующие объявления классов и методов, оставляя тела методов нереализованными.
**Рабочий код:** С возросшим акцентом на OOD-интервью интервьюеры иногда запрашивают полностью функциональные, безошибочные реализации. Они также могут попросить тест-кейсы. Этот подход обеспечивает наибольшее соответствие реальной производственной разработке.
Ожидаемый результат часто зависит от предпочтений интервьюера и временных ограничений. Если вас просят написать рабочий код, не пугайтесь. Интервьюеры обычно упрощают задачу, чтобы она была выполнима в отведённое время.
> **Совет:** На OOD-интервью путь не менее важен, чем конечный результат. Писать код молча — не лучшая стратегия. Вместо этого делитесь обдуманными замечаниями на протяжении всего процесса, чтобы продемонстрировать своё дизайн-мышление и коммуникативные навыки.
Направляющий фреймворк для OOD-интервью
Вот четыре шага, которые мы рекомендуем:
Шаг 1: Сбор требований (5–10 минут)
Начните с тщательного анализа постановки задачи и определения ключевых функциональных и нефункциональных требований. Задавайте целевые вопросы, чтобы устранить неоднозначности, установить реалистичные ограничения и подтвердить предположения. Это обеспечивает общее понимание области задачи и приоритетов между вами и интервьюером.
Шаг 2: Определение основных объектов (3–7 минут)
После прояснения требований выберите основной сценарий использования и пройдитесь по нему шаг за шагом, чтобы определить основные объекты и их взаимодействия. Практичный подход — сопоставить существительные в требованиях с объектами (например, «парковка», «транспортное средство», «талон») и глаголы — с методами (например, «занять место», «рассчитать стоимость»). Это создаёт наивный, но релевантный начальный дизайн, служащий основой для доработки.
> **Примечание:** Хотя диаграммы вариантов использования могут помочь визуализировать рабочие процессы и прояснить взаимодействия между объектами, они необязательны для большинства OOD-интервью.
Шаг 3: Дизайн диаграммы классов и код (20–25 минут)
Теперь, когда основные объекты и их роли ясны, пришло время разработать диаграмму классов и продемонстрировать, как она переводится в код.
Начните с проектирования классов, используя подход сверху вниз или снизу вверх:
- **Подход сверху вниз:** Сначала определите высокоуровневые компоненты или родительские классы, затем уточните их атрибуты и методы. - **Подход снизу вверх:** Сначала определите конкретные классы (атрибуты, методы) и стройте отношения на их основе.
Определите, как объекты будут взаимодействовать, и распределите ответственности таким образом, чтобы следовать ключевым принципам дизайна: слабая связанность и высокая связность. На этом этапе вы закрепляете свою объектную модель и прорабатываете детали атрибутов и методов.
После завершения дизайна реализуйте основные классы, чтобы продемонстрировать, как структура переводится в код. В некоторых случаях полная реализация не требуется. Сосредоточьтесь на существенных частях, если интервьюер не попросит иного.
> **Примечание:** Основной фокус OOD-интервью — дизайн и качество кода. Но не следует игнорировать временную и пространственную сложность и эффективность. Качественное моделирование классов и отношений включает выбор подходящих структур данных для производительности. Например, тщательно выбирайте между List и Set исходя из паттерна доступа и производительности. Аналогично, HashSet и TreeSet не взаимозаменяемы. Изучите более сложные коллекции или вложенные структуры. На реальном интервью озвучьте свои мысли и сделайте правильный выбор, но не проводите исчерпывающий анализ и не занимайтесь избыточной оптимизацией, изобретая собственные решения.
Шаг 4: Детальный разбор тем (10–15 минут, опционально)
После проверки своего дизайна на ключевых сценариях использования доработайте его для обработки граничных случаев и устранения несоответствий. Именно здесь, как правило, начинается детальный разбор. Интервьюеры могут задавать дополнительные вопросы для оценки вашего понимания, оспаривания дизайнерских решений или изучения более продвинутых аспектов вашего решения.
Пошаговый пример
Чтобы лучше понять, как разворачивается OOD-интервью, давайте пройдём реалистичный пример от начала до конца. В этом разделе показано, как интервью может естественно развиваться от расплывчатого описания задачи к структурированному и продуманному решению.
Шаг 1: Сбор требований
Анна, инженер-программист, проходит собеседование на позицию бэкенд-разработчика. Интервьюер Бет просит её спроектировать систему парковки, давая 45 минут на представление дизайна.
Анна начинает с осмысления задачи, задавая несколько уточняющих вопросов, чтобы создать общее понимание области. Она быстро выясняет, что парковка должна поддерживать различные типы транспортных средств, зарезервированные места и точный расчёт стоимости.
**Пример диалога:**
**Анна:** Какие типы транспортных средств должна поддерживать парковка? Рассматриваем ли мы автомобили и мотоциклы?
**Бет:** Да, и ещё автобусы. Каждый автобус занимает три места.
**Анна:** Следует ли нам проектировать разные типы парковочных мест для разных типов транспортных средств?
**Бет:** Да, вы можете решить, как это спроектировать.
Анна продолжает задавать обдуманные вопросы, чётко определяя область задачи и ограничения. Она избегает типичных ошибок:
- Задавать слишком очевидные или чрезмерно детальные вопросы. - Повторять ранее отвеченные вопросы, что может сигнализировать о невнимательности. - Вводить нерелевантные или излишне сложные темы, отвлекающие от основной задачи.
**Советы по эффективному сбору требований**
Первые несколько минут OOD-интервью критически важны. Вот несколько советов для эффективного сбора требований.
**Сосредоточьтесь на наиболее существенных требованиях**
Начните с фокуса на наиболее существенных требованиях и подтвердите, что и вы, и интервьюер согласованы по области задачи. После того как Анна чётко понимает задачу, она перефразирует и записывает основную функциональность для проверки своей интерпретации:
**Анна:** Система будет поддерживать въезд и выезд транспортных средств, отслеживать доступность мест и рассчитывать стоимость на основе типа транспортного средства и продолжительности парковки. Она также должна поддерживать три типа транспортных средств.
**Используйте примеры для прояснения области задачи**
Вместо того чтобы опираться только на озвучивание требований, Анна использует конкретные примеры для обоснования обсуждения и выявления граничных случаев. Она представляет один простой сценарий и один более сложный, чтобы полностью изучить ожидаемое поведение системы.
**Простой случай:**
**Анна:** Рассмотрим базовый сценарий: автомобиль въезжает на парковку, находит свободное место, паркуется и уезжает через два часа. Система должна выделить место, отследить продолжительность и рассчитать стоимость.
**Сложный случай:**
**Анна:** Теперь представим автобус с бронированием, въезжающий на парковку. Некоторые места слишком малы или зарезервированы для других типов. Системе нужно найти наиболее подходящее свободное место, оптимизируя при этом будущую доступность.
Прорабатывая эти контрастные примеры, Анна устраняет неоднозначности и обеспечивает единое понимание задачи между ней и интервьюером.
Твёрдо понимая основную задачу и её ограничения, она готова перейти к определению строительных блоков (классов, методов и атрибутов), которые составят основу её дизайна.
Шаг 2: Определение основных объектов
Чтобы приступить к дизайну, Анна прорабатывает ключевой сценарий использования: парковка автомобиля. Проходя через этот процесс, она определяет релевантные объекты, обращая внимание на существительные и глаголы в требованиях. Это приводит к простому, но эффективному начальному дизайну.
**Анна:** Когда автомобиль въезжает, система находит свободное место подходящего размера, занимает его, выдаёт талон и отмечает место как занятое.
Сосредоточившись на двух-трёх репрезентативных сценариях использования, Анна позволяет требованиям естественно направлять дизайн. Она избегает попыток смоделировать всё заранее, отдавая приоритет ясности и релевантности, а не полноте.
Прорабатывая сценарий использования, она сохраняет дизайн сфокусированным и минималистичным. Например:
**Бет:** Как вы будете обрабатывать граничные случаи, например, полностью занятую парковку?
**Анна:** Хороший вопрос. Если места недоступны, система должна вернуть соответствующее сообщение. Я доработаю эту логику после завершения основного дизайна.
Когда возникают более сложные темы, Анна признаёт их, не отвлекаясь:
**Анна:** Давайте сначала завершим основной сценарий использования. Если останется время, я расширю дизайн для поддержки настраиваемого ценообразования, возможно, используя паттерн Strategy (Стратегия).
Цель Анны на этом этапе — определить основные объекты и чётко описать их ответственности.
Шаг 3: Дизайн классов и код
После определения основных объектов Анна начинает описывать классы, набрасывать отношения и реализовывать базовую структуру в коде.
**Определение классов**
Она начинает с базовых компонентов, формирующих основу системы. В примере с парковкой она фокусируется на: ParkingLot, ParkingSpot, Vehicle и Ticket.
**Анна:** Ключевые сущности — ParkingLot, ParkingSpot, Vehicle и Ticket. Каждое место имеет атрибуты, такие как размер и доступность, а каждое транспортное средство имеет тип. Ticket будет отслеживать время въезда и рассчитывать стоимость.
Затем она набрасывает UML-диаграмму для отображения отношений:
- ParkingLot содержит несколько ParkingSpot. - Каждый ParkingSpot может вмещать одно Vehicle. - Ticket связывает Vehicle с ParkingSpot и отслеживает время.
Анна обеспечивает, чтобы каждый класс был хорошо определён и соответствовал принципам OOP: инкапсуляция, единственная ответственность и наследование:
**Анна:** ParkingLot управляет общей структурой, включая отслеживание мест и управление потоком транспортных средств. Каждый ParkingSpot управляет своим собственным статусом доступности и припаркованным в нём транспортным средством.
**Анна:** Мы можем определить базовый класс Vehicle с подклассами Car, Motorcycle и Bus, поскольку их требования к парковке и расчёты стоимости различаются.
Она избегает излишнего усложнения модели и фокусируется только на объектах, несущих значимое поведение.
**Реализация кода**
С готовым дизайном Анна пишет определения классов и добавляет соответствующие атрибуты и сигнатуры методов. Например:
- ParkingLot: управляет коллекцией мест и обрабатывает назначения. - ParkingSpot: отслеживает размер, доступность и назначенное транспортное средство. - Ticket: хранит время въезда и рассчитывает стоимость.
В процессе написания кода Анна объясняет своё обоснование интервьюеру, обеспечивая прозрачность мыслительного процесса. Она также проверяет дизайн по мере продвижения:
**Анна:** Эта структура охватывает основные сценарии использования, которые мы обсудили. Я проверю, выдержит ли она также граничные условия.
Оставаясь сфокусированной и обосновывая свои решения на принципах OOD, Анна строит практичный и расширяемый дизайн.
Шаг 4: Детальный разбор тем
На этом этапе дизайн Анны почти завершён — в виде детальной UML-диаграммы или связного скелета кода. Финальный шаг — доработка: отступить на шаг назад, чтобы рассмотреть дизайн с высокого уровня, устранить граничные случаи и рассмотреть улучшения.
**Устранение пробелов**
**Анна:** Для граничных случаев я добавлю логику обработки полных парковок и группировки мест для автобусов. Я также включу валидацию недействительных талонов при выезде.
Она обновляет диаграмму или код по мере необходимости для поддержки группированной обработки мест или специальной логики для крупных транспортных средств.
**Обобщение дизайна**
**Анна:** Этот дизайн поддерживает ключевые сценарии использования, масштабируется под разные типы транспортных средств и включает логику для основных граничных случаев. Если позволит время, я бы изучила улучшения, такие как динамическое ценообразование в зависимости от времени суток.
Это резюме укрепляет её понимание и даёт интервьюеру полное представление о её мышлении.
**Взвешенные компромиссы**
Доработки часто включают компромиссы в таких областях, как наследование против композиции, моделирование данных или паттерны проектирования. Цель — не просто выбрать «правильный» ответ, а чётко объяснить, почему решение имеет смысл.
Она также знает, когда сказать «этого достаточно» и двигаться дальше. Если её дизайн уже покрывает основные сценарии использования, она избегает погружения в гипотетические ситуации или избыточную оптимизацию.
Что делать, если интервью идёт не по плану?
Как бы хорошо вы ни готовились, реальные интервью редко следуют идеально линейному пути. Вы можете столкнуться с неожиданными ситуациями: изменением требований, неожиданным детальным разбором или даже незаинтересованным интервьюером. Ключ — оставаться адаптивным, чётко общаться и сохранять фокус на предоставлении продуманного дизайна.
В этом разделе рассматриваются типичные сложности на OOD-интервью и то, как справляться с ними уверенно и грамотно.
**1. Изменение требований и расширение области задачи**
На некоторых интервью область задачи может расширяться по ходу. Вы можете быть в середине дизайна, когда интервьюер вводит новые требования или ограничения. Не паникуйте. Это часто преднамеренно.
✅ Признайте новое требование и кратко оцените его влияние. Объясните, как ваш текущий дизайн может адаптироваться к изменению или какие компромиссы могут потребоваться. Будьте гибкими, но стратегическими — адаптируйте решение, не перестраивая его без необходимости. Если интервьюер продолжает расширять определённую область, скорее всего, именно гибкость и масштабируемость они тестируют.
**2. Слишком ранний уход в детали**
Иногда интервьюер может захотеть углубиться в детали до того, как вы наметили общую структуру. Если вы уйдёте в детали слишком рано, рискуете потерять из виду общую картину и не уложиться во время.
✅ Установите ожидания заранее: «Я начну с высокоуровневого обзора, затем при необходимости погрузимся в детали.» Периодически проверяйте время и структуру. Если вы застряли в одной области, скажите: «Вот направление, которое я бы выбрал сейчас. Завершение остальной части системы даст мне контекст для уточнения этого.»
**3. Трудности с изложением мыслительного процесса**
Чёткая коммуникация так же важна, как и надёжный дизайн. Если ваши мысли кажутся запутанными или их трудно объяснить, это может ослабить впечатление от вашего решения.
✅ Начните с высокоуровневого резюме системы, прежде чем переходить к деталям на уровне классов. Используйте визуализацию. Диаграмма классов или скелет кода могут стать опорой для разговора. Сосредоточьтесь на том, *почему* вы принимали дизайнерские решения, а не просто описывайте, *что* они собой представляют.
**4. Работа с незаинтересованным интервьюером**
Не каждый интервьюер даёт активную обратную связь. Если они кажутся незаинтересованными, растерянными или молчат, не давайте этому выбить вас из колеи.
✅ Вежливо попросите обратную связь: «Было бы полезно, если бы я что-то прояснил или сфокусировался на определённой части дизайна?» Если это не помогает, пусть ваша работа говорит сама за себя. Сосредоточьтесь на предоставлении чистых диаграмм или работающего кода.
**5. Когда ваши дизайнерские решения оспариваются**
Интервьюеры нередко оспаривают ваши решения. Это не плохой знак — это шанс продемонстрировать своё обоснование и адаптивность.
✅ Сохраняйте спокойствие и объясняйте свой мыслительный процесс. Используйте конкретные примеры или аналогии из реального мира для подкрепления своей точки зрения. При необходимости ссылайтесь на компромиссы, используя такие термины, как временная сложность, расширяемость или поддерживаемость. Предлагайте альтернативы: «Я рассматривал как наследование, так и композицию. Я выбрал наследование здесь, потому что...»
**6. Столкновение с незнакомой терминологией**
Если интервьюер использует термин или концепцию, с которой вы не знакомы, лучше уточнить, чем угадывать.
✅ Вежливо попросите пояснить: «Не могли бы вы уточнить, что вы имеете в виду под этим термином?» Или покажите частичное понимание и уточните: «Моё понимание этой концепции — X. Пожалуйста, объясните, чем она отличается в вашем контексте.»
**7. Трудности с выбором правильного уровня абстракции**
Не уверены, насколько детально погружаться? Это типичное напряжение на OOD-интервью. Слишком широкий охват делает решение расплывчатым; слишком раннее погружение в детали тратит время.
✅ Начните с общей структуры и добавляйте детали по мере необходимости. Спросите интервьюера, какой уровень глубины он предпочитает: «Вы предпочитаете высокоуровневую архитектуру или более детальную разбивку по классам?»
**8. Обработка параллелизма на OOD-интервью**
Параллелизм — продвинутая тема, которую интервьюеры могут затронуть, часто спрашивая, как ваша система обрабатывает множественных пользователей или процессы, одновременно обращающихся к одним и тем же ресурсам.
Классический пример — система бронирования билетов, где ключевая задача — предотвратить двойное бронирование, когда несколько пользователей пытаются выбрать одно и то же место. Этот сценарий — отличная возможность продемонстрировать техники, такие как блокировка, оптимистичная блокировка или использование языко-специфических механизмов синхронизации и конкурентных структур данных.
Держите объяснение кратким, а реализацию простой. На большинстве интервью высокоуровневое описание стратегии параллелизма вместе с кратким фрагментом кода, иллюстрирующим предотвращение гонок, более чем достаточно.
В некоторых случаях сама система может нуждаться в параллельном выполнении. Если вы пишете код на Java, полезно знать такие классы, как Thread, Runnable, Callable и ExecutorService, поскольку это помогает избежать повторного изобретения параллелизма из низкоуровневых примитивов.
Заключительные мысли
Интервью по объектно-ориентированному дизайну — это нечто большее, чем просто технические навыки. Это умение ясно мыслить под давлением, эффективно общаться и применять принципы OOP для построения поддерживаемых, масштабируемых решений.
Разбивая процесс на управляемые шаги и учась справляться с неожиданными трудностями, вы будете хорошо подготовлены к любым, даже самым непредсказуемым интервью. С практикой и правильным настроем вы сможете превратить неожиданные повороты в возможности и оставить незабываемое впечатление.
Основы OOP
Эта глава знакомит с OOP — популярной парадигмой программирования, которая организует код и данные в объекты. Эти объекты взаимодействуют для выполнения задач и моделирования сущностей реального мира, обеспечивая структурированный подход к построению гибкого, поддерживаемого программного обеспечения.
Зачем изучать OOP?
Понимание основ OOP, включая базовые принципы, такие как инкапсуляция, и продвинутые рекомендации по дизайну, такие как SOLID, является ключом к успеху на OOD-интервью. Знания OOP обеспечивают вас чёткой ментальной моделью и базовыми навыками. Они позволяют вам принимать дизайнерские решения, согласованные с широко принятыми принципами, артикулировать своё обоснование интервьюерам и использовать устоявшиеся паттерны для эффективного решения типичных задач.
OOP-интервью часто отражают реальные бизнес-приложения и технические компоненты. Понимание концепций OOP и принципов SOLID не только готовит вас к интервью, но и делает вас более сильным разработчиком после найма. Несмотря на то что функциональное программирование и другие парадигмы набирают популярность, OOP остаётся основой общей разработки программного обеспечения. Чтобы углубить свою экспертизу, дополните базовые сведения из этой главы дополнительными ресурсами по принципам OOP.
Краеугольные камни объектно-ориентированного программирования
Объектно-ориентированное программирование строится на четырёх фундаментальных принципах: инкапсуляция, абстракция, наследование и полиморфизм.
Эти принципы определяют, как мы организуем код и проектируем программное обеспечение. Другие техники и паттерны проектирования вытекают из этих принципов, и они важны для оценки решений. Давайте рассмотрим каждый принцип на практических примерах.
Инкапсуляция
Инкапсуляция — это концепция объединения данных в виде атрибутов и логики в виде методов, помещения связанных атрибутов и методов в единый блок, называемый объектом. Внутреннее состояние объекта скрыто от внешнего мира, а доступ к данным или состоянию контролируется через хорошо определённые интерфейсы, доступные как публичные методы. Описание типа объекта называется классом, а конкретный объект называется экземпляром.
Чтобы увидеть, как инкапсуляция работает на практике, рассмотрим класс Person, который объединяет данные, такие как имя и возраст, с методами для управления ими, контролируя доступ к этим данным.
### Как достичь инкапсуляции?
Для достижения инкапсуляции выполните следующие шаги:
1. **Определите классы:** Определите объекты в ваших требованиях, подумайте о данных, которые они хранят, и функциональности, которую они поддерживают. Для класса Person данные включают имя и возраст, а функциональность — доступ к этим атрибутам и их изменение. 2. **Обеспечьте инкапсуляцию:** Объявите члены данных (атрибуты) класса как private, чтобы ограничить прямой доступ извне класса. Предоставьте публичные методы (геттеры и сеттеры) для доступа к этим атрибутам и их изменения. 3. **Используйте модификаторы доступа:** Модификатор доступа private ограничивает прямой доступ к атрибутам снаружи класса. Только методы класса имеют доступ к этим закрытым членам. Публичные методы — это интерфейс, через который внешний код взаимодействует с атрибутами объекта, скрывая внутренние детали реализации и поддерживая целостность объекта.
### Реализация инкапсуляции в Java
Продемонстрируем инкапсуляцию в Java на примере простого класса «Person»:
```java public class Person { // Private data members (attributes) private String name; private int age;
// Public constructor public Person(String name, int age) { this.name = name; this.age = age; }
// Public getter methods (accessors) public String getName() { return name; }
public int getAge() { return age; }
// Public setter methods (mutators) public void setName(String name) { this.name = name; }
public void setAge(int age) { if (age >= 0) { this.age = age; } } } ```
- Класс Person имеет закрытые атрибуты `name` и `age`, к которым нельзя обратиться напрямую извне класса. - Публичные методы-геттеры, `getName()` и `getAge()`, позволяют внешнему коду читать (получать доступ к) закрытые атрибуты. - Методы-сеттеры, `setName()` и `setAge()`, также публичны, позволяя внешнему коду изменять (мутировать) закрытые атрибуты.
Эта реализация гарантирует, что внутреннее состояние объекта Person защищено, а внешний код взаимодействует с ним только через контролируемые методы.
### Когда использовать инкапсуляцию?
Инкапсуляция особенно полезна в следующих сценариях:
- **Защита целостности данных:** Когда необходимо обеспечить согласованность и достоверность данных объекта. Например, в классе Person инкапсуляция скрывает имя и возраст как закрытые атрибуты, позволяя методу `setAge()` применять правила, такие как неотрицательные значения, предотвращая недопустимые изменения и обеспечивая надёжность состояния объекта. - **Контроль доступа и повышение безопасности:** Инкапсуляция ограничивает прямой доступ к конфиденциальным данным. Хотя такие атрибуты, как имя и возраст в классе Person, не являются особо конфиденциальными, инкапсуляция становится незаменимой для классов, обрабатывающих критическую информацию, такую как пароли. Хотя сама по себе инкапсуляция не гарантирует полной безопасности, она действует как базовый уровень защиты, ограничивая нежелательный доступ. - **Модульность и повторное использование:** При проектировании классов, которые можно повторно использовать в разных приложениях. Чёткий интерфейс класса Person делает его модульным и пригодным для повторного использования в таких контекстах, как системы управления школой или социальные сети.
### Типичные ошибки
Хотя инкапсуляция мощна, избегайте следующих распространённых ошибок:
- **Избыточная инкапсуляция:** Создание излишнего количества геттеров и сеттеров для каждого атрибута может сделать код многословным и трудным для поддержки. - **Недостаточная инкапсуляция:** Неспособность скрыть внутренние детали может привести к тесной связанности и снижению модульности. Например, если бы имя и возраст в классе Person были публичными, другие части кода могли бы изменять их напрямую, что привело бы к потенциальным несоответствиям.
Абстракция
Абстракция может упрощать сложные системы, скрывая ненужные детали. Она разделяет «что» делает объект и «как» он это делает, позволяя пользователям взаимодействовать с объектами через упрощённые интерфейсы. Например, кнопка регулировки громкости на пульте телевизора предоставляет простой способ регулировать звук, не открывая внутренние схемы телевизора. В программировании абстракция достигается с помощью таких механизмов, как абстрактные классы и интерфейсы.
Чтобы увидеть, как абстракция работает на практике, рассмотрим класс Shape и интерфейс Drawable, которые определяют упрощённые поведения для фигур, таких как круг.
### Как достичь абстракции?
Для достижения абстракции используйте абстрактные классы и интерфейсы: определяйте абстрактные классы или интерфейсы с абстрактными методами, которые объявляются без реализации и должны быть реализованы подклассами. Они позволяют пользователям вызывать методы, не зная их внутренних деталей.
### Реализация абстракции в Java
Продемонстрируем абстракцию в Java на примере абстрактного класса Shape и интерфейса Drawable:
```java // Abstract class abstract class Shape { protected String color;
public Shape(String color) { this.color = color; }
// Abstract method public abstract double area();
// Concrete method public void displayColor() { System.out.println("This shape is " + color + "."); } }
// Interface interface Drawable { void draw(); }
// Concrete class implementing Shape and Drawable class Circle extends Shape implements Drawable { private double radius;
public Circle(String color, double radius) { super(color); this.radius = radius; }
// Implementing abstract method from Shape @Override public double area() { return Math.PI * radius * radius; }
// Implementing method from Drawable interface @Override public void draw() { System.out.println("Drawing a circle."); } } ```
Реализация демонстрирует абстракцию через:
- Абстрактный класс Shape, определяющий абстрактный метод `area()`, который подклассы должны реализовать, и конкретный метод `displayColor()`, предоставляющий поведение по умолчанию. - Интерфейс Drawable, объявляющий метод `draw()`, который реализующие классы должны определить. - Класс Circle, расширяющий Shape и реализующий Drawable, предоставляя конкретные реализации для `area()` (вычисление площади круга) и `draw()` (описание действия рисования).
Эта структура позволяет пользователям взаимодействовать с фигурами через высокоуровневые методы, такие как `area()` и `draw()`, не зная лежащей в основе логики рисования.
### Когда использовать абстракцию?
Абстракция особенно полезна в следующих сценариях:
- **Упрощение сложных систем:** Абстракция помогает предоставить чистый и согласованный интерфейс для сложной функциональности. Например, в классе Shape абстракция позволяет пользователям вызывать `area()`, не понимая математических вычислений, что упрощает использование системы. - **Обеспечение гибкости кода:** Когда ожидается, что подклассы будут предоставлять конкретные реализации обобщённого поведения, абстракция становится незаменимой. Абстрактный метод `area()` класса Shape гарантирует, что такие фигуры, как круги или прямоугольники, реализуют свои расчёты площади, обеспечивая гибкость дизайна. - **Поддержка расширяемости:** Абстракция упрощает расширение систем без изменения существующего кода. Например, добавление новой фигуры Triangle в иерархию Shape требует только реализации `area()`, без изменения существующего кода, использующего фигуры.
### Абстракция vs. инкапсуляция
Абстракция и инкапсуляция — различные, но взаимодополняющие принципы OOP, которые часто путают, потому что оба включают сокрытие деталей. Вот в чём они отличаются:
| Характеристика | Абстракция | Инкапсуляция | |---|---|---| | Фокус | Сокрытие сложности путём предоставления только того, что делает объект, через упрощённые интерфейсы, без раскрытия того, как он это делает. | Объединение данных и методов в единый блок (класс) и защита данных путём ограничения прямого доступа. | | Цель | Упрощает взаимодействие пользователей и обеспечивает гибкость путём определения высокоуровневых поведений. | Обеспечивает целостность данных и поддерживаемость путём контроля доступа к данным объекта. | | Реализация | Использует абстрактные классы и интерфейсы, например, абстрактный класс Shape с `area()` или интерфейс Drawable с `draw()`. | Использует модификаторы доступа (например, private, public) и методы, например, закрытый radius в Circle с публичными геттерами и сеттерами. |
Понимая эти различия, вы можете применять абстракцию для упрощения интерфейсов, а инкапсуляцию — для защиты данных, создавая надёжные и удобные системы.
Наследование
Наследование позволяет классу (подклассу или производному классу) наследовать свойства и поведения от другого класса (суперкласса или базового класса). Оно способствует повторному использованию кода и создаёт иерархические отношения между классами. Думайте о наследовании как о семейном дереве, где дети наследуют черты от родителей, а внуки наследуют черты как от родителей, так и от прародителей. Подкласс может расширять и специализировать функциональность своего суперкласса, сокращая дублирование кода.
### Общие паттерны иерархии классов
Опираясь на концепцию наследования, рассмотрим общие паттерны структурирования иерархий классов в Java.
**Одиночное наследование** — Подкласс расширяет только один суперкласс. Это стандартный тип наследования, поддерживаемый в Java.
**Многоуровневое наследование** — Подкласс, который наследует от другого подкласса, создавая цепочку наследования. Рассмотрим сценарий с тремя классами: Animal, Mammal и Dog, где Animal является суперклассом Mammal, а Mammal является суперклассом Dog.
**Иерархическое наследование** — Несколько подклассов наследуют от одного суперкласса, образуя иерархическую структуру. Например, Car и Motorcycle оба наследуют от класса Vehicle.
### Когда использовать наследование?
Наследование особенно полезно в следующих сценариях:
- Всякий раз, когда мы встречаем отношение «является» между объектами. - Когда несколько классов имеют общие атрибуты или методы, суперкласс может определить их один раз, позволяя всем подклассам наследовать их и избегать дублирования. - Когда классы образуют естественную иерархию, например Animal как родитель Dog и Cat.
### Недостатки наследования
Хотя наследование способствует повторному использованию кода, его злоупотребление может усложнить дизайны:
- **Тесная связанность:** Подклассы сильно зависят от своего суперкласса. Изменения в суперклассе могут сломать подклассы, что затрудняет поддержку кода. - **Наследование неподходящего поведения:** Наследование может вынудить подклассы наследовать поведения, которые к ним не применимы. Например, добавление метода `fly()` в суперкласс Animal предполагает, что все подклассы (например, Penguin) могут летать. - **Ограниченная гибкость:** Наследование фиксирует отношения во время проектирования. Если позже вам понадобится RobotDog, который лает, но не ест, он не может наследовать от Animal без наследования нерелевантных методов.
Для решения этих проблем рассмотрите альтернативы, такие как композиция (комбинирование объектов) или интерфейсы, которые обеспечивают гибкость и слабую связанность.
### Наследование vs. Композиция
Наследование создаёт отношение «является». Композиция создаёт отношение «имеет», где класс содержит другие объекты для обеспечения своего поведения.
Рассмотрим сценарий с Dog и RobotDog. Используя наследование, RobotDog расширяет Animal для наследования `bark()`, но также получает `eat()`, что к нему не применимо. Используя композицию, вы определяете интерфейс BarkBehavior с методом `bark()`. Dog и RobotDog каждый имеют объект BarkBehavior, реализованный по-разному (например, DogBark для «Woof!» и RobotBark для «Beep!»). Это позволяет RobotDog лаять без наследования `eat()`.
```java interface BarkBehavior { void bark(); }
class DogBark implements BarkBehavior { public void bark() { System.out.println("Woof!"); } }
class RobotBark implements BarkBehavior { public void bark() { System.out.println("Beep!"); } }
class Dog { private BarkBehavior barkBehavior;
public Dog(BarkBehavior barkBehavior) { this.barkBehavior = barkBehavior; }
public void bark() { barkBehavior.bark(); } }
class RobotDog { private BarkBehavior barkBehavior;
public RobotDog(BarkBehavior barkBehavior) { this.barkBehavior = barkBehavior; }
public void bark() { barkBehavior.bark(); } }
public class Main { public static void main(String[] args) { Dog dog = new Dog(new DogBark()); RobotDog robotDog = new RobotDog(new RobotBark()); dog.bark(); // Output: Woof! robotDog.bark(); // Output: Beep! } } ```
> **Выбор дизайна:** Используйте наследование для чётких отношений «является» со стабильными, разделяемыми поведениями. Выбирайте композицию для отношений «имеет» или когда вам нужны гибкие, заменяемые поведения, поскольку её легче изменять и поддерживать. На OOD-интервью отдавайте предпочтение композиции, когда ключевыми являются гибкость или слабая связанность, так как она предпочтительна в современном дизайне.
Полиморфизм
Полиморфизм — это концепция реализации объектов, которые могут принимать различные формы или вести себя по-разному в зависимости от контекста, при этом используя общий интерфейс. Он обеспечивает гибкость для добавления новых поведений без изменения существующего кода.
Рассмотрим медиаплеер как пример из реального мира. Различные типы медиа, такие как аудио, видео и потоковый контент, воспроизводятся на одном виджете отображения и управляются одной кнопкой «воспроизведение». Но они требуют разной внутренней обработки и логики отображения. Пользователь взаимодействует только с единым интерфейсом, а полиморфное поведение управляет различными объектами.
### Типы полиморфизма
Полиморфизм обычно подразделяется на два основных типа: полиморфизм на этапе компиляции (статический) и полиморфизм на этапе выполнения (динамический).
**Полиморфизм на этапе компиляции через перегрузку методов** — Перегрузка методов позволяет классу иметь несколько методов с одинаковым именем, но разными параметрами. Компилятор определяет подходящий метод для вызова на основе количества и типа аргументов, переданных во время компиляции.
```java class MathOperations { public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
public String add(String str1, String str2) { return str1 + str2; } }
public class Main { public static void main(String[] args) { MathOperations math = new MathOperations();
int sum1 = math.add(5, 10); double sum2 = math.add(3.5, 7.2); String result = math.add("Hello, ", "World!");
System.out.println("Sum of integers: " + sum1); System.out.println("Sum of doubles: " + sum2); System.out.println("Concatenated string: " + result); } } ```
Класс MathOperations определяет несколько методов `add` с разными типами параметров или их количеством. Компилятор выбирает подходящий метод `add` на основе переданных аргументов, повышая читаемость кода.
**Полиморфизм на этапе выполнения через переопределение методов** — Переопределение методов происходит, когда подкласс предоставляет конкретную реализацию для метода, уже определённого в его суперклассе. Метод, который нужно выполнить, определяется во время выполнения на основе фактического типа объекта. Это часто называют динамической диспетчеризацией.
```java class Animal { public void sound() { System.out.println("Animal makes a sound."); } }
class Dog extends Animal { @Override public void sound() { System.out.println("Dog barks: Woof!"); } }
class Cat extends Animal { @Override public void sound() { System.out.println("Cat meows: Meow!"); } }
public class Main { public static void main(String[] args) { Animal animal1 = new Dog(); Animal animal2 = new Cat();
animal1.sound(); // Dog's sound() method is called animal2.sound(); // Cat's sound() method is called } } ```
- Класс Animal определяет общий метод `sound`. - Подклассы Dog и Cat переопределяют `sound`, предоставляя конкретные реализации. - Во время выполнения JVM определяет фактический тип объекта (Dog или Cat) и вызывает соответствующий метод `sound`, даже если ссылочный тип — Animal.
### Когда использовать полиморфизм?
Полиморфизм особенно ценен в следующих сценариях:
- **Общий интерфейс:** Когда несколько классов должны выполнять одно и то же действие по-разному, например, метод `play` для различных типов медиа. Интерфейсы или суперклассы обеспечивают согласованный контракт между реализациями. - **Расширяемость:** При проектировании систем, которые должны принимать новые классы без изменения существующего кода. Добавление нового типа медиа требует только реализации существующего интерфейса воспроизведения. - **Настройка:** Когда подклассы должны адаптировать поведение унаследованных методов. Например, Dog лает иначе, чем Cat, использует переопределение метода для предоставления конкретных реализаций при соблюдении интерфейса Animal.
Принципы SOLID хорошего дизайна
Помимо основных принципов OOP (инкапсуляция, абстракция, наследование и полиморфизм), вы также должны быть знакомы с принципами SOLID. SOLID предлагает рекомендации для создания программного обеспечения, которое легко понимать, изменять и расширять. Эти принципы особенно ценны на OOD-интервью, где умение артикулировать дизайнерские решения и их обоснование может помочь вам выделиться.
Аббревиатура SOLID расшифровывается как:
- **S** — Single Responsibility Principle (SRP) — Принцип единственной ответственности - **O** — Open/Closed Principle (OCP) — Принцип открытости/закрытости - **L** — Liskov Substitution Principle (LSP) — Принцип подстановки Лисков - **I** — Interface Segregation Principle (ISP) — Принцип разделения интерфейсов - **D** — Dependency Inversion Principle (DIP) — Принцип инверсии зависимостей
Принцип единственной ответственности (SRP)
Принцип единственной ответственности (SRP) гласит, что класс должен иметь только одну причину для изменения — он должен иметь единственную, хорошо определённую ответственность или задачу в программной системе.
**Нарушение SRP**
Вот пример класса, нарушающего SRP, берущего на себя несколько ответственностей:
```java class Employee { private String name; private double salary;
public Employee(String name, double salary) { this.name = name; this.salary = salary; }
public double calculateSalary() { return salary * 12; // Annual salary }
public void generatePayrollReport() { System.out.println("Payroll Report for " + name + ": $" + salary * 12); } } ```
Класс Employee нарушает SRP, поскольку несёт две ответственности: расчёт зарплаты сотрудника и генерацию платёжной ведомости. Это означает, что класс может изменяться по двум несвязанным причинам.
**Исправление нарушения**
Для устранения нарушения выполните рефакторинг кода, разделив ответственности:
- Класс `Employee` управляет данными сотрудника (имя, зарплата) и рассчитывает годовую зарплату. - Класс `PayrollReportGenerator` принимает данные сотрудника и создаёт платёжные ведомости.
Это разделение гарантирует, что изменения в расчётах зарплаты не повлияют на отчётность, а обновления форматов отчётов не затронут данные сотрудника.
**Лучшие практики**
- Стремитесь определить чёткую роль для каждого класса, фокусируясь на одной конкретной задаче. - Если класс обрабатывает несколько задач, выполните рефакторинг, разбив его на меньшие, сфокусированные классы с единственными ответственностями. - Проектируйте классы так, чтобы изменения в одной задаче не влияли на другие.
Принцип открытости/закрытости (OCP)
Принцип открытости/закрытости (OCP) гласит, что программные сущности должны быть открыты для расширения, но закрыты для изменения. Это означает, что вы можете добавлять новую функциональность, не изменяя существующий код.
**Нарушение OCP**
Вот пример класса, нарушающего OCP, требующего изменений для поддержки новых фигур:
```java class Rectangle { private double width; private double height;
public Rectangle(double width, double height) { this.width = width; this.height = height; }
public double calculateArea() { return width * height; } }
class AreaCalculator { public double calculateArea(Rectangle rectangle) { return rectangle.calculateArea(); } } ```
Класс `AreaCalculator` работает только с объектами Rectangle. Добавление поддержки новых фигур, например, кругов или треугольников, потребует изменения его кода.
**Исправление нарушения**
Выполните рефакторинг, введя абстрактный класс Shape, определяющий общее поведение:
- Конкретные фигуры, такие как Rectangle и Circle, наследуют от Shape и предоставляют свои расчёты площади. - Этот дизайн позволяет добавлять новые фигуры (например, Triangle), создавая новые классы, наследующие от Shape, без изменения `AreaCalculator` или существующих классов фигур.
**Лучшие практики**
- Вводите абстрактные классы или интерфейсы для создания гибких шаблонов, которые классы могут расширять новой функциональностью. - Позволяйте подклассам переопределять методы для предоставления конкретных поведений. - Используйте полиморфизм для единообразного обращения с объектами разных классов через общий интерфейс.
Принцип подстановки Лисков (LSP)
Принцип подстановки Лисков (LSP) гласит, что объекты производного класса должны быть способны заменять объекты базового класса без нарушения корректности программы.
**Нарушение LSP**
Вот пример дизайна, нарушающего LSP, предполагающего, что все птицы умеют летать:
```java class Bird { public void fly() { System.out.println("Flying in the sky."); } }
class Ostrich extends Bird { @Override public void fly() { throw new UnsupportedOperationException("Ostriches cannot fly."); } }
// Program calls bird.fly() to test bird behavior ```
Класс Ostrich наследует от Bird, но генерирует исключение для `fly()`, поскольку страусы не умеют летать. Это нарушает ожидание того, что любая Bird может летать, нарушая принцип подстановки.
**Исправление нарушения**
Выполните рефакторинг иерархии для обеспечения подстановки:
- Переопределите класс Bird с более общим поведением, таким как `move()`, которое все птицы могут выполнять. - Класс Bird определяет метод `move`, который каждая птица реализует в соответствии со своими возможностями — Sparrow летит, Ostrich бежит по земле.
**Лучшие практики**
- Убедитесь, что производные классы сохраняют поведенческую совместимость со своими базовыми классами. - При переопределении методов соблюдайте контракты методов базового класса (предусловия, постусловия, инварианты). - Используйте полиморфизм, чтобы объекты производного класса могли заменять объекты базового класса.
Принцип разделения интерфейсов (ISP)
Принцип разделения интерфейсов (ISP) подчёркивает, что клиенты не должны быть вынуждены зависеть от интерфейсов, которые они не используют. Интерфейс должен иметь конкретный и сфокусированный набор методов, релевантных для реализующих классов.
**Нарушение ISP**
Вот пример, нарушающий ISP путём включения методов, которые не нужны всем реализующим классам:
```java interface Worker { void work(); void eat(); void sleep(); }
class Robot implements Worker { public void work() { System.out.println("Performing tasks like welding."); }
public void eat() { throw new UnsupportedOperationException("Robots don't eat."); }
public void sleep() { throw new UnsupportedOperationException("Robots don't sleep."); } }
class Human implements Worker { public void work() { System.out.println("Performing tasks like coding."); }
public void eat() { System.out.println("Eating a meal."); }
public void sleep() { System.out.println("Sleeping for rest."); } } ```
Интерфейс Worker вынуждает Robot реализовывать `eat` и `sleep`, которые не применимы, что приводит к неподдерживаемым операциям.
**Исправление нарушения**
Выполните рефакторинг, разбив на сфокусированные интерфейсы:
- `Workable` — включает только метод `work`, применимый ко всем работникам. - `Eatable` — включает `eat`, релевантный для людей, но не роботов. - `Sleepable` — включает `sleep`, специфичный для людей.
Этот дизайн гарантирует, что классы реализуют только те методы, которые им нужны.
**Лучшие практики**
- Проектируйте интерфейсы с конкретной целью, включая только методы, непосредственно связанные с этой целью. - Создавайте несколько меньших интерфейсов, которые классы могут выбирать для реализации. - Думайте с точки зрения классов, реализующих интерфейс.
Принцип инверсии зависимостей (DIP)
Принцип инверсии зависимостей (DIP) гласит, что высокоуровневые модули не должны зависеть от низкоуровневых; оба должны зависеть от абстракций. Это поощряет использование абстрактных интерфейсов для отделения высокоуровневых компонентов от низкоуровневых деталей.
**Нарушение DIP**
Вот пример, нарушающий DIP за счёт прямой зависимости высокоуровневого класса от низкоуровневого:
```java class LightBulb { public void turnOn() { System.out.println("LightBulb is on."); }
public void turnOff() { System.out.println("LightBulb is off."); } }
class Switch { private LightBulb bulb;
public Switch(LightBulb bulb) { this.bulb = bulb; }
public void operate() { bulb.turnOn(); } } ```
Класс Switch напрямую зависит от низкоуровневого класса LightBulb. Эта тесная связанность означает, что изменение LightBulb или его замена другим устройством (например, Fan) требует изменения класса Switch.
**Исправление нарушения**
Выполните рефакторинг, используя абстракции:
- Введите интерфейс `Switchable`, определяющий стандартные методы (`turnOn`, `turnOff`). - Измените класс Switch так, чтобы он зависел только от интерфейса `Switchable`. - Класс `LightBulb` реализует `Switchable`.
Теперь Switch может работать с любым устройством, реализующим Switchable (Fan, Heater), без изменений.
**Лучшие практики**
- Вводите интерфейсы или абстрактные классы для представления зависимостей, позволяя высокоуровневым модулям зависеть от этих абстракций. - Используйте внедрение зависимостей для передачи конкретных реализаций в высокоуровневые модули через их абстракции. - Это способствует слабой связанности и делает систему более гибкой и простой для расширения.
Подведение итогов
Эта глава помогла вам понять, как использовать инкапсуляцию, абстракцию, наследование, полиморфизм и принципы SOLID. Эти концепции составляют основу надёжного программного дизайна, позволяя вам создавать гибкие, поддерживаемые и масштабируемые системы. Применение этих инструментов поможет вам чётко формулировать дизайнерские решения, обосновывать свой подход и демонстрировать соответствие отраслевым стандартам.
Для дальнейшего совершенствования навыков и углубления экспертизы изучите следующие ресурсы:
- *Clean Code: A Handbook of Agile Software Craftsmanship* by Robert C. Martin - *Design Patterns: Elements of Reusable Object-Oriented Software* by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides - *Tidy First?: A Personal Exercise in Empirical Software Design* by Kent Beck
Проектирование системы парковки
В этой главе мы рассматриваем объектно-ориентированный дизайн системы парковки — один из наиболее популярных вопросов на технических интервью. Данное приложение для парковки направлено на предоставление комплексного решения для эффективного управления парковкой. Оно автоматизирует различные процессы, включая въезд и выезд транспортных средств и распределение мест, а также предоставляет точную информацию о заполненности парковки и генерирует парковочные талоны.
Для построения этой системы нам сначала необходимо прояснить её требования.
Сбор требований
Первый шаг в проектировании системы парковки — прояснение требований и определение области задачи. Вот пример типичного задания, которое может представить интервьюер:
> **Интервьюер:** «Представьте, что вы приезжаете на занятую парковку, стремясь припарковать свой автомобиль. При въезде вам выдают талон. Затем вы въезжаете, находите место, подходящее для вашего транспортного средства, и паркуетесь. Позже, когда вы готовитесь уехать, вы предъявляете талон на выезде, система рассчитывает стоимость, и место освобождается для следующего транспортного средства. За кулисами парковка распределяет места на основе размера транспортного средства, записывает время въезда и выезда и обновляет доступность для новых приезжающих. Теперь давайте спроектируем систему парковки, которая справляется со всем этим.»
---
> **Кандидат:** Какие типы транспортных средств поддерживает парковка?
> **Интервьюер:** Должны поддерживаться три типа: мотоциклы, автомобили и грузовики.
> **Кандидат:** Какие типы парковочных мест доступны на парковке?
> **Интервьюер:** Парковка поддерживает три типа мест: компактные, обычные и увеличенные.
> **Кандидат:** Как система определяет, на каком месте должно парковаться транспортное средство?
> **Интервьюер:** Система назначает места на основе размера транспортного средства, обеспечивая подходящее соответствие.
> **Кандидат:** Выдаются ли транспортным средствам парковочные талоны при въезде и взимается ли плата при выезде?
> **Интервьюер:** Да, при въезде выдаётся талон с данными транспортного средства и временем въезда. При выезде система рассчитывает стоимость на основе продолжительности и размера транспортного средства, затем помечает место как свободное.
> **Кандидат:** Как рассчитывается стоимость парковки?
> **Интервьюер:** Стоимость зависит от продолжительности парковки и размера транспортного средства, с тарифами, варьирующимися в зависимости от времени суток.
**Требования**
Вот ключевые функциональные требования, которые мы определили:
- Парковка имеет несколько парковочных мест, включая компактные, обычные и увеличенные. - Парковка поддерживает мотоциклы, автомобили и грузовики. - Клиенты могут парковать транспортные средства на местах, назначенных на основе размера транспортного средства. - Клиенты получают парковочный талон с данными транспортного средства и временем въезда на въезде и оплачивают стоимость на основе продолжительности, размера транспортного средства и времени суток при выезде.
Нефункциональные требования:
- Система должна масштабироваться для поддержки больших парковок с многочисленными местами и транспортными средствами. - Система должна надёжно отслеживать назначения мест и детали талонов для обеспечения точных операций.
Определение основных объектов
Перед погружением в дизайн важно перечислить основные объекты.
- **Vehicle:** Представляет транспортное средство, которому нужно место. Инкапсулирует такие детали, как номерной знак и размер (SMALL для мотоциклов, MEDIUM для автомобилей, LARGE для грузовиков). - **ParkingSpot:** Моделирует отдельное парковочное место на парковке, обеспечивая возможность парковки только транспортных средств подходящего размера исходя из его вместимости. - **Ticket:** Представляет парковочный талон, выдаваемый при въезде Vehicle. Хранит ID талона, связанное Vehicle, назначенный ParkingSpot и время въезда. - **ParkingManager:** Контролирует распределение мест, управляя назначением, поиском и освобождением экземпляров ParkingSpot. - **ParkingLot:** Действует как фасад, предоставляя центральный интерфейс для управления въездом транспортных средств, назначением мест, системой талонов и расчётом стоимости.
> **Выбор дизайна:** Мы выбрали эти пять объектов для разделения ответственностей. Vehicle и ParkingSpot определяют основные физические сущности, Ticket отслеживает сессии, ParkingManager обрабатывает распределение, а ParkingLot координирует как фасад.
Дизайн диаграммы классов
Теперь, когда мы определили основные объекты и их ответственности, следующий шаг — спроектировать классы и методы, которые воплотят систему парковки в жизнь.
### Vehicle
Мы смоделировали Vehicle как интерфейс для установки стандарта для всех типов транспортных средств. Он определяет два ключевых метода:
- `getLicensePlate()`: Возвращает номерной знак транспортного средства. - `getSize()`: Возвращает перечисление VehicleSize (SMALL, MEDIUM, LARGE), указывающее занимаемое пространство.
Конкретные классы — Motorcycle, Car и Truck — реализуют интерфейс Vehicle, каждый определяя свой размер.
> **Выбор дизайна:** Использование `getSize()` вместо `getType()` абстрагирует конкретные названия транспортных средств. Грузовик и микроавтобус могут быть оба LARGE, поэтому с ними обращаются одинаково для целей парковки. Добавление нового типа транспортного средства требует только указания его размера — система остаётся лёгкой и адаптируемой.
Дизайн ParkingSpot
Интерфейс ParkingSpot представляет парковочное место. Конкретные классы (CompactSpot, RegularSpot, OversizedSpot) реализуют его для малых, средних и крупных транспортных средств соответственно.
> **Выбор дизайна:** ParkingSpot намеренно сохранён простым, управляя только своим состоянием (доступность и размер). Сложные операции, такие как поиск доступных мест и мониторинг припаркованных транспортных средств, делегированы ParkingManager. Это упрощает добавление новых типов мест без внесения ненужной сложности.
Дизайн ParkingManager
ParkingManager отвечает за управление распределением и отслеживанием парковочных мест. Его основные функции:
- `parkVehicle(Vehicle vehicle)`: Назначает место, соответствующее размеру транспортного средства, при его прибытии. - `unparkVehicle(Vehicle vehicle)`: Освобождает место при выезде транспортного средства.
> **Выбор дизайна:** ParkingManager централизует распределение и отслеживание мест, позволяя ParkingLot оставаться лёгким фасадом. Это разделение ответственностей повышает модульность и масштабируемость.
Дизайн Ticket
Класс Ticket представляет парковочный талон, генерируемый при въезде транспортного средства. Он отслеживает время въезда/выезда для расчёта продолжительности и связывает транспортное средство с назначенным ему местом.
> **Выбор дизайна:** Ticket спроектирован как лаконичная, неизменяемая запись парковочного события. Его основная роль — контейнер данных; сложная логика, такая как расчёт стоимости, делегирована FareCalculator.
Дизайн FareStrategy и FareCalculator
Интерфейс FareStrategy устанавливает стандартный метод для изменения стоимости парковки. Конкретные классы обрабатывают специфические правила ценообразования:
- **BaseFareStrategy:** Устанавливает базовую стоимость на основе продолжительности талона и размера транспортного средства. - **PeakHoursFareStrategy:** Изменяет стоимость в зависимости от времени суток (на 50% выше в часы пик).
FareCalculator координирует эти стратегии для расчёта итоговой стоимости. Логика ценообразования опирается на **Strategy Pattern (паттерн Стратегия)**, который обеспечивает динамический выбор и замену правил ценообразования.
> **Примечание:** Чтобы узнать больше о Strategy Pattern (паттерне Стратегия), обратитесь к разделу «Дополнительное чтение» в конце этой главы.
> **Выбор дизайна:** Интерфейс FareStrategy обеспечивает модульные, взаимозаменяемые правила ценообразования. Использование `List<FareStrategy>` в FareCalculator сохраняет порядок стратегий — BaseFareStrategy должна выполняться перед PeakHoursFareStrategy для корректного расчёта.
Дизайн ParkingLot
Класс ParkingLot действует как **фасад**, предоставляя простой интерфейс для управления ключевыми операциями парковки. Он управляет въездом транспортных средств (генерация талонов, назначение мест) и выездом (расчёт стоимости), делегируя задачи ParkingManager и FareCalculator.
> **Примечание:** Чтобы узнать больше о Facade Pattern (паттерне Фасад), обратитесь к разделу «Дополнительное чтение» в конце этой главы.
Полная диаграмма классов
Уделите момент, чтобы рассмотреть полную структуру классов и отношения между ними. Эта диаграмма демонстрирует, как внешне сложную систему можно построить из простых, хорошо спроектированных компонентов, работающих согласованно.
Код — Система парковки
В этом разделе мы реализуем основные функциональности системы парковки.
### Vehicle
Мы определяем интерфейс Vehicle, перечисление VehicleSize и конкретный класс Car:
```java public interface Vehicle { String getLicensePlate(); VehicleSize getSize(); }
public class Car implements Vehicle { private String licensePlate;
public Car(String licensePlate) { this.licensePlate = licensePlate; }
@Override public String getLicensePlate() { return this.licensePlate; }
@Override public VehicleSize getSize() { return VehicleSize.MEDIUM; } }
public enum VehicleSize { SMALL, MEDIUM, LARGE } ```
Интерфейс гарантирует, что каждое транспортное средство предоставляет номерной знак для отслеживания и размер для управления парковочными местами. Классы Motorcycle и Truck следуют той же структуре, что и Car.
> **Выбор реализации:** Перечисление `VehicleSize` (SMALL, MEDIUM, LARGE) стандартизирует размеры транспортных средств и парковочных мест, обеспечивая типобезопасные, безошибочные сравнения размеров. Альтернативы в виде строк (подверженных опечаткам) или целых чисел (неоднозначных) были отклонены из-за хрупкости и отсутствия типовой безопасности.
### ParkingSpot
```java public interface ParkingSpot { boolean isAvailable(); void occupy(Vehicle vehicle); void vacate(); int getSpotNumber(); VehicleSize getSize(); }
public class CompactSpot implements ParkingSpot { private int spotNumber; private Vehicle vehicle; // The vehicle currently occupying this spot
public CompactSpot(int spotNumber) { this.spotNumber = spotNumber; this.vehicle = null; // No vehicle occupying initially }
@Override public int getSpotNumber() { return spotNumber; }
@Override public boolean isAvailable() { return vehicle == null; }
@Override public void occupy(Vehicle vehicle) { if (isAvailable()) { this.vehicle = vehicle; } else { // Spot is already occupied. } }
@Override public void vacate() { this.vehicle = null; // Make the spot available }
@Override public VehicleSize getSize() { return VehicleSize.SMALL; // Compact spots fit small vehicles } } ```
RegularSpot возвращает `VehicleSize.MEDIUM`, а OversizedSpot возвращает `VehicleSize.LARGE`, следуя той же структуре, что и CompactSpot.
### ParkingManager
```java public class ParkingManager { private final Map<VehicleSize, List<ParkingSpot>> availableSpots; private final Map<Vehicle, ParkingSpot> vehicleToSpotMap;
// Create Parking Manager based on a given map of available spots public ParkingManager(Map<VehicleSize, List<ParkingSpot>> availableSpots) { this.availableSpots = availableSpots; this.vehicleToSpotMap = new HashMap<>(); }
public ParkingSpot findSpotForVehicle(Vehicle vehicle) { VehicleSize vehicleSize = vehicle.getSize();
// Start looking for the smallest spot that can fit the vehicle for (VehicleSize size : VehicleSize.values()) { if (size.ordinal() >= vehicleSize.ordinal()) { List<ParkingSpot> spots = availableSpots.get(size); for (ParkingSpot spot : spots) { if (spot.isAvailable()) { return spot; // Return the first available spot } } } } return null; // No suitable spot found }
public ParkingSpot parkVehicle(Vehicle vehicle) { ParkingSpot spot = findSpotForVehicle(vehicle); if (spot != null) { spot.occupy(vehicle); vehicleToSpotMap.put(vehicle, spot); availableSpots.get(spot.getSize()).remove(spot); return spot; // Parking successful } return null; // No spot found for this vehicle }
public void unparkVehicle(Vehicle vehicle) { ParkingSpot spot = vehicleToSpotMap.remove(vehicle); if (spot != null) { spot.vacate(); availableSpots.get(spot.getSize()).add(spot); } } } ```
- `findSpotForVehicle()`: Ищет наименьшее доступное место, подходящее для размера транспортного средства. - `parkVehicle()`: Назначает место, записывает пару транспортное средство–место, удаляет место из доступного пула. - `unparkVehicle()`: Освобождает место, возвращает его в доступный пул.
> **Выбор реализации:** Две структуры HashMap обеспечивают доступ O(1): `availableSpots` организует места по VehicleSize для оптимального распределения; `vehicleToSpotMap` записывает, какое место занимает каждое транспортное средство.
### Ticket
```java public class Ticket { private final String ticketId; // Unique ticket identifier private final Vehicle vehicle; // The vehicle associated with the ticket private final ParkingSpot parkingSpot; // The parking spot where the vehicle is parked private final LocalDateTime entryTime; // The time the vehicle entered the parking lot private LocalDateTime exitTime; // The time the vehicle exited the parking lot
public Ticket( String ticketId, Vehicle vehicle, ParkingSpot parkingSpot, LocalDateTime entryTime) { this.ticketId = ticketId; this.vehicle = vehicle; this.parkingSpot = parkingSpot; this.entryTime = entryTime; this.exitTime = null; // Initially null — vehicle is still parked }
public BigDecimal calculateParkingDuration() { return new BigDecimal( Duration.between( entryTime, Objects.requireNonNullElseGet(exitTime, LocalDateTime::now)) .toMinutes()); } // getter and setter methods omitted for brevity } ```
### FareStrategy и FareCalculator
```java public interface FareStrategy { BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare); }
public class BaseFareStrategy implements FareStrategy { private static final BigDecimal SMALL_VEHICLE_RATE = new BigDecimal("1.0"); private static final BigDecimal MEDIUM_VEHICLE_RATE = new BigDecimal("2.0"); private static final BigDecimal LARGE_VEHICLE_RATE = new BigDecimal("3.0");
@Override public BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare) { BigDecimal fare = inputFare; BigDecimal rate; switch (ticket.getVehicle().getSize()) { case MEDIUM: rate = MEDIUM_VEHICLE_RATE; break; case LARGE: rate = LARGE_VEHICLE_RATE; break; default: rate = SMALL_VEHICLE_RATE; } fare = fare.add(rate.multiply(ticket.calculateParkingDuration())); return fare; } }
public class PeakHoursFareStrategy implements FareStrategy { private static final BigDecimal PEAK_HOURS_MULTIPLIER = new BigDecimal("1.5"); // 50% higher
@Override public BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare) { BigDecimal fare = inputFare; if (isPeakHours(ticket.getEntryTime())) { fare = fare.multiply(PEAK_HOURS_MULTIPLIER); } return fare; }
private boolean isPeakHours(LocalDateTime time) { int hour = time.getHour(); return (hour >= 7 && hour <= 10) || (hour >= 16 && hour <= 19); } }
public class FareCalculator { private final List<FareStrategy> fareStrategies;
public FareCalculator(List<FareStrategy> fareStrategies) { this.fareStrategies = fareStrategies; }
public BigDecimal calculateFare(Ticket ticket) { BigDecimal fare = BigDecimal.ZERO; for (FareStrategy strategy : fareStrategies) { fare = strategy.calculateFare(ticket, fare); } return fare; } } ```
> **Выбор реализации:** `FareCalculator` использует `List<FareStrategy>` (а не Set или массив) для сохранения порядка — стратегии вроде BaseFareStrategy должны применяться перед PeakHoursFareStrategy для корректного расчёта стоимости.
### ParkingLot
```java public class ParkingLot { private final ParkingManager parkingManager; private final FareCalculator fareCalculator;
public ParkingLot(ParkingManager parkingManager, FareCalculator fareCalculator) { this.parkingManager = parkingManager; this.fareCalculator = fareCalculator; }
public Ticket enterVehicle(Vehicle vehicle) { ParkingSpot spot = parkingManager.parkVehicle(vehicle); if (spot != null) { Ticket ticket = new Ticket(generateTicketId(), vehicle, spot, LocalDateTime.now()); return ticket; } else { return null; // No spot available } }
public void leaveVehicle(Ticket ticket) { if (ticket != null && ticket.getExitTime() == null) { ticket.setExitTime(LocalDateTime.now()); parkingManager.unparkVehicle(ticket.getVehicle()); BigDecimal fare = fareCalculator.calculateFare(ticket); } else { // Invalid ticket or vehicle already exited. } } } ```
Детальный разбор тем
### Добавление нового типа парковочного места
Для добавления места для инвалидов мы вводим класс `HandicappedSpot`, реализующий существующий интерфейс ParkingSpot. Это соответствует принципу открытости/закрытости — изменения существующих классов не требуются.
```java public class HandicappedSpot implements ParkingSpot { private int spotNumber; private Vehicle vehicle;
public HandicappedSpot(int spotNumber) { this.spotNumber = spotNumber; this.vehicle = null; }
@Override public int getSpotNumber() { return spotNumber; }
@Override public boolean isAvailable() { return vehicle == null; }
@Override public void occupy(Vehicle vehicle) { if (isAvailable()) { this.vehicle = vehicle; } }
@Override public void vacate() { this.vehicle = null; }
@Override public VehicleSize getSize() { return VehicleSize.MEDIUM; } } ```
### Ускорение управления парковочными местами
Текущее отображение однонаправленное (Vehicle → ParkingSpot). Для поиска транспортного средства на конкретном месте потребовалось бы перебирать все записи — O(n). Мы можем добавить обратную структуру HashMap `spotToVehicleMap` для поиска O(1) в обоих направлениях:
```java public class ParkingManager { private final Map<VehicleSize, List<ParkingSpot>> availableSpots; private final Map<Vehicle, ParkingSpot> vehicleToSpotMap; private final Map<ParkingSpot, Vehicle> spotToVehicleMap;
public ParkingManager(Map<VehicleSize, List<ParkingSpot>> availableSpots) { this.availableSpots = availableSpots; this.vehicleToSpotMap = new HashMap<>(); this.spotToVehicleMap = new HashMap<>(); }
public ParkingSpot findSpotForVehicle(Vehicle vehicle) { // No change in the method }
public ParkingSpot parkVehicle(Vehicle vehicle) { ParkingSpot spot = findSpotForVehicle(vehicle); if (spot != null) { spot.occupy(vehicle); vehicleToSpotMap.put(vehicle, spot); // Record bidirectional mapping spotToVehicleMap.put(spot, vehicle); availableSpots.get(spot.getSize()).remove(spot); return spot; } return null; }
public void unparkVehicle(Vehicle vehicle) { ParkingSpot spot = vehicleToSpotMap.remove(vehicle); if (spot != null) { spotToVehicleMap.remove(spot); spot.vacate(); availableSpots.get(spot.getSize()).add(spot); } }
public ParkingSpot findVehicleBySpot(Vehicle vehicle) { return vehicleToSpotMap.get(vehicle); }
public Vehicle findSpotByVehicle(ParkingSpot spot) { return spotToVehicleMap.get(spot); } } ```
**Преимущества:** Двунаправленное отображение обеспечивает поиск O(1) в обоих направлениях — особенно эффективно на больших парковках, где линейный поиск был бы дорогостоящим.
Подведение итогов
В этой главе мы собрали требования к системе парковки, определили основные объекты, спроектировали структуру классов и реализовали ключевые компоненты.
Ключевой вывод — ценность модульности и чёткого разделения ответственностей. Каждый компонент обрабатывает отдельную ответственность, поддерживая систему поддерживаемой и открытой для будущих улучшений.
Наши дизайнерские решения — использование ParkingLot как фасада, применение FareStrategy для гибкого ценообразования — подчёркивают простоту и адаптируемость. Альтернативный подход, встраивающий логику распределения мест и расчёта стоимости непосредственно в ParkingLot, мог бы уменьшить количество классов, но усложнил бы масштабируемость, перегружая один класс несколькими ответственностями.
Дополнительное чтение: паттерны проектирования Strategy и Facade
### Strategy Pattern (паттерн Стратегия)
Паттерн Strategy определяет семейство алгоритмов, инкапсулирует каждый в отдельном классе и делает их объекты взаимозаменяемыми.
**Когда использовать:** - Когда приложению нужно выбирать различные алгоритмы во время выполнения на основе конкретных условий. - Когда класс перегружен условными конструкциями, выбирающими между вариантами алгоритма. - Для отделения бизнес-логики от деталей реализации конкретных задач.
**Пример использования:** Система оплаты в интернет-магазине с опциями кредитной карты, PayPal и банковского перевода — каждый способ оплаты является стратегией, взаимозаменяемой без изменения основной логики оформления заказа.
### Facade Pattern (паттерн Фасад)
Паттерн Facade предоставляет простой интерфейс к сложной подсистеме, упрощая взаимодействие клиентов с ней за счёт сокрытия лежащей в основе сложности.
**Когда использовать:** - Когда подсистема сложна и вы хотите более простой клиентский интерфейс. - Когда вы хотите разбить систему на подсистемы с единой точкой входа для общих операций.
**Пример использования:** Домашний кинотеатр — метод `HomeTheaterFacade.watchMovie()` внутренне управляет проектором, звуковой системой и освещением, так что пользователь взаимодействует с одним простым вызовом вместо настройки каждого устройства.
Проектирование системы бронирования билетов в кино
В этой главе мы разберём проектирование системы бронирования билетов в кино. Задача оценивает вашу способность моделировать реальные системы и применять объектно-ориентированные принципы для создания хорошо структурированного решения. Цель — тщательно определить классы, представляющие ключевые сущности системы бронирования: залы, сеансы и фильмы. Мы стремимся построить чёткую и функциональную структуру, отражающую основные взаимодействия между этими компонентами — интуитивно понятную и масштабируемую.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
> **Интервьюер:** «Представьте, что вы хотите купить билеты на блокбастер в оживлённые выходные. Вы входите в систему бронирования, просматриваете доступные сеансы, выбираете понравившиеся места и оформляете бронь. Через мгновение ваши билеты подтверждены, и вы получаете электронный билет. За кулисами система эффективно управляет доступностью мест, отслеживает сеансы и рассчитывает стоимость. Давайте спроектируем систему бронирования билетов в кино, которая решает все эти задачи.»
---
> **Кандидат:** Поддерживает ли система поиск и бронирование билетов в разных кинотеатрах и залах?
> **Интервьюер:** Да, пользователи могут искать доступные билеты в нескольких кинотеатрах, каждый из которых содержит несколько залов.
> **Кандидат:** Позволяет ли система планировать несколько сеансов одного фильма в разных залах и в разное время?
> **Интервьюер:** Да, каждый фильм может иметь разные сеансы, запланированные в различных залах и временных слотах одного или разных кинотеатров.
> **Кандидат:** Поддерживает ли система разные ценовые категории для мест на одном сеансе?
> **Интервьюер:** Да, каждое место может иметь свою стратегию ценообразования: обычная, премиум или VIP, влияющую на стоимость билета.
> **Кандидат:** Может ли пользователь купить несколько билетов в одном заказе, и как система рассчитывает итоговую стоимость?
> **Интервьюер:** Да, пользователи могут объединить несколько билетов на конкретный сеанс в один заказ. Система рассчитывает итоговую стоимость путём суммирования цен всех выбранных мест согласно их тарифным классам.
> **Кандидат:** Нужно ли системе обрабатывать платежи в рамках процесса бронирования?
> **Интервьюер:** В данном проекте обработку платежей можно опустить и сосредоточиться на поиске, планировании, выборе мест и бронировании билетов.
> **Кандидат:** Что происходит, когда пользователь бронирует билет на конкретное место?
> **Интервьюер:** Система должна создать билет с указанием сеанса, места и цены на основе стратегии ценообразования этого места, затем добавить его в список билетов сеанса и отметить место как забронированное.
**Требования**
Функциональные требования:
- Каждый кинотеатр расположен в определённом месте и содержит несколько залов. - Фильмы могут иметь несколько сеансов, запланированных в разных залах, кинотеатрах и временных слотах. - В каждом зале есть сетка мест, доступных для бронирования. - Места в зале могут иметь различные стратегии ценообразования (обычная, премиум, VIP), влияющие на стоимость билетов. - Пользователи могут находить и бронировать доступные билеты. - Билет представляет конкретное место для просмотра фильма в зале в определённое время. - Пользователь может забронировать несколько билетов в рамках одного заказа. - Итоговая стоимость заказа рассчитывается суммированием цен всех выбранных мест.
Нефункциональные требования:
- Быстрый поиск сеансов для комфортного пользовательского опыта. - Базовая обработка ошибок, предотвращающая конфликты бронирования, например двойное бронирование одного места.
Определение основных объектов
Для построения модульной и удобной в сопровождении системы определим объекты, представляющие отдельные сущности с чёткими обязанностями:
- **Movie:** Представляет конкретный фильм, демонстрируемый в кинотеатрах, с такими деталями, как название и продолжительность. - **Cinema:** Моделирует физическое место, где показывают фильмы, содержащее несколько залов. - **Room:** Определяет пространство для показа внутри кинотеатра, связанное с уникальной схемой расположения мест. - **Layout:** Организует расположение мест в зале в виде сетки, управляя позициями мест. - **Seat:** Представляет отдельное место в зале, связанное со стратегией ценообразования. - **Screening:** Объединяет фильм, зал и временной слот, определяя, когда и где показывается фильм. - **Ticket:** Фиксирует выбор покупателем конкретного места на сеансе, включая его стоимость. - **Order:** Группирует несколько билетов, приобретённых вместе, в одну транзакцию и отслеживает итоговую стоимость.
> **Выбор дизайна:** Мы разделяем Movie и Screening, чтобы разграничить неизменные данные о фильме и динамическое расписание сеансов. Разделение Room и Layout позволяет залам совместно использовать или настраивать схемы расположения мест. Можно объединить Room и Layout, но это ограничит гибкость, если залам нужны разные схемы.
> **Совет для интервью:** При представлении объектов на интервью объясняйте, почему вы их выбрали и как они соответствуют требованиям. Упоминайте альтернативы, чтобы показать, что вы рассмотрели разные варианты и их компромиссы.
Проектирование диаграммы классов
### Movie
Класс Movie хранит основные сведения о конкретном фильме — название, жанр и продолжительность — данные, остающиеся неизменными на всех сеансах. Он отличается от Screening, который связывает фильм с залом и временным слотом.
> **Выбор дизайна:** Movie спроектирован как самостоятельная сущность, независимая от контекста конкретного кинотеатра или расписания. Это позволяет использовать один Movie в нескольких кинотеатрах и на нескольких сеансах без дублирования данных.
Проектирование Seat и PricingStrategy
Класс Seat хранит ключевые сведения об отдельном месте, включая его уникальный номер. Он использует **Strategy Pattern (паттерн Стратегия)** через интерфейс PricingStrategy с конкретными классами NormalRate, PremiumRate и VIPRate.
Паттерн Стратегия приносит системе следующие преимущества: - Расширяемость — легко добавлять новые тарифные классы. - Снижение избыточности кода — единый класс Seat обрабатывает все варианты ценообразования.
> **Альтернативный подход:** Встраивание логики ценообразования непосредственно в Seat снижает гибкость при изменении правил. Паттерн Стратегия, будучи сложнее, поддерживает будущие расширения.
> **Примечание:** Подробнее о паттерне Стратегия читайте в главе про парковку.
Проектирование Layout
Класс Layout организует места в структуру сетки, определяемую строками и столбцами. Он использует: - Вложенную карту `Map<Integer, Map<Integer, Seat>>` для эффективного поиска места по позиции (строка → столбец → место). - Отдельный индекс `Map<String, Seat>` для поиска за O(1) по номеру места.
> **Альтернативный подход:** Двумерный массив подошёл бы для залов фиксированного размера, но лишён гибкости для нестандартных схем или динамического добавления мест. Вложенная карта поддерживает динамическое создание строк через `computeIfAbsent`.
> **Совет для интервью:** При написании кода объясняйте, почему вы выбрали ту или иную структуру данных (например, карту вместо массива) и как это соответствует целям вашего дизайна.
Проектирование Cinema и Room
Cinema содержит несколько экземпляров Room (композиция). Каждый Room включает Layout для определения схемы расположения мест. Вместе они образуют чёткую иерархию для управления пространствами кинотеатра.
> **Выбор дизайна:** Отношение композиции между Cinema и Room позволяет каждому Room работать независимо со своей схемой и расписанием, а Cinema предоставляет унифицированный контекст.
Проектирование Screening
Класс Screening определяет конкретный показ фильма в определённом зале в запланированное время. Он объединяет Movie, Room и временные детали в единую сущность.
> **Выбор дизайна:** Screening централизует детали расписания, упрощая управление временными слотами в кинотеатрах и обеспечивая чёткое разделение ответственностей.
Проектирование Ticket и Order
Класс **Ticket** представляет купленное место на конкретном Screening. Он включает атрибут price, фиксирующий стоимость места в момент покупки — это гарантирует, что цена билета остаётся неизменной независимо от будущих изменений стратегии ценообразования места.
Класс **Order** группирует несколько билетов в одну транзакцию, записывает временную метку заказа и предоставляет итоговую стоимость.
Проектирование ScreeningManager и MovieBookingSystem
**ScreeningManager** — централизованный менеджер для сеансов и билетов. Он хранит соответствия между Movie и их Screening, а также между Screening и их Ticket.
> **Выбор дизайна:** Альтернативой было бы встроить эти операции в класс Cinema, но это связало бы статические атрибуты кинотеатра с логикой расписания и бронирования, снижая модульность.
**MovieBookingSystem** выступает в роли **фасада**, интегрируя фильмы, местоположения кинотеатров и ScreeningManager в единую систему. Он централизует ключевые операции: добавление фильмов, поиск сеансов, проверку доступности мест и бронирование билетов.
> **Альтернативный подход:** Прямое взаимодействие клиентского кода с ScreeningManager или Cinema увеличивает связанность и хрупкость. Паттерн Фасад повышает удобство сопровождения.
Полная диаграмма классов
Ниже представлена полная диаграмма классов системы бронирования билетов в кино.
Код — система бронирования билетов в кино
### Movie
```java public class Movie { private final String title; private final String genre; private final int durationInMinutes;
public Movie(String title, String genre, int durationInMinutes) { this.title = title; this.genre = genre; this.durationInMinutes = durationInMinutes; }
public Duration getDuration() { return Duration.ofMinutes(durationInMinutes); } // getter methods omitted for brevity } ```
Метод `getDuration()` преобразует `durationInMinutes` в объект Duration, предоставляя стандартизированный способ представления продолжительности фильма. Класс неизменяемый — без сеттеров — это гарантирует, что данные о фильме не могут быть изменены после создания.
### Cinema
```java public class Cinema { private final String name; private final String location; private final List<Room> rooms;
public Cinema(String name, String location) { this.name = name; this.location = location; this.rooms = new ArrayList<>(); }
public void addRoom(Room room) { rooms.add(room); } // getter and setter methods omitted for brevity } ```
### Room
```java public class Room { private final String roomNumber; private final Layout layout;
public Room(String roomNumber, Layout layout) { this.roomNumber = roomNumber; this.layout = layout; } // getter and setter methods omitted for brevity } ```
### Layout
```java // Represents the seating layout of a cinema room. public class Layout { private final int rows; private final int columns; // Maps seat numbers (e.g., "0-0") to Seat objects for direct access private final Map<String, Seat> seatsByNumber; // Nested map for position-based access (row → column → seat) private final Map<Integer, Map<Integer, Seat>> seatsByPosition;
public Layout(int rows, int columns) { this.rows = rows; this.columns = columns; this.seatsByNumber = new HashMap<>(); this.seatsByPosition = new HashMap<>(); initializeLayout(); }
private void initializeLayout() { for (int i = 0; i < rows; i++) { for (int j = 0; j < columns; j++) { String seatNumber = i + "-" + j; addSeat(seatNumber, i, j, new Seat(seatNumber, null)); } } }
public void addSeat(String seatNumber, int row, int column, Seat seat) { seatsByNumber.put(seatNumber, seat); seatsByPosition.computeIfAbsent(row, k -> new HashMap<>()).put(column, seat); }
public Seat getSeatByNumber(String seatNumber) { return seatsByNumber.get(seatNumber); }
public Seat getSeatByPosition(int row, int column) { Map<Integer, Seat> rowSeats = seatsByPosition.get(row); return (rowSeats != null) ? rowSeats.get(column) : null; }
public List<Seat> getAllSeats() { return List.copyOf(seatsByNumber.values()); } } ```
- `addSeat()` использует `computeIfAbsent` для динамического создания строк во вложенной карте. - `getSeatByNumber()` обеспечивает поиск за O(1) по идентификатору места. - `getSeatByPosition()` обращается к местам по координатам строки и столбца. - `getAllSeats()` возвращает немодифицируемый список для безопасного доступа.
> **Выбор реализации:** Вложенная `Map<Integer, Map<Integer, Seat>>` для сетки обеспечивает доступ за O(1) и поддерживает динамическое создание строк. Двумерный массив проще для фиксированных схем, но не гибок для нестандартных сеток.
### Seat
```java public class Seat { private final String seatNumber; private PricingStrategy pricingStrategy;
public Seat(String seatNumber, PricingStrategy pricingStrategy) { this.seatNumber = seatNumber; this.pricingStrategy = pricingStrategy; } // getter and setter methods omitted for brevity } ```
### PricingStrategy
```java public interface PricingStrategy { BigDecimal getPrice(); }
public class NormalRate implements PricingStrategy { private final BigDecimal price;
public NormalRate(BigDecimal price) { this.price = price; }
@Override public BigDecimal getPrice() { return price; } }
public class PremiumRate implements PricingStrategy { private final BigDecimal price;
public PremiumRate(BigDecimal price) { this.price = price; }
@Override public BigDecimal getPrice() { return price; } }
public class VIPRate implements PricingStrategy { private final BigDecimal price;
public VIPRate(BigDecimal price) { this.price = price; }
@Override public BigDecimal getPrice() { return price; } } ```
Новые стратегии ценообразования можно добавлять без изменения существующего кода, что соответствует принципу открытости/закрытости.
### Screening
```java // Represents a scheduled screening of a movie in a specific cinema room. public class Screening { private final Movie movie; private final Room room; private final LocalDateTime startTime; private final LocalDateTime endTime;
public Screening(Movie movie, Room room, LocalDateTime startTime, LocalDateTime endTime) { this.movie = movie; this.room = room; this.startTime = startTime; this.endTime = endTime; }
public Duration getDuration() { return Duration.between(startTime, endTime); } // getter and setter methods omitted for brevity } ```
### Ticket
```java public class Ticket { private final Screening screening; private final Seat seat; private final BigDecimal price;
public Ticket(Screening screening, Seat seat, BigDecimal price) { this.screening = screening; this.seat = seat; this.price = price; } // getter and setter methods omitted for brevity } ```
### Order
```java public class Order { private final List<Ticket> tickets; private final LocalDateTime orderDate;
public Order(LocalDateTime orderDate) { this.tickets = new ArrayList<>(); this.orderDate = orderDate; }
public void addTicket(Ticket ticket) { tickets.add(ticket); }
public BigDecimal calculateTotalPrice() { return tickets.stream() .map(Ticket::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); } // getter and setter methods omitted for brevity } ```
### ScreeningManager
```java // Manages the relationships between movies, screenings, and tickets public class ScreeningManager { private final Map<Movie, List<Screening>> screeningsByMovie; private final Map<Screening, List<Ticket>> ticketsByScreening;
public ScreeningManager() { this.screeningsByMovie = new HashMap<>(); this.ticketsByScreening = new HashMap<>(); }
public void addScreening(Movie movie, Screening screening) { screeningsByMovie.computeIfAbsent(movie, k -> new ArrayList<>()).add(screening); }
public List<Screening> getScreeningsForMovie(Movie movie) { return screeningsByMovie.getOrDefault(movie, new ArrayList<>()); }
public void addTicket(Screening screening, Ticket ticket) { ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket); }
public List<Ticket> getTicketsForScreening(Screening screening) { return ticketsByScreening.getOrDefault(screening, new ArrayList<>()); }
public List<Seat> getAvailableSeats(Screening screening) { List<Seat> allSeats = screening.getRoom().getLayout().getAllSeats(); List<Ticket> bookedTickets = getTicketsForScreening(screening);
List<Seat> availableSeats = new ArrayList<>(allSeats); for (Ticket ticket : bookedTickets) { availableSeats.remove(ticket.getSeat()); } return availableSeats; } } ```
`getAvailableSeats()` получает все места из Layout зала и удаляет места, уже связанные с билетами на данный сеанс.
> **Выбор реализации:** `Map<Movie, List<Screening>>` и `Map<Screening, List<Ticket>>` обеспечивают поиск за O(1). Плоский List с ручной фильтрацией потребовал бы O(n).
### MovieBookingSystem
```java // Manages the complete movie booking system operations public class MovieBookingSystem { private final List<Movie> movies; private final List<Cinema> cinemas; private final ScreeningManager screeningManager;
public MovieBookingSystem() { this.movies = new ArrayList<>(); this.cinemas = new ArrayList<>(); this.screeningManager = new ScreeningManager(); }
public void addMovie(Movie movie) { movies.add(movie); }
public void addCinema(Cinema cinema) { cinemas.add(cinema); }
public void addScreening(Movie movie, Screening screening) { screeningManager.addScreening(movie, screening); }
public void bookTicket(Screening screening, Seat seat) { BigDecimal price = seat.getPricingStrategy().getPrice(); Ticket ticket = new Ticket(screening, seat, price); screeningManager.addTicket(screening, ticket); }
public List<Screening> getScreeningsForMovie(Movie movie) { return screeningManager.getScreeningsForMovie(movie); }
public List<Seat> getAvailableSeats(Screening screening) { return screeningManager.getAvailableSeats(screening); }
public int getTicketCount(Screening screening) { return screeningManager.getTicketsForScreening(screening).size(); }
public List<Ticket> getTicketsForScreening(Screening screening) { return screeningManager.getTicketsForScreening(screening); } // getter and setter methods omitted for brevity } ```
Детальный разбор
### Обработка одновременных бронирований
На собеседованиях по OOD конкурентность часто обсуждается для систем вроде бронирования билетов в кино, где несколько пользователей взаимодействуют одновременно. Всегда уточняйте у интервьюера, нужно ли обрабатывать конкурентность.
**Проблема: гонка состояний** — два пользователя пытаются забронировать одно и то же место одновременно, что может привести к двойному бронированию.
**Решение: пессимистическая и оптимистическая блокировка**
**Пессимистическая блокировка** захватывает исключительную блокировку на место в начале процесса бронирования, предотвращая параллельный доступ до её снятия. Механизм тайм-аута (например, 30 секунд) автоматически снимает блокировку, если транзакция остаётся незавершённой. Подходит для сценариев с высокой конкуренцией.
```java public class SeatLockManager { private final Map<String, SeatLock> lockedSeats = new ConcurrentHashMap<>(); private final Duration lockDuration;
public SeatLockManager(Duration lockDuration) { this.lockDuration = lockDuration; }
public synchronized boolean lockSeat(Screening screening, Seat seat, String userId) { String lockKey = generateLockKey(screening, seat); cleanupLockIfExpired(lockKey); if (isLocked(screening, seat)) { return false; } SeatLock lock = new SeatLock(userId, LocalDateTime.now().plus(lockDuration)); lockedSeats.put(lockKey, lock); return true; }
public synchronized boolean isLocked(Screening screening, Seat seat) { String lockKey = generateLockKey(screening, seat); cleanupLockIfExpired(lockKey); return lockedSeats.containsKey(lockKey); }
private void cleanupLockIfExpired(String lockKey) { SeatLock lock = lockedSeats.get(lockKey); if (lock != null && lock.isExpired()) { lockedSeats.remove(lockKey); } }
private String generateLockKey(Screening screening, Seat seat) { return screening.getId() + "-" + seat.getSeatNumber(); }
private static class SeatLock { private final String userId; private final LocalDateTime expirationTime;
public SeatLock(String userId, LocalDateTime expirationTime) { this.userId = userId; this.expirationTime = expirationTime; }
public boolean isExpired() { return LocalDateTime.now().isAfter(expirationTime); }
public String getUserId() { return userId; } } } ```
**Оптимистическая блокировка** избегает блокировки мест во время бронирования, вместо этого проверяя доступность места на финальном этапе. Если другой пользователь забронировал место в промежутке, транзакция завершается с ошибкой и пользователь повторяет попытку. Подходит для сценариев с низкой конкуренцией.
```java // Simplified optimistic locking in ScreeningManager public synchronized Ticket bookSeatOptimistically(Screening screening, Seat seat) { // Check if seat is available (optimistic — no persistent lock held) if (isSeatBooked(screening, seat)) { throw new IllegalStateException("Seat is already booked"); }
BigDecimal price = seat.getPricingStrategy().getPrice(); Ticket ticket = new Ticket(screening, seat, price);
// Atomically add to booking system — effectively "reserves" the seat ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket);
return ticket; }
private boolean isSeatBooked(Screening screening, Seat seat) { List<Ticket> tickets = getTicketsForScreening(screening); return tickets.stream().anyMatch(ticket -> ticket.getSeat().equals(seat)); } ```
> **Совет для интервью:** Пессимистическая блокировка часто предпочтительна для популярных сеансов с высокой конкуренцией, тогда как оптимистическая подходит для простых случаев с меньшим количеством конфликтов. Уточните у интервьюера, какой подход соответствует его ожиданиям.
Подведение итогов
В этой главе мы собрали требования к системе бронирования билетов в кино, определили основные объекты, спроектировали структуру классов и реализовали ключевые компоненты.
Главный вывод — значимость модульности и соблюдения принципа единственной ответственности (SRP). Каждый компонент — Movie, ScreeningManager, Seat и Order — выполняет отдельную функцию, обеспечивая удобство сопровождения и адаптируемость системы.
Наши архитектурные решения — разделение Movie и Screening, использование паттерна Стратегия для ценообразования — ставят во главу угла гибкость и масштабируемость. Альтернативное объединение Screening и Ticket упростило бы модель, но могло бы осложнить управление отдельными местами.
Проектирование системы поиска файлов Unix
В этой главе мы рассмотрим проектирование системы поиска файлов Unix. Цель — создать классы, представляющие абстракции ключевых сущностей системы поиска файлов: директорий, файлов и критериев фильтрации. Мы стремимся построить чёткую и функциональную структуру, отражающую основные взаимодействия между этими компонентами, — интуитивно понятную и масштабируемую.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
> **Интервьюер:** «Представьте, что вы разработчик и ищете конкретные файлы в системе Unix: файлы, принадлежащие определённому пользователю, или текстовые файлы, соответствующие шаблону, спрятанные глубоко в структуре директорий. Вы запускаете команду поиска, указываете критерии, и система быстро возвращает подходящие файлы. За кулисами она рекурсивно обходит директории, оценивает атрибуты файлов и эффективно применяет фильтры. Давайте спроектируем систему поиска файлов Unix, которая обрабатывает этот процесс.»
---
> **Кандидат:** Какие атрибуты использует команда find для поиска файлов?
> **Интервьюер:** Поиск может быть основан на таких критериях, как размер, тип файла, имя файла и владелец.
> **Кандидат:** Нужно ли обрабатывать директории?
> **Интервьюер:** Да, директории тоже считаются файлами с отдельным типом.
> **Кандидат:** Какие типы сравнений поддерживает команда?
> **Интервьюер:** Это зависит от типа атрибута. Для строк поддерживаются «равно» и «совпадение с регулярным выражением». Для чисел — «больше», «равно» и «меньше».
> **Кандидат:** Можно ли комбинировать несколько критериев, в том числе по одному атрибуту?
> **Интервьюер:** Да, с несколькими критериями, используя условия «и», «или» и «не».
> **Кандидат:** Предполагаю, что мы проектируем систему для поиска в директории и её поддиректориях, возвращающую файлы, соответствующие заданным условиям.
> **Интервьюер:** Да, это правильное предположение.
**Построение конкретных примеров**
Посмотрим требования в действии:
- **Простой поиск:** найти файлы рекурсивно в `/`, где `size > 10`. - **Сложный поиск:** найти файлы рекурсивно в `/`, где `((size > 10 and size < 1000 and owner = "alice") or (size > 1000 and !(filename matches /prefix.*/)))`.
**Требования**
Функциональные требования:
- Система поиска может искать файлы по атрибутам: размер, тип, имя файла и владелец. - Система поддерживает типы сравнений: «равно» и «совпадение с регулярным выражением» для строк; «больше», «равно» и «меньше» для чисел. - Система может комбинировать несколько критериев поиска с помощью логических операторов (и, или, не). - Система поиска файлов может выполнять рекурсивный поиск в директориях. - Система может применять критерии поиска как к директориям, так и к файлам.
Нефункциональные требования:
- **Масштабируемость:** эффективная обработка больших деревьев директорий с использованием ресурсоэффективных стратегий обхода. - **Расширяемость:** добавление новых атрибутов и операторов сравнения без изменения основной логики. - **Разделение ответственностей:** логика обхода отделена от логики фильтрации для модульного дизайна.
Определение основных объектов
Вот основные объекты, которые нам понадобятся:
- **FileSearch:** Центральная сущность, управляющая процессом поиска. Рекурсивно обходит файловую систему начиная с исходного File и возвращает совпадения на основе объекта FileSearchCriteria. - **File:** Моделирует файл или директорию, хранящие атрибуты: размер, тип, имя файла и владелец. Поддерживает иерархическую структуру с записями поддиректорий. - **FileSearchCriteria:** Инкапсулирует условие поиска и определяет, соответствует ли File ему, делегируя Predicate. Разделяет выполнение поиска и оценку условий. - **Predicate:** Интерфейс, определяющий контракт для проверки соответствия File условию. Поддерживает как простые проверки (`size > 10`), так и составные условия (AND, OR, NOT). - **SimplePredicate:** Реализует Predicate для сравнения одного атрибута файла со значением с помощью оператора. - **CompositePredicate:** Расширяет Predicate для объединения условий с AndPredicate, OrPredicate и NotPredicate. - **ComparisonOperator:** Интерфейс, определяющий способ сравнения значений атрибутов, с реализациями: EqualsOperator, RegexMatchOperator, GreaterThanOperator и LessThanOperator.
> **Выбор дизайна:** Можно объединить FileSearchCriteria и Predicate в один класс, но это снизит модульность, так как логика поиска будет тесно связана с оценкой условий.
> **Выбор дизайна:** Объект File представляет файлы и директории как единую сущность, что соответствует принципу Unix, согласно которому всё является файлом, обеспечивая единообразную обработку.
Проектирование диаграммы классов
### File
Вместо использования стандартного `java.io.File` мы определяем собственный класс File как основную сущность. Он дополнен перечислением `FileAttribute` для атрибутов, используемых в условиях поиска.
> **Выбор дизайна:** `FileAttribute` определён как перечисление для предоставления фиксированного, типобезопасного набора атрибутов (размер, владелец и т.д.), исключая ошибки времени выполнения из-за недопустимых имён атрибутов. Добавление нового атрибута, например времени изменения, требует лишь расширения перечисления.
Проектирование FileSearch
Класс FileSearch обходит файловую систему начиная с заданного File, используя объект FileSearchCriteria для выбора подходящих файлов. Разделяя логику обхода и фильтрации, дизайн остаётся модульным и легко расширяемым.
Проектирование FileSearchCriteria
FileSearchCriteria выступает мостом между FileSearch и Predicate. Он сообщает FileSearch, что считается совпадением, делегируя это Predicate.
> **Выбор дизайна:** FileSearchCriteria делегирует Predicate проверку соответствия файла условиям поиска, не обрабатывая всю логику самостоятельно. Это сохраняет чёткость ответственностей: FileSearch управляет обходом, FileSearchCriteria хранит критерии, Predicate оценивает условия.
Проектирование Predicate и SimplePredicate
Интерфейс Predicate определяет единственный метод `isMatch(File)`. Для простых условий SimplePredicate оценивает один атрибут файла относительно значения с использованием ComparisonOperator — например, «является ли size > 10?» или «является ли owner 'bob'?»
Проектирование ComparisonOperator
Интерфейс ComparisonOperator определяет контракт для сравнения значения атрибута файла с ожидаемым значением. Мы используем обобщения (`<T>`) для обеспечения типобезопасности. Конкретные реализации:
- `EqualsOperator` — проверяет равенство двух значений. - `GreaterThanOperator` — проверяет, что одно значение больше другого. - `LessThanOperator` — проверяет, что одно значение меньше другого. - `RegexMatchOperator` — оценивает соответствие строки регулярному выражению.
> **Альтернативный подход:** Использование строк вроде `"equals"` или `">"` откладывает валидацию до времени выполнения и создаёт риск исключений. Перечисления обеспечивают безопасность на этапе компиляции, но требуют изменения перечисления для добавления нового оператора, тогда как подход на основе интерфейса позволяет добавить новый класс без изменения существующего кода.
Проектирование составного Predicate
Реальные поисковые запросы часто сочетают несколько условий. Мы используем **Composite Pattern (паттерн Компоновщик)** для построения сложных предикатов из более простых.
> **Примечание:** Подробнее о паттерне Компоновщик читайте в разделе «Дополнительное чтение».
Рассмотрим: `((size > 10 and size < 1000 and owner = "alice") or (size > 1000 and !(filename matches /prefix.*/)))`
Это соответствует дереву: - `A = size > 10`, `B = size < 1000`, `C = owner = "alice"`, `D = size > 1000`, `E = filename matches prefix.*` - Результат: `((A and B and C) or (D and !(E)))`
Дерево вычисляется рекурсивно:
Полная диаграмма классов
Построив все классы — от иерархической структуры File до сложной логики предикатов — представляем полную систему в виде UML диаграммы классов.
Код — поиск файлов Unix
### File
```java // Represents a file or directory in the file system public class File { private final boolean isDirectory; private final int size; private final String owner; private final String filename; private final Set<File> entries = new HashSet<>();
public File(final boolean isDirectory, final int size, final String owner, final String filename) { this.isDirectory = isDirectory; this.size = size; this.owner = owner; this.filename = filename; }
public Object extract(final FileAttribute attributeName) { switch (attributeName) { case SIZE -> { return size; } case OWNER -> { return owner; } case IS_DIRECTORY -> { return isDirectory; } case FILENAME -> { return filename; } } throw new IllegalArgumentException("invalid filter criteria type"); }
public void addEntry(final File entry) { entries.add(entry); } // getter methods omitted for brevity }
public enum FileAttribute { IS_DIRECTORY, SIZE, OWNER, FILENAME } ```
- `extract()` извлекает значение конкретного атрибута, сопоставляя константу перечисления FileAttribute с соответствующим полем; используется в SimplePredicate для оценки условий. - `addEntry()` строит иерархию директорий для рекурсивного обхода.
### Predicate
```java // Base interface for all file search predicates public interface Predicate { boolean isMatch(final File inputFile); } ```
### ComparisonOperator
```java // Base interface for all comparison operations public interface ComparisonOperator<T> { boolean isMatch(final T attributeValue, final T expectedValue); }
public class EqualsOperator<T> implements ComparisonOperator<T> { @Override public boolean isMatch(final T attributeValue, final T expectedValue) { return Objects.equals(attributeValue, expectedValue); } }
class GreaterThanOperator<T extends Number> implements ComparisonOperator<T> { @Override public boolean isMatch(final T attributeValue, final T expectedValue) { return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) > 0; } }
class LessThanOperator<T extends Number> implements ComparisonOperator<T> { @Override public boolean isMatch(final T attributeValue, final T expectedValue) { return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) < 0; } }
public class RegexMatchOperator<T extends String> implements ComparisonOperator<T> { @Override public boolean isMatch(final T attributeValue, final T expectedValue) { final Pattern p = Pattern.compile(expectedValue); return p.matcher(attributeValue).matches(); } } ```
> **Выбор реализации:** Обобщения (`<T>`) обеспечивают типобезопасность на этапе компиляции — числовые атрибуты (Double) сравниваются только с числами, строковые — только со строками.
### SimplePredicate
```java // A basic predicate that compares a file attribute with an expected value public class SimplePredicate<T> implements Predicate { private final FileAttribute attributeName; private final ComparisonOperator<T> operator; T expectedValue;
public SimplePredicate(final FileAttribute attributeName, final ComparisonOperator<T> operator, final T expectedValue) { this.attributeName = attributeName; this.operator = operator; this.expectedValue = expectedValue; }
@Override public boolean isMatch(final File inputFile) { Object actualValue = inputFile.extract(attributeName); if (expectedValue.getClass().isInstance(actualValue)) { return operator.isMatch((T) actualValue, expectedValue); } else { return false; } } } ```
### CompositePredicate
```java public interface CompositePredicate extends Predicate { // Marker interface: identifies predicates that combine multiple other predicates }
public class AndPredicate implements CompositePredicate { private final List<Predicate> operands;
public AndPredicate(final List<Predicate> operands) { this.operands = operands; }
@Override public boolean isMatch(final File inputFile) { return operands.stream().allMatch(predicate -> predicate.isMatch(inputFile)); } }
public class OrPredicate implements CompositePredicate { private final List<Predicate> operands;
public OrPredicate(final List<Predicate> operands) { this.operands = operands; }
@Override public boolean isMatch(final File inputFile) { return operands.stream().anyMatch(predicate -> predicate.isMatch(inputFile)); } }
public class NotPredicate implements CompositePredicate { private final Predicate operand;
public NotPredicate(final Predicate operand) { this.operand = operand; }
@Override public boolean isMatch(final File inputFile) { return !operand.isMatch(inputFile); } } ```
- `AndPredicate` и `OrPredicate` используют `List<Predicate>` для поддержки любого количества условий. - `NotPredicate` оборачивает один предикат и инвертирует его результат.
### FileSearchCriteria
```java // Wrapper class that encapsulates a search condition for file matching public class FileSearchCriteria { private final Predicate predicate;
public FileSearchCriteria(final Predicate predicate) { this.predicate = predicate; }
public boolean isMatch(final File inputFile) { return predicate.isMatch(inputFile); } } ```
> **Выбор реализации:** Оборачивание Predicate в FileSearchCriteria позволяет FileSearch сосредоточиться на обходе файловой системы и изолирует оценку условий в отдельный, переиспользуемый слой.
### FileSearch
```java // Main class responsible for performing file system searches public class FileSearch { public List<File> search(final File root, final FileSearchCriteria criteria) { final List<File> result = new ArrayList<>(); final ArrayDeque<File> recursionStack = new ArrayDeque<>(); recursionStack.add(root);
while (!recursionStack.isEmpty()) { File next = recursionStack.pop(); if (criteria.isMatch(next)) { result.add(next); } for (File entry : next.getEntries()) { recursionStack.push(entry); } } return result; } } ```
> **Выбор реализации:** Обход на основе стека (`ArrayDeque`) предотвращает переполнение стека в глубоких файловых системах. Рекурсивные вызовы работают для небольших структур, но могут давать сбой при глубоко вложенных директориях.
Детальный разбор
### Тест поиска файлов
Вот тест для условия «не-директории, принадлежащие пользователям, соответствующим 'ge.*'»:
```java public class FileSearchTest { @Test public void testFileSearch() { final File root = new File(true, 0, "adam", "root"); final File a = new File(false, 2000, "adam", "a"); final File b = new File(false, 3000, "george", "b");
root.addEntry(a); root.addEntry(b);
// Search criteria: non-directory files owned by users matching "ge.*" final FileSearchCriteria criteria = new FileSearchCriteria( new AndPredicate(List.of( new SimplePredicate<>( FileAttribute.IS_DIRECTORY, new EqualsOperator<>(), false), new SimplePredicate<>( FileAttribute.OWNER, new RegexMatchOperator<>(), "ge.*"))));
final FileSearch fileSearch = new FileSearch(); final List<File> result = fileSearch.search(root, criteria);
assertEquals(1, result.size()); assertEquals("b", result.get(0).getFilename()); } } ```
Этот тест создаёт корневую директорию с двумя файлами (принадлежащими 'adam' и 'george') и проверяет, что только файл 'b' (принадлежащий 'george') соответствует `AndPredicate`, сочетающему IS_DIRECTORY=false и OWNER, соответствующий 'ge.*'.
Подведение итогов
С полностью реализованной и протестированной системой поиска файлов UNIX ключевые выводы таковы:
- Чёткое разделение ответственностей между File, FileSearch, FileSearchCriteria и Predicate. - Разделение FileSearch и FileSearchCriteria, а также использование обобщений в ComparisonOperator улучшают масштабируемость и типобезопасность. - Можно объединить FileSearchCriteria с Predicate для более компактного дизайна, но это размоет их различные роли и усложнит замену логики условий без влияния на обход.
Дополнительное чтение: паттерн Компоновщик
Паттерн Компоновщик позволяет организовывать объекты в древовидные структуры и работать с ними как с отдельными объектами.
**Проблема:** У вас есть Files и Folders. Folder может содержать Files и меньшие Folders. Как найти все элементы, соответствующие условию вроде `size > 10`, во всей этой структуре?
**Решение:** Работайте с Files и Folders через общий интерфейс, объявляющий метод проверки условия: - Для File: непосредственно проверяет соответствие условию. - Для Folder: проверяет каждый содержащийся элемент, рекурсивно применяя процесс к вложенным папкам.
**Когда использовать:** - Когда нужно построить древовидную структуру объектов. - Когда клиентский код должен работать с простыми и составными элементами одинаково.
Проектирование торгового автомата
В этой главе мы рассмотрим проектирование системы торгового автомата, позволяющей пользователям выбирать и покупать товары, выдавать предметы, управлять запасами и обрабатывать платежи. Несмотря на то что реальные торговые автоматы включают аппаратные компоненты — такие как монетоприёмники, считыватели карт и сенсорные экраны, — мы сосредоточимся на моделировании состояний системы, данных и основной функциональности.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
> **Интервьюер:** «Представьте, что вы стоите у торгового автомата и хотите перекусить. Вы вставляете деньги, выбираете любимый товар, и через секунды он падает в лоток. Автомат также выдаёт сдачу при необходимости. За кулисами система слаженно отслеживает запасы, обрабатывает платежи и обеспечивает бесперебойную работу. Давайте спроектируем торговый автомат, который всё это делает.»
---
> **Кандидат:** Поддерживает ли торговый автомат разные типы товаров?
> **Интервьюер:** Да, торговый автомат поддерживает разнообразные товары: закуски, напитки и другие позиции.
> **Кандидат:** Как товары организованы внутри торгового автомата?
> **Интервьюер:** Товары размещаются в определённых ячейках, каждая из которых хранит только один тип товара. Каждый товар имеет уникальный код и ценник.
> **Кандидат:** Как будут обрабатываться платежи в торговом автомате?
> **Интервьюер:** Торговый автомат должен принимать только наличные и при необходимости рассчитывать сдачу.
> **Кандидат:** Как торговый автомат обрабатывает случай, когда пользователь выбирает отсутствующий товар?
> **Интервьюер:** Система должна проверять наличие товара. При его отсутствии должно отображаться сообщение об ошибке.
> **Кандидат:** Если пользователь вставил меньше полной стоимости товара, может ли он доплачивать поэтапно?
> **Интервьюер:** В данном проекте предположим, что пользователи вставляют полную сумму за один раз. При недостаточной сумме торговый автомат должен вернуть деньги и отобразить ошибку.
> **Кандидат:** Есть ли ограничения на доступ к торговому автомату?
> **Интервьюер:** Доступ предоставляется пользователям и администраторам с разными привилегиями. Пользователи могут выбирать и покупать товары. Администраторы могут добавлять и удалять товары.
> **Кандидат:** Есть ли требования к безопасности или отслеживанию запасов?
> **Интервьюер:** Да. Торговый автомат должен отслеживать запасы, и только администратор может добавлять или удалять товары.
**Требования**
Функциональные требования:
- **Выбор товара:** Пользователи могут выбирать из товаров, каждый из которых имеет уникальный код, описание и ценник. - **Управление запасами:** Товары хранятся в ячейках. Система отслеживает уровень запасов каждого товара в его ячейке. - **Обработка платежей:** Система принимает только наличные и при необходимости рассчитывает сдачу.
Нефункциональные требования:
- Интуитивный пользовательский интерфейс с чёткими сообщениями об ошибках. - Безопасность: только администраторы могут добавлять, удалять или обновлять товары. Наличные транзакции должны быть защищены от несанкционированного вмешательства.
Диаграмма вариантов использования
Диаграмма вариантов использования иллюстрирует взаимодействие участников с системой торгового автомата.
**Актор «Пользователь»:** - Вставить деньги, выбрать товар, получить товар, получить сдачу
**Актор «Администратор»:** - Добавить товар, удалить товар, обновить запасы
**Актор «Система»:** - Обработать платёж, выдать товар, проверить запасы, отобразить сообщение
Определение основных объектов
- **VendingMachine:** Центральная сущность, координирующая операции и служащая главной точкой входа. Использует паттерн Фасад, чтобы не превратиться в «объект-бог». - **Product:** Представляет товары, хранящиеся в торговом автомате, — идентификатор, цена и описание. Товары связаны с ячейками, в которых хранятся. - **Rack:** Выделенный слот, хранящий один тип товара в нескольких единицах. Включает аппаратный диспенсер. - **InventoryManager:** Отслеживает уровень запасов в торговом автомате. - **PaymentProcessor:** Взаимодействует с монетоприёмником для обработки платежей, отслеживает баланс и рассчитывает сдачу.
> **Выбор дизайна:** Товары связаны с ячейками, поскольку ячейки представляют физические места хранения. Это соответствует принципу единственной ответственности (SRP) — товары не управляют собственным хранением.
Проектирование диаграммы классов
### Product
Класс Product включает атрибуты: код товара, описание и цена.
> **Выбор дизайна:** Количество в запасах НЕ моделируется в Product — он инкапсулирует только врождённые свойства. Постоянно изменяющийся уровень запасов управляется отдельным InventoryManager, что обеспечивает более чистую декомпозицию объектов и соблюдение принципа единственной ответственности.
Проектирование Rack
Класс Rack моделирует отдельную ячейку, связанную с одним товаром и хранящую несколько единиц.
> **Выбор дизайна:** Rack не включает методы вроде `dispenseProductFromRack`. Вместо этого он сосредоточен на управлении счётчиком запасов и информацией о товаре. Выдача делегируется InventoryManager в соответствии с принципом единственной ответственности.
Проектирование InventoryManager
InventoryManager отвечает за отслеживание и хранение товаров. Он поддерживает добавление, удаление и выдачу товаров.
Основные методы: - `dispenseProductFromRack()` — выдаёт товар и уменьшает уровень запасов. - `updateRack()` — позволяет администратору заменить всю структуру ячеек для массовых обновлений. - `addRack()` / `removeRack()` — детализированные методы для изменений отдельных ячеек.
> **Выбор дизайна:** `updateRack(Map racks)` предоставляется для массовых административных обновлений. В большинстве случаев предпочтительнее детализированные методы `addRack`/`removeRack`, ограничивающие нежелательные изменения. Для потокобезопасности рассмотрите использование неизменяемых коллекций или защитного копирования.
Проектирование PaymentProcessor и Transaction
**PaymentProcessor** управляет приёмом платежей, отслеживает текущий баланс и возвращает сдачу. Он взаимодействует с монетоприёмником.
**Transaction** выступает структурой данных, отслеживающей текущее состояние покупки. Преимущества: - Инкапсулирует выбранный товар, его ячейку и итоговую стоимость. - Ведёт структурированную запись текущих транзакций. - Улучшает координацию — PaymentProcessor вычитает сумму, InventoryManager выдаёт товар, а Transaction связывает всё воедино.
Проектирование VendingMachine
VendingMachine — основной компонент, выступающий в роли **Фасада**, предоставляющего единый интерфейс клиентам (программным и аппаратным интерфейсам автомата).
> **Выбор дизайна:** Чтобы VendingMachine не стал «объектом-богом», фасад остаётся лёгким и делегирует задачи другим классам — InventoryManager для управления товарами, PaymentProcessor для обработки платежей.
> **Примечание:** Подробнее о паттерне Фасад читайте в главе про парковку.
Полная диаграмма классов
Ниже представлена полная диаграмма классов системы торгового автомата.
Код — торговый автомат
### Product
```java class Product { final String productCode; final String description; final BigDecimal unitPrice;
public Product(String productCode, String description, BigDecimal unitPrice) { this.productCode = productCode; this.description = description; this.unitPrice = unitPrice; } } ```
> **Выбор реализации:** Используйте `BigDecimal` для денежных значений вроде `unitPrice` — для точности и контроля округления. Избегайте `float` или `double` для валюты: они вносят ошибки точности. Используйте `String` для идентификаторов вроде `productCode`, даже если значения числовые.
### InventoryManager и Rack
Используем `HashMap<String, Rack>` для эффективного поиска за O(1) по коду ячейки.
```java public class InventoryManager { private Map<String, Rack> racks;
public InventoryManager() { racks = new HashMap<>(); }
public Product getProductInRack(String rackCode) { return racks.get(rackCode).getProduct(); }
public void dispenseProductFromRack(Rack rack) { if (rack.getProductCount() > 0) { rack.setCount(rack.getProductCount() - 1); } else { throw new IllegalStateException("Cannot dispense product. Rack is empty."); } }
public void updateRack(Map<String, Rack> racks) { this.racks = racks; }
public Rack getRack(String name) { return racks.get(name); } } ```
```java public class Rack { private final String rackCode; private final Product product; private int count;
public Rack(final String rackCode, final Product product, final int count) { this.rackCode = rackCode; this.product = product; this.count = count; }
public Product getProduct() { return product; }
public int getProductCount() { return count; } } ```
### PaymentProcessor
```java public class PaymentProcessor { private BigDecimal currentBalance = BigDecimal.ZERO;
public void addBalance(BigDecimal amount) { currentBalance = currentBalance.add(amount); }
public void charge(BigDecimal amount) { currentBalance = currentBalance.subtract(amount); }
public BigDecimal returnChange() { BigDecimal change = currentBalance; currentBalance = BigDecimal.ZERO; return change; }
public BigDecimal getCurrentBalance() { return currentBalance; } } ```
### VendingMachine
```java class VendingMachine { private final List<Transaction> transactionHistory; private final InventoryManager inventoryManager; private final PaymentProcessor paymentProcessor; private Transaction currentTransaction; private VendingMachineState currentState;
public VendingMachine() { transactionHistory = new ArrayList<>(); currentTransaction = new Transaction(); inventoryManager = new InventoryManager(); paymentProcessor = new PaymentProcessor(); this.currentState = new NoMoneyInsertedState(); }
void setRack(Map<String, Rack> rack) { inventoryManager.updateRack(rack); }
void insertMoney(final BigDecimal amount) { paymentProcessor.addBalance(amount); }
void chooseProduct(String rackId) { final Product product = inventoryManager.getProductInRack(rackId); currentTransaction.setRack(inventoryManager.getRack(rackId)); currentTransaction.setProduct(product); }
Transaction confirmTransaction() throws InvalidTransactionException { validateTransaction(); paymentProcessor.charge(currentTransaction.getProduct().getUnitPrice()); inventoryManager.dispenseProductFromRack(currentTransaction.getRack()); currentTransaction.setTotalAmount(paymentProcessor.returnChange()); transactionHistory.add(currentTransaction); Transaction completedTransaction = currentTransaction; currentTransaction = new Transaction(); return completedTransaction; }
private void validateTransaction() throws InvalidTransactionException { if (currentTransaction.getProduct() == null) { throw new InvalidTransactionException("Invalid product selection"); } else if (currentTransaction.getRack().getProductCount() == 0) { throw new InvalidTransactionException("Insufficient inventory for product."); } else if (paymentProcessor.getCurrentBalance() .compareTo(currentTransaction.getProduct().getUnitPrice()) < 0) { throw new InvalidTransactionException("Insufficient fund"); } }
public List<Transaction> getTransactionHistory() { return Collections.unmodifiableList(transactionHistory); }
public void cancelTransaction() { paymentProcessor.returnChange(); currentTransaction = new Transaction(); }
public InventoryManager getInventoryManager() { return inventoryManager; } } ```
- `insertMoney()` — добавляет сумму к балансу автомата через PaymentProcessor. - `chooseProduct()` — получает товар и ячейку из InventoryManager, связывает с текущей транзакцией. - `confirmTransaction()` — проверяет транзакцию, обрабатывает платёж, выдаёт товар, обновляет историю.
Детальный разбор
### Обеспечение последовательности действий с помощью паттерна Состояние
Чтобы гарантировать, что пользователи вставляют деньги перед выбором товара, вводим **State Pattern (паттерн Состояние)**.
> **Примечание:** Подробнее о паттерне Состояние читайте в разделе «Дополнительное чтение».
Три отдельных состояния:
**NoMoneyInsertedState:** - Начальное состояние. Отображает «Вставьте деньги для продолжения». - Разрешает только вставку денег. Выбор товара вызывает исключение. - Переходит в MoneyInsertedState при вставке денег.
**MoneyInsertedState:** - Отображает «Выберите товар». - Разрешает выбор товара; предотвращает дополнительную вставку денег. - Проверяет наличие товара и достаточность средств. - Переходит в DispenseState при успешном выборе товара.
**DispenseState:** - Отображает «Выдача товара...» или «Заберите сдачу». - Управляет выдачей и сбрасывает автомат в начальное состояние после завершения. - Предотвращает дальнейшие действия до завершения процесса.
Это гарантирует последовательность: **Вставить деньги → Выбрать товар → Получить товар**.
Код паттерна Состояние
### Интерфейс VendingMachineState
```java public interface VendingMachineState { void insertMoney(VendingMachine VM, double amount);
void selectProductByCode(VendingMachine VM, String productCode) throws InvalidStateException;
void dispenseProduct(VendingMachine VM) throws InvalidStateException;
String getStateDescription(); } ```
### NoMoneyInsertedState
```java public class NoMoneyInsertedState implements VendingMachineState { @Override public void insertMoney(VendingMachine VM, double amount) { VM.addBalance(amount); VM.setState(new MoneyInsertedState()); }
@Override public void selectProductByCode(VendingMachine VM, String productCode) throws InvalidStateException { throw new InvalidStateException("Cannot select a product without inserting money."); }
@Override public void dispenseProduct(VendingMachine VM) throws InvalidStateException { throw new InvalidStateException("Cannot dispense product without inserting money."); }
@Override public String getStateDescription() { return "No Money Inserted State - Please insert money to proceed"; } } ```
MoneyInsertedState и DispenseState следуют той же структуре.
Подведение итогов
В этой главе мы спроектировали и реализовали систему торгового автомата. Ключевые выводы:
- Распределение ответственностей между Product, Rack, InventoryManager и PaymentProcessor при их объединении под фасадом. - Паттерн Фасад упростил внешний интерфейс, соблюдая принцип единственной ответственности. - Паттерн Состояние (детальный разбор) обеспечивает строгую последовательность действий и предотвращает недопустимое поведение, например выдачу товара без оплаты.
На собеседованиях делайте акцент на валидации и обработке ошибок после реализации основной функциональности, особенно для систем, где неправильное поведение может привести к финансовым потерям.
Дополнительное чтение: паттерн Состояние
Паттерн Состояние позволяет объекту изменять своё поведение при изменении внутреннего состояния — так, словно он становится экземпляром другого класса.
**Проблема:** TrafficLight с состояниями Red (красный), Yellow (жёлтый) и Green (зелёный). Использование условных операторов для управления этими состояниями приводит к проблемам масштабируемости и сопровождения при добавлении новых состояний.
**Решение:** Инкапсулируйте поведение каждого состояния в отдельный класс (RedLightState, GreenLightState, YellowLightState). Контекст (TrafficLight) хранит ссылку на текущий объект состояния и делегирует ему задачи, связанные с состоянием. Переходы между состояниями просто заменяют текущий объект состояния.
**Когда использовать:** - Когда поведение объекта меняется в зависимости от его внутреннего состояния. - Когда нужно избежать громоздких условных операторов для управления состояниями. - Когда состояния имеют различное поведение, которое можно чисто разделить по классам.
Проектирование системы лифтов
В этой главе мы рассмотрим объектно-ориентированное проектирование системы лифтов. По сравнению с некоторыми другими популярными задачами на интервью эта задача делает больший акцент на моделировании поведения, а не данных. Наш подход сосредоточен на проектировании ключевых компонентов: как представить реальные лифты, состояние лифта, входящие запросы от кнопок в коридоре и алгоритм, определяющий движение лифта.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
> **Интервьюер:** «Представьте, что вы находитесь в офисном здании с несколькими одинаковыми кабинами лифтов, которые обслуживают одни и те же этажи. Вы нажимаете кнопку «вверх» или «вниз» на своём этаже, и лифт быстро прибывает. Внутри вы выбираете нужный этаж на панели кнопок, и лифт доставляет вас туда. За кулисами система эффективно управляет назначением лифтов и игнорирует запросы в неправильном направлении. Давайте спроектируем систему лифтов, которая всё это обрабатывает.»
---
> **Кандидат:** Мы проектируем систему лифтов для офисного здания или нужно рассмотреть другие типы лифтов?
> **Интервьюер:** Только для офисных зданий.
> **Кандидат:** Все кабины лифтов обслуживают одни и те же этажи?
> **Интервьюер:** Да, все кабины могут обслуживать каждый этаж.
> **Кандидат:** Какую стратегию должна использовать система для определения, какой лифт отправить?
> **Интервьюер:** Конкретная стратегия остаётся на ваше усмотрение. В идеале она должна быть настраиваемой — нужна возможность легко менять стратегии. Это может быть обслуживание в порядке поступления (FCFS) или другие стратегии.
> **Совет:** На верхнем этаже должна быть только кнопка «вниз», а на нижнем — только кнопка «вверх». Хорошо замечать эту деталь при проектировании, но она не критична, если не является основным фокусом.
**Требования**
Функциональные требования:
- Система управляет несколькими кабинами лифтов, обслуживающими одни и те же этажи. - На каждом этаже есть кнопки «вверх» и «вниз» в коридоре для вызова лифта. - Каждая кабина отображает текущий этаж и состояние (движется вверх, вниз или стоит). - В каждой кабине есть внутренняя панель управления с кнопками для каждого этажа. - Если пользователь нажимает кнопку этажа в направлении, противоположном текущему движению лифта, запрос игнорируется.
Нефункциональные требования:
- Алгоритм диспетчеризации должен быть настраиваемым, обеспечивая лёгкое переключение между стратегиями оптимизации.
**Панели управления лифтом:** - **Кнопки в коридоре (снаружи):** Расположены на каждом этаже — «вверх» для вызова лифта, едущего вверх; «вниз» — едущего вниз. - **Кнопки этажей (внутри):** На панели управления внутри кабины каждая кнопка соответствует определённому этажу.
Диаграмма вариантов использования
**Актор «Пассажир»:** - Вызвать лифт (нажать кнопку в коридоре) - Выбрать этаж (нажать кнопку внутри кабины)
**Актор «Система»:** - Назначить лифт, переместить лифт, сообщить статус лифта
Определение основных объектов
- **ElevatorSystem:** Класс-фасад, предоставляющий главный интерфейс. Координирует общую работу, отслеживает статусы всех кабин и делегирует запросы от кнопок коридора ElevatorDispatch. - **ElevatorDispatch:** Обрабатывает вызовы из коридора, назначая наиболее подходящий лифт с использованием стратегии диспетчеризации. - **ElevatorCar:** Одна кабина лифта, перевозящая пассажиров. Работает независимо с внутренней панелью управления и очередью целевых этажей.
Проектирование диаграммы классов
Поскольку варианты использования яснее, чем базовая модель данных, начинаем с поведения и взаимодействий пользователей, затем переводим их в ключевые классы.
### ElevatorSystem
Класс ElevatorSystem — центральный контроллер, предоставляющий API для управления всеми кабинами. Три основные обязанности: - **Получить статус:** Проверить текущее состояние каждого лифта. - **Вызвать лифт:** При нажатии кнопки в коридоре инициирует запрос на назначение лифта. - **Выбрать назначение:** Находясь в лифте, пользователь нажимает кнопку этажа.
> **Совет:** В OOD правильное именование очень важно. Используйте чёткие суффиксы: System, Dispatch или Strategy, чтобы избежать путаницы между отдельной кабиной и всей системой.
ElevatorCar, ElevatorStatus и Direction
**ElevatorCar** моделирует поведение кабины лифта. Поддерживает очередь целевых этажей и делегирует управление состоянием ElevatorStatus.
**ElevatorStatus** предоставляет снимок текущего этажа и направления лифта. Динамически обновляется по мере движения лифта.
Перечисление **Direction** обеспечивает типобезопасное направление движения: UP, DOWN, IDLE. Использование перечисления предотвращает ненужные смены направления и помогает отдавать приоритет лифтам, уже движущимся в направлении запрошенного этажа.
> **Выбор дизайна:** Выделение состояния лифта в ElevatorStatus повышает переиспользуемость и ясность, позволяя управлять логикой, связанной со статусом, независимо и добавлять будущие атрибуты (например, статус двери) без изменения ElevatorCar.
ElevatorDispatch и DispatchingStrategy
ElevatorDispatch управляет запросами от кнопок коридора и назначает подходящий лифт. Логика диспетчеризации основана на **Strategy Pattern (паттерн Стратегия)** для динамического выбора между алгоритмами.
> **Примечание:** Подробнее о паттерне Стратегия читайте в главе про парковку.
Общий процесс диспетчеризации: 1. **Анализ** запроса и статусов лифтов. 2. **Выбор** наилучшей кабины на основе стратегии (близость, направление, минимизация времени ожидания). 3. **Обновление** следующих остановок выбранного лифта.
Распространённые стратегии диспетчеризации: - **FCFS (First Come, First Serve — первым пришёл, первым обслужен):** Назначает следующий доступный лифт; простая, но не всегда оптимальная. - **SSTF (Shortest Seek Time First — кратчайшее время поиска):** Назначает лифт, ближайший к запрошенному этажу и находящийся в режиме ожидания или движущийся в нужном направлении. - **Динамические стратегии:** Настраиваются в зависимости от интенсивности трафика — «высокая пропускная способность» в часы пик, FCFS в тихие часы.
Полная диаграмма классов
Ниже представлена полная диаграмма классов системы лифтов.
Код — система лифтов
### ElevatorSystem
```java public class ElevatorSystem { private final List<ElevatorCar> elevators; private final ElevatorDispatch dispatchController;
public ElevatorSystem(List<ElevatorCar> elevators, DispatchingStrategy strategy) { this.elevators = elevators; this.dispatchController = new ElevatorDispatch(strategy); }
public List<ElevatorStatus> getAllElevatorStatuses() { List<ElevatorStatus> statuses = new ArrayList<>(); for (ElevatorCar elevator : elevators) { statuses.add(elevator.getStatus()); } return statuses; }
public void requestElevator(int currentFloor, Direction direction) { dispatchController.dispatchElevatorCar(currentFloor, direction, elevators); }
public void selectFloor(ElevatorCar car, int destinationFloor) { car.addFloorRequest(destinationFloor); } } ```
- `requestElevator()` — пользователь нажимает кнопку в коридоре, делегирует ElevatorDispatch. - `selectFloor()` — пользователь внутри лифта нажимает кнопку этажа, обрабатывается непосредственно ElevatorCar.
### ElevatorCar
```java public class ElevatorCar { private ElevatorStatus status; private final Queue<Integer> targetFloors;
public ElevatorCar(int startingFloor) { this.status = new ElevatorStatus(startingFloor, Direction.IDLE); this.targetFloors = new LinkedList<>(); }
public ElevatorStatus getStatus() { return status; }
public void addFloorRequest(int floor) { if (!targetFloors.contains(floor)) { targetFloors.offer(floor); updateDirection(floor); } }
public boolean isIdle() { return targetFloors.isEmpty(); }
private void updateDirection(int targetFloor) { if (status.getCurrentFloor() < targetFloor) { status = new ElevatorStatus(status.getCurrentFloor(), Direction.UP); } else if (status.getCurrentFloor() > targetFloor) { status = new ElevatorStatus(status.getCurrentFloor(), Direction.DOWN); } } // getters omitted for brevity } ```
> **Выбор реализации:** `Queue` (FIFO) для `targetFloors` обеспечивает справедливость — запросы этажей обрабатываются в порядке поступления. `PriorityQueue` может сократить время поездки, сортируя этажи по близости, но переупорядочивание может запутать пассажиров и требует дополнительной логики для предотвращения остановок в неправильном направлении.
### ElevatorDispatch
```java public class ElevatorDispatch { private final DispatchingStrategy strategy;
public ElevatorDispatch(DispatchingStrategy strategy) { this.strategy = strategy; }
public void dispatchElevatorCar(int floor, Direction direction, List<ElevatorCar> elevators) { ElevatorCar selectedElevator = strategy.selectElevator(elevators, floor, direction); if (selectedElevator != null) { selectedElevator.addFloorRequest(floor); } } } ```
### Стратегия FCFS (первым пришёл — первым обслужен)
```java public class FirstComeFirstServeStrategy implements DispatchingStrategy { @Override public ElevatorCar selectElevator(List<ElevatorCar> elevators, int floor, Direction direction) { for (ElevatorCar elevator : elevators) { if (elevator.isIdle() || elevator.getCurrentDirection() == direction) { return elevator; } } return elevators.get((int) (Math.random() * elevators.size())); } } ```
### Стратегия SSTF (кратчайшее время поиска)
```java public class ShortestSeekTimeFirstStrategy implements DispatchingStrategy { @Override public ElevatorCar selectElevator(List<ElevatorCar> elevators, int floor, Direction direction) { ElevatorCar bestElevator = null; int shortestDistance = Integer.MAX_VALUE;
for (ElevatorCar elevator : elevators) { int distance = Math.abs(elevator.getCurrentFloor() - floor); if ((elevator.isIdle() || elevator.getCurrentDirection() == direction) && distance < shortestDistance) { bestElevator = elevator; shortestDistance = distance; } } return bestElevator; } } ```
Детальный разбор
### Событийно-управляемая обработка запросов лифтов (паттерн Наблюдатель)
В настоящее время запросы от кнопок коридора и этажей добавляются в одну очередь последовательно. Ограничения: - Тесно связанные компоненты. - Потенциальные задержки при высокой нагрузке.
Решение: использовать **Observer Pattern (паттерн Наблюдатель)** для отделения кнопок коридора от контроллера диспетчеризации.
> **Примечание:** Подробнее о паттерне Наблюдатель читайте в разделе «Дополнительное чтение».
- **Observable Subject (наблюдаемый субъект):** Кнопки коридора — при нажатии инициируют событие наблюдателя. - **Observer (наблюдатель):** Контроллер диспетчеризации слушает события нажатия кнопок и выделяет лифт.
```java // Observable Subject: HallwayButtonPanel public class HallwayButtonPanel { private final int floor; private final List<ElevatorObserver> observers;
public HallwayButtonPanel(int floor) { this.floor = floor; this.observers = new ArrayList<>(); }
public void pressButton(Direction direction) { notifyObservers(direction); }
public void addObserver(ElevatorObserver observer) { observers.add(observer); }
private void notifyObservers(Direction direction) { for (ElevatorObserver observer : observers) { observer.update(floor, direction); } } }
// Observer Interface public interface ElevatorObserver { void update(int floor, Direction direction); }
// Observer Implementation public class ElevatorDispatchController implements ElevatorObserver { @Override public void update(int floor, Direction direction) { // Logic to handle the floor request } } ```
**Преимущества:** - Слабосвязанная архитектура — проще сопровождать, тестировать и расширять. - Более быстрый отклик в часы пик — обходит накопление очереди.
### Лифты, обслуживающие разные наборы этажей
Для поддержки лифтов, останавливающихся только на определённых этажах: 1. Каждый `ElevatorCar` получает `Set<Integer> accessibleFloors`. 2. `addFloorRequest()` проверяет доступность этажа перед добавлением в очередь. 3. Стратегии диспетчеризации рассматривают только лифты, способные достичь запрошенного этажа.
```java // In ElevatorCar — add accessible floors set private final Set<Integer> accessibleFloors;
// Modified addFloorRequest — check accessibility first public void addFloorRequest(int floor) { if (accessibleFloors.contains(floor) && !targetFloors.contains(floor)) { targetFloors.offer(floor); updateDirection(floor); } }
// Updated ShortestSeekTimeFirst — also checks accessible floors public class ShortestSeekTimeFirstStrategy implements DispatchingStrategy { @Override public ElevatorCar selectElevator(List<ElevatorCar> elevators, int floor, Direction direction) { ElevatorCar bestElevator = null; int shortestDistance = Integer.MAX_VALUE;
for (ElevatorCar elevator : elevators) { int distance = Math.abs(elevator.getCurrentFloor() - floor); if ((elevator.isIdle() || elevator.getCurrentDirection() == direction) && elevator.getAccessibleFloors().contains(floor) // New check && distance < shortestDistance) { bestElevator = elevator; shortestDistance = distance; } } return bestElevator; } } ```
**Пример:** Здание с 20 этажами. Лифт 1 обслуживает этажи 1, 5, 10, 15, 20. Лифт 2 обслуживает все этажи. Пользователь на 3-м этаже, нажимающий «вверх», получит Лифт 2, поскольку Лифт 1 не может остановиться на 3-м этаже.
Подведение итогов
В этой главе мы спроектировали систему лифтов, следуя структурированному подходу OOD. Ключевые выводы:
- Модульность: ElevatorSystem, ElevatorCar, ElevatorDispatch и DispatchingStrategy сосредоточены на конкретной ответственности. - Настраиваемая стратегия диспетчеризации позволяет легко переключаться между алгоритмами. - Паттерн Наблюдатель (детальный разбор) обеспечивает событийно-управляемую обработку вызовов из коридора для более быстрого назначения. - Поддержка доступных этажей (детальный разбор) демонстрирует расширяемость без нарушения существующего дизайна.
Дополнительное чтение: паттерн Наблюдатель
Паттерн Наблюдатель позволяет определять механизм подписки, позволяющий нескольким объектам автоматически получать уведомления при изменении состояния наблюдаемого объекта.
**Проблема:** Новостное приложение доставляет обновления в реальном времени пользователям. Прямая связь издателя новостей с каждым пользователем создаёт тесную зависимость — сложно сопровождать и масштабировать.
**Решение:** Отношение «один ко многим» между субъектом (издателем новостей) и наблюдателями (пользователями): - Субъект ведёт список наблюдателей и уведомляет их об изменении состояния. - Наблюдатели реализуют метод `update()` для получения уведомлений.
**Когда использовать:** - Когда изменения в одном объекте требуют уведомления других объектов, особенно если набор уведомляемых объектов заранее неизвестен. - Когда объектам нужно наблюдать за другими только при определённых условиях или в течение ограниченного времени.
Проектирование системы продуктового магазина
В этой главе мы рассмотрим проектирование системы продуктового магазина. Система предназначена для сотрудников магазина и позволяет упростить операции по управлению каталогом товаров, настройке цен и применению скидок.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
«Представьте, что вы находитесь в продуктовом магазине и наполняете корзину свежими продуктами, закусками и товарами для дома. На кассе кассир сканирует каждый товар, система мгновенно отслеживает заказ, применяет скидки и отображает итоговую сумму. За кулисами система незаметно управляет каталогом товаров, обновляет инвентарь по мере поступления или продажи товаров и обеспечивает точность каждой транзакции. Теперь давайте спроектируем систему продуктового магазина, которая делает всё это.»
**Уточнение требований**
> **Кандидат:** Какие основные операции должна поддерживать система продуктового магазина? > **Интервьюер:** Система должна помогать сотрудникам магазина — обработчикам поставок и кассирам — управлять каталогом товаров, отслеживать инвентарь и обрабатывать кассовые операции с применением скидок.
> **Кандидат:** Хочу подтвердить своё понимание процесса оформления покупки. Кассир сканирует или вводит код каждого товара, система отслеживает заказ, вычисляет промежуточную сумму, применяет скидки и обновляет итог. После ввода всех товаров кассир видит финальную сумму, принимает оплату и выдаёт сдачу. Затем формируется чек. Это верно? > **Интервьюер:** Да, вы правильно понимаете систему.
> **Кандидат:** Как система должна управлять инвентарём? > **Интервьюер:** Система должна отслеживать инвентарь для всех товаров: увеличивать количество при поступлении нового товара и автоматически уменьшать при продаже во время оформления покупки.
> **Кандидат:** Должна ли система разделять товары по категориям, например еда, напитки и т.д.? > **Интервьюер:** Да, хорошая идея.
> **Кандидат:** По поводу скидок — правильно ли я понимаю: система отслеживает акционные кампании, которые могут применяться к конкретным товарам или категориям? Если к одному товару применимо несколько скидок, система автоматически выбирает наибольшую? > **Интервьюер:** Отлично.
**Требования**
В этом задании несколько требований, поэтому группировка похожих упрощает управление ими. Требования можно разбить на четыре группы.
**Управление каталогом** - Администраторы могут добавлять, обновлять и удалять товары из каталога. - Каталог хранит сведения о товарах: название, категорию, цену и штрих-код.
**Управление инвентарём** - Обработчики поставок могут обновлять инвентарь при получении товаров. - Система должна автоматически уменьшать инвентарь при продаже товаров.
**Процесс оформления покупки** - Кассиры могут сканировать штрих-коды или вводить коды товаров вручную для формирования заказа. - Кассиры могут просматривать детали активного заказа: товары, скидки и промежуточную сумму. - Система автоматически рассчитывает и применяет актуальные скидки. - Кассиры могут завершить заказ, рассчитать итог, принять оплату и выдать сдачу. - Формируется подробный чек.
**Акционные кампании** - Администраторы могут определять акционные кампании для конкретных товаров или категорий. - Если к товару применимо несколько скидок, система выбирает наибольшую.
Нефункциональные требования: - Система должна отображать понятные, удобные для пользователя сообщения об ошибках (например, при недействительных штрих-кодах или недостаточном количестве товара) для кассира. - Компоненты системы (каталог, инвентарь, оформление покупки, скидки) должны быть модульными, чтобы обновление или замена отдельных модулей не влияла на всю систему.
Определение основных объектов
Перед началом проектирования важно перечислить основные объекты.
- **Item (Товар):** Представляет отдельный продукт в магазине, инкапсулируя такие сведения, как название, штрих-код, категория и цена. - **Catalog (Каталог):** Является центральным хранилищем всех товаров, управляет коллекцией продуктов и поддерживает операции добавления, обновления и удаления. - **Inventory (Инвентарь):** Отслеживает остатки каждого товара. Обновляет количество доступных позиций при получении нового товара (через поставки) или при продаже во время оформления покупки. - **Order (Заказ):** Отслеживает текущий процесс оформления покупки. Управляет сведениями о товарах в заказе, активных скидках и расчёте промежуточных и итоговых сумм. Эти данные используются для формирования чека после завершения заказа. - **DiscountCampaign (Акционная кампания):** Объект DiscountCampaign определяет правила применения скидок.
Проектирование диаграммы классов
Теперь, зная основные объекты и их роли, следующий шаг — создать классы и методы, превращающие требования в простую в обслуживании систему. Рассмотрим подробнее.
**Item (Товар)**
Первый компонент диаграммы классов — класс `Item`, представляющий отдельные продукты в магазине. Он инкапсулирует атрибуты: название, штрих-код, категорию и цену.
Классы Catalog, Inventory и Discount
**Catalog (Каталог)**
Класс `Catalog` отвечает за ведение структурированного списка всех доступных продуктов, каждый из которых уникально идентифицируется штрих-кодом. Он предоставляет методы для добавления, обновления, удаления и получения товаров.
Проектирование скидок (паттерны Strategy + Composite)
> **Выбор дизайна:** Для обеспечения модульности и простоты обслуживания мы намеренно разделили статические данные о продуктах и динамические уровни запасов: **Статические данные (Catalog)** управляют метаданными продуктов; **Динамические данные (Inventory)** обрабатывают уровни запасов, которые часто меняются. Такое разделение соответствует принципу единственной ответственности (SRP).
**DiscountCriteria (Критерии скидки)**
Интерфейс `DiscountCriteria` инкапсулирует логику определения применимости скидки к товару. Он предоставляет гибкую, расширяемую базу для определения проверок применимости — по товару и по категории.
- **CategoryBasedCriteria:** Определяет применимость скидки, проверяя принадлежность товара к определённой категории. - **ItemBasedCriteria:** Проверяет применимость скидки к конкретному товару по его уникальному идентификатору.
**DiscountCalculationStrategy (Стратегия расчёта скидки)**
Интерфейс `DiscountCalculationStrategy` инкапсулирует логику расчёта скидок, используя паттерн Стратегия (Strategy Pattern).
- **AmountBasedStrategy:** Применяет фиксированную сумму скидки к исходной цене. - **PercentageBasedStrategy:** Применяет процентную скидку к исходной цене.
> **Выбор дизайна:** Этот дизайн легко расширять — новые стратегии скидок можно добавлять без изменения существующей реализации, следуя принципу открытости/закрытости (Open/Closed Principle).
**DiscountCampaign (Акционная кампания)**
Класс `DiscountCampaign` моделирует активные акционные кампании. Он использует паттерн Стратегия для инкапсуляции различных стратегий расчёта и интерфейс `DiscountCriteria` для указания, какие товары подпадают под скидку.
> **Выбор дизайна:** Разделение логики применимости (критерии) и стратегии расчёта делает класс более модульным и простым для расширения.
Классы Order, Receipt и Checkout
**OrderItem (Позиция заказа)**
Класс `OrderItem` представляет конкретный товар в заказе вместе с его количеством. Он инкапсулирует методы для расчёта итоговой стоимости на основе цены за единицу и количества.
> **Выбор дизайна:** Класс `OrderItem` отделяет детали уровня позиции от заказа верхнего уровня, обеспечивая инкапсуляцию логики количества и цены для каждого товара.
**Order (Заказ)**
Класс `Order` представляет активную транзакцию в процессе оформления покупки. Он отслеживает список товаров и применённых скидок, предоставляя методы для расчёта промежуточной суммы (до скидок) и итоговой суммы (после скидок).
> **Выбор дизайна:** Класс `Order` делегирует управление количеством отдельных товаров классу `OrderItem`, обеспечивая чёткое разделение ответственности.
**Receipt (Чек)**
Класс `Receipt` служит финальной записью завершённой транзакции, объединяя сводку заказа, детали оплаты и сдачу.
> **Выбор дизайна:** Класс `Receipt` сосредоточен исключительно на представлении данных транзакции в удобном для покупателя формате, делегируя всю бизнес-логику классам `Order` и `OrderItem`.
**Checkout (Оформление покупки)**
Класс `Checkout` инкапсулирует логику обработки процесса оформления покупки. Он хранит активный объект `Order` и применяет активные акционные кампании.
> **Выбор дизайна:** Несмотря на центральную роль, класс `Checkout` остаётся лёгким, поскольку ответственность делегирована классам `Order`, `DiscountCampaign` и базовым классам стратегий.
GroceryStoreSystem (Фасад)
Класс `GroceryStoreSystem` выступает в роли **Фасада**, упрощающего взаимодействие с компонентами системы (Catalog, Inventory, Checkout). Предоставляя единый интерфейс, он скрывает лежащую в основе сложность.
Код — Система продуктового магазина
**Item (Товар)**
```java public class Item { private final String name; private final String barcode; private final String category; private BigDecimal price;
public Item(String name, String barcode, String category, BigDecimal price) { this.name = name; this.barcode = barcode; this.category = category; this.price = price; }
// getter and setter methods are omitted for brevity } ```
**Catalog (Каталог)**
```java public class Catalog { // Map of barcodes to their corresponding items private final Map<String, Item> items = new HashMap<>();
public void updateItem(Item item) { items.put(item.getBarcode(), item); }
public void removeItem(String barcode) { items.remove(barcode); }
public Item getItem(String barcode) { return items.get(barcode); } } ```
**Inventory (Инвентарь)**
```java public class Inventory { // Map of barcodes to their stock quantities private final Map<String, Integer> stock = new HashMap<>();
public void addStock(String barcode, int count) { stock.put(barcode, stock.getOrDefault(barcode, 0) + count); }
public void reduceStock(String barcode, int count) { stock.put(barcode, stock.getOrDefault(barcode, 0) - count); }
public int getStock(String barcode) { return stock.getOrDefault(barcode, 0); } } ```
> **Выбор реализации:** Класс `Inventory` использует `HashMap` для сопоставления штрих-кодов с количеством запасов, обеспечивая среднюю временну́ю сложность O(1) для обновлений и запросов.
**DiscountCampaign (Акционная кампания)**
```java public class DiscountCampaign { private final String discountId; private final String name; private final DiscountCriteria criteria; private final DiscountCalculationStrategy calculationStrategy;
public DiscountCampaign( String discountId, String name, DiscountCriteria criteria, DiscountCalculationStrategy calculationStrategy) { this.discountId = discountId; this.name = name; this.criteria = criteria; this.calculationStrategy = calculationStrategy; }
// Checks if this discount applies to the given item public boolean isApplicable(Item item) { return criteria.isApplicable(item); }
// Calculates the discounted price for the given order item public BigDecimal calculateDiscount(OrderItem item) { return calculationStrategy.calculateDiscountedPrice(item.calculatePrice()); } // getter and setter methods are omitted for brevity } ```
> **Выбор реализации:** Класс `DiscountCampaign` использует композицию для хранения единственных `DiscountCriteria` и `DiscountCalculationStrategy`, применяя полиморфизм для гибкой настройки скидок.
**OrderItem (Позиция заказа)**
```java public class OrderItem { private final Item item; private final int quantity;
public OrderItem(Item item, int quantity) { this.item = item; this.quantity = quantity; }
// Calculates the total price for this order item without any discount public BigDecimal calculatePrice() { return item.getPrice().multiply(BigDecimal.valueOf(quantity)); }
// Calculates the total price for this order item with the given discount public BigDecimal calculatePriceWithDiscount(DiscountCampaign newDiscount) { return newDiscount.calculateDiscount(this); } // getter and setter methods are omitted for brevity } ```
**Order (Заказ)**
```java public class Order { private final String orderId; private final List<OrderItem> items = new ArrayList<>(); private final Map<OrderItem, DiscountCampaign> appliedDiscounts = new HashMap<>(); private BigDecimal paymentAmount = BigDecimal.ZERO;
public Order() { this.orderId = String.valueOf(UUID.randomUUID()); }
public void addItem(OrderItem item) { items.add(item); }
// Calculates the subtotal of all items without discounts public BigDecimal calculateSubtotal() { return items.stream() .map(OrderItem::calculatePrice) .reduce(BigDecimal.ZERO, BigDecimal::add); }
// Calculates the total price including all applied discounts public BigDecimal calculateTotal() { return items.stream() .map(item -> { DiscountCampaign discount = appliedDiscounts.get(item); return discount != null ? item.calculatePriceWithDiscount(discount) : item.calculatePrice(); }) .reduce(BigDecimal.ZERO, BigDecimal::add); }
public void applyDiscount(OrderItem item, DiscountCampaign discount) { appliedDiscounts.put(item, discount); }
public BigDecimal calculateChange() { return paymentAmount.subtract(calculateTotal()); } // getter and setter methods are omitted for brevity } ```
> **Выбор реализации:** Класс `Order` использует `ArrayList` для быстрого добавления позиций O(1) и `HashMap<OrderItem, DiscountCampaign>` для поиска скидок O(1).
**Checkout (Оформление покупки)**
```java public class Checkout { private Order currentOrder; private final List<DiscountCampaign> activeDiscounts;
public Checkout(List<DiscountCampaign> activeDiscounts) { this.activeDiscounts = activeDiscounts; startNewOrder(); }
public void startNewOrder() { this.currentOrder = new Order(); }
public BigDecimal processPayment(BigDecimal paymentAmount) { currentOrder.setPayment(paymentAmount); return currentOrder.calculateChange(); }
// Adds an item to the current order and applies applicable discounts public void addItemToOrder(Item item, int quantity) { OrderItem orderItem = new OrderItem(item, quantity); currentOrder.addItem(orderItem);
for (DiscountCampaign newDiscount : activeDiscounts) { if (newDiscount.isApplicable(item)) { // If multiple discounts apply, keep the higher (lower final price) if (currentOrder.getAppliedDiscounts().containsKey(orderItem)) { DiscountCampaign existingDiscount = currentOrder.getAppliedDiscounts().get(orderItem); if (orderItem.calculatePriceWithDiscount(newDiscount) .compareTo(orderItem.calculatePriceWithDiscount(existingDiscount)) > 0) { currentOrder.applyDiscount(orderItem, newDiscount); } } else { currentOrder.applyDiscount(orderItem, newDiscount); } } } }
public Receipt getReceipt() { return new Receipt(currentOrder); }
public BigDecimal getOrderTotal() { return currentOrder.calculateTotal(); } } ```
> **Выбор реализации:** Класс `Checkout` использует `ArrayList` для хранения активных скидок, обеспечивая линейный перебор O(n) для оценки применимости и выбора наибольшей скидки.
**GroceryStoreSystem**
```java public class GroceryStoreSystem { private final Catalog catalog; private final Inventory inventory; private List<DiscountCampaign> activeDiscounts = new ArrayList<>(); private final Checkout checkout;
public GroceryStoreSystem() { this.catalog = new Catalog(); this.inventory = new Inventory(); this.checkout = new Checkout(activeDiscounts); }
public void addOrUpdateItem(Item item) { catalog.updateItem(item); }
public void updateInventory(String barcode, int count) { inventory.addStock(barcode, count); }
public void addDiscountCampaign(DiscountCampaign discount) { activeDiscounts.add(discount); }
public Item getItemByBarcode(String barcode) { return catalog.getItem(barcode); }
public void removeItem(String barcode) { catalog.removeItem(barcode); } } ```
Детальный разбор — Гибкие критерии скидок
Текущий дизайн инкапсулирует логику скидок в два компонента: **Критерии** (определяют, подпадает ли товар под скидку) и **Стратегия расчёта цены** (вычисляет цену со скидкой). Но что если интервьюер попросит реализовать более сложные составные скидки?
**Объединение нескольких критериев (паттерн Компоновщик)**
Для обработки вложенных или комбинированных критериев используем **паттерн Компоновщик (Composite Pattern)**. Составные критерии позволяют объединять несколько правил с помощью логических операторов AND и OR.
> **Примечание:** Подробнее о паттерне Компоновщик (Composite Pattern) можно прочитать в главе о поиске файлов в Unix.
```java public class CompositeCriteria implements DiscountCriteria { private final List<DiscountCriteria> criteriaList;
public CompositeCriteria(List<DiscountCriteria> criteriaList) { this.criteriaList = new ArrayList<>(criteriaList); }
@Override public boolean isApplicable(Item item) { return criteriaList.stream().allMatch(criteria -> criteria.isApplicable(item)); }
public void addCriteria(DiscountCriteria criteria) { criteriaList.add(criteria); }
public void removeCriteria(DiscountCriteria criteria) { criteriaList.remove(criteria); } } ```
**Наслоение расчётов скидок (паттерн Декоратор)**
Для управления последовательными расчётами скидок используем **паттерн Декоратор (Decorator Pattern)**. Оборачивая несколько стратегий расчёта, мы можем применять скидки в определённом порядке: 1. Сначала применить фиксированную скидку. 2. Затем применить процентную скидку к оставшейся цене.
```java public class FixedDiscountDecorator implements DiscountCalculationStrategy { private final DiscountCalculationStrategy strategy; private final BigDecimal fixedAmount;
public FixedDiscountDecorator(DiscountCalculationStrategy strategy, BigDecimal fixedAmount) { this.strategy = strategy; this.fixedAmount = fixedAmount; }
@Override public BigDecimal calculateDiscountedPrice(BigDecimal originalPrice) { return strategy.calculateDiscountedPrice(originalPrice).subtract(fixedAmount); } }
public class PercentageDiscountDecorator implements DiscountCalculationStrategy { private final DiscountCalculationStrategy strategy; private final BigDecimal additionalPercentage;
public PercentageDiscountDecorator( DiscountCalculationStrategy strategy, BigDecimal additionalPercentage) { this.strategy = strategy; this.additionalPercentage = additionalPercentage; }
@Override public BigDecimal calculateDiscountedPrice(BigDecimal originalPrice) { BigDecimal baseDiscountedPrice = strategy.calculateDiscountedPrice(originalPrice); return baseDiscountedPrice.multiply( BigDecimal.ONE.subtract(additionalPercentage.divide(BigDecimal.valueOf(100)))); } } ```
Подведение итогов
В этой главе мы спроектировали систему продуктового магазина. Мы начали с перечисления требований через диалог кандидата и интервьюера, определили основные объекты, создали диаграмму классов и представили код реализации.
Главный вывод — **чёткое разделение ответственности**: каждый компонент (Catalog, Inventory, Order, DiscountCampaign) сосредоточен на конкретной обязанности, обеспечивая модульность и бесшовную интеграцию.
В детальном разборе мы рассмотрели продвинутые темы: составные скидки и наслоение нескольких стратегий расчёта с использованием паттернов Компоновщик и Декоратор. Эти улучшения демонстрируют, как абстракция и расширяемость справляются со сложными реальными сценариями.
Дополнительное чтение — Паттерн Декоратор
**Паттерн проектирования Декоратор**
Декоратор — это структурный паттерн проектирования, позволяющий добавлять новое поведение объекту, оборачивая его в другой объект с дополнительной функциональностью, без изменения кода исходного объекта.
В системе продуктового магазина мы использовали паттерн Декоратор для наслоения нескольких расчётов скидок, оборачивая объекты `DiscountCalculationStrategy` в `FixedDiscountDecorator` и `PercentageDiscountDecorator`. Это позволяет применять скидки последовательно без изменения базовых стратегий скидок.
**Проблема**
Представьте текстовый редактор, где пользователи форматируют текст с помощью жирного, курсивного или подчёркнутого стиля. Подклассирование приводит к взрыву комбинаций (BoldText, BoldItalicText...), что затрудняет добавление новых стилей.
**Решение**
Паттерн Декоратор создаёт классы-декораторы, реализующие тот же интерфейс, что и класс Text, и оборачивающие объект Text. Декораторы можно стекировать для комбинирования стилей (жирный + курсивный), обеспечивая гибкость без изменения класса Text.
**Когда использовать**
- Когда нужно динамически добавлять функциональность или поведение объектам во время выполнения без изменения их кода. - Когда подклассирование приводит к слишком большому числу комбинаций функций — композиция является более простой альтернативой.
Проектирование игры Крестики-нолики
В этой главе мы рассмотрим объектно-ориентированное проектирование игры «Крестики-нолики». Наша цель — создать интерактивную платформу, где два игрока поочерёдно ставят свои символы на виртуальном поле. Мы спроектируем ключевые компоненты: игровое поле, отслеживание ходов, определение результата и систему рейтинга для управления очками игроков.
**Как работают Крестики-нолики:** Крестики-нолики — классическая игра для двух игроков на сетке 3×3. Каждый игрок выбирает символ («X» или «O») и по очереди ставит его в пустую клетку. Цель — выстроить три одинаковых символа по горизонтали, вертикали или диагонали. Игра заканчивается победой, если игрок добивается такого расположения, или ничьёй, если все девять клеток заполнены без победителя.
Сбор требований
Вот пример типичного условия задачи от интервьюера:
«Представьте, что вы с другом садитесь сыграть партию в крестики-нолики. Каждый выбирает символ (например, «X» или «O») и по очереди ставит его на поле. После каждого хода игра проверяет, выиграл ли кто-то или поле заполнено — тогда объявляется ничья. За кулисами игра отслеживает ходы, обновляет таблицу результатов для отражения побед и поддерживает рейтинг игроков для будущих матчей. Давайте спроектируем систему игры в крестики-нолики, которая справляется со всем этим.»
> **Кандидат:** Игра поддерживает разные размеры поля? > **Интервьюер:** Нет, для простоты будем использовать стандартное поле 3×3.
> **Кандидат:** Как игра должна обрабатывать исходы: победы, поражения и ничьи? > **Интервьюер:** Система должна распознавать выигрышные комбинации и уведомлять игроков о результате: победа, ничья или игра продолжается.
> **Кандидат:** Должна ли игра отслеживать рейтинг игроков? > **Интервьюер:** Да, игра должна поддерживать систему рейтинга, обновляющую очки игроков на основе результатов (победа, поражение или ничья).
> **Кандидат:** Как игра обрабатывает недействительные ходы? > **Интервьюер:** Если игрок пытается сделать ход в занятую или недействительную клетку, нужно уведомить его и попросить сделать другой ход.
**Требования**
- Игра ведётся на поле 3×3. - Система определяет статус игры: победа (три одинаковых символа в ряд), ничья (поле заполнено без победителя) или игра продолжается. - Система рейтинга фиксирует результаты игроков, обновляет очки на основе побед и поддерживает запросы вроде рейтинга или топ-игроков. - Недействительные ходы (например, установка символа в занятую клетку) отклоняются с обратной связью.
**Нефункциональные требования:** - Интерфейс должен быть интуитивным, обеспечивая чёткую обратную связь при недействительных ходах и результатах игры. - Система должна поддерживать будущие улучшения (разные размеры поля, режимы игры) без существенных архитектурных изменений.
Определение основных объектов
- **Board (Поле):** Моделирует игровую сетку 3×3. Обрабатывает обновления сетки, проверяет наличие победителя, анализируя строки, столбцы и диагонали, и определяет, заполнено ли поле. - **Player (Игрок):** Представляет отдельного участника игры. - **Game (Игра):** Центральная сущность, координирующая очерёдность ходов, валидирующая ходы и отслеживающая статус игры. - **ScoreTracker (Система рейтинга):** Отслеживает рейтинг игроков по играм, обновляя его на основе результатов.
> **Выбор дизайна:** Класс `Game` может стать перегруженным. Чтобы сохранить управляемость, мы делегируем `Board` управление сеткой, а `ScoreTracker` — обработку рейтинга игроков. Такая модульность улучшает поддерживаемость и масштабируемость.
Проектирование диаграммы классов
**Game (Игра)**
Класс `Game` является центральным координатором. Он управляет ходом игры, инициализирует компоненты, обрабатывает ходы и определяет результаты. `ScoreTracker` отвечает за отслеживание результатов игроков, `Board` управляет сеткой, а класс `Player` остаётся без состояния (без хранимых счётчиков побед).
Классы Board, ScoreTracker, Move и Player
**Board (Поле)**
Класс `Board` представляет игровую сетку 3×3 в виде двумерного массива объектов `Player`. Он проверяет допустимость ходов, определяет победителей, анализируя строки/столбцы/диагонали, и поддерживает сброс сетки.
> **Выбор дизайна:** Логика проверки победы принадлежит `Board` (а не `Game`), что соответствует принципу единственной ответственности (SRP) — поле владеет правилами, связанными с сеткой.
**ScoreTracker (Система рейтинга)**
`ScoreTracker` поддерживает централизованную `HashMap<Player, Integer>` счётчиков побед. Рейтинги намеренно отделены от класса `Player`, поскольку они контекстуальны (зависят от других игроков), меняются по мере игр и могут эволюционировать в расширенных системах (например, разные лиги).
**Move (Ход)**
Класс `Move` объединяет `row`, `column` и `player` в одну структуру данных, улучшая читаемость кода по сравнению с передачей их как отдельных параметров.
**Player (Игрок)**
Класс `Player` инкапсулирует `name` и `symbol`. Валидация ходов и обновление рейтинга намеренно исключены для соблюдения SRP — `Board` валидирует ходы, `ScoreTracker` управляет рейтингами.
Полная диаграмма классов
Ниже представлена полная диаграмма классов игры Крестики-нолики.
Код — Игра Крестики-нолики
**Game (Игра)**
```java public class Game { private final Board board; private final ScoreTracker scoreTracker; private Player[] players; private int currentPlayerIndex;
public Game(Player playerX, Player playerY) { board = new Board(); scoreTracker = new ScoreTracker(); startNewGame(playerX, playerY); }
public void startNewGame(Player playerX, Player playerY) { board.reset(); players = new Player[] {playerX, playerY}; currentPlayerIndex = 0; }
public void makeMove(int colIndex, int rowIndex, Player player) { if (getGameStatus().equals(GameCondition.ENDED)) { throw new IllegalStateException("game ended"); } if (players[currentPlayerIndex] != player) { throw new IllegalArgumentException("not the current player"); } if (board.getPlayerAt(colIndex, rowIndex) != null) { throw new IllegalArgumentException("board position is taken"); } board.updateBoard(colIndex, rowIndex, player); final Move newMove = new Move(colIndex, rowIndex, player); currentPlayerIndex = (currentPlayerIndex + 1) % players.length; if (getGameStatus().equals(GameCondition.ENDED)) { scoreTracker.reportGameResult(players[0], players[1], board.getWinner()); } }
public GameCondition getGameStatus() { Optional<Player> winner = board.getWinner(); if (winner.isPresent()) { return GameCondition.ENDED; } return board.isFull() ? GameCondition.ENDED : GameCondition.IN_PROGRESS; }
public Player getCurrentPlayer() { return players[currentPlayerIndex]; }
public ScoreTracker getScoreTracker() { return scoreTracker; } } ```
> **Выбор реализации:** Класс `Game` использует подход конечного автомата с перечислением `GameCondition` (IN_PROGRESS или ENDED), гарантируя, что в каждый момент ходит только один игрок, и игра останавливается при победе или ничьей.
**Board (Поле)**
```java public class Board { private final Player[][] grid = new Player[3][3];
public void updateBoard(int colIndex, int rowIndex, Player player) { if (grid[colIndex][rowIndex] == null) { grid[colIndex][rowIndex] = player; } }
public Optional<Player> getWinner() { // Check rows for three in a row for (int i = 0; i < grid.length; i++) { Player first = grid[i][0]; if (first != null && Arrays.stream(grid[i]).allMatch(p -> p == first)) { return Optional.of(first); } }
// Check columns for three in a column for (int j = 0; j < grid[0].length; j++) { final Player first = grid[0][j]; int finalJ = j; if (first != null && Arrays.stream(grid).allMatch(row -> row[finalJ] == first)) { return Optional.of(first); } }
// Check main diagonal (top-left to bottom-right) Player topLeft = grid[0][0]; if (topLeft != null && IntStream.range(0, grid.length).allMatch(i -> grid[i][i] == topLeft)) { return Optional.of(topLeft); }
// Check anti-diagonal (top-right to bottom-left) Player topRight = grid[0][grid[0].length - 1]; if (topRight != null && IntStream.range(0, grid.length) .allMatch(i -> grid[i][grid[0].length - 1 - i] == topRight)) { return Optional.of(topRight); }
return Optional.empty(); }
public boolean isFull() { return Arrays.stream(grid).flatMap(Arrays::stream).noneMatch(Objects::isNull); }
public void reset() { for (Player[] players : grid) { Arrays.fill(players, null); } }
public Player getPlayerAt(int colIndex, int rowIndex) { return grid[colIndex][rowIndex]; } } ```
> **Выбор реализации:** `Board` использует двумерный массив (`Player[][]`) для прямого пространственного отображения и доступа к позиции O(1).
**Player (Игрок)**
```java public class Player { private final String name; private final char symbol;
public Player(String name, char symbol) { this.name = name; this.symbol = symbol; }
public String getName() { return name; }
public char getSymbol() { return symbol; } } ```
**ScoreTracker (Система рейтинга)**
```java class ScoreTracker { private HashMap<Player, Integer> playerRatings = new HashMap<>();
// Simple victory count system: winner +1, loser -1, draw no change public void reportGameResult(Player player1, Player player2, Optional<Player> winningPlayer) { if (winningPlayer.isPresent()) { Player winner = winningPlayer.get(); Player loser = player1 == winner ? player2 : player1; playerRatings.putIfAbsent(winner, 0); playerRatings.put(winner, playerRatings.get(winner) + 1); playerRatings.putIfAbsent(loser, 0); playerRatings.put(loser, playerRatings.get(loser) - 1); } }
public Map<Player, Integer> getTopPlayers() { return playerRatings.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .map(Map.Entry::getKey) .collect(Collectors.toMap(player -> player, player -> playerRatings.get(player))); }
public int getRank(Player player) { List<Player> sortedPlayers = playerRatings.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .map(Map.Entry::getKey) .collect(Collectors.toList()); return sortedPlayers.indexOf(player) + 1; } } ```
> **Выбор реализации:** `HashMap<Player, Integer>` обеспечивает поиск и обновление в среднем O(1) для частых обновлений счёта и запросов рейтинга.
Детальный разбор — Функция отмены (паттерн Хранитель)
**Реализация функции отмены хода в Крестиках-ноликах**
**Шаг 1: Отслеживание истории ходов**
Каждый раз, когда игрок делает ход, мы сохраняем объект `Move` (уже спроектированный с `rowIndex`, `colIndex` и `player`).
**Шаг 2: Хранение ходов в стеке**
Поскольку ходы происходят в порядке LIFO, используем `ArrayDeque<Move>`. При совершении хода добавляем его в стек. При запросе отмены извлекаем последний ход и откатываем состояние поля.
```java class MoveHistory { private final ArrayDeque<Move> history = new ArrayDeque<>();
public void recordMove(Move move) { history.push(move); }
public Move undoMove() { return history.pop(); }
public void clearHistory() { history.clear(); } } ```
**Шаг 3: Откат состояния поля**
```java public void makeMove(int colIndex, int rowIndex, Player player) { if (getGameStatus().equals(GameCondition.ENDED)) { throw new IllegalStateException("game ended"); } if (players[currentPlayerIndex] != player) { throw new IllegalArgumentException("not the current player"); } if (board.getPlayerAt(colIndex, rowIndex) != null) { throw new IllegalArgumentException("board position is taken"); } board.updateBoard(colIndex, rowIndex, player); final Move newMove = new Move(colIndex, rowIndex, player); moveHistory.recordMove(newMove); currentPlayerIndex = (currentPlayerIndex + 1) % players.length; if (getGameStatus().equals(GameCondition.ENDED)) { scoreTracker.reportGameResult(players[0], players[1], board.getWinner()); } }
public void undoMove() { if (getGameStatus().equals(GameCondition.ENDED)) { throw new IllegalStateException("game ended and winner already reported"); } final Move lastMove = moveHistory.undoMove();
if (currentPlayerIndex == 0) { currentPlayerIndex = players.length - 1; } else { currentPlayerIndex--; }
board.updateBoard(lastMove.getColIndex(), lastMove.getRowIndex(), null); } ```
**Паттерн Хранитель (Memento Pattern)**
Это суть **паттерна Хранитель (Memento Pattern)** — поведенческого паттерна проектирования, позволяющего сохранять и восстанавливать предыдущее состояние без раскрытия деталей реализации.
- **Хранитель (Memento):** Класс `Move` — хранит состояние одного хода. - **Опекун (Caretaker):** Класс `MoveHistory` — поддерживает стек объектов `Move` и предоставляет `undoMove()`. - **Создатель (Originator):** Класс `Game` — создаёт объекты `Move` и использует `undoMove()` для восстановления состояния.
> **Примечание:** Одна из потенциальных проблем — накладные расходы на память. В Крестиках-ноликах они минимальны, но в более сложных играх с большими состояниями хранение каждого хранителя может стать узким местом.
Подведение итогов
В этой главе мы спроектировали игру Крестики-нолики. Мы собрали требования через диалог кандидата и интервьюера, определили основные объекты, спроектировали диаграмму классов и реализовали ключевые компоненты.
Главный вывод — важность **модульности и чёткого разделения ответственности**: `Board`, `Game`, `Player` и `ScoreTracker` каждый сосредоточен на конкретной задаче.
В детальном разборе мы изучили **паттерн Хранитель (Memento Pattern)** для реализации функции отмены, позволяющей игрокам откатывать ходы с сохранением целостности состояния игры.
Проектирование игры Блэкджек
В этой главе мы рассмотрим объектно-ориентированное проектирование игры Блэкджек (также называемой «21»). Блэкджек — популярная карточная игра, цель которой — набрать карты суммой как можно ближе к 21, но не превышая это значение. Игра сочетает стратегию (решение, когда взять карту или остановиться) и удачу (какие карты достанутся), что делает её захватывающей и знаковой казино-игрой.
Сбор требований
> **Примечание:** В Блэкджеке существуют различные правила (например, мягкая 17, удвоение ставки, разделение). В этой главе рассматривается техническое проектирование упрощённой стандартной игры в Блэкджек.
> **Кандидат:** Следует ли мне проектировать игру с поддержкой нескольких игроков или только одного против дилера? > **Интервьюер:** Игра должна поддерживать нескольких игроков.
> **Кандидат:** Что происходит после того, как игрок совершает свой ход? > **Интервьюер:** После того как каждый игрок завершает свой ход (взять карту или остановиться), игра проверяет, все ли игроки остановились или получили перебор. Затем игра определяет победителя, сравнивая значения рук, и рассчитывается по ставкам.
> **Кандидат:** Должен ли дилер следовать определённым правилам, когда брать карту или остановиться? > **Интервьюер:** Да, дилер должен брать карты, пока сумма его руки не достигнет как минимум 17, после чего остановиться.
> **Кандидат:** Как обрабатываются ставки? > **Интервьюер:** Игроки делают ставки до раздачи карт. Победители получают выплату, равную ставке (1:1 плюс возврат исходной ставки). Игроки с перебором теряют ставку.
**Требования** - Игра поддерживает нескольких игроков и дилера. - В начале каждому игроку раздаются две карты. - Игроки могут «взять карту» (запросить дополнительную карту) или «остановиться» (оставить текущую руку). - Тузы ценятся как 1 или 11 в зависимости от того, что выгоднее игроку. - После того как все игроки совершили ходы, дилер берёт карты до достижения 17, затем останавливается. Игра определяет победителей и рассчитывается по ставкам. - Победители получают выплату 1:1; игроки с перебором теряют ставку.
Диаграмма активности
Диаграмма активности визуализирует рабочий процесс игры, отражая каждое действие, решение и переход — от раздачи карт до определения победителей, включая ветки вроде перебора у игрока или добора карт дилером до 17.
Определение основных объектов
- **BlackJackGame (Игра в Блэкджек):** Центральная сущность, управляющая общим ходом игры от начала до конца — раздача карт, отслеживание действий игроков и определение победителей. - **Player (Игрок):** Интерфейс с двумя реализациями: `RealPlayer` (отслеживает ставки и баланс) и `DealerPlayer` (берёт карты до 17, без ставок). - **Hand (Рука):** Управляет картами, которые держит игрок, и вычисляет все возможные значения — критично для обработки туза (1 или 11). - **Deck (Колода):** Управляет коллекцией из 52 карт, тасует её и выдаёт карты при раздаче. - **Card (Карта):** Неизменяемая сущность, определяемая перечислениями `Rank` и `Suit`.
Проектирование диаграммы классов
**Card (Карта)**
Класс `Card` — неизменяемый строительный блок, хранящий масть и достоинство. Он использует `getRankValues()` для получения значений из `Rank` — возвращая список целых чисел, поскольку туз имеет два возможных значения (1 или 11).
> **Выбор дизайна:** Спроектирован как самостоятельная сущность для возможности повторного использования в нескольких колодах или вариантах игры.
**Перечисления Rank и Suit**
Перечисления идеально подходят: типобезопасны, читаемы и просты в поддержке. `Rank` фиксирует значения карт (числа 2–10 по номиналу, картинки = 10, туз = 1 или 11). `Suit` перечисляет масти: Червы, Бубны, Трефы, Пики.
> **Выбор дизайна:** Перечисления предпочтительнее строк (требующих валидации и громоздких преобразований) или целых чисел (подверженных ошибкам, лишённых ясности). Для точных значений и именованных констант перечисления — очевидный выбор.
**Deck (Колода)**
Обрабатывает стандартную колоду из 52 карт: тасование, раздачу, подсчёт оставшихся карт и сброс для новых раундов.
Классы Player, Hand и BlackJackGame
**Player (Игрок)**
Интерфейс `Player` служит основой для всех участников. `RealPlayer` отслеживает имя, руку, ставку и баланс. `DealerPlayer` не имеет ставок и баланса — только берёт карты до 17.
> **Выбор дизайна:** Дизайн на основе интерфейса абстрагирует общее поведение, обеспечивая расширяемость для новых типов игроков.
**Hand (Рука)**
Управляет картами и всеми возможными суммами руки. Обрабатывает тузы как 1 или 11 через `SortedSet<Integer>` всех возможных значений. Методы: `addCard()`, `getCards()`, `getPossibleValues()`, `clear()`, `isBust()`.
> **Выбор дизайна:** Отделение `Hand` от `Player` соблюдает SRP — Hand управляет картами и значениями, Player управляет ставками и балансом.
**BlackJackGame (Игра в Блэкджек)**
Центральный оркестратор, управляющий игроками, дилером, колодой, ходами и правилами игры.
Код — Блэкджек
**Card, Rank и Suit**
```java public class Card { public final Rank rank; public final Suit suit;
public Card(Rank rank, Suit suit) { this.rank = rank; this.suit = suit; }
public int[] getRankValues() { return rank.getRankValues(); } } ```
```java public enum Rank { ACE(new int[] {1, 11}), TWO(new int[] {2}), THREE(new int[] {3}), FOUR(new int[] {4}), FIVE(new int[] {5}), SIX(new int[] {6}), SEVEN(new int[] {7}), EIGHT(new int[] {8}), NINE(new int[] {9}), TEN(new int[] {10}), JACK(new int[] {10}), QUEEN(new int[] {10}), KING(new int[] {10});
private final int[] rankValues;
Rank(int[] rankValues) { this.rankValues = rankValues; }
public int[] getRankValues() { return this.rankValues; } }
public enum Suit { HEARTS, SPADES, CLUBS, DIAMONDS } ```
> **Примечание:** Туз определяется со значениями `[1, 11]` сразу (без дефолтного 11 с последующей корректировкой). Это позволяет `Hand` вычислять все возможные суммы заранее, эффективно обрабатывая несколько тузов.
**Deck (Колода)**
```java public class Deck { int nextCardIndex = 0; List<Card> cards;
public Deck() { initializeDeck(); }
private void initializeDeck() { cards = new ArrayList<>(); for (Suit suit : Suit.values()) { for (Rank rank : Rank.values()) { cards.add(new Card(rank, suit)); } } nextCardIndex = 0; }
public void shuffle() { Collections.shuffle(cards, new Random(System.currentTimeMillis())); }
public Card draw() { if (isEmpty() || nextCardIndex >= cards.size()) { throw new IllegalStateException("No more cards in deck"); } Card drawCard = cards.get(nextCardIndex); nextCardIndex++; return drawCard; }
public int getRemainingCardCount() { return cards.size() - nextCardIndex; }
public boolean isEmpty() { return getRemainingCardCount() == 0; }
public void reset() { nextCardIndex = 0; } } ```
> **Выбор реализации:** Вместо удаления розданных карт (сдвиг O(n)) колода отслеживает позицию через `nextCardIndex`. Раздача становится O(1).
**Hand (Рука)**
```java public class Hand { final List<Card> handCards = new ArrayList<>(); final SortedSet<Integer> possibleValues = new TreeSet<>();
public void addCard(Card card) { if (card == null) { throw new IllegalArgumentException("Cannot add null card to hand"); } handCards.add(card);
if (possibleValues.isEmpty()) { for (int value : card.getRankValues()) { possibleValues.add(value); } } else { SortedSet<Integer> newPossibleValue = new TreeSet<>(); for (int value : possibleValues) { for (int cardValue : card.getRankValues()) { newPossibleValue.add(value + cardValue); } } possibleValues.clear(); possibleValues.addAll(newPossibleValue); } }
public List<Card> getCards() { return Collections.unmodifiableList(handCards); }
public SortedSet<Integer> getPossibleValues() { return Collections.unmodifiableSortedSet(possibleValues); }
public void clear() { handCards.clear(); possibleValues.clear(); }
// Checks if all possible hand values exceed 21 public boolean isBust() { if (possibleValues.isEmpty()) { return false; } return possibleValues.first() > 21; } } ```
> **Выбор структуры данных:** `SortedSet` (`TreeSet`) поддерживает отсортированные значения, обеспечивая вставку O(log n) и доступ O(1) к `first()` для `isBust()`. `HashSet` даёт O(1) при вставке, но O(n) для поиска минимума.
**Player, RealPlayer, DealerPlayer**
```java public interface Player { void bet(int bet); void loseBet(); void returnBet(); void payout(); boolean isBust(); Hand getHand(); int getBalance(); String getName(); int getBet(); }
public class RealPlayer implements Player { private final String name; private final Hand hand; private final int bet; private final int balance;
public RealPlayer(String name, int startBalance) { this.name = name; this.hand = new Hand(); this.bet = 0; this.balance = startBalance; }
@Override public void bet(int bet) { if (bet > balance) { throw new IllegalArgumentException("Bet is greater than balance"); } this.bet = bet; this.balance -= bet; }
@Override public void loseBet() { this.bet = 0; }
@Override public void returnBet() { this.balance += bet; this.bet = 0; }
@Override public void payout() { this.balance += bet * 2; // Return bet plus equal amount this.bet = 0; } }
public class DealerPlayer implements Player { private final String name = "Dealer"; private final Hand hand;
public DealerPlayer() { this.hand = new Hand(); }
// Bet-handling methods (bet, loseBet, returnBet) are no-ops for the Dealer @Override public void payout() { // Dealer does not receive a payout } } ```
**BlackJackGame (Игра в Блэкджек)**
```java public class BlackJackGame { private final Deck deck = new Deck(); private final List<Player> players = new ArrayList<>(); protected final Player dealer = new DealerPlayer(); private Player currentPlayer = null; Map<Player, Action> playerTurnStatusMap = new HashMap<>(); GamePhase currentPhase = GamePhase.STARTED;
public BlackJackGame(List<Player> players) { for (Player player : players) { if (player == null) throw new IllegalArgumentException(); this.players.add(player); this.playerTurnStatusMap.put(player, null); } this.playerTurnStatusMap.put(dealer, null); deck.shuffle(); }
public Player getNextEligiblePlayer() { if (currentPlayer != null && !Action.STAND.equals(playerTurnStatusMap.get(currentPlayer)) && !currentPlayer.isBust()) { return currentPlayer; } if (currentPlayer == null) { for (Player player : players) { if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) { currentPlayer = player; return currentPlayer; } } } int currentPlayerIndex = players.indexOf(currentPlayer); for (int i = currentPlayerIndex + 1; i < players.size(); i++) { Player player = players.get(i); if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) { currentPlayer = player; return currentPlayer; } } return null; }
protected void dealerTurn() { while (dealer.getHand().getPossibleValues().last() < 17) { dealer.getHand().addCard(deck.draw()); } playerTurnStatusMap.put(dealer, Action.STAND); checkGameEndCondition(); }
public void dealInitialCards() { if (!GamePhase.BET_PLACED.equals(currentPhase)) { throw new IllegalStateException("All players must bet before dealing"); } for (Player player : players) { player.getHand().addCard(deck.draw()); } dealer.getHand().addCard(deck.draw()); for (Player player : players) { player.getHand().addCard(deck.draw()); } dealer.getHand().addCard(deck.draw()); currentPhase = GamePhase.INITIAL_CARD_DRAWN; }
public void hit(Player player) { if (Action.STAND.equals(playerTurnStatusMap.get(player))) { throw new IllegalStateException("Player has already stood"); } if (player.isBust()) { throw new IllegalStateException("Player is already bust"); } player.getHand().addCard(deck.draw()); playerTurnStatusMap.put(player, Action.HIT); }
public void stand(Player player) { if (Action.STAND.equals(playerTurnStatusMap.get(player))) { throw new IllegalStateException("Player has already stood"); } playerTurnStatusMap.put(player, Action.STAND); }
private void checkGameEndCondition() { boolean allPlayersDone = players.stream() .allMatch(p -> Action.STAND.equals(playerTurnStatusMap.get(p)) || p.isBust()); if (!allPlayersDone) return;
int dealerValue = dealer.getHand().getPossibleValues().last(); boolean dealerBusts = dealer.isBust();
for (Player player : players) { if (player.isBust()) { player.loseBet(); } else { int playerValue = player.getHand().getPossibleValues().last(); if (dealerBusts || playerValue > dealerValue) { player.payout(); } else if (playerValue == dealerValue) { player.returnBet(); } else { player.loseBet(); } } } currentPhase = GamePhase.END; } } ```
Детальный разбор — Отделение логики принятия решений (паттерн Стратегия)
В текущем дизайне правило дилера «брать карты до 17» жёстко закодировано в `dealerTurn()`. Любое изменение правила требует модификации `BlackJackGame`. Мы отделяем это, используя **паттерн Стратегия (Strategy Pattern)**.
**Шаг 1: Определение интерфейса принятия решений**
```java public interface PlayerDecisionLogic { Action decideAction(Hand hand); }
public class RealPlayerDecisionLogic implements PlayerDecisionLogic { @Override public Action decideAction(Hand hand) { return hand.getPossibleValues().last() < 16 ? Action.HIT : Action.STAND; } }
public class DealerDecisionLogic implements PlayerDecisionLogic { @Override public Action decideAction(Hand hand) { return hand.getPossibleValues().last() < 17 ? Action.HIT : Action.STAND; } } ```
**Шаги 2–3: Интеграция в игроков**
`RealPlayer` использует `RealPlayerDecisionLogic` (брать карты ниже 16). `DealerPlayer` использует `DealerDecisionLogic` (брать карты ниже 17). Каждый игрок предоставляет `getDecisionLogic()` через интерфейс `Player`.
**Шаг 4: Рефакторинг BlackJackGame**
```java public class BlackJackGame { // Fields unchanged from original...
// Updated: handles dealer as just another player via decision logic public Player getNextEligiblePlayer() { if (currentPlayer == null) { for (Player player : players) { if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) { currentPlayer = player; return currentPlayer; } } if (!Action.STAND.equals(playerTurnStatusMap.get(dealer))) { currentPlayer = dealer; return dealer; } } else { int currentIndex = players.indexOf(currentPlayer); for (int i = currentIndex + 1; i < players.size(); i++) { Player player = players.get(i); if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) { currentPlayer = player; return currentPlayer; } } if (currentPlayer != dealer && !Action.STAND.equals(playerTurnStatusMap.get(dealer))) { currentPlayer = dealer; return dealer; } } return null; }
public void playNextTurn() { Player nextPlayer = getNextEligiblePlayer(); if (nextPlayer != null) { performPlayerAction(nextPlayer); } }
public void performPlayerAction(Player player) { Action action = player.getDecisionLogic().decideAction(player.getHand()); if (action == Action.HIT) { hit(player); } else if (action == Action.STAND) { stand(player); } } // dealerTurn() removed — dealer now uses DealerDecisionLogic via performPlayerAction() } ```
`playNextTurn()` вызывает `performPlayerAction()`, который запрашивает логику принятия решений каждого игрока. Метод `dealerTurn()` удалён — теперь дилер обрабатывается через тот же поток `performPlayerAction()`.
**Соответствие паттерну Стратегия:** - `PlayerDecisionLogic` — контракт стратегии - `RealPlayerDecisionLogic` / `DealerDecisionLogic` — конкретные поведения - `BlackJackGame` — использует стратегии, не зная внутренностей принятия решений
> **Примечание:** Подробнее о паттерне Стратегия (Strategy Pattern) можно прочитать в главе о парковке.
Подведение итогов
В этой главе мы построили полноценную игру в Блэкджек, распределив ответственности между `Card`, `Deck`, `Hand`, `Player` и `BlackJackGame`. У каждого компонента чёткая роль: `Card` хранит суть, `Deck` тасует и раздаёт, `Hand` отслеживает суммы, а `BlackJackGame` управляет процессом.
Мы также отделили логику принятия решений с помощью `PlayerDecisionLogic`, применив паттерн Стратегия для упрощения замены стратегий игроков и дилера без переписывания кода.
Проектирование системы почтовых ячеек
В этой главе мы спроектируем систему почтовых ячеек (локеров), аналогичную UPS, FedEx или Amazon Locker. Она предоставляет клиентам удобный и безопасный способ получения онлайн-заказов. Система управляет доступностью ячеек, назначает входящие посылки в подходящие ячейки и обеспечивает бесперебойный процесс получения пакетов.
Сбор требований
> **Кандидат:** Поддерживает ли система несколько размеров ячеек? > **Интервьюер:** Да. Посылки назначаются в наименьшую подходящую ячейку, оптимизируя использование пространства.
> **Кандидат:** Что происходит при поступлении посылки и как клиент её забирает? > **Интервьюер:** Система находит свободную ячейку нужного размера, назначает посылку и отправляет клиенту уведомление с расположением ячейки и уникальным кодом доступа.
> **Кандидат:** Есть ли временные ограничения или плата за использование ячейки? > **Интервьюер:** Да. Предусмотрен бесплатный период (заданное количество дней), затем ежедневная плата в зависимости от размера ячейки. По истечении максимального срока персонал очищает ячейку.
> **Кандидат:** Система обрабатывает платежи? > **Интервьюер:** Нет — обработку платежей выполняет внешний сервис.
**Требования** - Отслеживать все ячейки и поддерживать различные размеры. - Назначать ячейки, подбирая наименьший подходящий вариант под размер посылки. - Клиенты открывают свою ячейку уникальным кодом доступа. - Отслеживать стоимость хранения согласно политике ячейки (ежедневный тариф по размеру ячейки, бесплатный период, максимальный срок).
**Нефункциональные требования:** - Обрабатывать большой объём операций с ячейками на объекте без деградации производительности. - Высокая доступность — ячейки должны быть доступны в любое время.
Определение основных объектов
- **Locker:** Представляет отдельную физическую ячейку. - **Site:** Объект с ячейками, содержащий множество ячеек различных размеров, организованных по размеру для эффективного назначения. - **ShippingPackage:** Интерфейс, определяющий стандарты посылки; `BasicShippingPackage` — конкретная реализация, отслеживающая ID заказа, габариты и статус. - **Account:** Представляет клиентов и их аккаунты, хранит информацию о политике (бесплатный период, максимальный срок) и текущий баланс.
Диаграмма классов — Locker и LockerSize
**Locker**
Представляет физическую единицу хранения. Атрибуты: `LockerSize size`, `ShippingPackage currentPackage`, `Date assignmentDate`, `String accessCode`. Методы: назначение посылки, освобождение ячейки, расчёт платы за хранение, проверка доступности, верификация кода доступа.
> **Выбор дизайна:** Спроектирован как самостоятельная сущность для инкапсуляции состояния и поведения отдельной ячейки, обеспечивая модульность.
**LockerSize**
Перечисление с предопределёнными размерами (SMALL, MEDIUM, LARGE), каждый с `sizeName`, `dailyCharge` и физическими измерениями (`width`, `height`, `depth`).
> **Выбор дизайна:** Перечисление вместо строк или целых чисел — типобезопасно, читаемо, гарантирует фиксированный допустимый набор размеров.
Site, ShippingPackage, Account и LockerManager
**Site**
Моделирует физическое местоположение с ячейками, организованными по размеру через `Map<LockerSize, Set<Locker>>`. Ключевые методы: `findAvailableLocker(LockerSize)` и `placePackage(ShippingPackage, Date)`.
**ShippingPackage**
Интерфейс для всех типов посылок. `BasicShippingPackage` реализует его, отслеживая габариты и статус. Перечисление `ShippingStatus` определяет PENDING, STORED, RETRIEVED.
> **Выбор дизайна:** Интерфейс обеспечивает расширяемость для разных типов посылок (хрупкие, скоропортящиеся) без изменения основной логики.
**Account / AccountLockerPolicy**
`Account` хранит баланс клиента и ссылается на `AccountLockerPolicy` (количество дней бесплатного периода + максимальное количество дней).
> **Выбор дизайна:** Разделение данных клиента и данных политики улучшает поддерживаемость и позволяет применять индивидуальные правила выставления счетов.
**NotificationInterface**
Интерфейс с методом `sendNotification(message, account)`. Обеспечивает гибкость и внешнее управление логикой уведомлений.
> **Лучшие практики:** На OOD-собеседованиях внешние системы, такие как уведомления, представляются в виде интерфейсов, чтобы избежать излишней сложности.
**LockerManager**
Фасад, координирующий назначение и получение посылок. Поддерживает `Map<String, Account>` и `Map<String, Locker>` (код доступа → ячейка).
> **Выбор дизайна:** Фасад обеспечивает единую точку управления назначением посылок, их получением и уведомлениями.
Код — Сервис почтовых ячеек
**Locker и LockerSize**
```java public class Locker { private final LockerSize size; private ShippingPackage currentPackage; private Date assignmentDate; private String accessCode;
public Locker(LockerSize size) { this.size = size; }
public void assignPackage(ShippingPackage pkg, Date date) { this.currentPackage = pkg; this.assignmentDate = date; this.accessCode = generateAccessCode(); }
public void releaseLocker() { this.currentPackage = null; this.assignmentDate = null; this.accessCode = null; }
public BigDecimal calculateStorageCharges() { if (currentPackage == null || assignmentDate == null) { return BigDecimal.ZERO; } AccountLockerPolicy policy = currentPackage.getUser().getLockerPolicy(); long totalDaysUsed = (new Date().getTime() - assignmentDate.getTime()) / (1000 * 60 * 60 * 24);
if (totalDaysUsed > policy.getMaximumPeriodDays()) { currentPackage.updateShippingStatus(ShippingStatus.EXPIRED); throw new MaximumStoragePeriodExceededException( "Package has exceeded maximum allowed storage period of " + policy.getMaximumPeriodDays() + " days"); }
long chargeableDays = Math.max(0, totalDaysUsed - policy.getFreePeriodDays()); return size.dailyCharge.multiply(new BigDecimal(chargeableDays)); }
public boolean isAvailable() { return currentPackage == null; }
public boolean checkAccessCode(String code) { return this.accessCode != null && accessCode.equals(code); } } ```
```java public enum LockerSize { SMALL("Small", new BigDecimal("5.00"), new BigDecimal("10.00"), new BigDecimal("10.00"), new BigDecimal("10.00")), MEDIUM("Medium", new BigDecimal("10.00"), new BigDecimal("20.00"), new BigDecimal("20.00"), new BigDecimal("20.00")), LARGE("Large", new BigDecimal("15.00"), new BigDecimal("30.00"), new BigDecimal("30.00"), new BigDecimal("30.00"));
final String sizeName; final BigDecimal dailyCharge; final BigDecimal width; final BigDecimal height; final BigDecimal depth;
LockerSize(String sizeName, BigDecimal dailyCharge, BigDecimal width, BigDecimal height, BigDecimal depth) { this.sizeName = sizeName; this.dailyCharge = dailyCharge; this.width = width; this.height = height; this.depth = depth; } } ```
> **Выбор реализации:** `BigDecimal` для `dailyCharge` и габаритов гарантирует точность финансовых вычислений — `float`/`double` вносили бы ошибки округления.
**Site**
```java public class Site { final Map<LockerSize, Set<Locker>> lockers = new HashMap<>();
public Site(Map<LockerSize, Integer> lockers) { for (Map.Entry<LockerSize, Integer> entry : lockers.entrySet()) { Set<Locker> lockerSet = new HashSet<>(); for (int i = 0; i < entry.getValue(); i++) { lockerSet.add(new Locker(entry.getKey())); } this.lockers.put(entry.getKey(), lockerSet); } }
public Locker findAvailableLocker(LockerSize size) { for (Locker locker : lockers.get(size)) { if (locker.isAvailable()) { return locker; } } return null; }
public Locker placePackage(ShippingPackage pkg, Date date) { LockerSize size = pkg.getLockerSize(); Locker locker = findAvailableLocker(size); if (locker != null) { locker.assignPackage(pkg, date); pkg.updateShippingStatus(ShippingStatus.IN_LOCKER); return locker; } throw new NoLockerAvailableException("No locker of size " + size + " is currently available"); } } ```
> **Выбор реализации:** `placePackage()` обеспечивает атомарное поведение «найти и назначить», предотвращая несогласованные состояния, которые могли бы возникнуть при раздельном выполнении этих операций.
**BasicShippingPackage**
```java public class BasicShippingPackage implements ShippingPackage { private final String orderId; private final Account user; private final BigDecimal width; private final BigDecimal height; private final BigDecimal depth; private ShippingStatus status;
public BasicShippingPackage( String orderId, Account user, BigDecimal width, BigDecimal height, BigDecimal depth) { this.orderId = orderId; this.user = user; this.width = width; this.height = height; this.depth = depth; this.status = ShippingStatus.CREATED; }
@Override public ShippingStatus getStatus() { return status; }
@Override public void updateShippingStatus(ShippingStatus status) { this.status = status; }
// Returns the smallest locker size that fits this package's dimensions @Override public LockerSize getLockerSize() { for (LockerSize size : LockerSize.values()) { if (size.getWidth().compareTo(width) >= 0 && size.getHeight().compareTo(height) >= 0 && size.getDepth().compareTo(depth) >= 0) { return size; } } throw new PackageIncompatibleException("No locker size available for the package"); } } ```
**Account и AccountLockerPolicy**
```java public class Account { private final String accountId; private final String ownerName; private final AccountLockerPolicy lockerPolicy; private BigDecimal usageCharges = new BigDecimal("0.00");
public Account(String accountId, String ownerName, AccountLockerPolicy lockerPolicy) { this.accountId = accountId; this.ownerName = ownerName; this.lockerPolicy = lockerPolicy; }
public void addUsageCharge(BigDecimal amount) { usageCharges = usageCharges.add(amount); } }
public class AccountLockerPolicy { final int freePeriodDays; final int maximumPeriodDays;
public AccountLockerPolicy(int freePeriodDays, int maximumPeriodDays) { this.freePeriodDays = freePeriodDays; this.maximumPeriodDays = maximumPeriodDays; } } ```
**LockerManager**
```java public class LockerManager { private final Site site; private final NotificationInterface notificationService; private final Map<String, Account> accounts; private final Map<String, Locker> accessCodeMap = new HashMap<>();
public LockerManager( Site site, Map<String, Account> accounts, NotificationInterface notificationService) { this.site = site; this.accounts = accounts; this.notificationService = notificationService; }
public Locker assignPackage(ShippingPackage pkg, Date date) { Locker locker = site.placePackage(pkg, date); if (locker != null) { accessCodeMap.put(locker.getAccessCode(), locker); notificationService.sendNotification( "Package assigned to locker" + locker.getAccessCode(), pkg.getUser()); } return locker; }
public Locker pickUpPackage(String accessCode) { Locker locker = accessCodeMap.get(accessCode); if (locker != null && locker.checkAccessCode(accessCode)) { try { BigDecimal charge = locker.calculateStorageCharges(); ShippingPackage pkg = locker.getPackage(); locker.releaseLocker(); pkg.getUser().addUsageCharge(charge); pkg.updateShippingStatus(ShippingStatus.RETRIEVED); return locker; } catch (MaximumStoragePeriodExceededException e) { locker.releaseLocker(); return locker; } } return null; } } ```
> **Лучшая практика:** Держите `LockerManager` компактным — делегируйте низкоуровневые операции (поиск ячеек, хранение посылок, применение политик) классам `Site` и `Locker`.
Детальный разбор — Factory Pattern (паттерн Фабрика) для создания ячеек
В текущем дизайне ячейки создаются напрямую на основе предопределённых размеров. Добавление нового типа ячейки (например, XLARGE, с температурным контролем) требует изменений в нескольких частях кодовой базы.
**Factory Pattern (паттерн Фабрика)** централизует создание ячеек в одном классе:
```java class LockerFactory { public static Locker createLocker(LockerSize size) { return switch (size) { case SMALL -> new Locker(LockerSize.SMALL); case MEDIUM -> new Locker(LockerSize.MEDIUM); case LARGE -> new Locker(LockerSize.LARGE); case XLARGE -> new Locker(LockerSize.XLARGE); }; } } ```
**Преимущества:** - **Централизованное создание:** Изменения в создании ячеек локализованы в `LockerFactory`. - **Расширяемость:** Новые размеры и типы добавляются без изменения основной бизнес-логики. - **SRP:** Разделяет создание ячеек от логики их использования.
Детальный разбор — Observer Pattern (паттерн Наблюдатель) для обработки событий
В текущем дизайне `LockerManager` напрямую вызывает `NotificationInterface.sendNotification()`, что тесно связывает его с системой уведомлений. Использование **Observer Pattern (паттерна Наблюдатель)** разрывает эту связь:
```java class LockerManagerChange { private final List<LockerEventObserver> observers = new ArrayList<>();
public void addObserver(LockerEventObserver observer) { observers.add(observer); }
public void removeObserver(LockerEventObserver observer) { observers.remove(observer); }
private void notifyObservers(String message, Account account) { for (LockerEventObserver observer : observers) { observer.update(message, account); } }
public void assignPackage(ShippingPackage pkg) { Locker locker = assignLockerToPackage(pkg); if (locker != null) { notifyObservers("Package assigned to locker", pkg.getUser()); } } }
public interface LockerEventObserver { void update(String message, Account account); }
class EmailNotification implements LockerEventObserver { public void update(String message, Account account) { // Send email to account owner } } ```
**Роли:** - **Subject (субъект):** `LockerManagerChange` — ведёт список наблюдателей, рассылает события. - **Observer (наблюдатель):** Интерфейс `LockerEventObserver` — определяет `update(message, account)`. - **Конкретные наблюдатели:** `EmailNotification`, SMS, аналитика — каждый реагирует независимо.
> **Примечание:** Подробнее об Observer Pattern (паттерне Наблюдатель) смотрите в главе о системе лифтов.
Подведение итогов
В этой главе мы спроектировали систему почтовых ячеек. Мы уточнили требования, определили основные объекты, разработали диаграмму классов и реализовали ключевые компоненты.
В детальном разборе мы изучили, как **Factory Pattern (паттерн Фабрика)** упрощает создание ячеек для масштабирования, а **Observer Pattern (паттерн Наблюдатель)** разрывает связь при обработке событий для гибких уведомлений.
Дополнительное чтение — Factory Design Pattern (паттерн Фабрика)
**Паттерн «Фабричный метод»** — порождающий паттерн проектирования, предоставляющий интерфейс для создания экземпляров класса, но позволяющий подклассам изменять тип создаваемых объектов.
**Проблема:** Система платежей в интернет-магазине изначально поддерживает только оплату кредитными картами. Добавление цифровых кошельков или криптовалюты требует изменений тесно связанного кода повсюду.
**Решение:** Фабричный метод инкапсулирует создание объектов, позволяя добавлять новые типы платежей через подклассы без изменения существующего кода.
**Когда использовать:** - Когда конкретные типы требуемых объектов заранее неизвестны. - Когда нужно предоставить пользователям библиотеки возможность расширять внутренние компоненты без изменения существующего кода. - Когда нужно оптимизировать использование ресурсов, повторно используя существующие объекты.
Проектирование системы банкомата
В этой главе мы рассмотрим объектно-ориентированный дизайн системы банкомата. Основное назначение банкомата — автоматизация банковских операций для пользователей: проверка баланса, снятие наличных и перевод средств. Цель данного дизайна — сделать эти операции бесперебойными путём проектирования классов, моделирующих ключевые компоненты: сам банкомат, банковские счета, аппаратные интерфейсы и состояния транзакций.
Сбор требований
> **Кандидат:** Банкомат должен иметь считыватель карт, клавиатуру, экран, диспенсер наличных и слот для депозита. Это покрывает основные компоненты? > **Интервьюер:** Хороший список.
> **Кандидат:** Процесс работы с банкоматом: вставить карту → ввести PIN → меню (проверить баланс, снять, пополнить) → продолжить или выйти → извлечь карту. Верно? > **Интервьюер:** Ваш процесс точен.
> **Кандидат:** Для аутентификации банкомат проверяет карту и PIN, показывая ошибку при неверном вводе. Нужно ли ограничивать количество попыток ввода PIN? > **Интервьюер:** Да — три попытки ввода PIN перед блокировкой карты. Учитывайте это в дизайне.
> **Кандидат:** Банкомат должен поддерживать расчётные и сберегательные счета, привязанные к карте. Пользователи выбирают счёт для транзакций. > **Интервьюер:** Верно.
> **Кандидат:** По транзакциям: снятие (с проверкой достаточности средств) и пополнение (обновление баланса). Нужно ли включать переводы? > **Интервьюер:** Сфокусируйтесь на снятии и пополнении. Переводы выходят за рамки задачи.
**Требования** - Аутентификация пользователей через дебетовую карту и PIN. - Поддержка расчётных и сберегательных счетов для каждого пользователя с выбором счёта. - Аппаратура: считыватель карт, клавиатура, экран, диспенсер наличных, слот для депозита, опциональный принтер. - Поддержка транзакций снятия и пополнения с валидацией. - Понятные сообщения об ошибках: недостаточно средств, неверный PIN, аппаратные сбои. Карта задерживается после повторных неверных попыток.
Диаграмма вариантов использования
**Варианты использования актора «Клиент»:** Вставить карту, ввести PIN, выбрать транзакцию, выбрать счёт, снять наличные, пополнить счёт, извлечь карту.
**Варианты использования актора «Система»:** Проверить карту и PIN, снять наличные, пополнить счёт, извлечь карту.
Определение основных объектов
- **Bank:** Хранит счета и управляет ими. - **Account:** Управляет балансом, номером счёта, номером карты, хешем PIN и типом счёта (расчётный/сберегательный). - **ATMMachine:** Главный координатор, управляющий взаимодействием с пользователем и связанный с аппаратурой (считыватель карт, клавиатура, экран, диспенсер наличных, слот для депозита). - **Transaction:** Управляет финансовыми транзакциями (снятие, пополнение), включая валидацию и выполнение.
> **Выбор дизайна:** Консолидация доступа к аппаратуре в `ATMMachine` обеспечивает согласованное поведение всех компонентов и поддерживает непрерывный пользовательский поток — от вставки карты до завершения сессии.
Диаграмма классов
**Account**
Инкапсулирует баланс, номер счёта, номер карты, хеш PIN и перечисление `AccountType` (CHECKING/SAVING). Обеспечивает валидацию PIN и обновление баланса.
> **Примечание:** Вместо ведения журнала транзакций (как в реальном банке) мы обновляем баланс напрямую — для простоты и эффективного управления областью задачи.
**Bank / BankInterface**
Класс `Bank` хранит объекты `Account` и связывает их с картами для быстрого поиска. `BankInterface` разрывает связь между банком и банкоматом, позволяя заменить локальную реализацию сетевым API-клиентом без изменения основной логики банкомата.
> **Альтернатива:** Интеграция Bank в ATMMachine тесно связала бы управление счетами с операциями банкомата, снижая модульность.
**Transaction**
Интерфейс `Transaction` определяет контракт для всех типов транзакций, поддерживаемый перечислением `TransactionType` (WITHDRAW, DEPOSIT). Конкретные классы `WithdrawTransaction` и `DepositTransaction` обрабатывают соответствующие операции.
> **Выбор дизайна:** Абстрагирование Transaction как отдельного объекта позволяет легко вводить новые типы (например, Transfer) без изменения основной логики.
**ATMState**
Интерфейс, определяющий операции для каждого этапа работы банкомата: вставка карты, ввод PIN, выбор транзакции, ввод суммы, приём депозита. Конкретные состояния: `IdleState`, `PinEntryState`, `TransactionSelectionState`, `WithdrawAmountEntryState`, `DepositCollectionState`.
**Аппаратные интерфейсы**
`CardProcessor`, `Keypad`, `Display`, `CashDispenser`, `DepositBox` — все являются интерфейсами, обеспечивающими тестирование с mock-реализациями.
Код — Система банкомата
**Account и AccountType**
```java public class Account { private BigDecimal balance; private final String accountNumber; private final String cardNumber; private final byte[] cardPinHash; private final AccountType accountType;
public Account(final String accountNumber, final AccountType type, final String cardNumber, final String pin) { this.accountNumber = accountNumber; this.accountType = type; this.cardNumber = cardNumber; this.cardPinHash = calculateMd5(pin); // PIN is hashed for security this.balance = BigDecimal.ZERO; }
public boolean validatePin(String pinNumber) { byte[] entryPinHash = calculateMd5(pinNumber); return Arrays.equals(cardPinHash, entryPinHash); }
public void updateBalanceWithTransaction(final BigDecimal balanceChange) { this.balance = this.balance.add(balanceChange); } }
public enum AccountType { CHECKING, SAVING } ```
> **Выбор реализации:** `BigDecimal` для баланса обеспечивает точные финансовые вычисления. `updateBalanceWithTransaction` обрабатывает как пополнения (положительные значения), так и снятия (отрицательные) через единый метод.
**BankInterface и Bank**
```java public interface BankInterface { void addAccount(String accountNumber, AccountType type, String cardNumber, String pin); boolean validateCard(String cardNumber); boolean checkPin(String cardNumber, String pinNumber); Account getAccountByAccountNumber(String accountNumber); Account getAccountByCard(String cardNumber); boolean withdrawFunds(Account account, BigDecimal amount); }
public class Bank implements BankInterface { private final Map<String, Account> accounts = new HashMap<>(); private final Map<String, Account> accountByCard = new HashMap<>();
@Override public void addAccount(final String accountNumber, final AccountType type, final String cardNumber, final String pin) { final Account newAccount = new Account(accountNumber, type, cardNumber, pin); accounts.put(newAccount.getAccountNumber(), newAccount); accountByCard.put(newAccount.getCardNumber(), newAccount); }
@Override public boolean validateCard(final String cardNumber) { return getAccountByCard(cardNumber) != null; }
@Override public boolean checkPin(String cardNumber, String pinNumber) { Account account = getAccountByCard(cardNumber); if (account != null) { return account.validatePin(pinNumber); } return false; }
@Override public Account getAccountByAccountNumber(String accountNumber) { return accounts.get(accountNumber); }
@Override public Account getAccountByCard(String cardNumber) { return accountByCard.get(cardNumber); }
@Override public boolean withdrawFunds(Account account, BigDecimal amount) { if (account.getBalance().compareTo(amount) >= 0) { account.updateBalanceWithTransaction(amount.negate()); return true; } return false; } } ```
> **Выбор реализации:** Два `HashMap` — `accounts` (номер счёта → Account) и `accountByCard` (номер карты → Account) — обеспечивают поиск за O(1) для операций реального времени: валидация карты и получение счёта.
**Transaction, WithdrawTransaction, DepositTransaction**
```java public interface Transaction { TransactionType getType(); boolean validateTransaction(); void executeTransaction(); }
public class WithdrawTransaction implements Transaction { Account account; BigDecimal amount;
public WithdrawTransaction(Account account, BigDecimal amount) { if (!validateTransaction()) { throw new IllegalStateException( "Cannot complete withdrawal: Insufficient funds in account"); } this.account = account; this.amount = amount; }
@Override public TransactionType getType() { return TransactionType.WITHDRAW; }
@Override public boolean validateTransaction() { assert account != null; return account.getBalance().compareTo(amount) > 0; }
@Override public void executeTransaction() { account.updateBalanceWithTransaction(amount.negate()); } }
public class DepositTransaction implements Transaction { final Account account; final BigDecimal amount;
public DepositTransaction(Account account, BigDecimal amount) { this.account = account; this.amount = amount; }
@Override public TransactionType getType() { return TransactionType.DEPOSIT; }
@Override public boolean validateTransaction() { return true; // Deposits are always valid }
@Override public void executeTransaction() { account.updateBalanceWithTransaction(amount); } } ```
**ATMState (базовый + IdleState + WithdrawAmountEntryState)**
```java public class ATMState { private static void renderDefaultAction(ATMMachine atmMachine) { atmMachine.getDisplay().showMessage("Invalid action, please try again."); }
public void processCardInsertion(ATMMachine atmMachine, String cardNumber) { renderDefaultAction(atmMachine); }
public void processCardEjection(ATMMachine atmMachine) { renderDefaultAction(atmMachine); }
public void processPinEntry(ATMMachine atmMachine, String pin) { renderDefaultAction(atmMachine); }
public void processWithdrawalRequest(ATMMachine atmMachine) { renderDefaultAction(atmMachine); }
public void processDepositRequest(ATMMachine atmMachine) { renderDefaultAction(atmMachine); }
public void processAmountEntry(ATMMachine atmMachine, BigDecimal amount) { renderDefaultAction(atmMachine); }
public void processDepositCollection(ATMMachine atmMachine, BigDecimal amount) { renderDefaultAction(atmMachine); } }
public class IdleState extends ATMState { @Override public void processCardInsertion(ATMMachine atmMachine, String cardNumber) { if (atmMachine.getBankInterface().validateCard(cardNumber)) { atmMachine.getDisplay().showMessage("Please enter your PIN"); atmMachine.transitionToState(new PinEntryState()); } else { atmMachine.getDisplay().showMessage("Invalid card. Please try again."); } } }
public class WithdrawAmountEntryState extends ATMState { @Override public void processCardEjection(ATMMachine atmMachine) { atmMachine.getDisplay().showMessage("Transaction cancelled, card ejected"); atmMachine.transitionToState(new IdleState()); }
@Override public void processAmountEntry(ATMMachine atmMachine, BigDecimal amount) { String cardNumber = atmMachine.getCardProcessor().getCardNumber(); Account account = atmMachine.getBankInterface().getAccountByCard(cardNumber); boolean isSuccess = atmMachine.getBankInterface().withdrawFunds(account, amount);
if (isSuccess) { atmMachine.getCashDispenser().dispenseCash(amount); atmMachine.getDisplay().showMessage("Please take your cash."); } else { atmMachine.getDisplay().showMessage("Insufficient funds, please try again."); } atmMachine.transitionToState(new TransactionSelectionState()); } } ```
> **Примечание:** `PinEntryState`, `TransactionSelectionState` и `DepositCollectionState` опущены для краткости — полный код доступен в сопроводительных материалах к книге.
**ATMMachine**
```java public class ATMMachine { private ATMState state; private final CardProcessor cardProcessor; private final DepositBox depositBox; private final CashDispenser cashDispenser; private final Keypad keypad; private final Display display; private final Bank bank;
public ATMMachine(Bank bank, CardProcessor cardProcessor, DepositBox depositBox, CashDispenser cashDispenser, Keypad keypad, Display display) { this.bank = bank; this.cardProcessor = cardProcessor; this.depositBox = depositBox; this.cashDispenser = cashDispenser; this.keypad = keypad; this.display = display; this.state = new IdleState(); }
public void insertCard(String cardNumber) { state.processCardInsertion(this, cardNumber); }
public void ejectCard() { state.processCardEjection(this); }
public void enterPin(String pin) { state.processPinEntry(this, pin); }
public void withdrawRequest() { state.processWithdrawalRequest(this); }
public void depositRequest() { state.processDepositRequest(this); }
public void enterAmount(BigDecimal amount) { state.processAmountEntry(this, amount); }
public void collectDeposit(BigDecimal amount) { state.processDepositCollection(this, amount); }
public void transitionToState(ATMState nextState) { this.state = nextState; }
public ATMState getCurrentState() { return state; }
// Getters for hardware components public Display getDisplay() { return display; } public CashDispenser getCashDispenser() { return cashDispenser; } public BankInterface getBankInterface() { return bank; } public CardProcessor getCardProcessor() { return cardProcessor; } public Keypad getKeypad() { return keypad; } public DepositBox getDepositBox() { return depositBox; } } ```
Подведение итогов
В этой главе мы спроектировали систему банкомата: собрали требования через структурированный диалог, определили основные объекты, разработали структуру классов и реализовали ключевые компоненты, включая конечный автомат и взаимодействие с аппаратурой.
Поддерживаемость системы обеспечивается чётким разделением ответственностей: - `Account` и `Bank` управляют данными счетов и банковскими операциями - `Transaction` обрабатывает финансовые операции - `ATMState` и классы состояний управляют этапами пользовательского потока - Аппаратные интерфейсы (`Keypad`, `CardProcessor` и др.) обрабатывают взаимодействие с пользователем - `ATMMachine` всё оркестрирует как фасад
Использование **State Pattern (паттерна Состояние)** с `ATMState` и разделение аппаратных взаимодействий на интерфейсы улучшает модульность и обеспечивает возможность будущих расширений: новые типы транзакций или аппаратные компоненты.
Проектирование системы управления рестораном
В этой главе мы рассмотрим дизайн системы управления рестораном. Цель — создать классы, представляющие ключевые компоненты системы: меню, бронирования и столики. Мы разработаем систему, поддерживающую критически важные функции: бронирование столиков, управление заказами и распределение мест — обеспечивая при этом простоту и гибкость для будущих расширений.
Сбор требований
> **Кандидат:** Система управляет бронированием, меню, отслеживанием заказов и платежами. Сейчас я сосредоточусь на бронировании и управлении заказами. > **Интервьюер:** Разумная отправная точка.
> **Кандидат:** Могут ли клиенты создавать и управлять бронированием? > **Интервьюер:** Да, клиенты могут бронировать столики на основе доступности.
> **Кандидат:** Как система определяет доступность столика? > **Интервьюер:** Она проверяет наличие столика нужного размера, свободного в запрошенное время. Каждое бронирование длится ровно один час.
> **Кандидат:** Могут ли клиенты отменить бронирование? > **Интервьюер:** Да.
> **Кандидат:** Когда забронировавшая компания приходит, они автоматически получают свой столик? > **Интервьюер:** Да — они называют своё имя, система находит бронирование и назначенный столик.
> **Кандидат:** Что насчёт посетителей без резервации? > **Интервьюер:** Распределяйте их по столикам на основе текущей доступности и размера компании.
> **Кандидат:** Можно ли изменять заказы после оформления? > **Интервьюер:** Да — удалять позиции или менять количество.
> **Кандидат:** Правила разделения счёта? > **Интервьюер:** Пока просто единый итоговый счёт.
**Требования**
*Бронирование:* Бронировать столики на будущее время; слоты по одному часу; проверять пересечения; назначать столик; клиенты приходят по имени; поддерживать отмену.
*Рассадка без резервации:* Назначать по доступности и размеру компании.
*Управление заказами:* Изменять/удалять позиции после оформления; отслеживать прогресс заказа.
*Расчёт:* Единый итоговый счёт при выписке.
Определение основных объектов
- **Menu:** Хранит доступные позиции для заказа. - **MenuItem:** Отдельная позиция меню с названием и ценой. - **Layout:** Физическая планировка ресторана, организующая все столики для эффективного распределения. - **Table:** Отдельный столик с вместимостью, текущими бронированиями и активными заказами. - **Reservation:** Хранит имя компании, её размер, время бронирования и назначенный столик. - **ReservationManager:** Управляет созданием, поиском и отменой бронирований. Проверяет доступность и работает с Layout для назначения столиков. - **Restaurant:** Фасад, предоставляющий центральный интерфейс для бронирования, распределения столиков, заказов и расчётов. Делегирует работу другим классам.
Диаграмма классов
**Menu**
Хранит объекты `MenuItem` в карте (ключ — название) для быстрого поиска при заказе. Отделяет данные меню от `Restaurant` и `Table`.
**MenuItem**
Содержит название, описание, цену и категорию (`MAIN`, `APPETIZER`, `DESSERT`). Неизменяемый — спроектирован как самостоятельный строительный блок.
**Table**
Фиксированные атрибуты: `tableId`, `capacity`. Изменяемое состояние: `reservations` (по времени) и `orderedItems` (по MenuItem). Методы: добавление/удаление заказов, расчёт счёта, проверка доступности.
**Layout**
Организует столики по ID и вместимости. Использует `SortedMap<Integer, Set<Table>>` (отсортированный по вместимости) для эффективного назначения наименьшего подходящего доступного столика.
> **Выбор дизайна:** Изоляция организации столиков в `Layout` оптимизирует эффективность назначения и отделяет эту логику от меню и заказов. Встраивание в `Restaurant` перегрузило бы роль фасада.
**OrderItem**
Связан с `MenuItem` и отслеживает статус через перечисление `Status`: `PENDING` → `SENT_TO_KITCHEN` → `DELIVERED` (или `CANCELED`).
**ReservationManager**
Центральный координатор, подключённый к `Layout` для поиска столиков и хранящий объекты `Reservation`.
**Restaurant**
Фасад, делегирующий задачи `ReservationManager` (бронирование/без резервации), `Menu` (позиции) и `Table` (заказы/расчёты).
> **Выбор дизайна:** `Restaurant` как фасад сохраняет его сфокусированность. Центральный контроллер, управляющий всей логикой внутри, увеличил бы сложность.
Код — Система управления рестораном
**Menu**
```java public class Menu { private final Map<String, MenuItem> menuItems = new HashMap<>();
public void addItem(MenuItem item) { menuItems.put(item.getName(), item); }
public MenuItem getItem(String name) { return menuItems.get(name); }
public Map<String, MenuItem> getMenuItems() { return Collections.unmodifiableMap(menuItems); } } ```
> **Выбор реализации:** `HashMap` для поиска по названию за O(1), в отличие от `List`, который требует линейного поиска.
**MenuItem**
```java public class MenuItem { private final String name; private final String description; private final BigDecimal price; // BigDecimal for precise financial calculations private final Category category;
public MenuItem(String name, String description, BigDecimal price, Category category) { this.name = name; this.description = description; this.price = price; this.category = category; }
public enum Category { MAIN, APPETIZER, DESSERT } // getter methods are omitted for brevity } ```
**Table**
```java public class Table { private final int tableId; private final int capacity; private final Map<LocalDateTime, Reservation> reservations = new HashMap<>(); private final Map<MenuItem, List<OrderItem>> orderedItems = new HashMap<>();
public Table(int tableId, int capacity) { this.tableId = tableId; this.capacity = capacity; }
public BigDecimal calculateBillAmount() { return orderedItems.values().stream() .flatMap(List::stream) .map(OrderItem::getItem) .map(MenuItem::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); }
public void addOrder(MenuItem item, int quantity) { for (int i = 0; i < quantity; i++) { addOrder(item); } }
public void addOrder(MenuItem item) { List<OrderItem> orderItems = orderedItems.get(item); if (orderItems == null) { orderItems = new ArrayList<>(); orderedItems.put(item, orderItems); } orderItems.add(new OrderItem(item)); }
public void removeOrder(MenuItem item) { List<OrderItem> orderItems = orderedItems.get(item); if (orderItems != null) { orderItems.remove(0); if (orderItems.isEmpty()) { orderedItems.remove(item); } } }
public boolean isAvailableAt(LocalDateTime reservationTime) { return !reservations.containsKey(reservationTime); }
public void addReservation(Reservation reservation) { reservations.put(reservation.getTime(), reservation); }
public void removeReservation(LocalDateTime reservationTime) { reservations.remove(reservationTime); } } ```
**Layout**
```java public class Layout { private final Map<Integer, Table> tablesById = new HashMap<>(); // SortedMap groups tables by capacity, smallest to largest private final SortedMap<Integer, Set<Table>> tablesByCapacity = new TreeMap<>();
public Layout(List<Integer> tableCapacities) { for (int i = 0; i < tableCapacities.size(); i++) { int capacity = tableCapacities.get(i); Table table = new Table(i, capacity); tablesById.put(i, table); tablesByCapacity.computeIfAbsent(capacity, k -> new HashSet<>()).add(table); } }
// Finds the smallest available table that fits the party at the given time public Table findAvailableTable(int partySize, LocalDateTime reservationTime) { for (Set<Table> tables : tablesByCapacity.tailMap(partySize).values()) { for (Table table : tables) { if (table.isAvailableAt(reservationTime)) { return table; } } } return null; } } ```
> **Выбор реализации:** `SortedMap` (`TreeMap`) для `tablesByCapacity` обеспечивает доступ к ключам в отсортированном порядке и эффективный поиск по диапазону через `tailMap(partySize)`. Обычный `Map` потребовал бы дополнительной логики для поиска наименьшего подходящего столика.
**OrderItem**
```java public class OrderItem { private final MenuItem item; private Status status = Status.PENDING;
public OrderItem(MenuItem item) { this.item = item; }
public void sendToKitchen() { if (status == Status.PENDING) status = Status.SENT_TO_KITCHEN; }
public void deliverToCustomer() { if (status == Status.SENT_TO_KITCHEN) status = Status.DELIVERED; }
public void cancel() { if (status == Status.PENDING || status == Status.SENT_TO_KITCHEN) { status = Status.CANCELED; } }
public enum Status { PENDING, SENT_TO_KITCHEN, DELIVERED, CANCELED } } ```
**ReservationManager и Reservation**
```java public class ReservationManager { private final Layout layout; private final Set<Reservation> reservations = new HashSet<>();
public ReservationManager(Layout layout) { this.layout = layout; }
public LocalDateTime[] findAvailableTimeSlots( LocalDateTime rangeStart, LocalDateTime rangeEnd, int partySize) { LocalDateTime current = rangeStart; List<LocalDateTime> possibleReservations = new ArrayList<>(); while (!current.isAfter(rangeEnd)) { Table availableTable = layout.findAvailableTable(partySize, current); if (availableTable != null) { possibleReservations.add(current); } current = current.plusHours(1); } return possibleReservations.toArray(new LocalDateTime[0]); }
public Reservation createReservation( String partyName, int partySize, LocalDateTime desiredTime) { desiredTime = desiredTime.truncatedTo(ChronoUnit.HOURS); Table table = layout.findAvailableTable(partySize, desiredTime); Reservation reservation = new Reservation(partyName, partySize, desiredTime, table); table.addReservation(reservation); reservations.add(reservation); return reservation; }
public void removeReservation( String partyName, int partySize, LocalDateTime reservationTime) { for (Reservation reservation : new HashSet<>(reservations)) { if (reservation.getTime().equals(reservationTime) && reservation.getPartySize() == partySize && reservation.getPartyName().equals(partyName)) { reservation.getAssignedTable().removeReservation(reservationTime); reservations.remove(reservation); return; } } } }
public class Reservation { private final String partyName; private final int partySize; private final LocalDateTime time; private final Table assignedTable;
public Reservation(String partyName, int partySize, LocalDateTime time, Table assignedTable) { this.partyName = partyName; this.partySize = partySize; this.time = time; this.assignedTable = assignedTable; } // getter methods are omitted for brevity } ```
> **Выбор реализации:** `Set<Reservation>` предотвращает дублирование бронирований. `List` проще для итерации, но допускает дубликаты. `Map` с ключами по времени ускорил бы поиск, но усложнил удаление.
**Restaurant**
```java public class Restaurant { private final String name; private final Menu menu; private final Layout layout; private final ReservationManager reservationManager;
public Restaurant(String name, Menu menu, Layout layout) { this.name = name; this.menu = menu; this.layout = layout; this.reservationManager = new ReservationManager(layout); }
public LocalDateTime[] findAvailableTimeSlots( LocalDateTime rangeStart, LocalDateTime rangeEnd, int partySize) { return reservationManager.findAvailableTimeSlots(rangeStart, rangeEnd, partySize); }
public Reservation createScheduledReservation( String partyName, int partySize, LocalDateTime time) { return reservationManager.createReservation(partyName, partySize, time); }
public void removeReservation(String partyName, int partySize, LocalDateTime reservationTime) { reservationManager.removeReservation(partyName, partySize, reservationTime); }
public Reservation createWalkInReservation(String partyName, int partySize) { return reservationManager.createReservation(partyName, partySize, LocalDateTime.now()); }
public void orderItem(Table table, MenuItem item) { table.addOrder(item); }
public void cancelItem(Table table, MenuItem item) { table.removeOrder(item); }
public BigDecimal calculateTableBill(Table table) { return table.calculateBillAmount(); } } ```
Детальный разбор — Очередь заказов (Command Pattern (паттерн Команда))
В текущем дизайне `Table` напрямую управляет статусом `OrderItem`. Такая децентрализованная структура не даёт целостного представления о прохождении заказов по всем столикам в часы пик.
**Решение: Централизованный OrderManager с Command Pattern (паттерном Команда)**
```java public interface OrderCommand { void execute(); }
public class SendToKitchenCommand implements OrderCommand { private final OrderItem orderItem;
public SendToKitchenCommand(OrderItem orderItem) { this.orderItem = orderItem; }
@Override public void execute() { orderItem.sendToKitchen(); } }
public class DeliverCommand implements OrderCommand { private final OrderItem orderItem;
public DeliverCommand(OrderItem orderItem) { this.orderItem = orderItem; }
@Override public void execute() { orderItem.deliverToCustomer(); } }
public class CancelCommand implements OrderCommand { private final OrderItem orderItem;
public CancelCommand(OrderItem orderItem) { this.orderItem = orderItem; }
@Override public void execute() { orderItem.cancel(); } }
public class OrderManager { private final List<OrderCommand> commandQueue = new ArrayList<>();
public void addCommand(OrderCommand command) { commandQueue.add(command); }
public void executeCommands() { for (OrderCommand command : commandQueue) { command.execute(); } commandQueue.clear(); } } ```
**Интеграция OrderManager в Restaurant:**
```java public class Restaurant { // ... fields unchanged ... private final OrderManager orderManager;
public Restaurant(String name, Menu menu, Layout layout) { // ... fields unchanged ... this.orderManager = new OrderManager(); }
public void orderItem(Table table, MenuItem item) { table.addOrder(item); List<OrderItem> orderItems = table.getOrderedItems().get(item); if (orderItems != null && !orderItems.isEmpty()) { OrderItem lastOrder = orderItems.get(orderItems.size() - 1); orderManager.addCommand(new SendToKitchenCommand(lastOrder)); orderManager.executeCommands(); } }
public void cancelItem(Table table, MenuItem item) { List<OrderItem> orderItems = table.getOrderedItems().get(item); if (orderItems != null && !orderItems.isEmpty()) { OrderItem lastOrder = orderItems.get(orderItems.size() - 1); orderManager.addCommand(new CancelCommand(lastOrder)); orderManager.executeCommands(); table.removeOrder(item); } }
public void deliverItem(Table table, MenuItem item) { List<OrderItem> orderItems = table.getOrderedItems().get(item); if (orderItems != null && !orderItems.isEmpty()) { OrderItem lastOrder = orderItems.get(orderItems.size() - 1); orderManager.addCommand(new DeliverCommand(lastOrder)); orderManager.executeCommands(); } } } ```
**Роли в Command Pattern (паттерне Команда):** - **Команда:** Интерфейс `OrderCommand` + `SendToKitchenCommand` / `DeliverCommand` / `CancelCommand` - **Вызывающий:** `OrderManager` — ставит команды в очередь и запускает выполнение - **Получатель:** `OrderItem` — обрабатывает фактические изменения состояния
Преимущества: команды можно группировать, откладывать, логировать или отменять. Вызывающий полностью развязан с тем, что делает команда.
Подведение итогов
В этой главе мы спроектировали систему управления рестораном: собрали требования через структурированный диалог, определили основные объекты, разработали структуру классов и реализовали ключевые компоненты.
Ключевые выводы: - **Модульность + SRP:** `Menu`, `ReservationManager`, `Layout` и `Table` — каждый управляет отдельной зоной ответственности. - **Паттерн фасад:** `Restaurant` унифицирует операции системы, делегируя их специализированным классам. - **Неизменяемость:** Объекты `MenuItem` неизменяемы для обеспечения согласованности. - **Command Pattern (паттерн Команда) (детальный разбор):** `OrderManager` централизует действия над заказами, обеспечивая группировку, логирование и отмену без прямой связи с `Table`.