Результаты обучения

  • Что такое композит?
  • Применимость композита.
  • Случаи использования композита.
  • Подходы к композиции объектов.
  • За и против.

Проблема

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

Решение

Composite позволяет объединять объекты в структурированное дерево для представления части или всей иерархии, а затем работать с этими структурами, как если бы они были отдельными объектами. Например:

Даже клиенты должны иметь возможность единообразно обрабатывать отдельные объекты и композиции объектов через общий единый интерфейс. Например:

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

  1. Листовой объект → Не расширяет композицию.
  2. Объект композиции → Расширяет композицию.

Я покажу вам, как использовать шаблон проектирования Composite, применяемый в нашем приложении. Например, я создал приложение с механизмом кэширования. Цель:

Подходы к композиции объектов

  1. Состав с бетонными видами
/**
 * Domain Module
 * */
interface PondFeedLoader {
    fun load(callback: (List<String>) -> Unit)
}

/**
 * Http Module
 * */
class LoadPondFeedRemoteUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        // Simulate insert to local
        callback(
            listOf(
                "Pond A from remote data",
                "Pond B from remote data"
            )
        )
    }
}
/**
 * Cache Module
 * */
class LoadPondFeedLocalUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        callback(
            listOf(
                "Pond A from local data",
                "Pond B from local data"
            )
        )
    }
}
/**
 * Presentation Module
 * */
class PondFeedViewModel(
    private val pondFeedLoader: PondFeedLoader
) : ViewModel() {
    fun loadPondFeed() {
        pondFeedLoader.load { pondFeed ->
            println(pondFeed)
        }
    }
}
/**
 * Factory Method
 * */
fun createRemoteWithLocalCompositeFactory(
    remote: LoadPondFeedRemoteUseCase,
    local: LoadPondFeedLocalUseCase,
): PondFeedLoader {
    return RemoteWithLocalComposite(
        remote = remote,
        local = local
    )
}
/**
 * Composite
 * */
fun isSuccess(): Boolean {
    return true
}
class RemoteWithLocalComposite(
    private val remote: LoadPondFeedRemoteUseCase,
    private val local: LoadPondFeedLocalUseCase
) : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        if (isSuccess()) {
            remote.load {
                callback(it)
            }
        } else {
            local.load {
                callback(it)
            }
        }
    }
}
// Output -> [Pond A from remote data, Pond B from remote data]
// If you change return value of this function to false
fun isSuccess(): Boolean {
    return false
}
/**
 * Main Module
 * */
fun main() {
    val pondFeedViewModel = PondFeedViewModel(
        pondFeedLoader = createRemoteWithLocalCompositeFactory(
            remote = LoadPondFeedRemoteUseCase(),
            local = LoadPondFeedLocalUseCase()
        )
    )
    pondFeedViewModel.loadPondFeed()
}
// The output would be -> [Pond A from local data, Pond B from local data]

Код может выглядеть следующим образом:

За и против:

✅ Принудительная проверка типов во время компиляции гарантирует, что мы составляем правильные объекты.

✅ Работа с конкретными классами в качестве зависимостей не отличается гибкостью. Что, если наши требования изменятся? Если требования диктуют сначала попытаться загрузить данные из локального хранилища, а если оно пусто или загрузка не удалась, попытаться загрузить из удаленного хранилища — вы не сможете выполнить это с помощью этого подхода. Вот почему нам нужно рассмотреть второй подход с использованием абстрактных типов.

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

  1. Композиция с абстрактными типами
/**
 * Domain Module
 * */
interface PondFeedLoader {
    fun load(callback: (List<String>) -> Unit)
}

/**
 * Http Module
 * */
class LoadPondFeedRemoteUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        // Simulate insert to local
        callback(
            listOf(
                "Pond A from remote data",
                "Pond B from remote data"
            )
        )
    }
}
/**
 * Cache Module
 * */
class LoadPondFeedLocalUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        callback(
            listOf(
                "Pond A from local data",
                "Pond B from local data"
            ),
        )
    }
}
/**
 * Presentation Module
 * */
class PondFeedViewModel(
    private val pondFeedLoader: PondFeedLoader
) : ViewModel() {
    fun loadPondFeed(callback: (List<String>) -> Unit) {
        pondFeedLoader.load { pondFeed ->
            callback(pondFeed)
        }
    }
}
/**
 * Composite
 * */
