Как сказал бы волк из небезызвестного мультика: «SOLID? Шо, опять?»

Но наша сфера пополняется новыми кадрами, и для них это всё действительно впервые. Именно для них я и написал эту статью. Недавно мне пришлось объяснять принципы SOLID начинающему разработчику на примере Go, и я понял, что нет хорошего материала, который я мог бы ему рекомендовать. Точнее, нет материала, который объяснял бы эти принципы так, как я это себе представляю, максимально понятно. Часто объяснения SOLID сводятся к разбору каждого принципа отдельно. Однако, по моему мнению, настоящее понимание приходит, когда ты видишь, как реализация одного принципа в коде неизбежно приводит к применению второго и третьего.

SOLID

Давайте быстро разложим аббревиатуру на составляющие и начнём погружение в суть дела:

  • Принцип единственной ответственности (Single Responsibility Principle)

  • Принцип открытости/закрытости (Open-Closed Principle)

  • Принцип подстановки Лисков (Liskov Substitution Principle)

  • Принцип разделения интерфейса (Interface Segregation Principle)

  • Принцип инверсии зависимостей (Dependency Inversion Principle)

Single Responsibility Principle

Этот принцип самый простой и, возможно, одновременно самый сложный. В оригинальном определении в Википедии что‑то говорится про класс, инкапсуляцию. Но в Go вот нет классов. И где тогда этот принцип тут существует? На самом деле, он, по сути, везде. И когда вы пишете функции, и когда создаёте структуры, и когда организуете ваши пакеты. Объединяйте вместе вещи, которые должны изменяться по одной и той же причине. И отделяйте от них вещи, которые должны изменяться по другой причине. Вот пример кода, который мы будем улучшать по всем принципам SOLID, и на нём сможем увидеть, как один принцип неизбежно влечёт за собой другой в этом процессе.

package main

import (
    "errors"
    "fmt"
)

// Функция proccessPaymentAndSendNotifications принимает информацию о платеже
// и обрабатывает платеж, а также отправляет уведомления.
func ProccessPaymentAndSendNotifications(paymentMethod string, amount float64) error {
    // Switch-case для обработки различных методов оплаты
    switch paymentMethod {
    case "masterCard":
        fmt.Printf("Обрабатывается платеж с MasterCard на сумму %.2f\n", amount)
        // Дополнительный код для обработки платежа с MasterCard
		// Отправка уведомлений
	    fmt.Println("Отправляются уведомления после обработки MasterCard")
    case "visa":
        fmt.Printf("Обрабатывается платеж с Visa на сумму %.2f\n", amount)
        // Дополнительный код для обработки платежа с Visa
        fmt.Println("Отправляются уведомления после обработки Visa")
    default:
        return errors.New("Неизвестный метод оплаты")
    }

    return nil
}

func main() {
    // Пример использования функции
    err := ProccessPaymentAndSendNotifications("masterCard", 100.50)
    if err != nil {
        fmt.Println("Ошибка обработки платежа:", err)
    }
}

Давайте подумаем, что же мы тут видим. Наша функция processPaymentAndSendNotifications явно берёт на себя много разнонаправленной работы. В одном месте и платёж обработаем, и логику отправки уведомлений реализуем. Если нам нужно изменить одно, то мы будем трогать код, который выполняет ещё и что-то другое. Это как раз противоречит нашему первому принципу единственной ответственности. Давайте применим его и модифицируем наш код:

package main

import (
	"errors"
	"fmt"
)

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(paymentMethod string, amount float64) (string, error) {
	// Switch-case для обработки различных методов оплаты
	switch paymentMethod {
	case "masterCard":
		fmt.Println("Обрабатывается платеж с MasterCard")
		// Дополнительный код для обработки платежа с MasterCard
		return "Результат обработки платежа MasterCard", nil
	case "visa":
		fmt.Println("Обрабатывается платеж с Visa")
		// Дополнительный код для обработки платежа с Visa
		return "Результат обработки платежа Visa", nil
	default:
		return "", errors.New("Неизвестный метод оплаты")
	}
}

// Функция sendNotifications отправляет уведомления получателю.
func SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

func main() {
	// Пример использования функций
	res, err := ProcessPayment("masterCard", 100.50)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		SendNotifications(res)
	}
}

Вот, так уже лучше. Мы разделили логику обработки и отправки. Первый принцип в действии и во всей красе. Но не только функциями ограничивается применение этого принципа. Программная единица, к которой применяется этот принцип, может быть и классом в других языках, как говорилось изначально, и структурой с привязанными к ней функциями, и даже пакетом. Во многом именно поэтому этот принцип в какой-то степени одновременно и самый простой, и самый сложный. Правильно определить границы этой единственной ответственности бывает очень и очень непросто. Особенно когда в дело вмешивается нечёткое определение бизнес-логики от заказчика или какие-то другие факторы, которые усложняют понимание структуры задачи. Умение работать в соответствии с этим принципом — отдельный особый навык.

