SOLID принципы в Go: теория и практика
SOLID — это акроним пяти фундаментальных принципов объектно-ориентированного проектирования, которые помогают создавать гибкий, поддерживаемый и масштабируемый код. Хотя Go не является классическим ООП-языком (в нём нет наследования классов), его интерфейсы, композиция и строгая типизация позволяют успешно применять SOLID. В этой статье мы разберём каждый принцип на примерах из реального мира на языке Go.
1. Принцип единственной ответственности (SRP — Single Responsibility Principle)
Теория: Каждый тип (структура, функция, интерфейс) должен иметь только одну причину для изменения. Другими словами, он отвечает только за одну часть функциональности программы.
Практика в Go: Частая ошибка — создание «божественных» структур, которые занимаются и бизнес-логикой, и логированием, и отправкой уведомлений.
Плохой пример (нарушение SRP):
type OrderService struct{}
func (s *OrderService) Create(order Order) error {
// 1. Сохранение в БД
// 2. Отправка email
// 3. Логирование
return nil
}
Хороший пример (соблюдение SRP):
type OrderRepository interface {
Save(order Order) error
}
type EmailService interface {
Send(to string, subject string, body string) error
}
type Logger interface {
Info(msg string)
}
type OrderService struct {
repo OrderRepository
email EmailService
log Logger
}
func (s *OrderService) Create(order Order) error {
if err := s.repo.Save(order); err != nil {
s.log.Info("Failed to save order")
return err
}
if err := s.email.Send(order.Email, "Order Confirmed", "Your order is placed"); err != nil {
s.log.Info("Failed to send email")
}
s.log.Info("Order created successfully")
return nil
}
Теперь каждая структура отвечает только за свою задачу, и изменение логики email не повлияет на сохранение заказов.
2. Принцип открытости/закрытости (OCP — Open/Closed Principle)
Теория: Классы (типы) должны быть открыты для расширения, но закрыты для модификации. То есть вы должны иметь возможность добавлять новую функциональность, не изменяя существующий код.
Практика в Go: Используйте интерфейсы и композицию, чтобы добавлять новые реализации без изменения основной логики.
Пример: Предположим, у нас есть система расчёта стоимости доставки. Изначально есть только «обычная доставка», но нужно добавить «экспресс-доставку».
// Определяем интерфейс
type ShippingCostCalculator interface {
Calculate(order Order) float64
}
// Стандартная реализация
type StandardShipping struct{}
func (s StandardShipping) Calculate(order Order) float64 {
return 5.99
}
// Новая реализация без изменения кода выше
type ExpressShipping struct{}
func (e ExpressShipping) Calculate(order Order) float64 {
return 15.99
}
// Использование
func ProcessOrder(order Order, calculator ShippingCostCalculator) {
cost := calculator.Calculate(order)
fmt.Printf("Shipping cost: %.2f\n", cost)
}
Мы не меняем ProcessOrder — просто передаём новый экземпляр ExpressShipping. Это и есть OCP.
3. Принцип подстановки Лисков (LSP — Liskov Substitution Principle)
Теория: Объекты в программе должны быть заменяемы экземплярами их подтипов без изменения корректности программы. В Go это означает, что если функция ожидает интерфейс, любая реализация этого интерфейса должна работать корректно.
Практика в Go: Следите, чтобы ваши реализации интерфейсов не нарушали ожидания вызывающего кода. Например, если интерфейс предполагает, что метод GetName() всегда возвращает непустую строку, реализации не должны возвращать пустую строку.
type Shape interface {
Area() float64
}
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
type Square struct {
Side float64
}
func (s Square) Area() float64 {
return s.Side * s.Side
}
// Функция ожидает Shape, подставить можно и Rectangle, и Square
func PrintArea(s Shape) {
fmt.Printf("Area: %.2f\n", s.Area())
}
Здесь LSP соблюдён: и Rectangle, и Square корректно реализуют Area(), и PrintArea работает с обоими.
4. Принцип разделения интерфейса (ISP — Interface Segregation Principle)
Теория: Клиенты не должны зависеть от интерфейсов, которые они не используют. Лучше иметь несколько маленьких специализированных интерфейсов, чем один большой «толстый» интерфейс.
Практика в Go: В Go интерфейсы по умолчанию маленькие (часто 1-3 метода). Не бойтесь создавать много маленьких интерфейсов.
Плохой пример (толстый интерфейс):
type Worker interface {
Work()
Eat()
Sleep()
}
Если у вас есть робот, который только работает, он вынужден реализовывать Eat() и Sleep(), что бессмысленно.
Хороший пример (разделение):
type Worker interface {
Work()
}
type Eater interface {
Eat()
}
type Sleeper interface {
Sleep()
}
type Robot struct{}
func (r Robot) Work() { /* ... */ }
type Human struct{}
func (h Human) Work() { /* ... */ }
func (h Human) Eat() { /* ... */ }
func (h Human) Sleep() { /* ... */ }
Теперь каждый тип реализует только те методы, которые ему нужны.
5. Принцип инверсии зависимостей (DIP — Dependency Inversion Principle)
Теория: Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций (интерфейсов). Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Практика в Go: Внедряйте зависимости через интерфейсы, а не через конкретные типы. Это позволяет легко менять реализацию (например, для тестирования).
// Абстракция (интерфейс)
type Database interface {
Save(user User) error
}