fun isSuccess(): Boolean {
    return true
}
class RemoteWithLocalComposite(
    private val remote: PondFeedLoader,
    private val local: PondFeedLoader
) : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        if (isSuccess()) {
            remote.load {
                callback(it)
            }
        } else {
            local.load {
                callback(it)
            }
        }
    }
}
/**
 * Factory Method
 * */
fun createLoadPondFeedRemoteUseCaseFactory(): PondFeedLoader {
    return LoadPondFeedRemoteUseCase()
}
fun createLoadPondFeedLocalUseCaseFactory(): PondFeedLoader {
    return LoadPondFeedLocalUseCase()
}
fun createRemoteWithLocalCompositeFactory(
    remote: PondFeedLoader,
    local: PondFeedLoader,
): PondFeedLoader {
    return RemoteWithLocalComposite(
        remote = remote,
        local = local
    )
}
/**
 * Main Module
 * */
fun main() {
    /**
     * If requirements change and we want load from local first
   * you just flip the params of remote and local
     * */
    val pondFeedViewModel = PondFeedViewModel(
        pondFeedLoader = createRemoteWithLocalCompositeFactory(
            remote = createLoadPondFeedRemoteUseCaseFactory(),
            local = createLoadPondFeedLocalUseCaseFactory()
        )
    )
    pondFeedViewModel.loadPondFeed { pondFeed ->
        println(pondFeed)
    // Output -> [Pond A from remote data, Pond B from remote data]
    // Output if you flip the params -> [Pond A from local data, Pond B from local data]
    // But you need change contex name of params 
    // from remote and local to primary and fallback like this
   val pondFeedViewModel = PondFeedViewModel(
        pondFeedLoader = createRemoteWithLocalCompositeFactory(
            primary = createLoadPondFeedLocalUseCaseFactory(),
            fallback = createLoadPondFeedRemoteUseCaseFactory()
        )
     )
    }
}

За и против:

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

✅ Композит можно тестировать изолированно, а не только путем интеграции.

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

С абстрактным типом вы можете использовать композицию с пользовательским интерфейсом. Например:

/**
 * Domain Module
 * */
interface PondFeedLoader {
    fun load(callback: (List<String>) -> Unit)
}

/**
 * Http Module
 * */
class LoadPondFeedRemoteUseCase : RemotePondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        // Simulate insert to local
        callback(
            listOf(
                "Pond A from remote data",
                "Pond B from remote data"
            )
        )
    }
}
/**
 * Cache Module
 * */
class LoadPondFeedLocalUseCase : LocalPondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        callback(
            listOf(
                "Pond A from local data",
                "Pond B from local data"
            ),
        )
    }
}
/**
 * Presentation Module
 * */
class PondFeedViewModel(
    private val pondFeedLoader: PondFeedLoader
) : ViewModel() {
    fun loadPondFeed(callback: (List<String>) -> Unit) {
        pondFeedLoader.load { pondFeed ->
            callback(pondFeed)
        }
    }
}
/**
 * Composite
 * */
interface RemotePondFeedLoader : PondFeedLoader
interface LocalPondFeedLoader : PondFeedLoader
fun isSuccess(): Boolean {
    return true
}
class RemoteWithLocalComposite(
    private val remote: RemotePondFeedLoader,
    private val local: LocalPondFeedLoader
) : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        if (isSuccess()) {
            remote.load {
                callback(it)
            }
        } else {
            local.load {
                callback(it)
            }
        }
    }
}
/**
 * Factory Method
 * */
fun createLoadPondFeedRemoteUseCaseFactory(): RemotePondFeedLoader {
    return LoadPondFeedRemoteUseCase()
}
fun createLoadPondFeedLocalUseCaseFactory(): LocalPondFeedLoader {
    return LoadPondFeedLocalUseCase()
}
fun createRemoteWithLocalCompositeFactory(
    remote: RemotePondFeedLoader,
    local: LocalPondFeedLoader,
): PondFeedLoader {
    return RemoteWithLocalComposite(
        remote = remote,
        local = local
    )
}
/**
 * Main Module
 * */
fun main() {
    /**
     * If requirements change and we want load from local first you just flip the params of remote and local
     * */
    val pondFeedViewModel = PondFeedViewModel(
        pondFeedLoader = createRemoteWithLocalCompositeFactory(
            remote = createLoadPondFeedRemoteUseCaseFactory(),
            local = createLoadPondFeedLocalUseCaseFactory()
        )
    )
    pondFeedViewModel.loadPondFeed { pondFeed ->
        println(pondFeed)
    }
}