Пара отвлечённых примеров кода, который соответствует этому принципу, прежде чем мы продолжим улучшать наш изначальный код:

package main

import "fmt"

// Структура, представляющая сущность Пользователя
type User struct {
	ID   int
	Name string
	Age  int
}

// Метод, отвечающий за предоставление информации о пользователе 
// в определенном формате
func (u *User) GetUserInfo() string {
	return fmt.Sprintf("ID: %d, Name: %s, Age: %d\n", u.ID, u.Name, u.Age)
}

// Метод, отвечающий за проверку совершеннолетия пользователя
func (u *User) IsAdult() bool {
	return u.Age >= 18
}

func main() {
	user := User{ID: 1, Name: "John", Age: 25}
	userInfo := user.GetUserInfo() // Информация о пользователе

	fmt.Println("userInfo:", userInfo)
	fmt.Println("Is adult?", user.IsAdult()) // Проверка совершеннолетия пользователя
}
package calculator

// Add складывает два числа
func Add(a, b int) int {
    return a + b
}

// Subtract вычитает одно число из другого
func Subtract(a, b int) int {
    return a - b
}

// Multiply умножает два числа
func Multiply(a, b int) int {
    return a * b
}

// Divide делит одно число на другое
func Divide(a, b int) int {
    if b == 0 {
        return 0 // Здесь лучше обработать ошибку, но для простоты примера возвращаем 0
    }
    return a / b
}

Говоря о пакетах, можно вспомнить ещё один хороший пример. Вы наверняка встречали папку utils в некоторых проектах, или в будущем столкнетесь с ней. Обычно там находится всё, что не знали, куда положить.

Она умеет и читать, и писать, и играть на дуде. Это как раз нарушает наш принцип единственной ответственности. Если вы когда-то решите создать такую папку, то не поленитесь соблюдать в ней порядок. Кладите туда код, который разбит по семантически логичным пакетам: utils/converters.go, utils/formatters.go и т. д.

И ещё один совет немного не по теме статьи, но раз мы коснулись пресловутой папки utils: держите там только тот код, который максимально независим от любой бизнес-логики вашего приложения. Это должна быть папка, которую вы можете взять, целиком вынуть из вашего проекта, вложить в другой и сразу начать пользоваться. Это буквально утилитарный код, максимально абстрактный и независимый от контекста приложения.

Open-Closed Principle

Давайте еще раз посмотрим на нашу последнюю версию кода:

package main

import (
	"errors"
	"fmt"
)

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(paymentMethod string, amount float64) (string, error) {
	// Switch-case для обработки различных методов оплаты
	switch paymentMethod {
	case "masterCard":
		fmt.Println("Обрабатывается платеж с MasterCard")
		// Дополнительный код для обработки платежа с MasterCard
		return "Результат обработки платежа MasterCard", nil
	case "visa":
		fmt.Println("Обрабатывается платеж с Visa")
		// Дополнительный код для обработки платежа с Visa
		return "Результат обработки платежа Visa", nil
	default:
		return "", errors.New("Неизвестный метод оплаты")
	}
}

// Функция sendNotifications отправляет уведомления получателю.
func SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

func main() {
	// Пример использования функций
	res, err := ProcessPayment("masterCard", 100.50)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		SendNotifications(res)
	}
}

Очень и очень вероятно, что со временем нам придётся добавлять в него всё новые и новые способы платежей. Для этого нам придётся идти в тело функции processPayment и вручную изменять его, добавляя новый блок в switch и прописывая там всю логику обработки платежа. А если платёж потребует каких-то дополнительных шагов, очень специфичных для него? К примеру, для PayPal? После внедрения обнаруживается, что для обработки платежей через PayPal требуется дополнительный шаг: необходимо проверить статус аккаунта пользователя. И вот наш код уже выглядит как‑то так:

package main

import (
	"errors"
	"fmt"
)

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(paymentMethod string, amount float64, accountStatus *string) (string, error) {
	// Switch-case для обработки различных методов оплаты
	switch paymentMethod {
	case "masterCard":
		fmt.Println("Обрабатывается платеж с MasterCard")
		// Дополнительный код для обработки платежа с MasterCard
		return "Результат обработки платежа MasterCard", nil
	case "visa":
		fmt.Println("Обрабатывается платеж с Visa")
		// Дополнительный код для обработки платежа с Visa
		return "Результат обработки платежа Visa", nil
	case "paypal":
		fmt.Println("Обрабатывается платеж через PayPal")
		if accountStatus == nil {
			return "", errors.New("Отстуствует accountStatus")
		}
		// Проверка статуса аккаунта пользователя
		if err := CheckPayPalAccountStatus(*accountStatus); err != nil {
			return "", err
		}
		return "Результат обработки платежа paypal", nil
	default:
		return "", errors.New("Неизвестный метод оплаты")
	}
}

