Эта статья расскажет о рисках, связанных с наследованием классов. Здесь будет показана альтернатива наследованию классов – композиция. После прочтения вы поймете, почему Kotlin по умолчанию делает все классы конечными. Статья объяснит, почему не следует делать класс Kotlin open (открытый), если на то нет веских причин.

Предположим, что у нас есть следующий интерфейс:

interface Insertable<T> {

    fun insert(item: T)

    fun insertAll(vararg items: T)

    val items: List<T>
}

Интерфейс Insertable (вставляемый)

Также, BaseInsert является имплементацией интерфейса Insertable<Number>. BaseInsert open (открыт). Поэтому мы можем его расширять.

Пусть CountingInsert будет расширением BaseInsert. Каждый раз, когда код вставляет Number, он должен увеличивать переменную count на единицу. Итак, получаем:

class CountingInsert : BaseInsert() {

    var count: Int = 0
        private set

    override fun insert(item: Number) {
        super.insert(item)
        count++
    }

    override fun insertAll(vararg items: Number) {
        super.insertAll(*items)
        count += items.size
    }
}

Алгоритм подсчета, реализованный через наследование

Данная имплементация должна работать. Строка 8 увеличивает count на единицу; строка 13 - на количество аргументов переменной.

Код работает не так, как ожидалось. См. строку 7 ниже.

fun main(args: Array<String>) {

    CountingInsert().apply {
        insert(1)
        insert(2)
        insertAll(3, 4)
        println(count) // prints 6, the incorrect value; should be 4
        println(items) // prints [1 2 3 4]
    }
}

CountingInsert() выдает неверный результат

Ошибка в строке 10 ниже:

open class BaseInsert : Insertable<Number> {
    
    private val numberList = mutableListOf<Number>()

    override fun insert(item: Number) {
        numberList.add(item)
    }

    override fun insertAll(vararg items: Number) {
        items.forEach { number -> insert(number) }
    }

    override val items: List<Number>
        get() = numberList
}

Имплементация BaseInsert

BaseInsert.insertAll является функцией удобства. Функция insertAll вызывает insert для каждого элемента в списке vararg. Класс CountingInsert повторно подсчитал вставку чисел 3 и 4. CountingInsert.insertAll дважды выполнил оператор count++; один раз - оператор count += items.size. Функция CountingInsert.insertAll увеличила count на четыре вместо двух.

Существуют ли альтернативы? Да. Мы можем изменить код или использовать композицию.

Изменение кода кажется очевидным решением. Предположим, нам разрешено изменить базовый класс. Можно изменить реализацию BaseInsert.insertAll на:

override fun insertAll(vararg items: Number) {
    numberList += items.toList()
}

Обновленная имплементация BaseInsert.insertAll

Такая имплементация позволяет избежать вызова BaseInsert.insert(), источника наших проблем.

Предположим, что у нас нет доступа к классу BaseInsert. Тогда можно удалить переопределение insertAll():

class CountingInsert : BaseInsert() {

    var count: Int = 0
        private set

    override fun insert(item: Number) {
        super.insert(item)
        count++
    }
}

Класс CountingInsert без переопределения insertAll

Решение проблемы путем изменения кода достаточно уязвимо. Класс CountingInsert зависит от тонкостей имплементации BaseInsert. Есть ли более эффективный способ? Да, давайте воспользуемся композицией.

Здесь показана имплементация с помощью композиции:

class CompositionInsert(private val insertable: Insertable<Number> = BaseInsert())
    : Insertable<Number> by insertable {

    var count: Int = 0
        private set

    override fun insert(item: Number) {
        insertable.insert(item)
        count++
    }

    override fun insertAll(vararg items: Number) {
        insertable.insertAll(*items)
        count += items.size
    }
}

Имплементация с помощью композиции

Предположим, что класс BaseInsert использует имплементацию с рис. 4. После тестирования класса InsertDelegation результат будет правильным. См. строку 15:

fun main(args: Array<String>) {

    CountingInsert().apply {
        insert(1)
        insert(2)
        insertAll(3, 4)
        println(count) // prints 6, the incorrect value; should be 4
        println(items) // prints [1 2 3 4]
    }

    CompositionInsert().apply {
        insert(1)
        insert(2)
        insertAll(3, 4)
        println(count) // prints 4, which is correct
        println(items) // prints [1 2 3 4]
    }
}

Результаты тестирования для CompositionInsert

Сравнивая фрагменты кода 2 и 7, можно сказать, что имплементации insert и insertAll похожи. См ниже:

// By inheritance
override fun insert(item: Number) {
    super.insert(item)
    count++
}
override fun insertAll(vararg items: Number) {
    super.insertAll(*items)
    count += items.size
}
// By delegation
override fun insert(item: Number) {
    insertable.insert(item)
    count++
}
override fun insertAll(vararg items: Number) {
    insertable.insertAll(*items)
    count += items.size
}

