Туториалы
18 ноября 2021
Туториалы
18 ноября 2021
Интерфейсы и абстрактные классы в Kotlin

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

До объектно-ориентированного программирования (ООП) кодирование было более сложным, потому что такие языки, как C, не позволяли абстрагироваться от концепций на высоком уровне. Абстракция помогает структурировать проект, группируя общие характеристики вместе для создания объекта. Например, цвет меха, длина хвоста и возраст - вот характеристики, которые описывают кошку.

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

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

  • О наследовании.
  • Как реализовать абстрактные классы и интерфейсы.
  • Различия между абстрактными классами и интерфейсами.
  • Как создать приложение, которое использует абстрактные классы, интерфейсы и наследование.

 

Приступим

Загрузите материалы, нажав Download Materials. Откройте Android Studio и выберите Open. Затем выберите стартовый проект (starter project).
Теперь запустим проект и проверим его поведение:

Выберите из списка Lion и вы увидите:

В приложении два экрана:

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

Посмотрим на структуру проекта:

Как видите, в проект входят шесть животных: летучая мышь (Bat), слон (Elephant), жираф (Giraffe), лев (Lion), попугай (Parrot) и пингвин (Penguin). Также есть две основные категории: птицы (Bird) и млекопитающие (Mammal). Наконец, есть класс Animal.

Прежде чем приступить к кодированию, нужно узнать о наследовании.

 

Наследование

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

Заметка

В Kotlin класс и его члены по умолчанию являются final. Поэтому, если вы хотите расширить их, вам нужно добавить ключевое слово open.

Взгляните на классы Animal, Mammal и Lion, чтобы увидеть, как работает наследование:

// 1
open class Animal(
    open val name: String,
    @DrawableRes open val image: Int,
    open val food: Food,
    open val habitat: Habitat
) {
  
  open fun getHungerAmount() : Int {
    return 0
  }
  
  //...
}

// 2
open class Mammal(
    override val name: String,
    @DrawableRes override val image: Int,
    override val food: Food,
    override val habitat: Habitat,
    val furColor: List
) : Animal(name, image, food, habitat)

// 3
class Lion : Mammal(
    "Lion",
    R.drawable.animal_elephant_sample,
    Food.ZEBRAS,
    Habitat.SAVANNA,
    listOf(Color.parseColor("#CB9C70"))
) {
  
  override fun getHungerAmount() : Int {
    return (0..100).random()
  }
}

Итак, что здесь происходит?

  1. Класс Animal с префиксом open определяет name (название), image (изображение), food (предпочитаемую пищу) и habitat (среду обитания) животного. Функция getHungerAmount() определяет, насколько животное голодно.
  2. Mammal, класс с префиксом open, расширяется от Animal и определяет еще одно свойство, furColor, одновременно реализуя все предыдущие свойства из Animal.
  3. Lion расширяет Mammal и снова наследует все свойства Mammal и реализует getHungerAmount().

Это принцип наследования. Это концепция ООП и основной способ установления отношений между классами. Этот подход связан с тремя основными проблемами:

  1. Поскольку Animal и Mammal open, вы можете создавать их экземпляры, что не имеет никакого смысла, поскольку они являются всего лишь абстрактными концепциями, а не конкретными, как Lion. Это нарушение чрезвычайно опасно.
  2. Вам нужны субклассы для реализации методов и свойств родительского класса. В этом случае вы можете использовать конструктор, чтобы заставить субклассы реализовать свойство из родительского класса, но вы не можете сделать то же самое с методами.
    Переопределение getHungerAmount() в Lion было необязательным: вам не нужно было его реализовывать. Если бы вы этого не сделали, компилятор не выдал бы вам никаких ошибок.
  3. Есть определенные характеристики, которые есть у некоторых млекопитающих, а у других - нет. Классы не могут поддерживать такое поведение.

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

 

Абстрактные классы

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

Вы можете думать об абстрактном классе как о шаблоне. Подумайте о каталоге шаблонов WordPress, где вы можете выбрать конкретный шаблон со всеми функциями, необходимыми для создания веб-сайта.

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

Что ж, это было намного проще, чем создать сайт с нуля.

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

Характеристики абстрактных классов

Абстрактные классы не могут быть final, потому что основная причина их существования заключается в том, что другие классы расширяют их. Однако их методы и свойства могут быть final. Фактически, они по умолчанию являются final.

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