// Функция checkPayPalAccountStatus проверяет статус аккаунта пользователя PayPal.
func CheckPayPalAccountStatus(accountStatus string) error {
	if (accountStatus == "active") {
		return nil
	}
	if (accountStatus == "inactive") {
		return errors.New("Аккаунт PayPal неактивен")
	}
	
	return errors.New("Неизвестный статус PayPal аккаунта")
}

// Функция sendNotifications отправляет уведомления получателю.
func SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

func main() {
	accountStatus := "active"
	// Пример использования функций
	res, err := ProcessPayment("masterCard", 100.50, nil)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		SendNotifications(res)
	}

	// Пример использования функций
	res, err = ProcessPayment("paypal", 100.50, &accountStatus)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		SendNotifications(res)
	}
}

Приступим к анализу. Какие проблемы мы тут видим? Разберём по пунктам:

  1. Функция processPayment начала принимать опциональный аргумент accountStatus. Он нужен только в случае работы с PayPal. Для остальных платёжных систем мы передаём nil. В Go отсутствует поддержка стандартных значений для аргументов, то есть мы не можем просто пропустить передачу ненужного аргумента в функцию. Значит, наш тип accountStatus должен быть не просто string, а *string. То есть он должен быть указателем на string, чтобы мы могли передавать туда и string, и nil в качестве значения. Это делает сигнатуру нашей функции очень размытой: мы не можем, глядя на аргументы функции, понять, когда нам нужно передать accountStatus, а когда нет. (На самом деле в Go есть паттерн, который позволяет решить проблему опциональных аргументов. Он называется «Функциональные опции» (Functional Options), советую ознакомиться с ним на досуге. Но в нашем случае это не тот путь, по которому стоит пойти. Здесь мы с помощью этого паттерна устраним симптом, но не саму проблему.)

  2. В блоке обработки платежа PayPal нам нужно делать дополнительные проверки на то, что не забыли передать accountStatus.

  3. Да и на самом деле с первым аргументом всё не так хорошо. Опять же, из сигнатуры функции мы не понимаем, какие платежи мы можем передать туда, а какие ещё не реализованы. (Тут мы могли бы решить проблему, сделав аналог типа данных Enum в Go через константы. О чём тоже следует почитать на досуге, но опять же, это не решит нашу проблему, а только уберёт ещё один симптом.)

Как вы видите, наш код закрыт для расширения. Мы вынуждены лезть в реализацию, городить не самые приятные изменения в этот код, ухудшая его понимание для внешних будущих пользователей.

Как нам поступить? И тут на сцену выходят паттерны проектирования!

Воспользуемся паттерном «Стратегия». Заранее не буду расписывать и объяснять его суть, так как вы его сейчас буквально увидите своими глазами, и дальше мы его обсудим в свете принципов SOLID.

package main

import (
	"errors"
	"fmt"
)

// Интерфейс для метода оплаты
type Payer interface {
	Pay(amount float64) (string, error)
}

// Структура для MasterCard платежа
type MasterCardPayment struct{}

func (m *MasterCardPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж с MasterCard")
	// Дополнительный код для обработки платежа с MasterCard
	return "Результат обработки платежа MasterCard", nil
}

// Структура для Visa платежа
type VisaPayment struct{}

func (v *VisaPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж с Visa")
	// Дополнительный код для обработки платежа с Visa
	return "Результат обработки платежа Visa", nil
}

// Структура для PayPal платежа
type PaypalPayment struct{}

func (p *PaypalPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж через PayPal")
  // Дополнительный код для обработки платежа с PayPal
	return "Результат обработки платежа PayPal", nil
}

// Функция checkAccountStatus проверяет статус аккаунта пользователя.
func (p *PaypalPayment) CheckAccountStatus(accountStatus string) error {
	if (accountStatus == "active") {
		return nil
	}
	if (accountStatus == "inactive") {
		return errors.New("Аккаунт PayPal неактивен")
	}
	
	return errors.New("Неизвестный статус PayPal аккаунта")
}

// Функция sendNotifications отправляет уведомления получателю.
func SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(p Payer, amount float64) (string, error) {
	return p.Pay(amount)
}

func main() {
	// Пример использования функций
	masterCard := &MasterCardPayment{}
	res, err := ProcessPayment(masterCard, 100.50)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		SendNotifications(res)
	}

	// Пример использования функций
	payPal := &PaypalPayment{}
	accountStatus := "active"
	err = payPal.CheckAccountStatus(accountStatus)
	if err != nil {
		fmt.Println("Ошибка проверки аккаунта:", err)
	} else {
		res, err = ProcessPayment(payPal, 100.50)
		if err != nil {
			fmt.Println("Ошибка обработки платежа:", err)
		} else {
			SendNotifications(res)
		}
	}
}

Итак, что мы тут видим? О, на самом деле много всего. Вы сейчас тут видите ещё один принцип SOLID, который мы будем разбирать дальше. А ещё тут есть инъекция зависимостей (dependency injection). Но обо всём по порядку.

