Оригинал

Множество kotlin разработчиков не имеют полного представления о SOLID принципах и даже если и имеют, то не знают для чего они используются. Вы готовы погрузиться в детали?

Введение Link to heading

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

Что такое принципы SOLID Link to heading

SOLID это акроним, за которым скрываются 5 принципов дизайна кода, которые помогают создавать легко поддерживаемые, масштабируемые и надежные приложения. Роберт С. Мартин описал эти принципы для программистов для написания качественного кода. Хотя изначально SOLID принципы описывались для ООП, они также применимы и для других языков таких как kotlin. Теперь перейдем к самим принципам:

  1. S - Single Responsibility Principle - Принцип единой ответственности
  2. O - Open/Closed Principle - Принцип открытости/закротости
  3. L - Liskov Substitution Principle - Принцип подстановки Барбары Лисков
  4. I - Interface Segregation Principle - Принцип разделения интерфейсов
  5. D - Dependency Inversion Principle - Принцип инверсии зависимостей

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

S - Single Responsibility Principle - Принцип единой ответственности Link to heading

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

Нарушение:

class SystemManager {
    fun addUser(user: User) { }
    fun deleteUser(user: User) { }
    fun sendNotification(notification:String) {}
    fun sendEmail(user: User, email: String) {}
}

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

Корректное использование:

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

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

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

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

O - Open/Closed Principle - Принцип открытости/закротости Link to heading

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

Нарушение:

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
}

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

Корректное использование:

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()

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

L - Liskov Substitution Principle - Принцип подстановки Барбары Лисков Link to heading

Принцип подстановки Барбары Лисков является очень важным в ООП. Он говорит нам что если наша программа работает с некоторым объектом, то мы должны быть способны использовать любой его подтип без каких-либо проблем. Это значит что все методы и свойство основного класса должны также работать для всех его под-классов без необходимости что-либо изменять.

Нарушение:

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

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

Как мы можем видеть в этом примере, метод который мы написали в главном классе должен работать и в его подклассах, однако это не работает в этом случае, наш метод fly() не работает так как задумывалось!

Корректное использование:

open class Bird {
    // common bird methods and properties
}

interface IFlyingBird {
    fun fly(): Boolean
}

class Penguin : Bird() {
    // methods and properties specific to penguins
}

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

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

I - Interface Segregation Principle - Принцип разделения интерфейсов Link to heading

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

Нарушение:

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")
    }
}

D - Dependency Inversion Principle - Принцип инверсии зависимостей Link to heading

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

Нарушение:

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)) // Process payment via Paypal API
    println(paymentService.processPaymentWithStripe(50.0)) // Process payment via Stripe API
}

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

Корректное использование:

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

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

class StripePaymentProcessor : PaymentProcessor {
    override fun processPayment(amount: Double): Boolean {
        // Process payment via Stripe API
        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)) // Process payment via Paypal API
}

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

Заключение Link to heading

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