За и против:

✅ Проверка типов во время компиляции обеспечивает точную композицию объектов.

✅ Использование абстракций в качестве зависимостей повышает гибкость.

✅ Композит можно тестировать отдельно, а не только через интеграцию.

❌ Приводит к увеличению типов и сложности.

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

/**
 * Domain Module
 * */
interface PondFeedLoader {
    fun load(callback: (List<String>) -> Unit)
}

/**
 * Http Module
 * */
class LoadPondFeedRemoteUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        callback(
            listOf(
                "Pond A from remote data",
                "Pond B from remote data"
            )
        )
        // Simulate insert to local
    }
}
/**
 * Cache Module
 * */
class LoadPondFeedLocalUseCase : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        callback(
            listOf(
                "Pond A from local data",
                "Pond B from local data"
            ),
        )
    }
}
/**
 * Presentation Module
 * */
class PondFeedViewModel(
    private val pondFeedLoader: PondFeedLoader
) : ViewModel() {
    fun loadPondFeed(callback: (List<String>) -> Unit) {
        pondFeedLoader.load { pondFeed ->
            callback(pondFeed)
        }
    }
}
/**
 * Composite
 * Simulate if load from remote success
 * */
fun isSuccess(): Boolean {
    return true
}
class PondFeedLoaderWithFallbackComposite(
    private val primary: PondFeedLoader,
    private val fallback: PondFeedLoader
) : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        if (isSuccess()) {
            primary.load {
                callback(it)
            }
        } else {
            fallback.load {
                callback(it)
            }
        }
    }
}
/**
 * Factory Method
 * */
fun createLoadPondFeedRemoteUseCaseFactory(): PondFeedLoader {
    return LoadPondFeedRemoteUseCase()
}
fun createLoadPondFeedLocalUseCaseFactory(): PondFeedLoader {
    return LoadPondFeedLocalUseCase()
}
fun createPondFeedLoaderWithFallbackCompositeFactory(
    primary: PondFeedLoader,
    fallback: PondFeedLoader,
): PondFeedLoader {
    return PondFeedLoaderWithFallbackComposite(
        primary = primary,
        fallback = fallback
    )
}
/**
 * Main Module
 * */
fun main() {
    /**
     * You have more flexibility, we can setup primary as remote or local, local or local and so on
     * */
    val pondFeedViewModel = PondFeedViewModel(
        pondFeedLoader = createPondFeedLoaderWithFallbackCompositeFactory(
            primary = createLoadPondFeedRemoteUseCaseFactory(),
            fallback = createPondFeedLoaderWithFallbackCompositeFactory(
                primary = createLoadPondFeedRemoteUseCaseFactory(),
                fallback = createLoadPondFeedLocalUseCaseFactory()
            )
        )
    )
    pondFeedViewModel.loadPondFeed { pondFeed ->
        println(pondFeed)
    }
}

За и против

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

✅ Композит можно тестировать изолированно, а не полагаться исключительно на интеграционное тестирование.

❌ Проверки типов во время компиляции не могут гарантировать, что мы компонуем правильные объекты. Единственный способ гарантировать правильный порядок композиции — написать тест.

За и против

✅ Шаблон Composite упрощает клиентский код, устраняя необходимость в условных операторах над определяющими классами композиции.

// From this
class PondFeedViewModel(
    private val remote: LoadPondFeedRemoteUseCase,
    private val local: LoadPondFeedLocalUseCase
) : ViewModel() {
    fun loadPondFeed() {
        if (isSuccess()) {
            remote.load {
                println(it)
            }
        } else {
            local.load {
                println(it)
            }
        }
    }
}

// To this
class RemoteWithLocalComposite(
    private val primary: PondFeedLoader,
    private val fallback: PondFeedLoader
) : PondFeedLoader {
    override fun load(callback: (List<String>) -> Unit) {
        if (isSuccess()) {
            primary.load {
                callback(it)
            }
        } else {
            fallback.load {
                callback(it)
            }
        }
    }
}

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

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

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

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

Позвольте мне открыть вам секрет. Секрет в том, что вы можете применять Composite Design Pattern не только для Android, например, Flutter, KMP, iOS, React Native, Web и Backend.

Сделано с ❤️ автором Fiqri Hafzain Islamici.