вступление

Как часто мы, разработчики программного обеспечения, слышим слова «обслуживаемый», «масштабируемый» и «тестируемый», когда говорим о программных решениях? Рискну предположить, много много? И насколько часто предлагаемые ответы туманны или в лучшем случае слишком сложны? В отличие от предыдущего вопроса, я оставлю ответ Вам, читатель. Единственное, что я мог бы предложить, это один из наиболее важных методов объектно-ориентированного программирования (ООП), а именно: программирование для интерфейса.

Многие объекты объектно-ориентированного программирования сосредоточены на принципах разработки программирования интерфейса, определяющего поведение объекта через интерфейс (набор абстрактных методов или свойств), а не в зависимости от каких-то конкретных реализации указанного интерфейса.

Программирование интерфейса может обеспечить множество преимуществ во многих программных решениях, в том числе:

  • Инкапсуляция. Определив контракт через интерфейс, вы можете скрыть детали реализации объекта (детали реализации). Этот уровень абстракции фокусируется только на необходимой функциональности, оставляя любое внутреннее состояние и поведение объекта защищенными.
  • Тестируемость. Программирование интерфейса упрощает тестирование, поскольку вы можете создавать фиктивные реализации интерфейса для модульного тестирования. Это может быть чрезвычайно мощным, поскольку позволяет тестировать определенные компоненты изолированно.
  • Развязка: использование контрактов для связи между двумя или более компонентами в проекте может упростить изменение, расширение или даже замену целых компонентов, по крайней мере, деталей реализации, не затрагивая другие зависимые компоненты. . Излишне говорить, что это может внести значительный вклад в создание более модульных и удобных в сопровождении решений.
  • Гибкость: сторонние решения и библиотеки, скрытые за контрактами интерфейса, можно легко заменить другими. Важно лишь то, что интерфейсные контракты выполняются новыми библиотеками. Это особенно полезно при работе со сторонними библиотеками или API, поскольку позволяет адаптировать код к новым требованиям или обновлениям без существенного рефакторинга.
  • Повторное использование. Когда вы программируете интерфейс, вы можете повторно использовать код, зависящий от интерфейса, с любым объектом, который его реализует.

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

Опять же, почему Котлин?

Потому что мне нужно было освежить знания языка программирования на момент написания этой статьи. Помимо этого, Kotlin — это современный язык программирования со статической типизацией, работающий на виртуальной машине Java (JVM). Язык приобрел популярность и использование благодаря нулевым функциям безопасности, современному синтаксису и совместимости с языком программирования Java. Этот язык получил широкое распространение, когда он был официально поддержан Google (Alphabet) для разработки приложений для Android.

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

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

Объяснение интерфейсов

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

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

interface InterfaceName {
    // Methods and properties
}

Простой интерфейс с методами и свойствами может выглядеть примерно так:

interface Shape {
    val name: String
    fun area(): Double
    fun perimeter(): Double
}

В приведенном выше примере интерфейс Shape имеет два абстрактных метода, площадь и периметр, и одно свойство, имя.

Имея определенный интерфейс, мы можем приступить к реализации процесса в классе. Мы будем использовать двоеточие (:) после имени класса, за которым следует имя интерфейса:

class ClassName : InterfaceName {
    // Class implementation
}

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

class Circle(val radius: Double) : Shape {
    override val name: String = "Circle"

    override fun area(): Double {
        return Math.PI * radius * radius // Math is a standard class in the java.lang package
    }

    override fun perimeter(): Double {
        return 2 * Math.PI * radius
    }
}

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

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

Давайте посмотрим на другой пример реализации Shape:

class Rectangle(private val width: Double, private val height: Double) : Shape {
    override val name: String = "Rectangle"

    override fun area(): Double {
        return width * height
    }

    override fun perimeter(): Double {
        return 2 * (width + height)
    }
}

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

Проверка типа

В ООП концепция полиморфизма позволяет рассматривать объекты разных классов как объекты общего суперкласса или интерфейса. Эта функция позволяет нам писать более гибкий и повторно используемый код путем программирования интерфейса.