Каждый раз, когда класс расширяет абстрактный класс абстрактными членами, класс должен реализовывать все абстрактные члены. Это важно, когда подкласс должен реализовать метод для создания определенного поведения.

Взгляните на этот пример:

// 1
abstract class Wrestler {
    abstract fun themeMusic(): String
}

// 2
class JohnCena : Wrestler() {
    override fun themeMusic() = "johncena_theme.mp3"
}

В этом примере у каждого борца есть своя themeMusic (музыкальная тема), которую вы должны определять каждый раз, когда создаете нового борца. При расширении Wrestler компилятор заставит класс JohnCena реализовать themeMusic(), чтобы вы не забыли об этом.

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

Заметка

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

Теперь, когда вы увидели, как работают абстрактные классы, пора обновить приведенный выше код.

 

Реализация абстрактного класса Animal

Откройте файл Animal.kt в пакете model/animals. Сделайте класс abstract (абстрактным):

 abstract class Animal

Удалите существующий метод getHungerAmount() и переместите свойства из конструктора в тело класса как абстрактные свойства.

 abstract fun getHungerAmount() : Int

  abstract val name: String

  @get:DrawableRes
  abstract val image: Int

  abstract val food: Food

  abstract val habitat: Habitat

Сделайте то же самое для класса Mammal и на этот раз добавьте furColor в качестве неизменяемого абстрактного свойства:

abstract class Mammal : Animal() {

  abstract val furColor: List
}

Вместо этого для класса Bird добавьте абстрактное свойство feathersColor:

abstract class Bird: Animal() {

  abstract val feathersColor: List
}

Для класса Bat так будет выглядеть код после обновления:

class Bat : Mammal() {

  override val furColor: List
    get() = listOf(
        Color.parseColor("#463D4A")
    )

  override fun getHungerAmount() : Int {
    return (0..100).random()
  }

  override val name: String
    get() = "Bat"

  override val image: Int
    get() = R.drawable.bat

  override val food: Food
    get() = Food.INSECTS

  override val habitat: Habitat
    get() = Habitat.CAVE
}

Следуйте той же инструкции для Elephant, Giraffe, Lion, Mammal и Penguin. Не стесняйтесь ссылаться на final project в материалах, которые вы загрузили в начале туториала.

В обновлениях выше вы:

1. Установили Animal и Mammal как abstract. Вы больше не можете создавать экземпляры этих классов, поэтому вы решили первую проблему.

2. Переместили переменные из конструктора в тело класса. Как видите, теперь код более понятен, но есть и другие преимущества использования этого нового подхода:

  • Меньше шаблонов. Mammal не реализует все члены, которые определяет Animal, потому что они оба являются абстрактными классами. Абстрактный класс не требует реализации абстрактного члена.
  • Lion реализует и инициализирует все члены, реализованные как в Animal, так и в Mammal.

3. Определили getHungerAmount() как abstract. Теперь компилятор заставляет вас реализовать метод внутри Lion. Попробуйте закомментировать это, и компилятор будет кричать на вас. А пока вы решили и вторую проблему.

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

Интерфейсы

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

Характеристики интерфейсов

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

Поскольку Kotlin не поддерживает множественное наследование, компилятор не позволяет субклассу иметь несколько родительских классов. Он может расширять только один класс. Но есть и хорошие новости: вы можете реализовать столько интерфейсов, сколько захотите.

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

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

Пример использования интерфейсов

Представьте, что у вас есть два класса, Microwave (микроволновая печь) и WashingMachine (стиральная машина), у которых есть только одна общая черта: им для работы требуется электричество. Что бы вы сделали, чтобы их соединить? Взгляните на приведенный ниже код, чтобы увидеть, как это можно сделать:

// 1
interface Pluggable {
  
    val neededWattToWork: Int
  
    //Measured in Watt
    fun electricityConsumed(wattLimit: Int) : Int

    fun turnOff()
    
    fun turnOn()
}

// 2
class Microwave : Pluggable {
    
    override val neededWattToWork = 15

    override fun electricityConsumed(wattLimit: Int): Int {
        return if (neededWattToWork > wattLimit) {
            turnOff()
            0
        } else {
            turnOn()
            neededWattToWork
        }
    }

    override fun turnOff() {
        println("Turning off..")
    }

    override fun turnOn() {
        println("Turning on..")
    }
}

// 3
class WashingMachine : Pluggable {

    override val neededWattToWork = 60

