Кратко
Принципы проектирования
Список принципов проектирования для ООП интервью
Принципы проектирования помогают принимать решения для создания чистого, расширяемого и поддерживаемого кода. Когда на собеседовании вы проектируете систему парковки или ограничитель запросов, вы постоянно сталкиваетесь с решениями: должен ли это быть отдельный класс? Стоит ли здесь использовать наследование? Оправдана ли эта абстракция? Принципы проектирования дают вам основу, чтобы принимать такие решения и объяснять их.
Этот список принципов может показаться большим, но все это концепции, которые вы изучали в университете и, вероятно, используете каждый день в работе. Поэтому не переживайте из-за заучивания аббревиатур, интервьюерам важно, что вы применяете идеи на практике, а не то, что вы знаете их названия.
Есть две категории принципов, которые стоит знать: Общие принципы проектирования программного обеспечения и объектно-ориентированные принципы проектирования. Обе категории появляются на собеседованиях, но принципам ООП уделяется больше внимания, потому что большинство задач ожидают от вас проектирования иерархий классов.
Хотя в интернете можно найти бесконечные списки принципов проектирования, мы свели их к самым важным для ООП интервью.
Общие принципы проектирования программного обеспечения
Если из этого руководства вы запомните только три общих принципа проектирования программного обеспечения, пусть это будут KISS, DRY и YAGNI. Этого хватит для большинства собеседований.
KISS - Keep It Simple, Stupid
Самое простое решение, которое работает - обычно правильное. Когда вы проектируете класс или выбираете между паттернами, выбирайте прямолинейный подход. Если вы можете решить проблему простым условием вместо паттерна стратегия, сделайте это. Если один класс справляется с задачей, не превращаясь в кучу, не разделяйте его.
Это единственный принцип, который чаще всего нарушают на ООП собеседованиях. Кандидаты часто переусложняют решения, потому что хотят показать свои знания паттернов проектирования. Они добавляют фабрики, билдеры и декораторы там, где отлично сработал бы базовый класс. Интервьюеры замечают это. Они хотят видеть, что вы можете отличать задачи, которым нужны сложные решения, от задач, которым нужны простые.
Добавлять сложность стоит тогда, когда простота перестает работать. Если ваш единственный класс вырастает до 500 строк с десятью разными обязанностями, пора делать рефакторинг. Если добавление нового способа оплаты требует менять код в пяти местах, пора переходить к паттерну стратегия. Но начинать нужно с простого.
DRY - Don't Repeat Yourself
Когда вы замечаете, что пишете одну и ту же логику в нескольких местах, вынесите ее в одно место. Если три класса одинаково валидируют email-адреса, создайте общий метод валидации. Если двум сервисам нужно преобразовывать временные метки, поместите это преобразование в utility-функцию.
Польза этого принципа - в поддерживаемости. Когда правила валидации email изменятся, вы обновите один метод, а не будете искать все места в кодовой базе, где продублировали эту логику. Когда в преобразовании временных меток есть баг, вы исправите его один раз.
Но не доводите DRY до крайности. Если два фрагмента кода выглядят похоже, но служат разным целям, дублирование иногда нормально. Принудительное объединение может создать искусственную связанность, при которой изменение одного сценария ломает другой. Важно не то, насколько код похож по тексту, а то, является ли логика концептуально одной и той же.
DRY также конфликтует с KISS. Иногда самое простое решение - продублировать код
в двух местах, а не строить абстракцию. Единственно правильного ответа здесь
нет, и умение показать, что вы понимаете эти компромиссы, отличает сильных
кандидатов. На собеседовании лучше всего проговорить обе стороны: "Я ожидаю, что
эта логика валидации появится в нескольких местах, но сначала оставлю ее в
классе User, чтобы не добавлять лишнюю сложность слишком рано. Если мы увидим
ее дублирование три или четыре раза, вынесем ее в общий валидатор". Так вы
показываете, что умеете балансировать между конкурирующими принципами, а не
слепо следуете правилам.
YAGNI - You Aren't Gonna Need It
Стройте то, что нужно сейчас, а не то, что может понадобиться когда-нибудь потом. Если на собеседовании вы проектируете систему парковки, не добавляйте поддержку зарядных станций для электромобилей, если только это явно не указано в требованиях. Не делайте классы расширяемыми под любой сценарий просто на всякий случай.
Проблема проектирования под будущие требования в том, что вы почти всегда угадываете неправильно. Вы добавляете сложность для сценариев, которые никогда не случаются, а когда появляется реальное новое требование, оно отличается от того, к чему вы готовились. В итоге вам приходится поддерживать мертвый код.
Этот принцип не означает "никогда не думайте наперед". Он означает: не реализуйте наперед. Проектируйте с учетом возможного расширения, но реализуйте только то, что нужно сейчас.
Это всплывает, когда интервьюеры спрашивают "как бы вы расширили это?". В ответ вам нужно рассказать о том, как бы вы изменили дизайн, если бы появились новые требования. Но в первоначальном дизайне придерживайтесь того, что действительно необходимо.
Разделение ответственности (Separation of Concerns)
Разные части кода должны отвечать за разные обязанности и не должны знать внутреннее устройство друг друга. Слой пользовательского интерфейса не должен содержать бизнес-логику. Бизнес-логика не должна знать, как хранятся данные. Слой доступа к данным не должен форматировать строки для отображения.
Посмотрите на этот пример, где мы смешиваем логику отображения, обработку ввода и правила игры в одном методе.
package main
type TicTacToeBad struct {
board [3][3]string
}
func (g *TicTacToeBad) Play() {
// Rendering, input handling, and win checking all mixed together.
for range g.board {
// display board
}
// read input, update board, check winner...
}
Вместо этого мы можем разделить обязанности по отдельным классам, чтобы классы
Board, Display и InputHandler отвечали каждый за свою часть.
package main
type Board interface {
HasWinner() bool
MakeMove(move string)
GetWinner() string
}
type Display interface {
Render(board Board)
ShowWinner(winner string)
}
type InputHandler interface {
NextMove() string
}
type TicTacToeGood struct {
board Board
display Display
inputHandler InputHandler
}
func NewTicTacToeGood(board Board, display Display, inputHandler InputHandler) *TicTacToeGood {
return &TicTacToeGood{
board: board,
display: display,
inputHandler: inputHandler,
}
}
func (g *TicTacToeGood) Play() {
for !g.board.HasWinner() {
g.display.Render(g.board)
move := g.inputHandler.NextMove()
g.board.MakeMove(move)
}
g.display.ShowWinner(g.board.GetWinner())
}
Теперь, если вы захотите перейти с консольного ввода на графический интерфейс,
вам нужно будет изменить только InputHandler. Если захотите поменять способ
отображения поля, вы измените только Display. Если понадобится добавить новые
условия победы, вы обновите только Board. Каждое изменение изолировано в одном
классе. Именно это позволяет тестировать каждую часть системы независимо.
Закон Деметры (Law of Demeter)
Этот принцип также называют принципом наименьшего знания. Метод должен общаться
только со своими непосредственными "друзьями", а не проходить через цепочку
объектов, чтобы добраться до далеких частей системы. Если вы видите код
order.getCustomer().getAddress().getZipCode(), это нарушение закона Деметры.
Проблема глубоких цепочек вызовов - это связанность. Ваш код теперь знает
внутреннюю структуру трех разных объектов. Если любой из них изменит организацию
своих данных, ваш код сломается. Вместо этого добавьте в Order метод
getCustomerZipCode(), который сам выполнит нужный переход внутри объекта.
Связывание методов в цепочку само по себе не является проблемой.
Fluent-интерфейсы, такие как builder.setName("John").setAge(30).build() - это
нормально, потому что они возвращают один и тот же тип объекта. Проблема
возникает именно тогда, когда цепочка раскрывает внутреннюю структуру путем
прохождения через несколько разных типов объектов.
На собеседованиях это всплывает при определении методов классов. Вместо того чтобы возвращать сложные объекты, в которых нужно копаться вызывающему коду, верните конкретные данные или предоставьте более высокоуровневые методы, которые выполняют внутреннюю логику.
Принципы объектно-ориентированного проектирования (SOLID)
Эти принципы объединены аббревиатурой SOLID и применяются именно тогда, когда вы проектируете классы и связи между ними. Они постоянно встречаются на ООП интервью, потому что большинство задач ожидают от вас проектирования иерархий классов.
Принципы SOLID пришли из эпохи расцвета Java с глубокими иерархиями наследования и дизайном, сильно зависящим от интерфейсов. За пределами Java и C# чрезмерное применение SOLID выходит из моды. Современные языки отдают предпочтение более простым подходам - композиции вместо иерархий классов, функциям вместо интерфейсов. Не нарушайте KISS, навязывая паттерны SOLID там, где отлично работают более простые решения. На собеседованиях стоит применять эти принципы, только когда задача этого требует, а не добавлять сложность ради сложности.
SRP - Single Responsibility Principle
У класса должна быть одна причина для изменения. Если класс смешивает несколько зон ответственности, разделите их. Это фундамент хорошего проектирования классов.
Посмотрите на этот класс Report, который обрабатывает генерацию контента, форматирование PDF и хранение файлов в одном месте:
package main
type Report struct{}
func (Report) GenerateContent() string {
return "content"
}
func (Report) PrintToPDF() {
// PDF formatting
}
func (Report) SaveToFile() {
// file I/O
}
Вместо этого мы можем разделить эти обязанности на отдельные классы:
package main
type PDFPrinter struct{}
func (PDFPrinter) Print(report Report) {
_ = report
// PDF formatting
}
type FileStorage struct{}
func (FileStorage) Save(content string) {
_ = content
// file I/O
}
Теперь, когда библиотека форматирования PDF меняется, это изменение затрагивает
только PDFPrinter. Когда вы переключаетесь с хранения в файлах на базу данных,
вы обновляете только FileStorage. Когда меняется логика содержания отчета, это
изменение затрагивает только Report. Каждое изменение изолировано.
OCP - Open/Closed Principle
Классы должны быть открыты для расширения, но закрыты для модификации. У вас должна быть возможность добавлять новое поведение, не изменяя существующий код. Обычно для этого используют интерфейсы или абстрактные классы, чтобы добавлять новые реализации, не трогая исходный код.
Каждый раз, когда вы меняете существующий код, вы рискуете сломать то, что уже работает. Если вы проектируете на основе интерфейсов, добавление новой функциональности сводится к написанию новых классов, реализующих эти интерфейсы. Старый код не меняется, а значит, не может сломаться.
Посмотрите на PaymentProcessor, который нужно менять каждый раз при добавлении
нового типа оплаты:
package main
type PaymentProcessorBad struct{}
func (PaymentProcessorBad) Process(paymentType string, amount float64) {
switch paymentType {
case "card":
// card logic
case "sbp":
// sbp logic
}
_ = amount
}
Вместо этого мы можем использовать интерфейсы, чтобы позволить добавлять новые типы оплаты без изменения существующего кода:
package main
type PaymentMethod interface {
Process(amount float64)
}
type CreditCardPaymentGood struct{}
func (CreditCardPaymentGood) Process(amount float64) {
_ = amount
}
type PayPalPaymentGood struct{}
func (PayPalPaymentGood) Process(amount float64) {
_ = amount
}
type CryptoPayment struct{}
func (CryptoPayment) Process(amount float64) {
_ = amount
}
type PaymentProcessorGood struct{}
func (PaymentProcessorGood) Process(method PaymentMethod, amount float64) {
method.Process(amount)
}
Теперь, когда нужно добавить оплату криптовалютой, вы просто создаете новый
класс CryptoPayment. Существующий код PaymentProcessor не меняется.
LSP - Liskov Substitution Principle
Подклассы могут быть использованы везде, где ожидается базовый класс. Если у вас
есть метод, принимающий Bird, передача Penguin не должна ломать код,
несмотря на то, что пингвины не умеют летать. Это значит, что подклассы не могут
нарушать ожидания, задаваемые родительским классом.
Иначе говоря, если код использует родительский класс или интерфейс, он должен
уметь использовать любой подкласс, не зная, какой именно подкласс получил. Если
подкласс выбрасывает исключение из метода, который предоставляет родительский
класс, принцип подстановки Лисков нарушен. Если подкласс заставляет вызывающий
код добавлять специальные проверки, например if (bird instanceof Penguin),
принцип подстановки Лисков нарушен.
Посмотрите на этот классический пример, где Penguin расширяет Bird, но
нарушает ожидание того, что все птицы умеют летать:
package main
import "errors"
type Bird struct{}
func (Bird) Fly() error {
// flying logic
return nil
}
type PenguinBad struct {
Bird
}
func (PenguinBad) Fly() error {
return errors.New("penguins can't fly")
}
Вместо этого мы можем вынести поведение полета в отдельный интерфейс, чтобы его реализовывали только те птицы, которые действительно умеют летать:
package main
type BirdGood interface {
Eat()
}
type FlyingBird interface {
BirdGood
Fly()
}
type Sparrow struct{}
func (Sparrow) Eat() {}
func (Sparrow) Fly() {}
type PenguinGood struct{}
func (PenguinGood) Eat() {}
Это часто всплывает на собеседованиях, когда вы проектируете иерархии классов. Внимательно думайте о том, какие методы должны находиться в базовом классе, а какие в подклассах.
ISP - Interface Segregation Principle
Предпочитайте маленькие, лаконичные интерфейсы большим и универсальным. Не заставляйте классы реализовывать методы, которые им не нужны. Если классу нужны только два метода из интерфейса с десятью методами, этот интерфейс слишком большой.
Проблема "толстых" интерфейсов в том, что классы вынуждены реализовывать методы, которые никогда не будут использовать. Это приводит к пустым реализациям или методам, которые выбрасывают исключения, а это признак плохого дизайна. Классы могут реализовывать несколько маленьких интерфейсов, если им это нужно, но они не будут обязаны реализовывать нерелевантные методы.
package main
type Worker interface {
Work()
Eat()
Sleep()
}
type RobotBad struct{}
func (RobotBad) Work() {}
func (RobotBad) Eat() {} // robots don't eat
func (RobotBad) Sleep() {} // robots don't sleep
package main
type Workable interface {
Work()
}
type Feedable interface {
Eat()
}
type Restable interface {
Sleep()
}
type Human struct{}
func (Human) Work() {}
func (Human) Eat() {}
func (Human) Sleep() {}
type Robot struct{}
func (Robot) Work() {}
DIP - Dependency Inversion Principle
Инверсия зависимостей утверждает, что ваш код должен зависеть от абстракций, а
не от конкретных реализаций. Вместо того чтобы NotificationService создавал
EmailSender напрямую, он должен принимать интерфейс MessageSender через
конструктор.
"Инверсия" здесь относится к тому, кто определяет контракт взаимодействия. Обычно бизнес-логика подстраивается под то, что предоставляет реализация. Принцип инверсии переворачивает эту зависимость: мы определяем интерфейс на основе того, что нужно бизнес-логике, а затем заставляем реализации соответствовать этому интерфейсу. Конкретная реализация интерфейса подстраивается под бизнес-логику, а не наоборот.
Это важно и для тестируемости, и для гибкости. Когда NotificationService
зависит от конкретного EmailSender, вы не можете написать юнит-тест без
отправки реальных электронных писем, и вы не можете переключиться на SMS без
модификации сервиса. Когда он зависит от интерфейса, вы можете внедрить заглушку
(mock) для тестирования или другую реализацию для разных каналов отправки
уведомлений.
Посмотрите на этот NotificationService, который жестко связан с конкретной
реализацией отправки email.
package main
type EmailSender struct{}
func (EmailSender) Send(message string) {
_ = message
}
// High-level module depends directly on low-level detail.
type NotificationServiceBad struct {
emailSender EmailSender
}
func NewNotificationServiceBad() *NotificationServiceBad {
return &NotificationServiceBad{emailSender: EmailSender{}}
}
func (s *NotificationServiceBad) Notify(message string) {
s.emailSender.Send(message)
}
Вместо этого мы определяем интерфейс MessageSender на основе того, что нужно
NotificationService, и внедряем реализацию через конструктор.
package main
type MessageSender interface {
Send(message string)
}
type EmailMessageSender struct{}
func (EmailMessageSender) Send(message string) {
_ = message
}
type NotificationServiceGood struct {
sender MessageSender
}
func NewNotificationServiceGood(sender MessageSender) *NotificationServiceGood {
return &NotificationServiceGood{sender: sender}
}
func (s *NotificationServiceGood) Notify(message string) {
s.sender.Send(message)
}
Теперь NotificationService зависит от MessageSender (абстракции), и
EmailSender также зависит от MessageSender (он реализует интерфейс). Ни
высокоуровневый, ни низкоуровневый модуль не знают друг о друге. Вы можете
заменить email на SMS, передав другую реализацию. Вы можете проводить
юнит-тестирование с заглушкой MessageSender, которая не отправляет реальные
сообщения. Бизнес-логика изолирована от деталей реализации.
Обратите внимание, что инверсия зависимостей - это принцип проектирования, в то время как внедрение зависимостей (dependency injection), то есть передача зависимостей через конструктор, - это техника для его достижения. Они связаны, но это не одно и то же.
Собираем все вместе
Помните, вам не нужно постоянно называть эти принципы. Используйте их для принятия решений и кратко ссылайтесь на них при объяснении компромиссов. Принципы - это инструменты мышления, а не список для заучивания.
Вот быстрая шпаргалка по принципам, которые вы должны знать.
Общие принципы
- KISS -> начинайте с простого, добавляйте сложность только при необходимости
- DRY -> уменьшайте дублирование и упрощайте поддержку
- YAGNI -> проектируйте под сегодняшний день, а не под гипотетическое будущее
- Разделение ответственности -> обеспечивайте независимое тестирование и внесение изменения
- Закон Деметры -> снижайте связанность и скрывайте внутреннюю структуру
Принципы SOLID
- SRP -> держите классы сфокусированными на одной обязанности
- OCP -> поддерживайте будущие требования без изменения существующего кода
- LSP -> избегайте хрупких иерархий, которые ломаются во время выполнения
- ISP -> держите интерфейсы чистыми и лаконичными
- DIP -> отделяйте бизнес-логику от деталей реализации