ООП

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

Во время разработки 11 экранов, я столкнулся с проблемой повторяющегося кода. В частности, в каждой вьюмодели реализовывался один и тот же метод observe для StateFlow из Kotlin Multiplatform, а некоторые блоки кода и вовсе были одинаковыми. В этом контексте, абстракция, как первый принцип ООП, вступает в игру, позволяя нам объединить повторяющийся код в один общий модуль. Этот модуль может быть использован как основа для наследования в дальнейшем, что позволяет избежать дублирования кода и упростить его поддержку.

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

Абстракция в Kotlin и Swift

Абстракция представляет собой процесс скрытия деталей реализации и представления только функциональности. Это позволяет упростить взаимодействие с объектами и уменьшить сложность кода.

Начнем с базовых определений:

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

Пример с абстрактным классом:

abstract class AbstractClass {
    abstract fun abstractMethod()

    fun printThis() {
        println("Реализация")
    }
}


class ParentClass: AbstractClass() {  
    override fun abstractMethod() {
        // TODO("Not yet implemented")
        printThis() // <- реализация данного метода написана выше
    }
}

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

Есть несколько правил у abstract class в Kotlin:

  1. В Kotlin абстрактный класс - это класс, который предназначен исключительно для наследования и не может быть создан как экземпляр;

  2. Абстрактный класс может содержать как абстрактные методы (методы без реализации), так и конкретные методы (методы с реализацией);

  3. Абстрактный класс используется для предоставления общего интерфейса и реализации для его подклассов;

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

Пример с интерфейсом:

interface TestInterface {
    fun doIt()
}

class TestClass: TestInterface {
    override fun doIt() {
        print("Hello")
    }
}

Здесь мы определили интерфейс как контракт для класса, требующий иметь метод doIt(). Внутри TestClass мы обязаны написать реализацию для данного метода, так как класс реализует TestInterface

Пример использования абстрактного класса в Kotlin:

// Абстрактный класс
abstract class Person(private val name: String, private var age: Int) {

    // Абстрактный метод
    abstract fun birthDate(date: String)

    // Неабстрактный метод
    open fun personDetails() {
        println("Имя человека: $name")
        println("Возраст: $age")
    }
}

// Дочерний класс от абстрактного
class Employee(name: String, age: Int, private var salary: Double): Person(name, age) {

    // Реализация абстрактного метода birthDate()
    override fun birthDate(date: String) {
        println("Дата рождения: $date")
    }

    // Переопределение метода personDetails()
    override fun personDetails() {
        // Вызов personDetails с реализацией из абстрактного класса
        super.personDetails()
        println("Зарплата: $salary")
    }
}

Мы создаем абстрактный класс Person, и добавляем в конструктор константу name и переменную age. Name будет неизменным, а в будущем возраст сотрудника будет меняться и потому напишем var. Попутно их инкапсулируем для работы с ними только внутри абстрактного класса.

Реализация метода birthDate будет отличаться в зависимости от наследника, поэтому напишем ключевое слово abstract для того, чтобы требовать реализацию в дочерних классах. У абстрактного метода personDetails() уже есть тело, однако также в зависимости от наследника мы хотим получать дополнительные сведения. Так, например, мы можем сделать метод с ключевым словом open, добавить внутреннюю переменную salary в конструктор дочернего класса и вывести дополнительную строку с зарплатой после вывода основных данных о сотруднике.

Преимущества и недостатки абстрактных классов в Kotlin:

Преимущества:

  • Абстрактные классы позволяют определить общую функциональность, которая может быть использована в нескольких классах-наследниках;

  • Они могут содержать как абстрактные методы, которые должны быть реализованы в подклассах, так и обычные методы с реализацией, которые могут быть переопределены в подклассах. Однако в Kotlin, в отличие от Java, функции изначально по умолчанию представлены в виде final методов, так что если вы предусматриваете переопределение метода, то помечайте его ключевым словом 'open';

  • Абстрактные классы могут иметь состояние в виде полей и свойств, которые могут быть унаследованы и использованы в подклассах;

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

Недостатки:

  • Класс может наследоваться только от одного абстрактного класса, в отличие от интерфейсов, которых может быть реализовано в классе любое количество;

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