    override fun electricityConsumed(wattLimit: Int): Int {
        return if (neededWattToWork > wattLimit) {
            turnOff()
            0
        } else {
            turnOn()
            neededWattToWork
        }
    }

    override fun turnOff() {
        println("Turning off..")
    }

    override fun turnOn() {
        println("Turning on..")
    }
}

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

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

String password = "Password1234";

Компилятор добавляет под капот public static final. В Java вам нужно инициализировать свойства, чтобы вы не могли делать что-то вроде этого:

String password; //This code won't run

Kotlin, с другой стороны, позволяет вам определять нестатические свойства внутри интерфейсов, которые могут быть только val. Это становится полезным, когда вы обрабатываете только одно значение, а метод слишком большой, как в свойстве neededWattToWork выше. Было бы неудобно определять метод только для возврата значения, хотя, как показывает декомпилированный код Java, компилятор в любом случае создает метод для свойства.

public interface Pluggable {
   int getNeededWattToWork();

   int electricityConsumed(int var1);

   void turnOff();

   void turnOn();
}

Однако Kotlin не позволяет создавать свойства var, потому что свойство var включает три компонента:

  • Поле private для хранения его значения.
  • getter, принимающий его значение.
  • setter для установки его значения.

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

 

Дефолтные методы интерфейса

Дефолтные методы - отличный способ бороться с шаблонностью. В Kotlin метод может иметь тело, а свойство может содержать внутри себя значение. Имеет ли смысл иметь методы с телом внутри интерфейса?

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

Рассматривая приведенный выше пример с бытовой техникой, было бы здорово узнать, какое напряжение необходимо каждому потребителю. Для этого вы можете добавить новый метод voltage() в Pluggable следующим образом:

fun voltage() : Int {
        return 230
    }

Затем переопределите его в Microwave:

 // There is no need to implement this method
    override fun voltage(): Int {
        return super.voltage()
    }

А также в WashingMachine:

override fun voltage(): Int {
        return 300
    }

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

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

Пришло время реализовать интерфейсы в Zoo Guide.

 

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

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

В этом проекте вы реализуете три основных способности животных: ходьбу, полет и плавание. Эти возможности представлены следующими интерфейсами: Walkable, Flyable и Swimmable.

Хотя это может показаться простым, категории животных сложны. Например, не все млекопитающие могут ходить и не все птицы умеют летать. Поэтому, Mammal не может определить член runningSpeed(), если не все млекопитающие могут ходить или бегать. Вы должны реализовать эти конкретные варианты поведения отдельно.

В корне проекта создайте пакет и назовите его interfaces. В этом пакете создайте следующие интерфейсы в соответствующих файлах:

Walkable.kt

interface Walkable {
  val runningSpeed: Int
}

Walkable.kt

interface Flyable {
  val flyingSpeed: Int
}

Walkable.kt

interface Swimmable {
  val swimmingSpeed: Int
}

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

Наконец, у каждого животного должен быть правильный интерфейс. Пусть Bat и Parrot реализуют Flyable. Затем установите их flyingSpeed (скорость полета) на 95 и 30 соответственно. Например, обновите Bat с помощью:

class Bat : Mammal(), Flyable

и…

override val flyingSpeed: Int
    get() = 95

Пусть Elephant, Giraffe и Lion реализуют Walkable. Затем установите их runningSpeed (скорости) на 40, 60 и 80 соответственно. Например, обновите Elephant с помощью:

class Elephant : Mammal(), Walkable 
  override val runningSpeed: Int
    get() = 40

Наконец, пусть Penguin реализует как Walkable, так и Swimmable. Затем установите runningSpeed (скорость бега) и swimmingSpeed (скорость плавания) на 7 и 40 соответственно. Вот необходимые изменения:

class Penguin : Bird(), Walkable, Swimmable 

и…

  override val runningSpeed: Int
    get() = 7

  override val swimmingSpeed: Int
    get() = 40

Разбираем код:

1. Bat, Parrot, Elephant, Giraffe и Lion реализуют единый интерфейс и переопределяют единый метод.
2. У Penguin два навыка. Он может плавать и бегать, даже несмотря на то, что он довольно медленный. Вот почему так важно иметь возможность реализовать несколько интерфейсов.

 

Практическая сторона интерфейсов

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

Как вы могли заметить, в Zoo Guide все еще отсутствует важная функция - фильтр.

Spinner позволяет вам выбирать между тремя различными вариантами поведения, определенными выше: Walkable, Flyable  и Swimmable, а также опцией All, которая включает всех животных в списке. Когда вы выбираете один из этих вариантов, список обновляется.