Чтобы использовать полиморфизм в Kotlin, мы можем создать функцию, которая принимает интерфейс в качестве параметра в своей сигнатуре. Это позволяет использовать функцию с любым объектом, реализующим этот интерфейс. Мы продолжим наш пример Shape из предыдущих разделов:

fun someFunctionName(shape: Shape) {
   print(shape.name) // Prints the name of a concrete Shape object to the console
}

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

fun main(){
  val circle = Circle(2.0)
  val rectangle = Rectangle(2.0,2.0)

  someFunctionName(circle) // Prints “Circle”
  someFunctionName(rectangle) // Prints “Rectangle”
}

Мы уже видим потенциал программирования интерфейса. Что мешает нам изменить детали реализации объектов Circle и Rectangle и продолжать использовать те же функции, а также что удерживает нас от создания новых объектов, реализующих Shape интерфейс? Остальной код нашего проекта останется без изменений.

Мы также можем использовать интерфейсы для проверки типов. Иногда нам может понадобиться проверить, относится ли объект, реализующий интерфейс, к определенному классу. Kotlin предоставляет ключевое слово is для проверки типов. Функция умного приведения приводит объект к целевому типу в рамках проверки типа.

Мы можем создать функцию, которая type проверяет объект, реализующий интерфейс Shape, и выполняет пользовательское действие для экземпляров объекта Circle:

fun someDifferentFunction(shape: Shape) {
   if (shape is Circle) {
       val doubleTheRadius = shape.radius * 2
       println("The updated circle diameter is $doubleTheRadius")
   } else {
       println("This is not a circle!")
   }
}

В приведенном выше коде, если проверка типа прошла успешно, функция интеллектуального приведения Kotlin автоматически преобразует объект shape в экземпляр Circle, позволяя вам получить доступ к его radius свойство. Теперь давайте обновим нашу основнуюфункцию и посмотрим, как можно применить новую функцию:

fun main() {
   val circle = Circle(5.0)
   val rectangle = Rectangle(4.0, 6.0)

   someDifferentFunction(circle) // Prints: The updated circle diameter is 10.0
   someDifferentFunction(rectangle) // prints: This is not a circle!
}

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

Наследование интерфейса и композиция

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

В Kotlin мы должны разделять имена интерфейсов запятой (,) после двоеточия (:), чтобы наследоваться от нескольких интерфейсов:

interface MixedInterface : InterfaceA, InterfaceB {
    // Additional properties and methods
}

Чтобы расширить функциональность наших объектов Shape, давайте создадим два дополнительных интерфейса, Drawable и Resizable, для работы с интерфейсом Shape из предыдущие разделы:

interface Drawable {
    fun draw()
}

interface Resizable {
    fun resize(factor: Double)
}

Имея два новых интерфейса, мы теперь можем создать новый контракт для AdvancedShape, который будет иметь их функции и свойства:

interface AdvancedShape : Shape, Drawable, Resizable // no need for the body curly braces

Теперь мы можем создавать новые расширенные фигуры:

class AdvancedCircle(val radius: Double) : AdvancedShape {
    override val name: String = "AdvancedCircle"

    override fun area(): Double {
        return Math.PI * radius * radius //
    }

    override fun perimeter(): Double {
        return 2 * Math.PI * radius
    }

    override fun draw() {
        println("Drawing a $name with radius $radius.")
    }

    override fun resize(factor: Double) {
        val newRadius = radius * factor
        println("Resizing the $name to new radius of $newRadius.")
    }
}

Как мы видим, новый класс AdvancedCircle предоставляет реализации для свойства name и области, периметра, >draw и resize.

Внедрение зависимостей и инверсия управления

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

Внедрение зависимостей

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

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

Инверсия управления

Инверсия управления (IoC) — принцип проектирования, который делегирует контроль над управлением зависимостями от отдельных компонентов внешнему контейнеру или фреймворку. IoC обеспечивает развязку и гибкость, инвертируя традиционный поток управления, когда компоненты создают свои зависимости и управляют ими. Использование интерфейсов в сочетании с принципом IoC позволяет создавать модульные и удобные в сопровождении решения.

