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