Паттерн «Стратегия» заключается в том, что мы создаём возможность выбрать поведение нашего кода более гибким образом, без его прямой модификации. Буквально передать поведение снаружи внутрь нашего кода.

Мы создали интерфейс Payer. Это абстракция, всеми нами программистами любимая абстракция. Мы описываем то, как мы хотим вызвать нужное поведение внутри функции processPayment. И нам становится буквально всё равно, какой именно из методов проведения платежа нам передадут. Нам только нужно, чтобы нам передали то, что соответствует нашему интерфейсу. То есть мы буквально говорим, что мы хотим получить на вход. processPayment начал зависеть не от чего‑то конкретного, а от абстракции. И это (внимание, барабанная дробь):

Dependency Inversion Principle

Суть принципа заключается в том, что не наш вызываемый кем-то код зависит от внешнего кода, а внешний код зависит от того, какой интерфейс мы хотим получить на вход. Ещё один пример, чтобы стало ещё понятнее.

func ProcessPaymentMasterCard(mcp masterCardPayment, amount float64) (string, error) {
	return mcp.PayByMasterCard(amount)
}

func ProcessPaymentVisa(vp visaPayment, amount float64) (string, error) {
	return vp.PaByVisa(amount)
}

Мы могли бы пойти вот таким путём: наплодить кучу отдельных функций, которые были бы завязаны на конкретные структуры. То есть каждая из processPaymentMasterCard и processPaymentVisa зависела бы от конкретной, не абстрактной реализации конкретной структуры. В нашем примере мы сразу проскочили этот этап и сделали всё по уму. Как вы можете увидеть, выстраивается цепочка взаимосвязанных переплетений. Одно органично вытекает из другого.

Мы захотели сделать наш код открытым для изменений и закрытым для расширения в соответствии с SOLID → мы применили паттерн «Стратегия» → паттерн «Стратегия» буквально основывается на принципе инверсии зависимостей из SOLID → а сама инверсия зависимостей тесно связана с внедрением зависимости (Dependency Injection).

Говоря о Dependency Injection, вы можете увидеть сам процесс внедрения в теле функции main. Там мы инициализируем наши структуры и передаём их внутрь processPayment — это и есть те самые внедряемые зависимости, которые ждёт эта функция. Внедрение зависимости в общем случае — штука довольно банальная. К примеру, в том же функциональном программировании само это понятие даже избыточно, так как там функции повсеместно принимают другие функции в качестве аргументов, что по сути и является тем же Dependency Injection.

А знаете, что мы тут ещё видим? Да, как вы уже правильно догадались, мы видим тут ещё один принцип SOLID:

Liskov substitution principle

Тут не могу не привести оригинальное определение:

«Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.»

Давайте простым языком и на наших примерах. Мы с вами создали VisaPayment, MasterCardPayment, PaypalPayment. Это всё буквально типы. Те самые «некоторые типы» из определения выше. И интерфейс Payer — это тоже тип.

Принцип подстановки Лисков говорит нам о том, что если какой-то тип является родительским для другого, то там, где используется родительский тип, мы всегда можем подставить дочерний тип, и всё продолжит работать так, как есть.

Всё ещё сложно и непонятно? Думаю, да, так что давайте ещё упрощать и вникать:

func ProcessPayment(p Payer, amount float64) (string, error) {
	return p.Pay(amount)
}

processPayment ждёт на вход тип Payer. И тут стоит вспомнить, как в Go работает типизация интерфейсов. Какие вообще типы типизации у нас есть и чем они отличаются? Давайте на примере Go и вспомним. У нас есть:

  • Номинальная типизация.

  • Номинальная типизация подтипов.

  • Структурная типизация.

Номинальная типизация — это, буквально, когда что-то является экземпляром чего-то. Так типизируются структуры в Go.

func ProcessPaymentMasterCard(mcp masterCardPayment, amount float64) (string, error) {
	return mcp.PayByMasterCard(amount)
}

func ProcessPaymentVisa(vp visaPayment, amount float64) (string, error) {
	return vp.PaByVisa(amount)
}

Каждая из этих функций ожидает, чтобы ей был передан конкретный экземпляр конкретной структуры. И даже если оба экземпляра будут иметь одинаковые поля, закреплённые за ними методы, то мы не сможем передать один вместо другого, если они не созданы от одной и той же структуры. ProcessPaymentVisa не примет MasterCardPayment, как бы мы ни старались. В итоге мы буквально ждём, что передаваемое значение будет до этого номинально объявлено как то, которое мы ждём. Явно и недвусмысленно.

Структурная типизация фокусируется на внутренней структуре объекта. Если бы в Go типизация структур была структурной, то не важно было бы, от какой изначальной структуры создаётся экземпляр. Принимающая сторона, например, ProcessPaymentVisa, просто проверяла бы наличие всех необходимых полей и методов в экземпляре. Этого было бы достаточно для корректной работы.