Разница между abstract class и interface:

  • Классы в Kotlin могут реализовывать множество интерфейсов, но наследоваться может только от одного абстрактного класса

  • Свойства в интерфейсе не могут сохранять состояние, в то время как в абстрактном классе могут.

Что использовать?

  • Если необходимо определить свод правил, которые должны будут соблюдать классы при реализации, то подойдут интерфейсы;

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

Swift Протоколы

В отличие от некоторых других языков программирования, таких как Kotlin или Java, Swift не имеет понятия абстрактного класса. Вместо этого Swift использует подход, известный как протокол-ориентированное программирование (POP).

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

Протоколы в Swift аналогичны интерфейсам в других языках программирования:

  • Они определяют набор методов и свойств, которые любой соответствующий тип должен реализовать;

  • Протоколы могут быть приняты классами, структурами и перечислениями, что делает их гибкими инструментами.

  • Протоколы могут быть расширены для предоставления реализаций по умолчанию, что делает методы или свойства "необязательными". Это означает, что типы, соответствующие протоколу, могут предоставить свои собственные реализации или использовать предоставленные по умолчанию (в extension, см. далее).

Например, вы можете определить протокол Drawable, который требует от типа реализации метода draw(). Любой класс, структура или перечисление, которые реализуют протокол Drawable, могут считаться "рисуемыми". Протоколы также могут наследоваться от других протоколов, что позволяет вам создавать более сложные протоколы из более простых.

Вот пример протокола и реализация его метода по умолчанию внутри расширения в Swift:

protocol Drawable {  
  func draw()
}
extension Drawable {  
  func draw() {  
    print("Default drawing")  
  }
}
class Circle: Drawable {  
  // Мы можем предоставить собственную реализацю метода или использовать
  // уже существующую в extension Drawable
    func draw() {  
      print("Drawing a circle")  
    }
}
class Square: Drawable {  
  // Этот класс будет использовать реализацию по умолчанию
  func test() {
    draw()
  }
}

В этом примере Circle предоставляет свою собственную реализацию метода draw(), в то время как Square использует реализацию по умолчанию. Оба типа считаются Drawable, несмотря на то, что они предоставляют разные реализации этого протокола.

Расширение интерфейсов и протоколов

В Kotlin можно расширять интерфейсы, аналогично Swift:

interface test {
    fun doIt()
}

fun test.make() {
    doIt()
}

class TestClass: test {
    override fun doIt() {
        print("Hello")
    }

    fun method() {
        make() // Получим "Hello"
    }
}

То есть мы первоначально объявляем интерфейс с методом doIt(), а затем реализуем метод make() на объекте-приемнике. Для примера данная функция будет просто вызывать метод doIt() из интерфейса.

Однако, следует добавить, что необходимо чтобы класс реализовывал интерфейс для вызова make(), а значит следует написать реализацию всех требуемых методов, в данном случае doIt().

С 8 версии Java появилась возможность написания реализации внутри интерфейса:

public interface MyInterface {
   // Объявление метода по умолчанию
   default void defaultMethod() {
       // Реализация метода по умолчанию
   }
}

В Kotlin же, ключевое слово default писать не нужно и все выглядит проще:

interface TestInterface {
    fun doIt() {
        println("Hello")
    }
}

class TestClass: TestInterface {
    fun makeIt() {
        doIt()
    } 
}

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

Давайте разберем как сделать реализацию по умолчанию в протоколах в Swift:

Создадим протокол TestProtocol с функцией doIt()

protocol TestProtocol {
  func doIt()
}

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

extension TestProtocol {
  func doIt() {
    // реализация по умолчанию
  }
}

В процессе написания данного кода вы заметите, что автокомплита для doIt() нет, и даже больше - только с Xcode 9 появился вывод ошибки в случае, если у класса не реализованы все методы из протокола (2017 год, времена WatchOS 4 и iOS 11).

Выходит так, что в Swift (в Xcode, см. далее) мы не столько реализуем метод из протокола, сколько "подставляем кальку и переводим рисунок с оригинального листа":

Отсутствует autocomplete в расширении протокола
Отсутствует autocomplete в расширении протокола

Однако, в AppCode от JetBrains, при написании того же кода все же присутствует autocomplete:

Скриншот из AppCode IDE
Скриншот из AppCode IDE

В расширении можно также спокойно написать новый метод с реализацией, без объявления в протоколе:

protocol TestProtocol {
  func doIt()
}