Сравнение наследования и композиции

Сравниваемые методы одинаковы за одним исключением. В наследовании используется super, а в композиции – insertable. Сравните строки 3 и 12; а также 7 и 16 на рис. 8. Шаблон делегирования передает выполнение задачи по вставке на insertable. Класс CompositionInsert увеличивает переменную count. Наследование, напротив, нарушает инкапсуляцию класса BaseInsert.

Какова первопричина проблемы? Предположим, что BaseInsert не open (открыт). См. строку 1 во фрагменте кода 4. Если бы BaseInsert был final (конечный), то компилятор Kotlin отметил бы код на фрагменте 2 и 5 как ошибку. Только решение на фрагменте 7 оказалось бы работоспособным. Когда мы делаем класс BaseInsert final, инкапсуляция BaseInsert не нарушается.

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


Приглашаем на открытое занятие «Основы бизнес-логики и разработка библиотеки для шаблона CoR». На этом открытом уроке:
– поговорим про общие принципы построения бизнес-логики приложения,
– рассмотрим фреймворки для разработки бизнес-логики,
– узнаем про такие шаблоны проектирования, как фасад и цепочка обязанностей,
– разработаем библиотеку для шаблона "Цепочка обязанностей" с использованием DSL и coroutines.

Регистрация на урок открыта на странице онлайн-курса "Kotlin Backend Developer. Professional".

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


  1. nin-jin
    30.01.2023 14:57
    +5

    class CountingInsert : BaseInsert() {
    
        var count: Int = 0
            private set
    
        override fun insert(item: Number) {
            super.insert(item)
            count++
        }
    
    }

    Починил вашу высосанную из пальца проблему путём удаления бессмысленного кода.


    1. lymes
      30.01.2023 17:25

      Последнее время развелось среди джунов и мидлов много фанатиков SOLID, причем каждый трактует его по своему, но в одном их мысли сходятся - interface oriented programming (в swift - protocol oriented). В принципе, подход изящный и имеет свои преимущества, но когда фанатизм возведен в степень идиотизма, рождаются вот такие нелепые и никому не нужные абстракции и конструкции, которые в конце концов, если не тормозить полет мысли solid-манов, приведут любой проект к плачевному концу.


      1. Sigest
        31.01.2023 11:55

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

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


    1. LordDarklight
      30.01.2023 18:02
      +1

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

      open class BaseInsert : Insertable<Number> {
          
          private val numberList = mutableListOf<Number>()
      
          private fun beginInserting() {
              //какой-то вспомогательный код
          }
          private fun endInserting() {
              //какой-то вспомогательный код
          }  
        
          private fun insertImpl(item: Number) {
              numberList.add(item)
          }
          
          override fun insert(item: Number) {
            beginInserting();  
            insertImpl(item);
            endInserting();
          }
      
          override fun insertAll(vararg items: Number) {
            beginInserting();  
            items.forEach { number -> insertImpl(number) }
            endInserting();
          }
      
          override val items: List<Number>
              get() = numberList
      }

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

      Но, я всё же встану на Вашу сторону. Ибо я полный адепт наследования (по крайнем мере в чистом виде, без специального синтаксического сахара). Так как тут у автора пример очень вырожденный. А так на практике тут как минимум две проблемы (в пользу наследования):

      1. Базовый класс может быть куда больше, и содержать десятки и сотни членов - и внешний код может хотеть их все использовать - т.е. без наследования их все придётся заново дублировать в классе-обёртке. А если потом базовый класс начнёт меняться - всё тут же негативно повлияет на надстроенный код - вплоть до того, что он перестанет компилироваться.

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

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

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

      Но в конце, я всё-таки хочу заметить, что как раз у Kotlin есть отличное встроенное решение для создания обёрток объектов над интерфейсами - «Delegation» -  и автор как раз приводи  такой пример – просто его статья ну очень плохо написана – лично мне очень сложно было сводить в ней «концы с концами» - а суть тут как раз в этом коде

      class CompositionInsert(private val insertable: Insertable<Number> = BaseInsert())
          : Insertable<Number> by insertable {
      
          var count: Int = 0
              private set
      
          override fun insert(item: Number) {
              insertable.insert(item)
              count++
          }
      
          override fun insertAll(vararg items: Number) {
              insertable.insertAll(*items)
              count += items.size
          }
      }
      

      Вот эта "магическая" строчка «by insertable» - решает первую проблему – переносит в класс обёртку все члены базового интерфейса через проксирование на указанный член «private val insertable».

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

      Вот бы Kotlin хотя бы такой синтаксический саха в помощь – уже лучше было бы:

      undefine class BaseInsert<T default Number> : api Insertable<T> {
          
          private val numberList = mutableListOf<T>()
      
          api fun insert(item: T) {
              numberList.add(item)
          }
      
          api fun insertAll(vararg items: T) {
              items.forEach { number -> insert(T) }
          }
      
          api val items: List<Number>
              get() = numberList
      }

      Здесь ключевое слово «api»– определяет, как Новый интерфейс (ранее он не определяется) «Insertable», так и его члены как «api fun insert(item: Number»  (без api – они не войдут в интерфейс), кстати они могут быть и приватными (в интерфейсе они будут всегда публичными – просто тогда реализация будет скрытой по дефолту).

      Ключевое слово «default» в дженерике «<T default Number>»  объявит не только дженерик тип BaseInsert<T> , но и тип class BaseInsert : BaseInsert<Number> как реализацию по умолчанию с конктетным типом.

      Ключевое слово «undefine» запретит класс использовать напрямую как типообразующий (т.е. вот так уже не написать «val b : BaseInsert» - а если написать вот так «val b = BaseInsert()» - то типом переменной/свойства «b» будет интерфейс «Insertable», с объектом «BaseInsert» внутри.

      Конечно, может синтаксис получился не очень удачным и может вот так было бы лучше

      @Undefine class BaseInsert<T default Number>  {
          
          private val numberList = mutableListOf<T>()
      
          @Api fun insert(item: T) {
              numberList.add(item)
          }
      
          @Api fun insertAll(vararg items: T) {
              items.forEach { number -> insert(T) }
          }
      
          api val items: List<Number>
              get() = numberList
      }
      interface Insertable<T> by BaseInsert<T>; 
      

      То есть от обратного – интерфейс «Insertable» определяется по классу (по его членам, аннотироанным через «@Api»), в остальном всё тоже самое (т.е. напрямую объъявлять переменные/свойства класса BaseInsert нельзяЮ только через его интерфейсы). Но всё-равно - это надо изначально так писать - что в часто опять-таки делать не будут!

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

      Кстати, под конец хочу рассмотреть ещё один пример с наследованием:

      abstract class BaseInsert<L,T> : Insertable<T> where L : MutableCollection<T> {
          
          private val numberList : L = createList()
      
          protected abstract fun createList() : L;
          protected fun insertImpl(item: T) {
              (numberList as MutableCollection).add(item)
          }
          
          override fun insert(item: T) {
            insertImpl(item);
          }
      
          override fun insertAll(vararg items: T) {
            items.forEach { number -> insertImpl(number) }
          }
      
          override val items: MutableCollection<T>
              get() = numberList
      }
      
      open class DerivedInsert : BaseInsert<List<Number>>
        {
          override fun createList() = MutableList();
          protected fun processItem(item : Number) { //какая-то обработка}
          override insertImpl(item : Number)
          {
            numberList.Add(processItem(item));
          }
        }

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


      1. nin-jin
        30.01.2023 18:34
        +1

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


        1. LordDarklight
          31.01.2023 09:44
          -1

          Мы тут наследуемся от конкретного класса с конкретным поведением

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

          Мы не можем не знать особенностей его реализации

          (У вас тут, возможно, опечатка - лишняя частица "не" - меняющая смысл выражения - вероятно имели в виду это "Мы можем не знать особенностей его реализации" аналогично "Мы не можем знать особенностей его реализации" - далее я имею в виду именно такой смысл)

          Об этом я тоже как раз пишу. Но это не идёт в плюс стратегии закрытых/запечатанных классов. Единственная изложенная в статье проблема - не более чем проблема архитектуры исходного базового класса. Ну и недостаток его документирования. Так же такого рода проблемы можно списать на недостаток тестирования хотя бы переопределённого класса (хотя, тут всё не так просто - т.к. внутренняя архитектура таких слабо документированных классов без исходников может быть очень сложной, и предварительное тестирование может и не выявлять всех проблем) - но в очередной раз подчеркну - считаю это скорее проблемой построения и описания исходного класса.

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

          Так же при росте уровня абстракций должно быть максимальное отдаление от конкретных реализаций в архитектуре строимых приложений - условно в сторону повсеместного применения контрактов (интерфейсов), и динамических (полностью не определённых на этапе конкретного применения в исходном коде) структур данных (условно я бы назвал такие структуры динамическими классами из спецификации C# и .NET но это просто некая приближённая аналогия).

          Но вся эта динамика должна максимально конкретизироваться к моменту финальной компиляции (JIT или AOT) для максимизации оптимизации (хотя даже после JIT и AOT компиляций я не стал бы сбрасывать и возможности дальнейшего эмитирования доп кода для оптимизации работы с оставшимися абстракциями, конкретизация которых динамически может меняться во время рантайм исполнения). Таково моё виденье будущего программирования


  1. AYamangulov
    30.01.2023 20:18
    +1

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