В Go структурная типизация как раз у интерфейсов. То есть когда мы объявляем интерфейс PaymentMethod, то нам становится интересно только наличие всех необходимых методов у того, что мы передадим в ProcessPayment.

Посмотрите на PaypalPayment и VisaPayment:

// Структура для Visa платежа
type VisaPayment struct{}

func (v *VisaPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж с Visa")
	// Дополнительный код для обработки платежа с Visa
	return "Результат обработки платежа Visa", nil
}

// Структура для PayPal платежа
type PaypalPayment struct{}

func (p *PaypalPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж через PayPal")
  // Дополнительный код для обработки платежа с PayPal
	return "Результат обработки платежа PayPal", nil
}

// Функция checkAccountStatus проверяет статус аккаунта пользователя.
func (p *PaypalPayment) CheckAccountStatus(accountStatus string) error {
	if (accountStatus == "active") {
		return nil
	}

	if (accountStatus == "inactive") {
		return errors.New("Аккаунт PayPal неактивен")
	}
	

	return errors.New("Неизвестный статус PayPal аккаунта")
}

Они оба удовлетворяют описанию интерфейса Payer. PaypalPayment имеет дополнительный метод CheckAccountStatus, но для структурной типизации это не важно. Мы смотрим только на то, что нам необходимо.

В качестве небольшой ремарки: утиная типизация, которая распространена в динамических языках программирования, является частным случаем структурной типизации, когда проверка соответствия структуры требованиям вызывающего кода выносится на этап исполнения программы, а не на этап компиляции, как в Go. В динамическом языке с утиной типизацией интерпретатор промолчал бы, что у структуры нет метода Pay, когда мы передавали бы её в функцию. Просто на этапе исполнения этот метод попытался бы быть вызван, и вылетела бы ошибка.

А как же это всё вяжется с принципом подстановки Лисков? Терпение, мы как раз идём к этому и раскладываем фундаментальные кирпичики, которые твердо помогут нам запомнить суть этого принципа.

У нас осталась номинальная типизация подтипов. Что это за зверь? В Go он не водится. В простом объяснении через наследование в других языках: номинальная типизация подтипов позволяет нам использовать класс-потомок вместо родителя. Если у нас есть класс Animal и мы на его базе создали класс Dog, то любой код, который номинально ждёт экземпляр класса Animal, примет и экземпляр класса Dog. Так как при наследовании мы явно указываем, что Dog это наследник Animal и по сути является его подтипом.

А что же в Go? В Go мы не выстраиваем явных иерархий структур. Мы не имеем механизма сказать, что какая-то структура является дочерней для другой структуры. Встраивание структуры в структуру не создаёт иерархии типов. И как раз в этот момент становится особенно очевидно, что в Go нет наследования. Всякий раз, когда изучение Go сопровождается терминологией «тут мы наследуем структуру А от структуры В», это больше вредит пониманию сути вещей, чем помогает. В Go есть только композиция, и у этого есть свои чёткие свойства, которые отличны от принципов наследования.

И тут ещё небольшая ремарка, раз уж мы вспомнили про встраивание. На самом деле, встраивание является частным случаем соблюдения open-closed principle от создателей самого языка. Да, вот так говоря об одном принципе, мы снова возвращаемся к другому и учимся видеть одно в другом. Структуры в Go имеют из коробки механизм расширения через встраивание. В языках с наследованием это самое наследование тоже является реализацией принципа OCP. Классы могут быть расширены без изменений через механизм наследования в их потомках новым поведением.

Вместо иерархий типов в Go мы используем интерфейсы и структурную типизацию. Для нас дочерним типом PaypalPayment будет считаться любой тип, который удовлетворяет этому интерфейсу. А раз удовлетворяет, то мы можем его передать на вход.

Вот здесь и вступает в силу принцип подстановки Лисков. В Go он соблюдается буквально автоматически. Компилятор накажет нас, если мы попробуем передать что-то, что не соответствует нашему интерфейсу. Например, если VisaPayment не возвращает ошибку:

// Структура для Visa платежа
type VisaPayment struct{}

func (v *VisaPayment) Pay(amount float64) string {
	fmt.Println("Обрабатывается платеж с Visa")
	// Дополнительный код для обработки платежа с Visa
	return "Результат обработки платежа Visa"
} 

И это нарушение принципа подстановки Лисков. В других языках этот принцип требует более строгой дисциплины. Давайте посмотрим на примере Python в базовом варианте без использования аннотаций типов.

class Aminal():
	def sleep(self):
		print('сплю...')


class MisticCreature(Aminal):

	def sleep(self, isFullMoon):
		if isFullMoon:
			print("В полночь мне не уснуть!")
			return

		print('сплю...')

def go_to_sleep(animal):
	animal.sleep()

if __name__ == "__main__":
	animal = Aminal()
	mistic_creature = MisticCreature()

	go_to_sleep(animal)
	go_to_sleep(mistic_creature)