extension TestProtocol {
  func makeIt() {
    print("Реализация нового метода")
  }
}

При попытке написать собственную реализацию в дочернем классе, вопреки существующему методу по умолчанию, мы также обнаружим отсутствие автокомплита:

Здесь так же не будет никаких override ключевых слов и никаких автокомплитов
Здесь так же не будет никаких override ключевых слов и никаких автокомплитов

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

метод makeIt() использует реализацию внутри класса
метод makeIt() использует реализацию внутри класса

Так и только так мы заменим своей реализацией данную функцию, отбросив makeIt() из расширения протокола. В случае, если мы ошибемся на хотя бы один символ и не перепроверим, а та ли функция вызывается, то произойдет дублирование кода.

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

interface TestInterface {
    fun doIt() {
        println("Hello")
    }
}

class TestClass: TestInterface {
    override fun doIt() {
        super.doIt() // здесь вызовется объявленный ещё в интерфейсе метод
        // а сюда можно ввести свою реализацию:
        print("Bye!")
    }
}

То есть, введя doIt() и использовав autcomplete, мы не только избегаем различных вероятностей появления ошибок, но и и получаем вызов super.doIt() внутри нашего переопределения "бонусом".

Однако, таким же исключением будет и расширение интерфейсов в Kotlin:

Отсутствует autocomplete
Отсутствует autocomplete

Здесь мы написали расширение для интерфейса testInt с методом makeIt(). Если его подставить вместо TODO, то вызов будет успешным. Однако автокомплит для данного метода недоступен внутри TestClass, можно лишь создать новый метод с таким же названием и компилятор уже в дальнейшем определит реализацию makeIt() внутри TestClass.

И тут начинают возникать следующие вопросы касаемо Swift и Xcode:

  1. Если мы объявляем функцию с таким же названием внутри класса, то какая функция вызовется? Как компилятор понимает что нужно вызвать именно ту или иную реализацию функции?

  2. Где ключевое слово override перед func, как в классах?

protocol TestProtocol {
  func doIt()
}

extension TestProtocol {
  func doIt() {
    // Вызовется она?
  }
}

class ViewController: UIViewController, TestProtocol {
  func doIt() {
    // Или она?
  }
}

Пройдемся по новоиспеченным вопросам:

  1. Соответственно если doIt реализован внутри класса, то вызовется он, в противном случае будет вызываться функция из extension протокола. Компилятор проверяет реализацию сначала внутри класса, а потом идет в протокол.

    В случае работы с протоколами в Swift существует Witness Table или Таблица свидетелей. По сути она является структурой данных, которая отображает каждое требование метода или свойства в протоколе на его реализацию в соответствующем типе.

    То есть представим два класса и таблички к ним:

protocol FirstProtocol {
  func doIt()
}

protocol SecondProtocol {
  func makeIt()
}

class OurClass: FirstProtocol, SecondProtocol {
  func doIt() {
    // обязаны написать реализацию
  }
  func makeIt() {
    // реализация метода из второго протокола
  }
}

class SecondClass: FirstProtocol {
  func doIt() {
    //  и здесь обязаны написать реализацию
  }
}

OurClass:

FirstProtocol

0x1337

doIt()

0xx1234

SecondProtocol

0x2882

makeIt()

0xx5555

SecondClass:

FirstProtocol

0x1337

doIt()

0xx5678

Прошу обратить внимание на выделенные жирным шрифтом адреса методов у двух классов, при этом адрес у FirstProtocol одинаковый.

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

Выходит так, что:

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

В случае с примером ранее, когда мы написали свой метод makeIt() в классе, компилятор проверил сначала есть ли makeIt() внутри класса и, так как он его нашел, он не будет связывать его с протоколом и будет в бОльшей вероятности задействована статическая диспетчеризация, а не Таблица свидетелей.

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

Где override?

Переходим ко второму вопросу: где все же ключевое слово override перед func, как в классах?

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

Однако, в контексте расширения протоколов, когда мы предлагаем "реализацию по умолчанию" для определенного метода протокола в расширении (extension), переопределение этого метода в классе, который принимает протокол, происходит без использования ключевого слова override.

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

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

И вот тут мы плавно переходим к теме абстрактных классов, которую мы рассмотрели в самом начале на примере Kotlin.

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

