Search
Write a publication
Pull to refresh

Comments 7

class CountingInsert : BaseInsert() {

    var count: Int = 0
        private set

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

}

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

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

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

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

С одной стороны Вы правы. С другой - это всё просто, когда есть знания о реализации базового класса. В данном случае то - что метод 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 как открытая для наследования. Но тут я просто намекаю, что во многих случаях именно такая архитектура предпочтительна для разработки - т.е. изначально более абстрактная, дающая гибкость в дальнейшем полиморфном использовании. Парадигма полиморфизма - это вообще большая сила. А вместе с парадигмой внедрения зависимостей мощь архитектуры возрастает многократно

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

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

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

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

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

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

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

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

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

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

Sign up to leave a comment.