В данном случае всё сломается из-за нарушения принципа подстановки Лисков. Наша функция go_to_sleep ожидает родительский тип Animal и вызывает его метод sleep. Однако в классе MysticCreature мы изменили сигнатуру метода, теперь ожидается аргумент isFullMoon. Но функция go_to_sleep об этом не знает и не может знать. Следовательно, мы не можем использовать дочерний тип (класс потомок) вместо родительского.

На самом деле, есть момент, когда в Go можно нарушить принцип подстановки Лисков, и компилятор вам не поможет. Например, если где-то не ожидается выбрасывание panic, а вы добавили эту панику в свой код. С этим нужно быть осторожным, как и вообще с таким инструментом, как panic.

И на самом деле, мы сейчас наблюдали ещё одно понятие — полиморфизм. Давайте освежим в памяти, что это такое:

Полиморфизм — это принцип, который позволяет использовать один и тот же интерфейс для работы с различными типами данных или объектами, обеспечивая выполнение одной и той же операции или функции с разными типами входных данных.

Формальное определение не всегда легко передаёт суть вещей. Поэтому давайте упростим его и освежим нашу память следующей аналогией. Что такое полиморфизм в реальной жизни? Какой пример мы можем привести? Например, цоколь лампочки и патрон, куда она вкручивается. Лампа может быть любой: накаливания, диодная, энергосберегающая. Патрон может работать с любой лампой, у которой есть нужный цоколь. Таким образом, патрон — это интерфейс, а цоколь — это реализация этого интерфейса. Каждая лампочка реализует этот интерфейс, чтобы работать с патроном. Есть цоколи E14, E27 и так далее. Резюмируя: каждая лампочка, это экземпляр разного типа лампочек, а умение нашего патрона работать с разными типами лампочек есть пример того самого полиморфизма в реальной жизни.

Опять же, на примере Go, давайте вспомним, какие типы полиморфизма мы знаем:

  • Параметрический полиморфизм.

  • Ad-hoc полиморфизм.

  • Полиморфизм подтипов.

Параметрический полиморфизм — это дженерики. Фактически, параметрический полиморфизм представляет собой теоретическое понятие, а дженерики — это его конкретная реализация. О дженериках в Go отдельно говорить не будем, просто отметим, что они есть.

Ad‑hoc полиморфизм — это, по сути, интерфейсы. «Программируйте на уровне интерфейса, а не на уровне реализации». Это также перегрузка методов или функций, но этого в Go уже нет. Точнее, не доступно при написании собственного кода, но создатели языка активно это использовали. Например, встроенная функция make(). Вы можете передавать туда разные типы и количество аргументов, и в зависимости от этого она адаптирует своё поведение. Или, другими словами, перегружается.

mySlice := make([]int, 5) // Создание слайса типа int длиной 5 элементов
myMap := make(map[string]int) // Создание map с ключами типа string и значениями типа int
myChannel := make(chan int) // Создание канала для передачи данных типа int

А вот полиморфизма подтипов в Go нет.

Полиморфизм подтипов — это принцип программирования, который позволяет использовать объекты совместимых типов вместо объектов базового типа в любом контексте, где ожидается базовый тип, при этом сохраняется корректное выполнение программы.

Или, используя термины классов: мы видели выше пример такого полиморфизма на языке Python. Когда вместо класса родителя мы можем поставить класс потомка. Полиморфизм подтипов тесно связан с принципом Лисков, поскольку полиморфизм подтипов является конкретной реализацией этого принципа.

И тем не менее, как мы увидели выше, в контексте Go этот принцип тоже имеет место, и вы с ним сталкиваетесь повсеместно, но в несколько иной форме.

Interface segregation principle

Включим машину времени и вернёмся к нашему изначальному коду:

package main

import (
    "errors"
    "fmt"
)

// Функция proccessPaymentAndSendNotifications принимает информацию о платеже
// и обрабатывает платеж, а также отправляет уведомления.
func ProccessPaymentAndSendNotifications(paymentMethod string, amount float64) error {
    // Switch-case для обработки различных методов оплаты
    switch paymentMethod {
    case "masterCard":
        fmt.Printf("Обрабатывается платеж с MasterCard на сумму %.2f\n", amount)
        // Дополнительный код для обработки платежа с MasterCard
		// Отправка уведомлений
	    fmt.Println("Отправляются уведомления после обработки MasterCard")
    case "visa":
        fmt.Printf("Обрабатывается платеж с Visa на сумму %.2f\n", amount)
        // Дополнительный код для обработки платежа с Visa
        fmt.Println("Отправляются уведомления после обработки Visa")
    default:
        return errors.New("Неизвестный метод оплаты")
    }

    return nil
}

func main() {
    // Пример использования функции
    err := ProccessPaymentAndSendNotifications("masterCard", 100.50)
    if err != nil {
        fmt.Println("Ошибка обработки платежа:", err)
    }
} 