Предположим у нас есть родительский класс, добавим также ему две любые функции:

class ParentClass {
  func eat() {
    print("Eating")
  }

  func drink() {
    print("Drinking")
  }

}

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

class ChildClassA: ParentClass {}

class ChildClassB: ParentClass {}

На данный момент, А и В и даже ParentClass с виду ничем не отличаются друг от друга, однако теперь мы можем добавлять новые независимые методы и свойства в child class:

class ChildClassA: ParentClass {
  func playGames() {}
}

class ChildClassB: ParentClass {
  func buyFood() {}
}

Помимо добавления новых свойств или методов, есть также возможность переопределения существующих, объявленых в родительском классе:

class ParentClass {
  func eat() {
    print("Eating")
  }
  
}

class ChildClassA: ParentClass {
    override func eat() {
        print("NOT EATING")
    }
}

А чтобы избежать случайного переопределения метода суперкласса, можно добавить перед функцией ключевое слово final:

class ParentClass {
  final func eat() {
    print("Eating")
  }
  
}

class ChildClassA: ParentClass {
    override func eat() {
        // Error
    }
}

final также можно поставить перед словом class у родительского ParentClass, полностью запретив наследование от него.

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

Резюмируем первую часть некоторыми ограничениями наследований в Swift:

  1. Ограничение на множественное наследование: В отличие от некоторых других языков программирования, таких как C++ или Python, Swift не поддерживает множественное наследование классов. Это означает, что класс в Swift может наследовать только от одного родительского класса:

class ParentClass {}
class SecondParentClass {}

class FirstSubclass: ParentClass, SecondParentClass {
  // error
}
  1. В Swift структуры и перечисления не могут наследоваться от классов или друг друга. Это ограничение языка, и оно должно обеспечивать безопасность типов и простоту наследования:

class ParentClass {}

struct SubStruct: ParentClass {
  // error
}
  1. Ограничение на наследование от протоколов: В Swift классы, структуры и перечисления могут соответствовать нескольким протоколам, но они не могут наследовать реализации методов от этих протоколов. Вместо этого, каждый метод, определенный в протоколе, должен быть реализован явно или иметь реализацию по умолчанию, предоставленную в расширении протокола

  2. Ограничение на переопределение методов: В Swift методы, определенные в суперклассе, могут быть переопределены в подклассе с использованием ключевого слова override. Однако, подкласс не может переопределить методы, которые были определены в расширениии протоколов.

protocol TestProtocol {
  func method()
}

extension TestProtocol {
  func method() {
    print("Printing it")
  }
}

class ParentClass: TestProtocol {
    
}

class SubClass: ParentClass {
  override func method() {
    // Error: Method does not override any method from its superclass
  }
}

Конечно, можно написать method() в ParentClass, но это уже будет явной реализацией.

Итог

Я надеюсь эта часть помогла прояснить ситуацию с теоретической частью наследований, переопределением методов и работой с протоколами в Swift и интерфейсами в Kotlin.

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

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


  1. Подробнее о Табличной диспетчеризации и Таблице свидетелей

  2. Статья про разницу по скорости между Static Dispatch и Dynamic Dispatch

  3. Подробнее об интерфейсах в Kotlin и с какими нюансами можно столкнуться


На момент написания статьи были следующие версии ПО:

  • macOS Sonoma 14.1.1 (23B81)

  • Xcode Version 15.1 (15C65) (а также другие версии ранее, начиная с 15.0 RC)

  • Android Studio Hedgehog | 2023.1.1

  • Android Studio Giraffe | 2022.3.1 Patch 2

  • AppCode 2023.1.4

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


  1. lair
    24.12.2023 18:49

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

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


  1. Gorthauer87
    24.12.2023 18:49

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

    extension SomeType: SomeProtocol {
        // implementation of protocol requirements goes here
    }

    А штуки типа

    extension TestProtocol {
      func method() {
        print("Printing it")
      }
    }

    на мой взгляд, довольно странные.


  1. house2008
    24.12.2023 18:49

    На дворе 2024, а статья про наследование) Даже сами эплы swiftui слелали на структурах, а за 10 лет развития swift так и не добавили возможность делать абстрактные классы, думаю о чем то это говорит)

    За статью спасибо, освежил память.


  1. Dmitriy_III
    24.12.2023 18:49

    Спасибо, было интересно!