Чтобы продемонстрировать истинную мощь DI и IoC, давайте посмотрим на следующий пример предлагаемого платежного процессора, и не беспокойтесь о несколько увеличенных строках кода, чем в предыдущих примерах, мы прогрессируем, не так ли?

Начнем с определения контракта интерфейса обработчика платежей:

interface PaymentProcessor {
   fun processPaymentTransaction(amount: Double, currency: String): Boolean
}

Когда мы определили общий контракт, теперь мы можем определить пару примеров платежных систем:

class StripeProcessor : PaymentProcessor {
   override fun processPaymentTransaction(amount: Double, currency: String): Boolean {
       println("Stripe processing: $amount $currency")
       return true
   }
}


class PayPalProcessor : PaymentProcessor {
   override fun processPaymentTransaction(amount: Double, currency: String): Boolean {
       println("PayPal processing: $amount $currency")
       return true
   }
}

Давайте теперь создадим воображаемый компонент интернет-магазина, который зависит от контракта PaymentProcessor, а не от конкретной реализации платежного процессора:

class SomeInterestingShop(private val paymentProcessor: PaymentProcessor) {
   fun checkoutCart(amount: Double, currency: String): Boolean {
       val transactionResult = paymentProcessor.processPaymentTransaction(amount, currency)
       if (transactionResult) {
           println("Payment successful! Thank you for your purchase.")
       } else {
           println("Payment failed! Please try again.")
       }
       return transactionResult;
   }
}

Теперь наша основнаяфункция должна выглядеть примерно так:

fun main() {
   val stripeProcessor = StripeProcessor()
   val payPalProcessor = PayPalProcessor()


   val storeWithStripe = SomeInterestingShop(stripeProcessor)
   val storeWithPayPal = SomeInterestingShop(payPalProcessor)


   storeWithStripe.checkoutCart(10.0, "EUR") // Prints: Stripe processing: 10.0 EUR
   storeWithPayPal.checkoutCart(7500.0, "RSD") // Prints: PayPal processing: 7500.0 RSD
}

Если мы будем следовать принципу внедрения зависимостей, мы можем внедритьнеобходимые реализации PaymentProcessor (StripeProcessor и PayPalProcessor) во время выполнения, что неизбежно сделает компонент SomeInterestingShop более модульный и адаптируемый к различным ситуациям.

Тестирование и насмешка

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

Для Kotlin доступно несколько сред тестирования, которые могут помочь нам создать тестовые двойники, такие как Mockito или MockK. Любая фиктивная среда должна позволять создавать макеты и заглушки для интерфейсов и даже предоставлять некоторую функциональность для проверки взаимодействия между вашим кодом и его зависимостями.

Мы продолжим работу с нашим вымышленным решением PaymentProcessor и предоставим следующий пример модульного теста для нашего SomeInterestingShop с использованием платформы MockK.

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

Чтобы добавить зависимость MockK, мы поместим в наш файл build.gradle.kst следующее:

dependencies {
   // Other dependencies
   // JUnit5
   testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2") //Check for latest versions
   testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2") //Check for latest versions


   // MockK
   testImplementation("io.mockk:mockk:1.13.5") //Check for latest versions
}

Затем мы синхронизируем наш проект и приступим к созданию SomeInterestingShopкласса модульного теста(тестовыйпакет проекта):

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class SomeInterestingShopTest {
   @Test
   fun `process payment and return true`() {
       // Arrange
       val mockPaymentProcessor = mockk<PaymentProcessor>()
       every { mockPaymentProcessor.processPaymentTransaction(any(), any()) } returns true

       val onlineStore = SomeInterestingShop(mockPaymentProcessor)

       // Act
       val result = onlineStore.checkoutCart(100.0, "RSD")

       // Assert
       verify { mockPaymentProcessor.processPaymentTransaction(100.0, "RSD") }
       assertTrue(result, "Payment was successful.")
   }
}

Мы видим, как легко для среды MockK создать макет PaymentProcessor. Мы настраиваем макет так, чтобы он возвращал true всякий раз, когда вызывается его метод processPaymentTransaction. Затем мы внедряем макет в новый экземпляр SomeInterestingShop и вызываем его метод checkoutCart. На последнем шаге мы проверяем, действительно ли был вызван метод processPaymentTransaction с ожидаемыми аргументами.