Если бы мы начали наш рефакторинг, но где-то свернули не туда, то могло бы получиться вот так:

package main

import (
	"errors"
	"fmt"
)

// Интерфейс для метода оплаты и отправки нотификаций
type Payer interface {
	Pay(amount float64) (string, error)
	SendNotifications(paymentResult string)
}

// Структура для MasterCard платежа
type MasterCardPayment struct{}

func (m *MasterCardPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж с MasterCard")
	// Дополнительный код для обработки платежа с MasterCard
	return "Результат обработки платежа MasterCard", nil
}

func (m *MasterCardPayment) SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам для MasterCard
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

// Структура для Visa платежа
type VisaPayment struct{}

func (v *VisaPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж с Visa")
	// Дополнительный код для обработки платежа с Visa
	return "Результат обработки платежа Visa", nil
}

func (v *VisaPayment) SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам для Visa
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

// Структура для PayPal платежа
type PaypalPayment struct{}

func (p *PaypalPayment) Pay(amount float64) (string, error) {
	fmt.Println("Обрабатывается платеж через PayPal")
	// Дополнительный код для обработки платежа с PayPal
	return "Результат обработки платежа PayPal", nil
}

func (p *PaypalPayment) SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам для PayPal
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

// Функция checkAccountStatus проверяет статус аккаунта пользователя.
func (p *PaypalPayment) CheckAccountStatus(accountStatus string) error {
	if accountStatus == "active" {
		return nil
	}

	if accountStatus == "inactive" {
		return errors.New("Аккаунт PayPal неактивен")
	}

	return errors.New("Неизвестный статус PayPal аккаунта")
}

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(p Payer, amount float64) (string, error) {
	result, err := p.Pay(amount)
	if err != nil {
		return "", err
	}
	p.SendNotifications(result)
	return result, nil
}

func main() {
	// Пример использования функций
	masterCard := &MasterCardPayment{}
	res, err := ProcessPayment(masterCard, 100.50)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		masterCard.SendNotifications(res)
	}

	// Пример использования функций
	payPal := &PaypalPayment{}
	accountStatus := "active"
	err = payPal.CheckAccountStatus(accountStatus)
	if err != nil {
		fmt.Println("Ошибка проверки аккаунта:", err)
	} else {
		res, err = ProcessPayment(payPal, 100.50)
		if err != nil {
			fmt.Println("Ошибка обработки платежа:", err)
		} else {
			masterCard.SendNotifications(res)
		}
	}
}

Где-то в процессе рефакторинга для реализации принципа OCP мы бы свалили в одну кучу в одном интерфейсе сразу и обработку платежей, и отправку уведомлений. По сути, мы соблюли принцип разделения интерфейса уже изначально в процессе нашего рефакторинга. Суть принципа проста и тесно перекликается с принципом единственной ответственности (да, всё в природе тесно связано): делайте ваши интерфейсы маленькими и узкоспециализированными. Пусть каждый кусок программного кода полагается только на те методы интерфейса, которые ему действительно нужны, а не на те, которые идут в довесок к нужным, просто потому что кто-то решил запихать всё на свете в один гигантский интерфейс. В качестве хороших примеров часто приводят код стандартной библиотеки Go.

type Reader interface {
	Read(p []byte) (n int, err error)
}
type Seeker interface {
	Seek(offset int64, whence int) (int64, error)
}

Теперь давайте закончим наш рефакторинг. Остался один момент, который выглядит не очень:

payPal := &paypalPayment{}
accountStatus := "active"
err = payPal.checkAccountStatus(accountStatus)
if err == nil {
	res, err = processPayment(payPal, 100.50)
	if err != nil {
		fmt.Println("Ошибка обработки платежа:", err)
	} else {
		sendNotifications(res)
	  }
} else {
	fmt.Println("Ошибка проверки аккаунта:", err)
}

Тут не очень хорошо, что код сильно вложенный, и не очень очевидно, что нужно вызвать checkAccountStatus перед передачей экземпляра структуры в ProcessPayment. Давайте исправим это:

package main

import (
	"errors"
	"fmt"
)

// Интерфейс для метода оплаты
type Payer interface {
	Pay() (string, error)
}

// Интерфейс для предварительной проверки перед оплатой
type PreprocessChecker interface {
	PreprocessCheck() error
}

// Интерфейс, объединяющий методы из Payer и PreprocessChecker
type PaymentProcessor interface {
	Payer
	PreprocessChecker
}

// Структура для MasterCard платежа
type MasterCardPayment struct {
	Amount int
}

func (m *MasterCardPayment) Pay() (string, error) {
	fmt.Printf("Обрабатывается платеж с MasterCard c суммой %v\n", m.Amount)
	// Дополнительный код для обработки платежа с MasterCard
	return "Результат обработки платежа MasterCard", nil
}

