Принципы SOLID в Kotlin

Что такое принципы SOLID?

SOLID  —  это аббревиатура, обозначающая пять принципов дизайна, которые позволяют создавать сопровождаемое, масштабируемое и надежное ПО. Роберт К. Мартин ввел эти принципы, чтобы помочь программистам писать высококачественный код. Изначально предназначенные для объектно-ориентированного программирования, принципы SOLID применимы и к другим языкам, например Kotlin. Они направлены на обеспечение чистоты кода и улучшение дизайна программного обеспечения.

Вот 5 принципов SOLID:

  1. Single Responsibility Principle (SRP)  —  принцип единственной ответственности.
  2. Open-Closed Principle (OCP)  —  принцип открытости/закрытости.
  3. Liskov Substitution Principle (LSP)  —  принцип подстановки Лисков.
  4. Interface Segregation Principle (ISP)  —  принцип разделения интерфейса.
  5. Dependency Inversion Principle (DIP)  —  принцип инверсии зависимостей.

Теперь рассмотрим подробнее каждый из этих принципов с примерами выполнения и нарушения.

Принцип единственной ответственности

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

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

Теперь рассмотрим ситуации нарушения и выполнения этого принципа.

Нарушение:

// Нарушение принципа единственной ответственности 
// В данном примере класс System пытается одновременно обрабатывать множество различных ситуаций.
// Такой подход может привести к серьезным проблемам в будущем.
class SystemManager {
fun addUser(user: User) { }
fun deleteUser(user: User) { }
fun sendNotification(notification:String) {}
fun sendEmail(user: User, email: String) {}
}

В данном примере класс System пытается обработать множество различных ситуаций в одном и том же месте. Такой подход может привести к серьезным проблемам в будущем.

Выполнение:

// Выполнение принципа единственной отвественности.
// Как видно из этого примера, мы разделили класс System на определенные части
// и поместили функции в соответствующие классы.

class MailManager() {
fun sendEmail(user: User, email: String) {}
}

class NotificationManager() {
fun sendNotification(notification: String) {}
}

class UserManager {
fun addUser(user: User) {}
fun deleteUser(user: User) {}
}

Как видно из этого примера, класс System разделен на определенные части и функции помещены в соответствующие классы.


Принцип открытости/закрытости

Принцип “открытости/закрытости”  —  это правило объектно-ориентированного программирования, согласно которому классы, модули, функции и другие программные объекты должны быть открыты для расширения, но закрыты для изменений. Это означает, что вы можете добавлять новые элементы в класс, не модифицируя его исходный код. Таким образом, вместо изменения самого класса для добавления новых возможностей можно написать новый код, использующий существующий класс. Вследствие этого, код становится более удобным для сопровождения и повторного использования.

Теперь рассмотрим ситуации нарушения и корректного выполнения этого принципа.

Нарушение:

// Нарушение приницпа открытости/закрытости 
// В данном примере, когда мы пытаемся добавить что-то новое в класс,
// приходится переписывать существующий код, что в дальнейшем может привести к проблемам.
class Shape(val type: String, val width: Double, val height: Double)

fun calculateArea(shape: Shape): Double {
if (shape.type == "rectangle") {
return shape.width * shape.height
} else if (shape.type == "circle") {
return Math.PI * shape.width * shape.width
}
return 0.0
}

В данном примере, для добавления чего-то нового в класс существующий код переписывается, что в дальнейшем может привести к проблемам.

Выполнение:

// Выполнение принципа открытости/закрытости. 
// Вместо изменения самого класса этот принцип
// предусматривает написание новых классов, используя существующий класс,
// и реализацию функций в новых классах.

interface Shape {
fun area(): Double
}

class Rectangle(val width: Double, val height: Double) : Shape {
override fun area() = width * height
}

class Circle(val radius: Double) : Shape {
override fun area() = Math.PI * radius * radius
}

fun calculateArea(shape: Shape) = shape.area()

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


Принцип подстановки Лисков

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

Теперь рассмотрим ситуации нарушения и выполнения этого принципа.

Нарушение:

// Нарушение принципа подстановки Лисков. 
// В этом примере метод, который мы написали в главном классе, должен правильно работать в его подклассах в соответствии с принципом подстановки Лисков,
// но когда подкласс наследовался от суперкласса, метод fly не работал так, как ожидалось.

open class Bird {
open fun fly() {}
}

class Penguin : Bird() {
override fun fly() {
print("Penguins can't fly!")
}
}

Согласно принципу подстановки Лисков, метод, написанный в главном классе, должен корректно работать в его подклассах. Но как показано в этом примере, при наследовании подкласса от суперкласса метод fly стал работать не так, как ожидалось.

Выполнение:

// Выполнение приципа подстановки Лисков. 
// Как видно из этого примера, все, что мы пишем в суперклассе, будет работать и для подклассов,
// потому что мы реализовали метод, который не действителен для подклассов, создав интерфейс и используя его там, где нам это нужно.