Перейдите в пакет ui/fragments. Затем откройте ListFragment.kt. Внутри onItemSelected() замените TODO на:

val updatedList: List = when(position) {
  0 -> AnimalStore.getAnimalList()
  1 -> AnimalStore.getAnimalList().filter { it is Flyable }
  2 -> AnimalStore.getAnimalList().filter { it is Walkable }
  3 -> AnimalStore.getAnimalList().filter { it is Swimmable }
  else -> throw Exception("Unknown animal")
}
viewModel.updateItem(updatedList)

Разбираем код:

1. Каждый раз, когда пользователь щелкает элемент внутри Spinner, вызывается onItemSelected().
2. Оператор when проверяет позицию и фильтрует список на основе элемента, который выбирает пользователь.
3. ListViewModel определяет updateItem(). Вам нужно вызывать его каждый раз, когда вы обновляете список, чтобы уведомить адаптер о том, что что-то изменилось.

filter проверит каждый объект Animal в списке и выберет только тот, который соответствует поведению, выбранному пользователем.

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

 

Сохранение состояния внутри интерфейса

Если вы искали интерфейсы в Интернете, вы, вероятно, встречали некоторые блоги, в которых говорится, что интерфейсы не могут содержать состояния. Так было до тех пор, пока в Java 8 не появились методы с телом внутри интерфейсов. Но в каком состоянии?

Состояние - это то, что есть у объекта. Например, свойство name внутри Bird определяет состояние объекта Bird. Если объект не имеет свойств экземпляра или имеет некоторые свойства с неизвестными значениями, которые не меняются, он не имеет состояния.

Где можно хранить состояние? Вы сохраняете его в свойстве companion объекта.

Рассмотрим код ниже:

// 1
interface Animal {
    var name: String
        get() = names[this]?.toUpperCase() ?: "Not in the list"
        set(value) { names[this] = value }

    var speed: Int
        get() = speeds[this] ?: -1
        set(value) { speeds[this] = value }

    val details: String
        get() = "The $name runs at $speed km/h"

    companion object {
        private val names = mutableMapOf()
        private val speeds = mutableMapOf()
    }
}

// 2
class Lion: Animal

// 3
class Penguin: Animal

// 4
fun main() {
    val lion = Lion()
    lion.name = "Lion"
    lion.speed = 80

    val penguin = Penguin()
    penguin.name = "Penguin"
    penguin.speed = 7

    println(lion.details) // The LION runs at 80 km/h
    println(penguin.details) // The PENGUIN runs at 7 km/h
}

Подобно Zoo Guide, этот код сохранит список животных.

Вот что делает код:

1. У каждого животного есть три свойства: name, speed и details. Первые два изменяемы, а последний неизменен.
2. И name, и speed могут получать и сохранять значение с помощью MutableMap. Обратите внимание, что key Map - это ссылка на сам интерфейс Animal, который изменяется каждый раз, когда класс его реализует.
3. Интерфейс Animal имеет два приватных статических mutableMap, которые хранят состояние.
4. И Lion, и Penguin реализуют Animal. Им не нужно определять какие-либо члены Animal, поскольку все они были инициализированы.

Измените видимость names с private на public и пройдите по списку:

// 1
interface Animal {
    
    companion object {
        val names = mutableMapOf()
    }
}

// 2
Animal.names.forEach { name ->
    println(name.value) 
} 
    
//Output:
//Lion
//Penguin

Как видите, Animal.names содержит все значения.

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

 

Абстрактные классы против интерфейсов

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

Для абстрактных классов и интерфейсов:

  • Невозможно создать экземпляр.
  • Определяют как абстрактные, так и обычные члены.
  • Определяют static члены.
  • Субклассы для реализации поведения.

Однако у интерфейсов нет конструктора, и вы можете реализовать их несколько раз.

С точки зрения функций и характеристик эти два инструмента очень похожи. То, что отличает их друг от друга, - это концептуальное различие.

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

Например, в классе Animal food (еда) слишком общая, чтобы ее можно было определять непосредственно в родительском классе. Но любое животное, расширяющее Animal, должно реализовать его и возвратить еду, которую ест животное.

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

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

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

Оригинал статьи


Оцените статью
0
0
0
0
0

Чтобы добавить комментарий, авторизуйтесь
Войти
Акулов Иван Борисович
Пишет и переводит статьи для SwiftBook