func (m *MasterCardPayment) PreprocessCheck() error {
	return nil
}

// Структура для Visa платежа
type VisaPayment struct {
	Amount int
}

func (v *VisaPayment) Pay() (string, error) {
	fmt.Printf("Обрабатывается платеж с Visa c суммой %v\n", v.Amount)
	// Дополнительный код для обработки платежа с Visa
	return "Результат обработки платежа Visa", nil
}

func (m *VisaPayment) PreprocessCheck() error {
	return nil
}

// Структура для PayPal платежа
type PaypalPayment struct {
	Amount        int
	AccountStatus string
}

func (p *PaypalPayment) Pay() (string, error) {
	fmt.Printf("Обрабатывается платеж через PayPal c суммой %v\n", p.Amount)
	// Дополнительный код для обработки платежа с PayPal
	return "Результат обработки платежа PayPal", nil
}

func (p *PaypalPayment) PreprocessCheck() error {
	if p.AccountStatus == "active" {
		return nil
	}
	if p.AccountStatus == "inactive" {
		return errors.New("Аккаунт PayPal неактивен")
	}

	return errors.New("Неизвестный статус PayPal аккаунта")
}

// Функция sendNotifications отправляет уведомления получателю.
func SendNotifications(paymentResult string) {
	// Отправка уведомлений по результатам
	fmt.Printf("Отправлены уведомления: %s\n", paymentResult)
}

func PreprocessCheck(pc PreprocessChecker) error {
	return pc.PreprocessCheck()
}

// Функция processPayment принимает информацию о платеже и обрабатывает его.
func ProcessPayment(p Payer) (string, error) {
	return p.Pay()
}

func main() {
	payPal := &PaypalPayment{AccountStatus: "active", Amount: 500}
	masterCard := &MasterCardPayment{Amount: 200}

	payments := []PaymentProcessor{payPal, masterCard}

	for _, payment := range payments {
		err := PreprocessCheck(payment)
		if err != nil {
			fmt.Println("Ошибка первичной проверки платежа:", err)
			continue
		}

		res, err := ProcessPayment(payment)
		if err != nil {
			fmt.Println("Ошибка обработки платежа:", err)
			continue
		}

		SendNotifications(res)
	}
}

Мы сделали наши интерфейсы чуть более общими, упростив их сигнатуры. Передачу параметров мы заменили на обращение к внутренним полям Amount и AccountStatus, так как поняли, что поведение стало чуть более разрозненным для разных платежей. Чтобы нивелировать эти различия, мы сместили работу с необходимыми данными с этапа вызова методов на этап инициализации структуры. Благодаря этому PreprocessCheck() может быть реализована под конкретные сценарии конкретных платёжных методов. А вызов этих сценариев будет обобщённым. И теперь мы точно не пропустим этап предварительной проверки у платежа, если такой появится в будущем. Так как при добавлении нового платежа статическая типизация сразу подскажет нам, какие методы нам обязательно нужно реализовать, чтобы соответствовать необходимым интерфейсам.

Вот мы и пришли к финалу. Рефакторинг когда‑то нужно обязательно останавливать. Нет предела совершенству, и всё хорошо в меру. Это относится и к принципам SOLID.

«Заставь дурака чистый код писать, он и FizzBuzzEnterpriseEdition напишет» ©

На самом деле, в разных принципах и паттернах самая сложная часть — не понять их, а найти разумную меру их применения. Где‑то можно написать и проще, топорнее, а где‑то — строго по SOLID. Поиск этого баланса тоже часть искусства программиста. Не заниматься оверинжинирингом, но при этом не падать в другую крайность. Это приходит с опытом, если вы будете постоянно рефлексировать над вашими уже созданными проектами. Как бы вы поступили, если бы начали писать его с нуля? Какие части системы вы бы упростили, а какие сделали бы наоборот более обобщенными? Если не игнорировать такую рефлексию, то постепенно у вас будет вырабатываться интуиция разработчика. Которая будет помогать и следовать принципам, и видеть паттерны в разных местах системы. И, как мне кажется, важным этапом к выработке такой интуиции является взгляд на SOLID в том ключе, который я сегодня попытался вам изложить. Как все эти принципы соединены в единую паутину, которая окружает вас в вашем коде и коде, с которым вы работаете. Даже не осознавая этого.

На этом всё. Надеюсь, вам было интересно и, главное, понятно.

Комментарии (5)


  1. SergeyPo
    02.07.2024 16:49
    +5

    Абревиатуру SOLID выбрал один американец как красивое словосочетание, после того как тасуя первые буквы разных полезных принципов и идей наткнулся на красивое с точки зрения маркетинга слово. А теперь авторы разных статей и блогов разжёвывают именно эти 5 принципов и так и этак, объясняя нубам великое искусство "чистого кода" :)) Это очень забавно. Такое впечатление, что программирование из инженерной дисциплины превратилось в религию, со скрижалями с заповедями, проповедниками и паствой :))