Интерфейсы в сочетании с мощными фреймворками, такими как MockK, могут предоставить более целенаправленные и удобные в сопровождении модульные тесты для наших проектов.

Случаи использования

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

Шаблон адаптера

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

Примером может служить интеграция нашего проекта с различными аналитическими сервисами, такими как Google Analytics и Flurry. Новый интерфейс AnalyticsAdapter и его реализации могут выглядеть примерно так:

interface AnalyticsAdapter {
   fun trackEvent(eventName: String, properties: Map<String, Any>)
}

class GoogleAnalyticsAdapter : AnalyticsAdapter {
   // Implementation details...
   override fun trackEvent(eventName: String, properties: Map<String, Any>) {
       TODO("Not yet implemented")
   }
}

class FlurryAdapter : AnalyticsAdapter {
   // Implementation details...
   override fun trackEvent(eventName: String, properties: Map<String, Any>) {
       TODO("Not yet implemented")
   }
}

Теперь мы можем гораздо эффективнее добавлять различные сервисы аналитики в наш проект.

Шаблон стратегии

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

Давайте рассмотрим утилиту сжатия файлов, которая поддерживает различные алгоритмы сжатия, такие как ZIP, RAR и 7z. Вы можете создать интерфейс CompressionStrategy и конкретные реализации для каждого алгоритма сжатия:

package patterns

import java.io.File


interface CompressionStrategy {
   fun compress(inputFiles: List<File>, outputFile: File)
}


class ZipCompressionStrategy : CompressionStrategy {
   override fun compress(inputFiles: List<File>, outputFile: File) {
       TODO("Not yet implemented")
   }
}


class RarCompressionStrategy : CompressionStrategy {
   override fun compress(inputFiles: List<File>, outputFile: File) {
       TODO("Not yet implemented")
   }
}


class SevenZipCompressionStrategy : CompressionStrategy {
   override fun compress(inputFiles: List<File>, outputFile: File) {
       TODO("Not yet implemented")
   }
}

Шаблон репозитория

Широко используемый шаблон проектирования — это шаблон репозиторий. С помощью шаблона репозитория мы можем абстрагироваться от любой логики доступа к данным для проекта. С предлагаемым интерфейсом репозитория мы можем переключаться между различными источниками данных (API REST, базами данных или кэшами в памяти) без изменения кода, зависящего от репозитория.

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

Давайте создадим интерфейс TaskRepository и различные реализации для разных систем хранения:

package patterns

data class Task(val id: String, val title: String, val description: String, val completed: Boolean)

interface TaskRepository {
   fun getTask(id: String): Task?
   fun addTask(task: Task): Task
   fun updateTask(task: Task): Task
   fun deleteTask(id: String): Boolean
   fun getAllTasks(): List<Task>
}

lass InMemoryTaskRepository : TaskRepository {
   // Implementation details...
   override fun getTask(id: String): Task? {
       TODO("Not yet implemented")
   }

   override fun addTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun updateTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun deleteTask(id: String): Boolean {
       TODO("Not yet implemented")
   }

   override fun getAllTasks(): List<Task> {
       TODO("Not yet implemented")
   }
}


class LocalDatabaseTaskRepository : TaskRepository {
   // Implementation details...
   override fun getTask(id: String): Task? {
       TODO("Not yet implemented")
   }

   override fun addTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun updateTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun deleteTask(id: String): Boolean {
       TODO("Not yet implemented")
   }

   override fun getAllTasks(): List<Task> {
       TODO("Not yet implemented")
   }
}


class CloudDatabaseTaskRepository : TaskRepository {
   // Implementation details...
   override fun getTask(id: String): Task? {
       TODO("Not yet implemented")
   }

   override fun addTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun updateTask(task: Task): Task {
       TODO("Not yet implemented")
   }

   override fun deleteTask(id: String): Boolean {
       TODO("Not yet implemented")
   }

   override fun getAllTasks(): List<Task> {
       TODO("Not yet implemented")
   }
}

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

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

Заключительное примечание

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

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

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

Спасибо за чтение, и я с нетерпением жду ваших мыслей по этому вопросу. Удачного кодирования!