Привет, Хабр!
Все знают, что SOLID и DRY делают код более чистым, гибким и, что немаловажно, понятным для других разрабов. Каждый компонент выполняет свою функцию и вместе они создают гармонию.
В этой статье рассмотрим как эти принципы применяются в golang.
SOLID
Single Responsibility Principle гласит, что класс или модуль должен иметь только одну причину для изменения. Корочег говоря - каждый класс или функция должны решать лишь одну задачу, не более. Если у вас есть функция или класс, который меняется по нескольким причинам, это первый звоночек, что вы нарушаете SRP.
Когда есть компонент, который в ответе только за одну задачу, его намного проще изменять, не затрагивая остальные части системы.
Неправильное применение SRP:
package main
import (
"fmt"
"net/http"
)
type User struct {
ID int
FirstName string
LastName string
}
// функция обрабатывает HTTP-запросы И управляет пользователями
func (u *User) HandleRequest(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
// получение данных пользователя
case "POST":
// создание нового пользователя
}
}
HandleRequest
класса User
выполняет две задачи: обрабатывает HTTP-запросы и управляет пользователями, это большая ошибка
Правильное применение SRP:
package main
import (
"fmt"
"net/http"
)
type User struct {
ID int
FirstName string
LastName string
}
type UserHandler struct {
// ...
}
// UserHandler отвечает только за обработку HTTP-запросов
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
// получение данных пользователя
case "POST":
//создание нового пользователя
}
}
User
хранит данные о пользователе, а UserHandler
управляет HTTP-запросами. Каждый класс фокусируется на своей уникальной задаче. Если потребуется изменить логику обработки HTTP-запросов, можно это сделать в UserHandler
, не затрагивая класс User
.
Open/closed Principle - программные сущности (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для изменения. Нужно свой код таким образом, чтобы для добавления новой функциональности не требовалось менять существующий код. Соблюдение этого уменьшает вероятность возникновения багов, т.к вам не нужно трогать уже работающий код
Пример без использования OCP:
package main
import "fmt"
type Printer struct {}
func (p *Printer) Print(data string) {
fmt.Println("Data: ", data)
}
// Допустим, нам нужно добавить функционал печати в HTML
// придется измнить класс Printer, что нарушает OCP
func (p *Printer) PrintHTML(data string) {
fmt.Println("<habr>" + data + "</habr>")
}
func main() {
printer := Printer{}
printer.Print("Hello, World!")
printer.PrintHTML("Hello, HABR World!")
}
Для добавления новой функциональности (печати в HTML), мы изменили класс Printer
. Это нарушает OCP.
Пример с использованием OCP:
package main
import "fmt"
type Printer interface {
Print(data string)
}
type TextPrinter struct {}
func (p *TextPrinter) Print(data string) {
fmt.Println("Data: ", data)
}
type HTMLPrinter struct {}
func (h *HTMLPrinter) Print(data string) {
fmt.Println("<html>" + data + "</html>")
}
func main() {
var printer Printer
printer = &TextPrinter{}
printer.Print("Hello, World!")
printer = &HTMLPrinter{}
printer.Print("Hello, HTML World!")
}
Вместо изменения существующего кода, мы расширили функциональность системы, добавив новую реализацию интерфейса Printer
. Соблюдаем OCP: существующий код не изменяется, а новый функционал добавляется через новые реализации.
LSP гласит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности работы программы. Это звучит как-то непонятно, но на самом деле всё просто: если у вас есть класс-родитель и класс-потомок, то любой код, который использует родительский класс, должен работать так же хорошо и с объектами дочернего класса.
Пример:
package main
import "fmt"
// Bird базовый тип
type Bird struct {}
func (b *Bird) Fly() {
fmt.Println("Птица летит")
}
// Penguin - подтип Bird, но не может летать
type Penguin struct {
Bird
}
func main() {
var bird = &Bird{}
bird.Fly()
var penguin = &Penguin{}
penguin.Fly() // Нарушение LSP, т.к. пингвины не летают
}
Penguin
наследуется от Bird
, но не соответствует поведению, ожидаемому от Bird
, что нарушает LSP.
В данном случае, так как пингвины не умеют летать (или все же умеют?), нам следует отделить способность летать от базового класса Bird
:
package main
import "fmt"
// Bird базовый тип
type Bird struct {}
func (b *Bird) MakeSound() {
fmt.Println("Птица издает звук")
}
// FlyingBird интерфейс для летающих птиц
type FlyingBird interface {
Fly()
}
// Sparrow подтип Bird, который умеет летать
type Sparrow struct {
Bird
}
func (s *Sparrow) Fly() {
fmt.Println("Воробей летит")
}
// Penguin подтип Bird, но не реализует интерфейс FlyingBird
type Penguin struct {
Bird
}
func main() {
var sparrow FlyingBird = &Sparrow{}
sparrow.Fly()
var penguin = &Penguin{}
penguin.MakeSound() // Penguin может издавать звук, но не летать
}
Bird
остается базовым классом для всех птиц, обеспечивая общее поведение (например, издавать звук). Создается интерфейс FlyingBird
для птиц, которые могут летать. Sparrow
реализует интерфейс FlyingBird
, так как воробьи умеют летать. Penguin
является подтипом Bird
, но не реализует интерфейс FlyingBird
, поскольку пингвины не летают.
ISP утверждает, что юзеры не должны быть вынуждены зависеть от интерфейсов, которые они не используют. Это означает, что вместо одного наполненного интерфейса лучше иметь несколько тонких и специализированных
Пример:
package main
type Printer interface {
Print(document string)
}
type Scanner interface {
Scan(document string)
}
// MultiFunctionDevice наследует от обоих интерфейсов
type MultiFunctionDevice interface {
Printer
Scanner
}
// класс, реализующий только функцию печати
type SimplePrinter struct {}
func (p *SimplePrinter) Print(document string) {
// реализация печати
}
// класс, реализующий обе функции
type AdvancedPrinter struct {}
func (p *AdvancedPrinter) Print(document string) {
}
func (p *AdvancedPrinter) Scan(document string) {
}
Не заставляем SimplePrinter
реализовывать функции сканирования, которые он не использует, соблюдая ISP.
DIP гласит, что высокоуровневые модули не должны зависеть от низкоуровневых модулей. Оба типа модулей должны зависеть от абстракций.
Приме:
package main
import "fmt"
// Интерфейс для абстракции хранения данных
type DataStorage interface {
Save(data string)
}
// Низкоуровневый модуль для хранения данных в файле
type FileStorage struct {}
func (fs *FileStorage) Save(data string) {
fmt.Println("Сохранение данных в файл:", data)
}
// Высокоуровневый модуль, не зависит напрямую от FileStorage
type DataManager struct {
storage DataStorage // зависит от абстракции
}
func (dm *DataManager) SaveData(data string) {
dm.storage.Save(data) // делегирование сохранения
}
func main() {
fs := &FileStorage{}
dm := DataManager{storage: fs}
dm.SaveData("Тестовые данные")
}
DataManager
не зависит напрямую от FileStorage
. Вместо этого он использует интерфейс DataStorage
, что позволяет легко заменить способ хранения данных без изменения DataManager
.
DRY
Каждый кусочек знаний в системе должен иметь единственное, недвусмысленное, авторитетное представление в рамках системы. Проще говоря, надо избегать повторения одного и того же кода в разных частях вашей программы. Когда логика дублируется, любое изменение в ней требует обновления во всех местах, где она встречается.
Нарушение DRY:
package main
import "fmt"
type User struct {
Name string
Age int
}
func (u User) PrintName() {
fmt.Println(u.Name)
}
func (u User) PrintAge() {
fmt.Println(u.Age)
}
func main() {
user := User{Name: "Alex", Age: 30}
user.PrintName()
user.PrintAge()
}
Соблюдение DRY:
package main
import "fmt"
type User struct {
Name string
Age int
}
func (u User) PrintInfo() {
fmt.Printf("Name: %s, Age: %d\n", u.Name, u.Age)
}
func main() {
user := User{Name: "Alex", Age: 30}
user.PrintInfo()
}
В первом примере мы видм, что методы PrintName
и PrintAge
дублируют логику вывода информации о пользователе. Во втором примере мы исправляем это, объединяя логику в одном методе PrintInfo
.
SOLID и DRY это крутые и очень полезные инструменты, которые позволяют строить код, который не только решает текущие задачи, но и готов к будущим вызовам в виде багов, которых будет меньше при соблюдение данных принципов.
В завершение хочу порекомендовать вам вебинар, в рамках которого эксперты отрасли расскажут, какие конкретные компетенции необходимы Golang-инженерам на разных этапах развития карьеры. Регистрация доступна по ссылке.
Комментарии (6)
DevilSter
18.01.2024 07:06+3Я конечно новичек на хабре.. Может здесь есть какое-то разделение на "рекламные" посты и полезные? Прихожу обычно за полезными...
Но выскажу свое субъективное мнение мнение - определения копи-паст из книг (название не скажу, но совсем недавно читал, у Фаулера что-ли), в применении к Го никак не объяснены, только через очень фантастические надуманные примеры.
Прочтение по примерам может нанести и вред. Потом начинают писать абстракции на абстракции и превращают Го в Java, читать код невозможно. Сотни интерфейсов там где они не нужны.В этом плане мне нравится подход из ardanlabs - никаких "DI" и передачи через интерфейсов до тех пор, пока оно действительно не нужно будет. "Наследование" в Го также довольно интересная штука в плане проблем - если честно не так часто встречаю кейсы когда оно действительно нужно, чаще от него проблемы только. Дробление на функции и "вынос" функционала - тоже надо использовать с умом, иначе потом задолбаешься в ИДЕшке скакать по модулям, и быстрее забудешь зачем вообще зашел.
И вот эти все моменты, что выше обычно в нормальной литературе подсвечиваются, объясняются побочные моменты и варианты использования.
В общем, сорян за негатив, но от статей на хабре ожидаю большего импакта обычно. (знаю про "не нравится - напиши сам", но именно потому что не хочу писать подобные посты - не пишу их, а прихожу к умным людям)sshikov
18.01.2024 07:06+1Это и есть рекламный пост. Это компания, она рекламирует свои услуги - в данном случае обучение.
Вы правда этого сами не видите? Если нет - то вот вам хороший признак. Смотрите на автора: 128 публикаций и 29 комментариев, с марта 2023 года, т.е. менее чем за год. По-моему, выводы вполне очевидны.
Gromilo
Меня всегда в примерах про S из SOLID смущают примеры: нам показали как сделать 2 кирпича, которые ни от чего не зависят, а вот как из них дом стоить не показали. Ведь каждое использование такого кирпича тоже должно быть по S.
На мой вкус сильно механистическое упрощение. Важно не то, что код одинаковый, а то что это один и тот же код. Разница в том, что один и тот же код должен всегда одинаково изменяться во всех своих копиях. А просто одинаковый код, может иметь разные причины для изменения. Сейчас одинаковый, а завтра разный. И усилия потраченные на объединение одинакового кода будут дополнены усилиями по его разделению. В общем нужно смотреть не только на код как на набор символов, но и вникать что это за код.
adal
К сожалению статья довольно проходная, с уже оскомину набившими примерами. Определение S в частности и в оригинале и в 99 процентов материалов очень мутное, которое красиво звучит, но только если не задавать лишних вопросов. Небольшая ремарка касательно повторения сам же Мартин выделяет два типа дублирования - ложное и истинное. Так вот ложное дублирование не требует устранения и наоборот - вредно. Так что одержимо пытаться вырезать все дублирования значит привести в тому от чего хотелось уйти - к сильной связности. Если говорить про SRP, то тут как мне кажется, несколько уровней непонимания - первый трактовка в лоб, одна сущность должна делать что-то одно. Что одно и какая сущность остается за скобками. Мартин пишет, что этот принцип применим только к функциям/методам. Второй уровень как раз представлен в статье, одна причина для изменения. Опять же не понятно, очень завуалировано, если изменились бизнес требования, то одним изменением можно и не отделаться, кроме того как это бьется со вторым требованием, где изменения текущей функциональности не приветствуются (OCP). По разъяснениям самого Мартина итоговый верный вариант звучит так: "Модуль должен отвечать за одного и только за одного актора". Приведу тривиальный пример. Модуль должен быть спроектирован таким образом, что изменяется только по требованию стейкхолдера (владельца продукта, бизнес аналитика), но не скажем DBA. А там где изменяется по требованиям DBA не меняется по требованиям бизнеса. То есть каждая часть кода зависит от одного и только одного актора. Причем внутри бизнес требований это правило так же соблюдается, но чтобы это поддержать нужно уходить в проектирование по доменам (DDD), если делать все так тогда это работает.
Gromilo
Интересный подход, не знал о таком. Плохо, что у меня у всех модулей 1 стекхолдер :)
На практике я делаю так: ответственность одна, если я могу вменяемо сформулировать эту ответственность. Так подход ставит рамки, на то что может содержаться в сущности и становится понятно, когда можно добавлять функционал, а когда пора делать новую сущность.