open class Bird {
// общие методы и свойства Bird (птица)
}

interface IFlyingBird {
fun fly(): Boolean
}

class Penguin : Bird() {
// Методы и свойства, специфические для пингвинов
}

class Eagle : Bird(), IFlyingBird {
override fun fly(): Boolean {
return true
}
}

Как видно из этого примера, все, что написано в суперклассе, будет работать и в подклассах, поскольку реализован метод, не действительный для подклассов, путем создания и использования интерфейса там, где это необходимо.


Принцип разделения интерфейса

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

Теперь рассмотрим ситуации нарушения и выполнения этого принципа.

Нарушение:

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

interface Animal {
fun swim()
fun fly()
}

class Duck : Animal {
override fun swim() {
println("Duck swimming")
}

override fun fly() {
println("Duck flying")
}
}

class Penguin : Animal {
override fun swim() {
println("Penguin swimming")
}

override fun fly() {
throw UnsupportedOperationException("Penguin cannot fly")
}
}

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

Выполнение:

// Выполнение принципа разделения интерфейса.
// Как видим на примере правильного использования этого принципа, разделение системы на более мелкие интерфейсы и использование их там, где это необходимо, значительно упрощает внесение изменений в систему в будущем.

interface CanSwim {
fun swim()
}

interface CanFly {
fun fly()
}

class Duck : CanSwim, CanFly {
override fun swim() {
println("Duck swimming")
}

override fun fly() {
println("Duck flying")
}
}

class Penguin : CanSwim {
override fun swim() {
println("Penguin swimming")
}
}

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


Принцип инверсии зависимостей

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

Теперь рассмотрим ситуации с нарушением и выполнением этого принципа.

Нарушение:

// Нарушение принципа инверсии зависимостей.
// Как видно из приведенного примера, каждый из способов оплаты обрабатывается отдельно в классе Service посредством хардкодинга.
// Вместо реализации хардкодинга нужно было сделать так, чтобы система ЗАВИСЕЛА от абстрактной структуры.

class PaymentService {
private val paymentProcessorPaypal = PaypalPaymentProcessor()
private val paymentProcessorStripe = StripePaymentProcessor()

fun processPaymentWithPaypal(amount: Double): Boolean {
return paymentProcessorPaypal.processPayment(amount)
}

fun processPaymentWithStripe(amount: Double): Boolean {
return paymentProcessorStripe.processPayment(amount)
}
}

class PaypalPaymentProcessor {
fun processPayment(amount: Double): Boolean {
// Process payment via Paypal API
return true
}
}

class StripePaymentProcessor {
fun processPayment(amount: Double): Boolean {
// Process payment via Stripe API
return true
}
}


fun main() {
val paymentService = PaymentService()
println(paymentService.processPaymentWithPaypal(50.0)) // Обработка платежей через API Paypal
println(paymentService.processPaymentWithStripe(50.0)) // Обработка платежей через API Stripe
}

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

Выполнение:

// Выполнение принципа инверсии зависимостей. 
// В случае выполнения принципа нам не нужно прибегать к хардкодингу для обработки методов оплаты в классе Service,
// потому что мы задали абстрактную структуру с помощью созданного нами интерфейса.

interface PaymentProcessor {
fun processPayment(amount: Double): Boolean
}

class PaypalPaymentProcessor : PaymentProcessor {
override fun processPayment(amount: Double): Boolean {
// Обработка платежей через API Paypal
return true
}
}

class StripePaymentProcessor : PaymentProcessor {
override fun processPayment(amount: Double): Boolean {
//Обработка платежей через API Stripe
return true
}
}

class PaymentService(private val paymentProcessor: PaymentProcessor) {
fun processPayment(amount: Double): Boolean {
return paymentProcessor.processPayment(amount)
}
}

fun main() {
val paymentProcessor = PaypalPaymentProcessor()
val paymentService = PaymentService(paymentProcessor)
println(paymentService.processPayment(50.0)) // Обработка платежей через API Paypal
}

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


Заключение

Таким образом, принципы SOLID необходимы для создания сопровождаемого, масштабируемого и эффективного программного обеспечения. Используя уникальные возможности и конструкции Kotlin, разработчики могут создавать модульные, слабосвязанные системы, которые соответствуют этим принципам.

Соблюдение принципов SOLID не только повышает тестируемость кода, но и способствует развитию культуры непрерывного совершенствования и использования лучших практик. В конечном итоге применение этих принципов при разработке на языке Kotlin приводит к созданию более качественного ПО, которое можно эффективно поддерживать и адаптировать к изменяющимся требованиям.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Hüseyin Özkoç: Kotlin SOLID Principles | Huawei Developers

Предыдущая статьяПрощайте, useState и useEffect: революция в React
Следующая статьяРеализация паттерна доступа к данным при работе с Drizzle